Compare commits

...
3 Commits
Author SHA1 Message Date
subtrack 95fa4fd6fd feat(v1.4): 初始管理员(id=1)可修改其他管理员的用户名、昵称和密码
build / build-and-test (push) Successful in 3m25s
- 设置 → 管理员:仅 id=1 的账户在“其他人”那一行看到「编辑」入口,自己那行不显示
- 新增编辑页 /settings/users/{id}/edit:可改用户名、显示名称,密码留空则不修改
- 重置密码后立即清除该账户的所有登录会话,强制对方用新密码重新登录;只改昵称/用户名不影响会话
- 服务端三重校验:非 id=1 拒绝、不能编辑自己、用户名 3-32 字符且唯一、密码 ≥6 位且两次一致
- 所有修改写入操作日志(旧用户名/昵称 → 新值,是否重置密码)
- 设置页根据当前账户显示不同提示:超管看到可编辑说明,其他人只看到“只有初始管理员可以修改”

数据库:无需变更(复用 users 表与 sessions 表)
测试:
- 新增单测 TestUpdateUserByAdmin:昵称更新、会话保留、改密后旧会话失效、新密码可登录旧密码失效、重名返回 ErrDuplicate、空用户名拒绝
- 功能验证 20 项:入口显示/隐藏、普通管理员与自我编辑被拒、改密踢下线、新旧密码、长度/一致性/重名校验、留空密码、日志记录
- 原有 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.4.0
2026-10-19 00:21:47 +08:00
subtrack 8cfc9f5891 feat(v1.3): 管理员(打手)颜色,客户归属打手,日历/待办按颜色标识
1) 管理员颜色
- users 表新增 color;设置 → 管理员 每行一个颜色选择器,保存即生效
- 新建管理员自动从 8 色调色板分配,尽量不重复;支持 #RRGGBB 校验,非法值拒绝

2) 客户归属打手
- customers 表新增 assignee_id(可空,指向 users)
- 新建/编辑客户时可选择「归属打手」,客户详情页也能直接改派
- 语义:一个客户一个打手,该客户之后的所有单子都归这个打手
- 停用的账户不出现在打手候选里;未指派显示灰色「未指派」

3) 日历
- 每个日期格左侧新增 5px 竖条,按当天涉及的打手颜色等分显示(几种颜色几段)
- 竖条按“当天全部任务”去重计算,不受格内色签 3 条上限影响
- 未指派的任务用中性灰段落

4) 待办与大屏
- 「当日待完成」每条任务左侧 3px 打手色条 + 圆点色标 + 打手名字,未指派灰字
- 大屏:今日待打列表、热力格竖条、悬停浮层(新增 who / who_color 字段)统一使用同一套颜色
- 加急样式改为红底,避免与打手色左边框冲突

数据库兼容性:
- 只做加法:users.color、customers.assignee_id 两列;老库打开时自动 ALTER,可重复迁移
- 原有表结构、字段、页面接口不变;未指派的存量数据照常工作

测试:
- 新增单测:颜色自动分配/规范化/非法值拒绝、客户指派与取消、订阅联表带出打手、停用账户不进候选
- 旧库迁移测试扩展:老 users / customers 表自动补列并可写
- 功能验证 16 项(颜色保存、指派、详情展示、日历多色竖条分段、大屏列表与浮层接口)全部通过
- 原有 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.3.0
2026-10-19 00:16:34 +08:00
subtrack 85f98dff5c feat(v1.2): 亮暗主题、按尾号快速完成、加急置顶、按套餐分日历、大屏升级
1) 全站亮色 / 暗色
- 管理端与大屏都提供切换按钮(顶栏 ☾/☀、大屏右上角),偏好写 cookie,服务端首屏直出,不闪
- 未选择时跟随系统(prefers-color-scheme);两套配色都改成 CSS 变量,色签、热力、表格、抽屉全部适配
- 大屏默认仍是深色记分板,浅色为纸感背景

2) 工作台 → 当日待完成:按手机尾号快速完成
- 输入尾号回车:唯一匹配直接完成并提示
- 同尾号多客户 / 一单多服务:跳到选择面板,默认全选,可多选后一次完成
- 新增 POST /checkins/quick 与 POST /checkins/batch;抽屉标题改为「未完成数 / 总单数」

3) 加急
- 每条任务右侧可勾选/取消加急;加急任务红 tag + 置顶(抽屉与大屏「今日待打」)
- 日历格子出现红点,单据色签加红边;大屏热力格也带红点
- 新增表 task_flags(sub_id, service_date, urgent),按 订阅+日期 存,避免和打卡状态耦合

4) 套餐「在日历中独立显示」
- 套餐表单新增勾选项,列表页显示「独立日历」标记
- 勾选后工作台与大屏顶部出现套餐标签页(全部 / 每日任务 / …),可只看某类委托的排单、热量与待办
- 新增列 packages.calendar_separate;老库打开时自动 ALTER,无需手工迁移

5) 大屏
- 热力默认改为「每日待完成单量」,右上角可切回「到期热力」
- 鼠标悬停任意日期格,浮出当日待办明细(客户、套餐、剩余、加急),数据来自 GET /api/screen/day
- 底部面板改为「今日到期」(左)+「今日待打」(右,加急置顶),最近追加改为最底部细条

数据库兼容性:
- 只做加法:新增 task_flags 表与 packages.calendar_separate 列,原有表结构、字段、接口与已有数据均不变
- 迁移可重复执行(CREATE TABLE IF NOT EXISTS + PRAGMA 检查后 ALTER)

测试:
- 新增单元测试:套餐独立日历标记、加急增删查、旧库自动加列与重复迁移
- 新增功能冒烟 33 项(主题 cookie、套餐标签、快速完成单/多匹配、加急排序、大屏接口)全部通过
- 原有 119 项验收回归全绿(3 条断言按新 UI 更新:抽屉标题、热力默认口径、今日待打数取值方式)
- go vet / go test ./... 通过;版本号 1.2.0
2026-10-18 22:07:25 +08:00
32 changed files with 2549 additions and 422 deletions
+29 -2
View File
@@ -1,7 +1,7 @@
# 代打台 · 代打管理平台
一个给代打工作室用的单文件管理程序:**套餐 → 订阅 → 追加时长 → 日历打卡 → 大屏**。
整个程序就是一个 `daili.exe` + 一个 `daili.ini`,数据存在同目录的 `daili.db` 里,局域网内手机、电视都能打开。
整个程序就是一个 `daili.exe` + 一个 `daili.ini`,数据存在同目录的 `daili.db` 里,局域网内手机、电视都能打开。界面支持亮色 / 暗色切换,默认跟随系统。
---
@@ -129,6 +129,21 @@
---
## 4.1 工作台速用
- **按尾号快速完成**:日历右侧「当日待完成」顶部输入手机尾号 → 回车。
- 只匹配到 1 项:直接完成并提示;
- 同一尾号有多个客户,或一个客户今天有多个服务:弹出勾选面板,默认全选,可多选后一次完成。
- **加急**:每条任务右侧点「加急」,会加红色 `加急` 标签并排到最前;日历格子出现红点,大屏「今日待打」同步置顶。再点一次取消。
- **按套餐分日历**:套餐编辑页勾选「在日历中独立显示」后,工作台与大屏顶部会出现套餐标签(全部 / 每日任务 / …),可以只看某类委托的排单。
- **打手颜色**:`设置 → 管理员` 里给每个管理员选一个颜色。客户详情或编辑页可以把客户指派给某个打手,之后这个客户的所有单子都用该颜色标识:
- 日历格左侧竖条 = 当天涉及的所有打手颜色(几种人几条,等分显示);
- 「当日待完成」每条前面有打手色条 + 名字,未指派的显示灰色「未指派」;
- 大屏的今日待打、悬停浮层、热力格竖条同样带颜色。
- **主题**:顶栏右上角 ☾ / ☀ 切换亮 / 暗,偏好记在浏览器里;不选时跟随系统。
---
## 5. 数据、备份与导出
| 文件 | 说明 |
@@ -141,6 +156,7 @@
- **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。
- **重置**:关闭程序 → 删除 `daili.db` → 重新打开,会回到「创建第一个管理员」的全新状态。
- 打手颜色存在 `users.color`,客户归属打手是 `customers.assignee_id`;加急标记存在 `daili.db` 的 `task_flags` 表;套餐的「独立日历」是 `packages.calendar_separate` 列,老版本数据库打开时会**自动加列**,不用手工迁移;
- `daili.ini`(监听/备份/日志配置)与 `backup/`、`logs/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。
- 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库;
@@ -153,7 +169,12 @@
- 地址:`http://<本机IP>:17777/screen`
- 默认 `screen_public = true`:**免登录**,电视/副屏直接打开即可(仅建议局域网使用);
- 改成 `screen_public = false` 后大屏回到登录保护;
- 深色「记分板」风格:客户数 / 活跃订阅 / 今日到期 / 7 天内到期、到期热力月历、即将到期 TOP10、今日待打、最近追加;
- 「记分板」风格,亮 / 暗主题同样可切(右上角 ☾ / ☀):
- 顶部 KPI:客户 / 活跃订阅 / 今日到期 / 7 天内到期;
- 中部:**热力月历 + 即将到期 TOP10**。热力默认统计「每日待完成单量」,点右上角按钮可切回「到期热力」;鼠标悬停任意格子会浮出当日待办明细(含加急标记);
- 底部:**今日到期**(左)+ **今日待打**(右,加急置顶带红 tag);
- 最下方细条:最近追加流水;
- 套餐勾选「在日历中独立显示」后,大屏顶部会出现套餐标签,可切换只看某类委托;
- 默认 30 秒自动刷新(设置页可改),按 `F` 全屏。
---
@@ -253,6 +274,12 @@ daili.ini 首次运行自动生成
## 9. 安全说明
- **初始管理员**(第一个创建的账号,`id=1`)在 `设置 → 管理员` 每行可以点「编辑」,修改其他管理员的用户名、昵称,或重置密码;
- 重置密码会立刻清除对方的登录会话,他需要用新密码重新登录;昵称/用户名的修改不影响会话;
- 自己那一行不显示编辑(改自己的密码用「修改我的密码」),避免误操作把自己锁在门外;
- 其余管理员权限相同,但只能改自己的密码。
- 口令使用 PBKDF2-HMAC-SHA256(12 万次迭代 + 随机盐)存储,不可逆;
- 所有写操作记录操作人、时间、IP 与摘要,可在「操作日志」里查询;
- 服务端模板全部走 `html/template` 自动转义;
+46 -7
View File
@@ -23,6 +23,11 @@ type Customer struct {
CreatedAt string
UpdatedAt string
// 归属打手(管理员)
AssigneeID int64
AssigneeName string
AssigneeColor string
// 列表聚合
SubCount int
Summary string
@@ -41,12 +46,13 @@ func (c *Customer) TagList() []string {
return out
}
const customerCols = `id,tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at`
const customerCols = `id,tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at,IFNULL(assignee_id,0),IFNULL((SELECT u.display_name FROM users u WHERE u.id=customers.assignee_id),''),IFNULL((SELECT u.color FROM users u WHERE u.id=customers.assignee_id),'')`
func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) {
var c Customer
err := row.Scan(&c.ID, &c.Tail, &c.Name, &c.GameName, &c.GameServer, &c.Contact,
&c.Tags, &c.Source, &c.Note, &c.CreatedAt, &c.UpdatedAt)
&c.Tags, &c.Source, &c.Note, &c.CreatedAt, &c.UpdatedAt,
&c.AssigneeID, &c.AssigneeName, &c.AssigneeColor)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
@@ -59,10 +65,10 @@ func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) {
// CreateCustomer 新建客户(尾号+姓名唯一)。
func (db *DB) CreateCustomer(c *Customer) (int64, error) {
now := nowStamp()
res, err := db.sql.Exec(`INSERT INTO customers(tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`,
res, err := db.sql.Exec(`INSERT INTO customers(tail,name,game_name,game_server,contact,tags,source,note,assignee_id,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact,
c.Tags, c.Source, c.Note, now, now)
c.Tags, c.Source, c.Note, nullIfZero(c.AssigneeID), now, now)
if err != nil {
if isUnique(err) {
return 0, ErrDuplicate
@@ -74,10 +80,10 @@ func (db *DB) CreateCustomer(c *Customer) (int64, error) {
// UpdateCustomer 更新客户。
func (db *DB) UpdateCustomer(c *Customer) error {
_, err := db.sql.Exec(`UPDATE customers SET tail=?,name=?,game_name=?,game_server=?,contact=?,tags=?,source=?,note=?,updated_at=?
_, err := db.sql.Exec(`UPDATE customers SET tail=?,name=?,game_name=?,game_server=?,contact=?,tags=?,source=?,note=?,assignee_id=?,updated_at=?
WHERE id=?`,
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact,
c.Tags, c.Source, c.Note, nowStamp(), c.ID)
c.Tags, c.Source, c.Note, nullIfZero(c.AssigneeID), nowStamp(), c.ID)
if err != nil && isUnique(err) {
return ErrDuplicate
}
@@ -199,3 +205,36 @@ func (db *DB) CustomerCount() (int, error) {
err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers`).Scan(&n)
return n, err
}
// 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 tail = ? ORDER BY id`, tail)
if err != nil {
return nil, err
}
if len(out) > 0 {
return out, nil
}
return db.queryCustomers(`SELECT `+customerCols+` FROM customers WHERE tail LIKE ? ORDER BY id LIMIT 20`, "%"+tail+"%")
}
func (db *DB) queryCustomers(q string, args ...any) ([]Customer, error) {
rows, err := db.sql.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Customer{}
for rows.Next() {
c, err := scanCustomer(rows)
if err != nil {
return nil, err
}
out = append(out, *c)
}
return out, rows.Err()
}
+57
View File
@@ -116,6 +116,50 @@ func (db *DB) Migrate() error {
return fmt.Errorf("迁移失败: %w\nSQL: %s", err, stmt)
}
}
// 增量列:老库升级时补上(SQLite 没有 ADD COLUMN IF NOT EXISTS)
if err := db.ensureColumn("packages", "calendar_separate",
"ALTER TABLE packages ADD COLUMN calendar_separate INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
// v1.3:管理员颜色 + 客户归属打手
if err := db.ensureColumn("users", "color",
"ALTER TABLE users ADD COLUMN color TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := db.ensureColumn("customers", "assignee_id",
"ALTER TABLE customers ADD COLUMN assignee_id INTEGER REFERENCES users(id)"); err != nil {
return err
}
return nil
}
// ensureColumn 在列不存在时执行 ddl,保证迁移可重复执行。
func (db *DB) ensureColumn(table, column, ddl string) error {
rows, err := db.sql.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return fmt.Errorf("读取 %s 表结构失败: %w", table, err)
}
found := false
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt any
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
}
if strings.EqualFold(name, column) {
found = true
}
}
rows.Close()
if found {
return nil
}
if _, err := db.sql.Exec(ddl); err != nil {
return fmt.Errorf("迁移失败(%s.%s): %w", table, column, err)
}
return nil
}
@@ -126,6 +170,7 @@ var schemaStatements = []string{
display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
color TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
last_login_at TEXT
)`,
@@ -146,6 +191,7 @@ var schemaStatements = []string{
note TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
calendar_separate INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
@@ -159,6 +205,7 @@ var schemaStatements = []string{
tags TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
assignee_id INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (tail, name)
@@ -224,6 +271,16 @@ var schemaStatements = []string{
ip TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS task_flags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sub_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
service_date TEXT NOT NULL,
urgent INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (sub_id, service_date)
)`,
`CREATE INDEX IF NOT EXISTS idx_task_flags_date ON task_flags(service_date)`,
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
+23 -11
View File
@@ -20,6 +20,8 @@ type Package struct {
Note string
Archived bool
SortOrder int
// CalendarSeparate 勾选后在工作台/大屏日历中单独成页
CalendarSeparate bool
CreatedAt string
UpdatedAt string
SubCount int
@@ -37,9 +39,9 @@ func (p *Package) UnitLabel() string {
func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
var p Package
var price sql.NullInt64
var archived int
var archived, separate int
err := row.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt)
&archived, &p.SortOrder, &separate, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
@@ -47,6 +49,7 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
return nil, err
}
p.Archived = archived == 1
p.CalendarSeparate = separate == 1
if price.Valid {
p.PriceCents = int(price.Int64)
p.HasPrice = true
@@ -56,7 +59,7 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
// ListPackages 列出套餐(含订阅计数)。
func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
q := `SELECT p.id,p.name,p.unit,p.default_amount,p.price_cents,p.color,p.note,p.archived,p.sort_order,p.created_at,p.updated_at,
q := `SELECT p.id,p.name,p.unit,p.default_amount,p.price_cents,p.color,p.note,p.archived,p.sort_order,p.calendar_separate,p.created_at,p.updated_at,
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id),
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id AND s.status<>'closed')
FROM packages p`
@@ -73,12 +76,13 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
for rows.Next() {
var p Package
var price sql.NullInt64
var archived int
var archived, separate int
if err := rows.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
&archived, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
&archived, &p.SortOrder, &separate, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
return nil, err
}
p.Archived = archived == 1
p.CalendarSeparate = separate == 1
if price.Valid {
p.PriceCents = int(price.Int64)
p.HasPrice = true
@@ -90,7 +94,7 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
// Package 取单个套餐。
func (db *DB) Package(id int64) (*Package, error) {
return scanPackage(db.sql.QueryRow(`SELECT id,name,unit,default_amount,price_cents,color,note,archived,sort_order,created_at,updated_at
return scanPackage(db.sql.QueryRow(`SELECT id,name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,created_at,updated_at
FROM packages WHERE id=?`, id))
}
@@ -108,9 +112,9 @@ func (db *DB) CreatePackage(p *Package) (int64, error) {
if p.HasPrice {
price = p.PriceCents
}
res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,created_at,updated_at)
VALUES(?,?,?,?,?,?,0,?,?,?)`,
p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, now, now)
res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,created_at,updated_at)
VALUES(?,?,?,?,?,?,0,?,?,?,?)`,
p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), now, now)
if err != nil {
return 0, err
}
@@ -130,11 +134,19 @@ func (db *DB) UpdatePackage(p *Package) error {
if p.HasPrice {
price = p.PriceCents
}
_, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,updated_at=?
WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, nowStamp(), p.ID)
_, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,calendar_separate=?,updated_at=?
WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), nowStamp(), p.ID)
return err
}
// b2i 把 bool 转成 SQLite 的 0/1。
func b2i(v bool) int {
if v {
return 1
}
return 0
}
// SetPackageArchived 归档/取消归档。
func (db *DB) SetPackageArchived(id int64, archived bool) error {
v := 0
+372
View File
@@ -2,6 +2,7 @@ package store
import (
"database/sql"
"errors"
"os"
"path/filepath"
"strconv"
@@ -647,3 +648,374 @@ func TestOpenLegacyWALDatabaseKeepsData(t *testing.T) {
t.Fatalf("旧库未切到单文件模式: %s", mode)
}
}
func TestPackageCalendarSeparate(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "每日任务", domain.UnitDay, 30)
if pkg.CalendarSeparate {
t.Fatal("新建套餐默认不应勾选独立日历")
}
pkg.CalendarSeparate = true
if err := db.UpdatePackage(pkg); err != nil {
t.Fatal(err)
}
got, err := db.Package(pkg.ID)
if err != nil || !got.CalendarSeparate {
t.Fatalf("独立日历标记未保存: %+v err=%v", got, err)
}
list, err := db.ListPackages(true)
if err != nil || len(list) != 1 || !list[0].CalendarSeparate {
t.Fatalf("列表未带独立日历标记: %+v err=%v", list, err)
}
got.CalendarSeparate = false
if err := db.UpdatePackage(got); err != nil {
t.Fatal(err)
}
if again, _ := db.Package(pkg.ID); again.CalendarSeparate {
t.Fatal("取消勾选失败")
}
}
func TestTaskUrgentFlags(t *testing.T) {
db := newTestDB(t)
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
cid := mustCustomer(t, db, "4321", "老王")
sub1, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
})
if err != nil {
t.Fatal(err)
}
pkg2 := mustPackage(t, db, "按次", domain.UnitCount, 10)
sub2, err := db.CreateSub(CreateSubInput{
CustomerID: cid, PackageID: pkg2.ID, StartDate: domain.Date(domain.Today()),
TotalCount: 10, EndDate: domain.Date(domain.AddDays(domain.Today(), 30)),
})
if err != nil {
t.Fatal(err)
}
d1 := domain.Date(domain.Today())
d2 := domain.Date(domain.AddDays(domain.Today(), 1))
if err := db.SetUrgent(sub1.ID, d1, true); err != nil {
t.Fatal(err)
}
if err := db.SetUrgent(sub2.ID, d2, true); err != nil {
t.Fatal(err)
}
flags, err := db.UrgentFlags(d1, d2)
if err != nil {
t.Fatal(err)
}
if !flags[sub1.ID][d1] || !flags[sub2.ID][d2] {
t.Fatalf("加急标记缺失: %+v", flags)
}
if ok, _ := db.IsUrgent(sub1.ID, d1); !ok {
t.Fatal("IsUrgent 应为 true")
}
if n, _ := db.CountUrgent(d1, d2); n != 2 {
t.Fatalf("加急计数=%d 期望 2", n)
}
// 重复设置不报错(UPSERT)
if err := db.SetUrgent(sub1.ID, d1, true); err != nil {
t.Fatalf("重复标记加急失败: %v", err)
}
// 取消
if err := db.SetUrgent(sub1.ID, d1, false); err != nil {
t.Fatal(err)
}
if ok, _ := db.IsUrgent(sub1.ID, d1); ok {
t.Fatal("取消加急后仍为 true")
}
if n, _ := db.CountUrgent(d1, d2); n != 1 {
t.Fatalf("取消后计数=%d 期望 1", n)
}
// 非法日期
if err := db.SetUrgent(sub1.ID, "2026-13-40", true); err == nil {
t.Fatal("非法日期应报错")
}
// 不存在的订阅
if err := db.SetUrgent(99999, d1, true); err == nil {
t.Fatal("不存在的订阅应报错")
}
}
// TestMigrateOldDatabaseAddsColumn 模拟 v1.1 的旧库:packages 表没有 calendar_separate。
func TestMigrateOldDatabaseAddsColumn(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "old.db")
dsn := strings.ReplaceAll(path, "\\", "/")
old, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatal(err)
}
stmts := []string{
`CREATE TABLE packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
unit TEXT NOT NULL,
default_amount INTEGER NOT NULL DEFAULT 30,
price_cents INTEGER,
color TEXT NOT NULL DEFAULT '#2F5D50',
note TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`INSERT INTO packages(name,unit,default_amount,color,note,archived,sort_order,created_at,updated_at)
VALUES('老套餐','day',30,'#2F5D50','',0,1,'2026-01-01 00:00:00','2026-01-01 00:00:00')`,
`CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
last_login_at TEXT
)`,
`INSERT INTO users(username,display_name,password_hash,is_active,created_at)
VALUES('admin','老板','x',1,'2026-01-01 00:00:00')`,
`CREATE TABLE customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tail TEXT NOT NULL,
name TEXT NOT NULL,
game_name TEXT NOT NULL DEFAULT '',
game_server TEXT NOT NULL DEFAULT '',
contact TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (tail, name)
)`,
`INSERT INTO customers(tail,name,created_at,updated_at)
VALUES('1001','老客户','2026-01-01 00:00:00','2026-01-01 00:00:00')`,
}
for _, s := range stmts {
if _, err := old.Exec(s); err != nil {
t.Fatal(err)
}
}
if err := old.Close(); err != nil {
t.Fatal(err)
}
db, err := Open(path)
if err != nil {
t.Fatalf("打开旧库失败: %v", err)
}
defer db.Close()
if err := db.Migrate(); err != nil {
t.Fatalf("迁移失败: %v", err)
}
pkg, err := db.Package(1)
if err != nil {
t.Fatalf("读取老套餐失败: %v", err)
}
if pkg.Name != "老套餐" || pkg.CalendarSeparate {
t.Fatalf("老数据/默认值不对: %+v", pkg)
}
// 新列可写
pkg.CalendarSeparate = true
if err := db.UpdatePackage(pkg); err != nil {
t.Fatal(err)
}
if got, _ := db.Package(1); !got.CalendarSeparate {
t.Fatal("迁移后新列不可写")
}
// 老库的 users / customers 也补上了新列
var userColor string
if err := db.sql.QueryRow(`SELECT IFNULL(color,'') FROM users WHERE id=1`).Scan(&userColor); err != nil {
t.Fatalf("users.color 未补上: %v", err)
}
if err := db.SetUserColor(1, "#123456"); err != nil {
t.Fatalf("老库 users.color 不可写: %v", err)
}
c, err := db.Customer(1)
if err != nil {
t.Fatalf("老客户读取失败: %v", err)
}
if c.AssigneeID != 0 {
t.Fatalf("老客户不应有打手: %+v", c)
}
if err := db.SetCustomerAssignee(1, 1); err != nil {
t.Fatalf("老库 customers.assignee_id 不可写: %v", err)
}
if c2, _ := db.Customer(1); c2.AssigneeID != 1 {
t.Fatalf("指派未生效: %+v", c2)
}
// 新表 task_flags 已建立
var tbl string
if err := db.sql.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='task_flags'`).Scan(&tbl); err != nil {
t.Fatalf("task_flags 表未建立: %v", err)
}
// 迁移可重复执行
if err := db.Migrate(); err != nil {
t.Fatalf("重复迁移失败: %v", err)
}
}
func TestUserColorAndAssignee(t *testing.T) {
db := newTestDB(t)
// 新建管理员自动分配调色板颜色
u1, err := db.CreateUser("dashou1", "阿强", "daili123")
if err != nil {
t.Fatal(err)
}
u2, err := db.CreateUser("dashou2", "小美", "daili123")
if err != nil {
t.Fatal(err)
}
if u1.Color == "" || u2.Color == "" || u1.Color == u2.Color {
t.Fatalf("自动颜色不合理: %q %q", u1.Color, u2.Color)
}
// 改色(小写转大写)
if err := db.SetUserColor(u2.ID, "#c0392b"); err != nil {
t.Fatal(err)
}
got, err := db.UserByID(u2.ID)
if err != nil || got.Color != "#C0392B" {
t.Fatalf("颜色未保存或未规范化: %+v err=%v", got, err)
}
// 非法颜色
if err := db.SetUserColor(u2.ID, "red"); err == nil {
t.Fatal("非法颜色应报错")
}
if err := db.SetUserColor(u2.ID, "#12345"); err == nil {
t.Fatal("长度不对应报错")
}
// 不存在的账户
if err := db.SetUserColor(9999, "#123456"); err == nil {
t.Fatal("不存在的账户应报错")
}
// 客户指派
cid := mustCustomer(t, db, "3001", "客户甲")
if err := db.SetCustomerAssignee(cid, u1.ID); err != nil {
t.Fatal(err)
}
c, err := db.Customer(cid)
if err != nil {
t.Fatal(err)
}
if c.AssigneeID != u1.ID || c.AssigneeName != "阿强" || c.AssigneeColor != "#2F5D50" {
t.Fatalf("客户指派信息不对: %+v", c)
}
// 取消指派
if err := db.SetCustomerAssignee(cid, 0); err != nil {
t.Fatal(err)
}
if c2, _ := db.Customer(cid); c2.AssigneeID != 0 || c2.AssigneeName != "" {
t.Fatalf("取消指派失败: %+v", c2)
}
// 指派给不存在的打手
if err := db.SetCustomerAssignee(cid, 9999); err == nil {
t.Fatal("不存在的打手应报错")
}
// 客户创建时直接带指派
cid2, err := db.CreateCustomer(&Customer{Tail: "3002", Name: "客户乙", AssigneeID: u2.ID})
if err != nil {
t.Fatal(err)
}
c3, _ := db.Customer(cid2)
if c3.AssigneeName != "小美" || c3.AssigneeColor != "#C0392B" {
t.Fatalf("新建客户的指派未生效: %+v", c3)
}
// 订阅联表能带出打手
pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30)
sub, err := db.CreateSub(CreateSubInput{
CustomerID: cid2, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30,
})
if err != nil {
t.Fatal(err)
}
gotSub, err := db.Sub(sub.ID)
if err != nil {
t.Fatal(err)
}
if gotSub.AssigneeName != "小美" || gotSub.AssigneeColor != "#C0392B" {
t.Fatalf("订阅未带出打手: %+v", gotSub)
}
// 只有启用中的账户可作为打手候选
if err := db.SetUserActive(u1.ID, false); err != nil {
t.Fatal(err)
}
users, err := db.ActiveUsers()
if err != nil {
t.Fatal(err)
}
for _, u := range users {
if u.ID == u1.ID {
t.Fatal("停用账户不应出现在打手候选里")
}
}
}
func TestUpdateUserByAdmin(t *testing.T) {
db := newTestDB(t)
admin, err := db.CreateUser("admin", "老板", "daili123")
if err != nil {
t.Fatal(err)
}
u, err := db.CreateUser("dashou", "阿强", "dashou123")
if err != nil {
t.Fatal(err)
}
// 建一个会话,稍后用密码重置把它踢掉
token := "tok-" + domain.Date(domain.Today())
if err := db.CreateSession(token, u.ID, time.Hour, "127.0.0.1"); err != nil {
t.Fatal(err)
}
if _, err := db.SessionUser(token); err != nil {
t.Fatalf("会话应有效: %v", err)
}
// 只改昵称:会话保留
if err := db.UpdateUserByAdmin(u.ID, "dashou", "强哥", ""); err != nil {
t.Fatal(err)
}
got, _ := db.UserByID(u.ID)
if got.DisplayName != "强哥" || got.Username != "dashou" {
t.Fatalf("昵称未更新: %+v", got)
}
if _, err := db.SessionUser(token); err != nil {
t.Fatalf("未改密码不应踢下线: %v", err)
}
// 改用户名 + 重置密码:会话失效,新密码可登录
if err := db.UpdateUserByAdmin(u.ID, "laoban", "强哥", "newpass123"); err != nil {
t.Fatal(err)
}
if _, err := db.SessionUser(token); err == nil {
t.Fatal("改密后旧会话应失效")
}
if _, err := db.Authenticate("laoban", "newpass123"); err != nil {
t.Fatalf("新密码应可登录: %v", err)
}
if _, err := db.Authenticate("laoban", "dashou123"); err == nil {
t.Fatal("旧密码不应还能登录")
}
// 用户名冲突
if err := db.UpdateUserByAdmin(u.ID, "admin", "", ""); !errors.Is(err, ErrDuplicate) {
t.Fatalf("重名应返回 ErrDuplicate, got %v", err)
}
// 空用户名
if err := db.UpdateUserByAdmin(u.ID, " ", "", ""); err == nil {
t.Fatal("空用户名应报错")
}
// 超管自己也在表里,但接口层才限制“只能改别人”
if admin.ID != 1 {
t.Fatalf("首个账户 id 应为 1,实际 %d", admin.ID)
}
}
+11 -2
View File
@@ -33,6 +33,11 @@ type Subscription struct {
CustomerTail string
CustomerName string
CustomerContact string
// 客户归属的打手(管理员)
AssigneeID int64
AssigneeName string
AssigneeColor string
}
// Eval 计算展示状态。
@@ -93,7 +98,10 @@ func (a *Adjustment) KindLabel() string {
const subSelect = `SELECT s.id,s.customer_id,s.package_id,s.unit,s.status,s.start_date,IFNULL(s.end_date,''),
s.total_count,s.used_count,s.note,IFNULL(s.created_by,0),s.created_at,s.updated_at,IFNULL(s.closed_at,''),
p.name,p.color,c.tail,c.name,IFNULL(c.contact,'')
p.name,p.color,c.tail,c.name,IFNULL(c.contact,''),
IFNULL(c.assignee_id,0),
IFNULL((SELECT u.display_name FROM users u WHERE u.id=c.assignee_id),''),
IFNULL((SELECT u.color FROM users u WHERE u.id=c.assignee_id),'')
FROM subscriptions s
JOIN packages p ON p.id=s.package_id
JOIN customers c ON c.id=s.customer_id`
@@ -102,7 +110,8 @@ func scanSub(row interface{ Scan(dest ...any) error }) (*Subscription, error) {
var s Subscription
err := row.Scan(&s.ID, &s.CustomerID, &s.PackageID, &s.Unit, &s.Status, &s.StartDate, &s.EndDate,
&s.TotalCount, &s.UsedCount, &s.Note, &s.CreatedBy, &s.CreatedAt, &s.UpdatedAt, &s.ClosedAt,
&s.PackageName, &s.PackageColor, &s.CustomerTail, &s.CustomerName, &s.CustomerContact)
&s.PackageName, &s.PackageColor, &s.CustomerTail, &s.CustomerName, &s.CustomerContact,
&s.AssigneeID, &s.AssigneeName, &s.AssigneeColor)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
+67
View File
@@ -0,0 +1,67 @@
package store
import (
"errors"
"daili/internal/domain"
)
// UrgentFlags 返回 [from,to] 区间内的加急标记,索引为 [subID][date]。
// 表里只存“加急=1”的行,未出现即未加急。
func (db *DB) UrgentFlags(from, to string) (map[int64]map[string]bool, error) {
out := map[int64]map[string]bool{}
rows, err := db.sql.Query(`SELECT sub_id, service_date FROM task_flags
WHERE urgent=1 AND service_date BETWEEN ? AND ?`, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var subID int64
var date string
if err := rows.Scan(&subID, &date); err != nil {
return nil, err
}
if out[subID] == nil {
out[subID] = map[string]bool{}
}
out[subID][date] = true
}
return out, rows.Err()
}
// SetUrgent 设置或取消某天某订阅的加急标记。
func (db *DB) SetUrgent(subID int64, date string, urgent bool) error {
if _, err := domain.ParseDate(date); err != nil {
return errors.New("日期格式不正确")
}
if _, err := db.Sub(subID); err != nil {
return errors.New("订阅不存在")
}
now := nowStamp()
if urgent {
_, err := db.sql.Exec(`INSERT INTO task_flags(sub_id, service_date, urgent, created_at, updated_at)
VALUES(?,?,1,?,?)
ON CONFLICT(sub_id, service_date) DO UPDATE SET urgent=1, updated_at=excluded.updated_at`,
subID, date, now, now)
return err
}
_, err := db.sql.Exec(`DELETE FROM task_flags WHERE sub_id=? AND service_date=?`, subID, date)
return err
}
// IsUrgent 查询单条加急状态。
func (db *DB) IsUrgent(subID int64, date string) (bool, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM task_flags WHERE sub_id=? AND service_date=? AND urgent=1`,
subID, date).Scan(&n)
return n > 0, err
}
// CountUrgent 统计区间内加急任务数。
func (db *DB) CountUrgent(from, to string) (int, error) {
var n int
err := db.sql.QueryRow(`SELECT COUNT(*) FROM task_flags WHERE urgent=1 AND service_date BETWEEN ? AND ?`,
from, to).Scan(&n)
return n, err
}
+130 -5
View File
@@ -3,6 +3,7 @@ package store
import (
"database/sql"
"errors"
"strings"
"time"
"daili/internal/pw"
@@ -14,17 +15,41 @@ type User struct {
Username string
DisplayName string
IsActive bool
Color string
CreatedAt string
LastLoginAt string
}
// AssigneePalette 是新建管理员时自动分配的颜色(区分度较高的深色系)。
var AssigneePalette = []string{
"#2F5D50", "#8C4A3C", "#3E5C8A", "#7A5AA6",
"#A66A1E", "#2C7A7B", "#8A3B5C", "#5B6B2F",
}
// NormalizeColor 校验并规范化 #RRGGBB;空值返回空。
func NormalizeColor(c string) (string, error) {
c = strings.TrimSpace(c)
if c == "" {
return "", nil
}
if len(c) != 7 || c[0] != '#' {
return "", errors.New("颜色格式应为 #RRGGBB")
}
for _, r := range c[1:] {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return "", errors.New("颜色格式应为 #RRGGBB")
}
}
return strings.ToUpper(c), nil
}
func scanUser(row interface {
Scan(dest ...any) error
}) (*User, error) {
var u User
var active int
var last sql.NullString
if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &active, &u.CreatedAt, &last); err != nil {
if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &active, &u.CreatedAt, &last, &u.Color); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -35,7 +60,7 @@ func scanUser(row interface {
return &u, nil
}
const userCols = `id, username, display_name, is_active, created_at, IFNULL(last_login_at,'')`
const userCols = `id, username, display_name, is_active, created_at, IFNULL(last_login_at,''), IFNULL(color,'')`
// CountUsers 返回账户数量。
func (db *DB) CountUsers() (int, error) {
@@ -51,8 +76,9 @@ func (db *DB) CreateUser(username, displayName, password string) (*User, error)
return nil, err
}
now := nowStamp()
res, err := db.sql.Exec(`INSERT INTO users(username, display_name, password_hash, is_active, created_at)
VALUES(?,?,?,1,?)`, username, displayName, hash, now)
color := db.nextAssigneeColor()
res, err := db.sql.Exec(`INSERT INTO users(username, display_name, password_hash, is_active, color, created_at)
VALUES(?,?,?,1,?,?)`, username, displayName, hash, color, now)
if err != nil {
if isUnique(err) {
return nil, ErrDuplicate
@@ -60,7 +86,106 @@ func (db *DB) CreateUser(username, displayName, password string) (*User, error)
return nil, err
}
id, _ := res.LastInsertId()
return &User{ID: id, Username: username, DisplayName: displayName, IsActive: true, CreatedAt: now}, nil
return &User{ID: id, Username: username, DisplayName: displayName, IsActive: true, Color: color, CreatedAt: now}, nil
}
// nextAssigneeColor 依据现有账户数循环取调色板,尽量不重复。
func (db *DB) nextAssigneeColor() string {
n, _ := db.CountUsers()
return AssigneePalette[n%len(AssigneePalette)]
}
// SetUserColor 设置管理员颜色(空字符串表示清除)。
func (db *DB) SetUserColor(userID int64, color string) error {
c, err := NormalizeColor(color)
if err != nil {
return err
}
res, err := db.sql.Exec(`UPDATE users SET color=? WHERE id=?`, c, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// SetCustomerAssignee 把客户指派给某个打手;userID=0 表示取消指派。
func (db *DB) SetCustomerAssignee(customerID, userID int64) error {
var v any
if userID > 0 {
if _, err := db.UserByID(userID); err != nil {
return errors.New("打手不存在")
}
v = userID
}
res, err := db.sql.Exec(`UPDATE customers SET assignee_id=?, updated_at=? WHERE id=?`, v, nowStamp(), customerID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// ActiveUsers 返回在用的管理员(打手候选)。
func (db *DB) ActiveUsers() ([]User, error) {
rows, err := db.sql.Query(`SELECT ` + userCols + ` FROM users WHERE is_active=1 ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []User{}
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
// DeleteUserSessions 清除某个账户的全部登录会话。
func (db *DB) DeleteUserSessions(userID int64) error {
_, err := db.sql.Exec(`DELETE FROM sessions WHERE user_id=?`, userID)
return err
}
// UpdateUserByAdmin 由超管修改用户名/昵称,password 非空时同时重置密码。
// 重置密码会强制该账户下线。
func (db *DB) UpdateUserByAdmin(userID int64, username, displayName, password string) error {
username = strings.TrimSpace(username)
displayName = strings.TrimSpace(displayName)
if username == "" {
return errors.New("用户名不能为空")
}
hash := ""
if password != "" {
var err error
hash, err = pw.Hash(password)
if err != nil {
return err
}
}
if _, err := db.sql.Exec(`UPDATE users SET username=?, display_name=? WHERE id=?`,
username, displayName, userID); err != nil {
if isUnique(err) {
return ErrDuplicate
}
return err
}
if hash != "" {
if _, err := db.sql.Exec(`UPDATE users SET password_hash=? WHERE id=?`, hash, userID); err != nil {
return err
}
if err := db.DeleteUserSessions(userID); err != nil {
return err
}
}
return nil
}
// Authenticate 校验用户名口令。
+175 -32
View File
@@ -3,6 +3,7 @@ package web
import (
"fmt"
"net/http"
"net/url"
"sort"
"time"
@@ -22,9 +23,12 @@ type DayCell struct {
DueCount int
TaskCount int
DoneCount int
UrgentCnt int
Heat int
Tickets []Ticket
More int
// Strip 是左侧的打手色竖条(按出现顺序去重,未指派为灰)
Strip []string
}
// Ticket 是日历格里的色签。
@@ -37,6 +41,9 @@ type Ticket struct {
Done bool
Skipped bool
IsDue bool
Urgent bool
// AssigneeColor 打手颜色(未指派为空,模板用 spanAssignee 兜底)
AssigneeColor string
}
// TaskRow 是当日待打清单的一行。
@@ -47,6 +54,15 @@ type TaskRow struct {
Skipped bool
Note string
Operator string
Urgent bool
}
// PkgTab 是日历上方的套餐标签。
type PkgTab struct {
ID int64
Name string
Color string
Count int
}
type calendarData struct {
@@ -62,15 +78,27 @@ type calendarData struct {
SelectedIsToday bool
SelectedIsPast bool
Tasks []TaskRow
TaskPending int
Due []SubView
OtherDue []SubView
Extend *ExtendPanel
MonthDue int
MonthTasks int
MonthDone int
MonthUrgent int
NoPackages bool
NoCustomers bool
Back string
// 套餐标签(在日历中独立显示的套餐)
Tabs []PkgTab
PkgID int64
HasTabs bool
// 快速完成:按尾号筛选出的候选
PickTail string
PickRows []TaskRow
PickMsg string
}
// calSub 是日历计算用的订阅上下文。
@@ -108,6 +136,40 @@ func (c calSub) covers(date string) bool {
return !c.pausedOn(date)
}
// loadCalendarSubs 读取区间内与日历相交的订阅;pkgID>0 时只保留该套餐。
func (s *Server) loadCalendarSubs(from, to string, pkgID int64) ([]store.Subscription, map[int64][]store.Pause, map[int64]map[string]store.Checkin, map[int64]map[string]bool, error) {
subs, err := s.db.CalendarSubs(from, to)
if err != nil {
return nil, nil, nil, nil, err
}
if pkgID > 0 {
filtered := make([]store.Subscription, 0, len(subs))
for _, sub := range subs {
if sub.PackageID == pkgID {
filtered = append(filtered, sub)
}
}
subs = filtered
}
ids := make([]int64, 0, len(subs))
for _, sub := range subs {
ids = append(ids, sub.ID)
}
pauses, err := s.db.PausesForSubs(ids)
if err != nil {
return nil, nil, nil, nil, err
}
checkins, err := s.db.CheckinsBetween(from, to)
if err != nil {
return nil, nil, nil, nil, err
}
urgent, err := s.db.UrgentFlags(from, to)
if err != nil {
return nil, nil, nil, nil, err
}
return subs, pauses, checkins, urgent, nil
}
func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
today := domain.Today()
y := s.queryInt(r, "y", today.Year())
@@ -118,6 +180,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
monthStart := domain.MonthStart(y, time.Month(m))
monthEnd := domain.MonthEnd(y, time.Month(m))
pkgID := int64(s.queryInt(r, "pkg", 0))
sel := r.URL.Query().Get("d")
selT, err := domain.ParseDate(sel)
if sel != "" && err != nil {
@@ -132,30 +195,15 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
selT = domain.ParseDateOr(sel, today)
}
// 网格:以周一为首日,铺满整月所需的周数
gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart))
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
gridEnd := domain.AddDays(gridStart, weeks*7-1)
subs, err := s.db.CalendarSubs(domain.Date(gridStart), domain.Date(gridEnd))
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID)
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
ids := make([]int64, 0, len(subs))
for _, sub := range subs {
ids = append(ids, sub.ID)
}
pauses, err := s.db.PausesForSubs(ids)
if err != nil {
http.Error(w, "读取暂停记录失败", http.StatusInternalServerError)
return
}
checkins, err := s.db.CheckinsBetween(domain.Date(gridStart), domain.Date(gridEnd))
if err != nil {
http.Error(w, "读取打卡记录失败", http.StatusInternalServerError)
return
}
warn := s.warnDays()
list := make([]calSub, 0, len(subs))
@@ -173,6 +221,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
list = append(list, cs)
}
back := r.URL.RequestURI()
data := &calendarData{
Year: y, Month: m,
Title: fmt.Sprintf("%d 年 %d 月", y, m),
@@ -180,7 +229,8 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
Selected: sel,
SelectedIsToday: sel == domain.Date(today),
Back: r.URL.RequestURI(),
Back: back,
PkgID: pkgID,
}
data.PrevY, data.PrevM = m-1, y
if data.PrevM < 1 {
@@ -192,8 +242,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
data.NextM = 1
data.NextY = y + 1
}
label := selT.Format("1 月 2 日")
data.SelectedLabel = label + " 周" + dowCN(sel)
data.SelectedLabel = selT.Format("1 月 2 日") + " 周" + dowCN(sel)
data.SelectedIsPast = selT.Before(today)
// 铺格子
@@ -221,6 +270,10 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
}
cell.TaskCount++
ci := checkins[cs.sub.ID][ds]
isUrgent := urgent[cs.sub.ID][ds]
if isUrgent {
cell.UrgentCnt++
}
t := Ticket{
SubID: cs.sub.ID,
Tail: cs.sub.CustomerTail,
@@ -230,6 +283,8 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
Done: ci.Status == "done",
Skipped: ci.Status == "skipped",
IsDue: cs.sub.EndDate == ds,
Urgent: isUrgent,
AssigneeColor: cs.sub.AssigneeColor,
}
if t.Done || t.Skipped {
cell.DoneCount++
@@ -237,11 +292,16 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
tickets = append(tickets, t)
}
sort.SliceStable(tickets, func(i, j int) bool {
if tickets[i].Urgent != tickets[j].Urgent {
return tickets[i].Urgent
}
if tickets[i].Done != tickets[j].Done {
return !tickets[i].Done
}
return tickets[i].SubID < tickets[j].SubID
})
// 竖条按“当天全部任务”算,不能被色签的 3 条上限截断
cell.Strip = assigneeStrip(tickets)
if len(tickets) > 3 {
cell.More = len(tickets) - 3
tickets = tickets[:3]
@@ -252,6 +312,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
data.MonthDue += cell.DueCount
data.MonthTasks += cell.TaskCount
data.MonthDone += cell.DoneCount
data.MonthUrgent += cell.UrgentCnt
}
week = append(week, cell)
}
@@ -268,8 +329,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
if !cs.covers(sel) {
continue
}
v := newSubView(cs.sub, today, warn)
row := TaskRow{SubView: v}
row := TaskRow{SubView: newSubView(cs.sub, today, warn), Urgent: urgent[cs.sub.ID][sel]}
if ci, ok := checkins[cs.sub.ID][sel]; ok {
row.CheckinID = ci.ID
row.Done = ci.Status == "done"
@@ -279,15 +339,13 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
}
tasks = append(tasks, row)
}
sort.SliceStable(tasks, func(i, j int) bool {
ai := tasks[i].Done || tasks[i].Skipped
aj := tasks[j].Done || tasks[j].Skipped
if ai != aj {
return !ai
}
return tasks[i].EndDate < tasks[j].EndDate
})
sortTasks(tasks)
data.Tasks = tasks
for _, row := range tasks {
if !row.Done && !row.Skipped {
data.TaskPending++
}
}
data.Due = due
// 其它日期到期(不在当天的 7 天内)
@@ -296,25 +354,61 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
if u.EndDate == sel {
continue
}
if pkgID > 0 && u.PackageID != pkgID {
continue
}
data.OtherDue = append(data.OtherDue, newSubView(u, today, warn))
}
}
// 套餐标签
pkgs, _ := s.db.ListPackages(true)
separate := []store.Package{}
for _, p := range pkgs {
if p.CalendarSeparate && !p.Archived {
separate = append(separate, p)
}
}
if len(separate) > 0 {
data.HasTabs = true
data.Tabs = append(data.Tabs, PkgTab{ID: 0, Name: "全部", Color: "#8A8F98", Count: len(list)})
for _, p := range separate {
cnt := 0
for _, sub := range subs {
if sub.PackageID == p.ID {
cnt++
}
}
data.Tabs = append(data.Tabs, PkgTab{ID: p.ID, Name: p.Name, Color: p.Color, Count: cnt})
}
}
// 快速完成:按尾号列出候选
if tail := r.URL.Query().Get("pick"); tail != "" {
data.PickTail = tail
for _, row := range tasks {
if row.CustomerTail == tail {
data.PickRows = append(data.PickRows, row)
}
}
if len(data.PickRows) == 0 {
data.PickMsg = "尾号 " + tail + " 在今天没有待完成的服务"
}
}
// 追加面板
if eid := s.queryInt(r, "extend", 0); eid > 0 {
if sub, err := s.db.Sub(int64(eid)); err == nil {
if pkg, err := s.db.Package(sub.PackageID); err == nil {
back := r.URL.Path + "?" + r.URL.RawQuery
data.Extend = buildExtendPanel(sub, pkg, today, back, warn)
}
}
}
// 空态提示
if n, _ := s.db.CustomerCount(); n == 0 {
data.NoCustomers = true
}
if pkgs, _ := s.db.ListPackages(false); len(pkgs) == 0 {
if len(pkgs) == 0 {
data.NoPackages = true
}
@@ -323,6 +417,43 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "calendar", p)
}
// sortTasks 加急优先,其次未完成,最后按结束日。
func sortTasks(tasks []TaskRow) {
sort.SliceStable(tasks, func(i, j int) bool {
ai, aj := tasks[i], tasks[j]
if ai.Urgent != aj.Urgent {
return ai.Urgent
}
adi := ai.Done || ai.Skipped
adj := aj.Done || aj.Skipped
if adi != adj {
return !adi
}
return ai.EndDate < aj.EndDate
})
}
// assigneeStrip 把一格里所有任务的打手颜色去重后返回,未指派用灰色占位。
func assigneeStrip(tickets []Ticket) []string {
if len(tickets) == 0 {
return nil
}
seen := map[string]bool{}
out := make([]string, 0, 4)
for _, t := range tickets {
c := t.AssigneeColor
if c == "" {
c = "#8A8F98"
}
if seen[c] {
continue
}
seen[c] = true
out = append(out, c)
}
return out
}
func heatLevel(n int) int {
switch {
case n <= 0:
@@ -346,3 +477,15 @@ func shortName(s string) string {
}
return string(r[:2])
}
// stripParam 去掉地址里的某个参数,用于拼“返回”链接。
func stripParam(raw, key string) string {
u, err := url.Parse(raw)
if err != nil {
return raw
}
q := u.Query()
q.Del(key)
u.RawQuery = q.Encode()
return u.String()
}
+39 -6
View File
@@ -51,15 +51,17 @@ func (s *Server) handleCustomers(w http.ResponseWriter, r *http.Request) {
type customerFormData struct {
Customer *store.Customer
IsNew bool
Users []store.User
}
func (s *Server) handleCustomerNewPage(w http.ResponseWriter, r *http.Request) {
p := s.newPage(r, "新建客户", "customers")
p.D = &customerFormData{Customer: &store.Customer{}, IsNew: true}
users, _ := s.db.ActiveUsers()
p.D = &customerFormData{Customer: &store.Customer{}, IsNew: true, Users: users}
s.render(w, r, "customer_form", p)
}
func readCustomerForm(r *http.Request) *store.Customer {
func (s *Server) readCustomerForm(r *http.Request) *store.Customer {
return &store.Customer{
Tail: strings.TrimSpace(r.PostFormValue("tail")),
Name: strings.TrimSpace(r.PostFormValue("name")),
@@ -69,6 +71,7 @@ func readCustomerForm(r *http.Request) *store.Customer {
Tags: normalizeTags(r.PostFormValue("tags")),
Source: strings.TrimSpace(r.PostFormValue("source")),
Note: strings.TrimSpace(r.PostFormValue("note")),
AssigneeID: int64(s.formInt(r, "assignee_id", 0)),
}
}
@@ -104,10 +107,11 @@ func validateCustomer(c *store.Customer) string {
}
func (s *Server) handleCustomerCreate(w http.ResponseWriter, r *http.Request) {
c := readCustomerForm(r)
c := s.readCustomerForm(r)
p := s.newPage(r, "新建客户", "customers")
if msg := validateCustomer(c); msg != "" {
p.D = &customerFormData{Customer: c, IsNew: true}
users, _ := s.db.ActiveUsers()
p.D = &customerFormData{Customer: c, IsNew: true, Users: users}
s.fail(w, r, "customer_form", msg, p)
return
}
@@ -117,7 +121,8 @@ func (s *Server) handleCustomerCreate(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, store.ErrDuplicate) {
msg = "该「尾号 + 姓名」的客户已存在,请直接搜索后追加订阅"
}
p.D = &customerFormData{Customer: c, IsNew: true}
users, _ := s.db.ActiveUsers()
p.D = &customerFormData{Customer: c, IsNew: true, Users: users}
s.fail(w, r, "customer_form", msg, p)
return
}
@@ -143,7 +148,7 @@ func (s *Server) handleCustomerUpdate(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
c := readCustomerForm(r)
c := s.readCustomerForm(r)
c.ID = id
p := s.newPage(r, "编辑客户", "customers")
if msg := validateCustomer(c); msg != "" {
@@ -179,6 +184,7 @@ type customerDetailData struct {
AdjSubColor map[int64]string
Checkins []CheckinRow
Packages []store.Package
Users []store.User
Timeline *Timeline
Extend *ExtendPanel
TotalRemain string
@@ -208,7 +214,9 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
}
pauses, _ := s.db.PausesForSubs(ids)
users, _ := s.db.ActiveUsers()
data := &customerDetailData{
Users: users,
Customer: c,
Subs: subViews(subs, today, warn),
Packages: pkgs,
@@ -247,6 +255,31 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "customer_detail", p)
}
// handleCustomerAssignee 从客户详情快速指派/取消打手。
func (s *Server) handleCustomerAssignee(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
userID := int64(s.formInt(r, "assignee_id", 0))
if err := s.db.SetCustomerAssignee(id, userID); err != nil {
s.redirect(w, r, "/customers/"+itoa(id), "指派失败:"+err.Error())
return
}
c, _ := s.db.Customer(id)
word := "已取消指派"
if userID > 0 {
if u, err := s.db.UserByID(userID); err == nil {
name := u.DisplayName
if name == "" {
name = u.Username
}
word = "已指派给 " + name
}
}
if c != nil {
s.logOp(r, "指派打手", "customer", id, c.Tail+" "+c.Name+" "+word)
}
s.redirect(w, r, "/customers/"+itoa(id), word)
}
func (s *Server) handleSubCreate(w http.ResponseWriter, r *http.Request) {
customerID := pathID(r)
c, err := s.db.Customer(customerID)
+113
View File
@@ -2,6 +2,7 @@ package web
import (
"encoding/csv"
"errors"
"fmt"
"net"
"net/http"
@@ -152,6 +153,118 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "settings", p)
}
type userFormData struct {
Target *store.User
}
// isSuperAdmin 只有一个超级管理员(id=1)可以修改他人资料。
func isSuperAdmin(u *store.User) bool { return u != nil && u.ID == 1 }
// handleUserEditPage 超管编辑其他管理员。
func (s *Server) handleUserEditPage(w http.ResponseWriter, r *http.Request) {
me := s.currentUser(r)
if !isSuperAdmin(me) {
s.redirect(w, r, "/settings", "只有初始管理员可以修改其他人的资料")
return
}
id := pathID(r)
if id == me.ID {
s.redirect(w, r, "/settings", "自己的资料请用「修改我的密码」")
return
}
u, err := s.db.UserByID(id)
if err != nil {
s.redirect(w, r, "/settings", "账户不存在")
return
}
p := s.newPage(r, "编辑管理员", "settings")
p.D = &userFormData{Target: u}
s.render(w, r, "user_form", p)
}
// handleUserEditSave 保存超管对其他管理员的修改。
func (s *Server) handleUserEditSave(w http.ResponseWriter, r *http.Request) {
me := s.currentUser(r)
if !isSuperAdmin(me) {
s.redirect(w, r, "/settings", "只有初始管理员可以修改其他人的资料")
return
}
id := pathID(r)
if id == me.ID {
s.redirect(w, r, "/settings", "自己的资料请用「修改我的密码」")
return
}
u, err := s.db.UserByID(id)
if err != nil {
s.redirect(w, r, "/settings", "账户不存在")
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
displayName := strings.TrimSpace(r.PostFormValue("display_name"))
password := r.PostFormValue("password")
password2 := r.PostFormValue("password2")
back := "/settings/users/" + itoa(id) + "/edit"
if l := len([]rune(username)); l < 3 || l > 32 {
s.redirect(w, r, back, "用户名长度需在 3-32 个字符之间")
return
}
if len([]rune(displayName)) > 32 {
s.redirect(w, r, back, "显示名称不能超过 32 个字符")
return
}
if password != "" {
if len([]rune(password)) < 6 {
s.redirect(w, r, back, "新密码至少 6 位")
return
}
if password != password2 {
s.redirect(w, r, back, "两次输入的新密码不一致")
return
}
}
if err := s.db.UpdateUserByAdmin(id, username, displayName, password); err != nil {
msg := "保存失败:" + err.Error()
if errors.Is(err, store.ErrDuplicate) {
msg = "用户名已被占用"
}
s.redirect(w, r, back, msg)
return
}
detail := u.Username + " → " + username
if displayName != u.DisplayName {
detail += ";昵称 " + u.DisplayName + " → " + displayName
}
if password != "" {
detail += ";已重置密码并强制下线"
}
s.logOp(r, "修改管理员资料", "user", id, detail)
s.redirect(w, r, "/settings", "已更新「"+username+"」的资料")
}
// handleUserColor 设置管理员(打手)颜色。
func (s *Server) handleUserColor(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
color, err := store.NormalizeColor(r.PostFormValue("color"))
if err != nil {
s.redirect(w, r, "/settings", "颜色格式不正确")
return
}
if err := s.db.SetUserColor(id, color); err != nil {
s.redirect(w, r, "/settings", "保存颜色失败:"+err.Error())
return
}
name := ""
if u, err := s.db.UserByID(id); err == nil {
name = u.DisplayName
if name == "" {
name = u.Username
}
}
s.logOp(r, "设置打手颜色", "user", id, name+" → "+color)
s.redirect(w, r, "/settings", "已更新 "+name+" 的颜色")
}
func (s *Server) handleSettingsSave(w http.ResponseWriter, r *http.Request) {
warn := s.formInt(r, "warn_days", 3)
if warn < 0 {
+1
View File
@@ -62,6 +62,7 @@ func readPackageForm(r *http.Request) *store.Package {
if p.Unit == "" {
p.Unit = domain.UnitDay
}
p.CalendarSeparate = r.PostFormValue("calendar_separate") != ""
return p
}
+192 -45
View File
@@ -1,9 +1,9 @@
package web
import (
"encoding/json"
"fmt"
"net/http"
"time"
"daili/internal/domain"
"daili/internal/store"
@@ -15,8 +15,15 @@ type screenDay struct {
InMonth bool
IsToday bool
IsPast bool
Due int
Count int
Urgent int
Heat int
// Strip 打手色竖条(去重)
Strip []string
}
type screenTask struct {
TaskRow
}
type screenData struct {
@@ -27,17 +34,51 @@ type screenData struct {
Weekdays []string
Top []SubView
DueToday []SubView
TodayList []SubView
TodayList []screenTask
Recent []store.Adjustment
TodayTasks int
TodayDone int
TodayUrgent int
Refresh int
Version string
HeatMode string // task = 每日待完成;due = 到期
HeatTotal int
Tabs []PkgTab
PkgID int64
HasTabs bool
Theme string
}
// screenCalSubs 读取大屏日历区间内的订阅,并带上暂停/打卡/加急。
func (s *Server) screenCalSubs(from, to string, pkgID int64) ([]calSub, map[int64]map[string]store.Checkin, map[int64]map[string]bool, error) {
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(from, to, pkgID)
if err != nil {
return nil, nil, nil, err
}
today := domain.Today()
warn := s.warnDays()
list := make([]calSub, 0, len(subs))
for _, sub := range subs {
cs := calSub{sub: sub, pauses: pauses[sub.ID], eval: sub.Eval(today, warn)}
if sub.EndDate != "" {
cs.hasEnd = true
cs.end = domain.ParseDateOr(sub.EndDate, today)
}
list = append(list, cs)
}
return list, checkins, urgent, nil
}
func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
today := domain.Today()
warn := s.warnDays()
pkgID := int64(s.queryInt(r, "pkg", 0))
heatMode := r.URL.Query().Get("heat")
if heatMode != "due" {
heatMode = "task"
}
stats, err := s.db.ScreenStats(today, warn)
if err != nil {
http.Error(w, "读取大屏数据失败", http.StatusInternalServerError)
@@ -50,85 +91,191 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
Month: int(today.Month()),
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
Top: subViews(stats.Top, today, warn),
DueToday: subViews(stats.DueTodaySubs, today, warn),
Refresh: s.screenRefresh(),
Version: s.cfg.Version,
}
if len(data.DueToday) > 6 {
data.DueToday = data.DueToday[:6]
HeatMode: heatMode,
PkgID: pkgID,
}
// 本月热力
// 今日到期(可按套餐过滤)
dueToday := []store.Subscription{}
for _, sub := range stats.DueTodaySubs {
if pkgID > 0 && sub.PackageID != pkgID {
continue
}
dueToday = append(dueToday, sub)
}
data.DueToday = subViews(dueToday, today, warn)
// 本月热力:默认统计“每日待完成的单子”,可切换到“到期”
monthStart := domain.MonthStart(today.Year(), today.Month())
monthEnd := domain.MonthEnd(today.Year(), today.Month())
gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart))
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
gridEnd := domain.AddDays(gridStart, weeks*7-1)
list, checkins, urgent, err := s.screenCalSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID)
if err != nil {
http.Error(w, "读取大屏日历失败", http.StatusInternalServerError)
return
}
for wi := 0; wi < weeks; wi++ {
week := make([]screenDay, 0, 7)
for di := 0; di < 7; di++ {
d := domain.AddDays(gridStart, wi*7+di)
ds := domain.Date(d)
due := stats.Heat[ds]
count, urgentN := 0, 0
strip := []string{}
seenColor := map[string]bool{}
for _, cs := range list {
if heatMode == "due" {
if cs.sub.EndDate == ds {
count++
}
continue
}
if !cs.covers(ds) {
continue
}
ci, has := checkins[cs.sub.ID][ds]
if has && (ci.Status == "done" || ci.Status == "skipped") {
continue // “待完成”只统计还没处理的
}
count++
if urgent[cs.sub.ID][ds] {
urgentN++
}
color := cs.sub.AssigneeColor
if color == "" {
color = "#8A8F98"
}
if !seenColor[color] {
seenColor[color] = true
strip = append(strip, color)
}
}
if d.Month() == today.Month() {
data.HeatTotal += count
}
week = append(week, screenDay{
Date: ds,
Day: d.Day(),
InMonth: d.Month() == today.Month(),
IsToday: ds == domain.Date(today),
IsPast: d.Before(today),
Due: due,
Heat: heatLevel(due),
Count: count,
Urgent: urgentN,
Heat: heatLevel(count),
Strip: strip,
})
}
data.Weeks = append(data.Weeks, week)
}
// 今日待打(加急置顶)
rows, err := s.pendingTasks(domain.Date(today), nil, pkgID)
if err == nil {
for _, row := range rows {
if row.Done || row.Skipped {
continue
}
data.TodayList = append(data.TodayList, screenTask{TaskRow: row})
if row.Urgent {
data.TodayUrgent++
}
}
}
data.TodayTasks = len(data.TodayList)
for _, row := range rows {
if row.Done {
data.TodayDone++
}
}
// 最近追加(底部细条)
if adj, err := s.db.RecentAdjustments(6); err == nil {
data.Recent = adj
}
list, done := s.todayTaskList(today)
data.TodayTasks = len(list)
data.TodayDone = done
data.TodayList = subViews(list, today, warn)
// 套餐标签
pkgs, _ := s.db.ListPackages(true)
for _, p := range pkgs {
if p.CalendarSeparate && !p.Archived {
if !data.HasTabs {
data.HasTabs = true
data.Tabs = append(data.Tabs, PkgTab{ID: 0, Name: "全部", Color: "#8A8F98"})
}
data.Tabs = append(data.Tabs, PkgTab{ID: p.ID, Name: p.Name, Color: p.Color})
}
}
data.Theme = themeFromRequest(r)
p := s.newPage(r, "大屏", "screen")
p.Theme = data.Theme
p.D = data
s.render(w, r, "screen", p)
}
// todayTaskList 返回今天应服务的订阅与已完成数量。
func (s *Server) todayTaskList(today time.Time) ([]store.Subscription, int) {
ts := domain.Date(today)
subs, err := s.db.CalendarSubs(ts, ts)
// handleScreenDay 返回某天的待办明细,供大屏悬停浮层使用。
func (s *Server) handleScreenDay(w http.ResponseWriter, r *http.Request) {
date := r.URL.Query().Get("date")
if _, err := domain.ParseDate(date); err != nil {
http.Error(w, "日期不正确", http.StatusBadRequest)
return
}
pkgID := int64(s.queryInt(r, "pkg", 0))
rows, err := s.pendingTasks(date, nil, pkgID)
if err != nil {
return nil, 0
http.Error(w, "读取失败", http.StatusInternalServerError)
return
}
ids := make([]int64, 0, len(subs))
for _, sub := range subs {
ids = append(ids, sub.ID)
type item struct {
Tail string `json:"tail"`
Name string `json:"name"`
Pkg string `json:"pkg"`
Color string `json:"color"`
Remain string `json:"remain"`
Urgent bool `json:"urgent"`
Done bool `json:"done"`
Who string `json:"who"`
WhoCol string `json:"who_color"`
}
pauses, _ := s.db.PausesForSubs(ids)
checkins, _ := s.db.CheckinsBetween(ts, ts)
warn := s.warnDays()
out := []store.Subscription{}
done := 0
for _, sub := range subs {
if sub.Status == domain.StatusClosed {
out := struct {
Date string `json:"date"`
Total int `json:"total"`
Done int `json:"done"`
Urgent int `json:"urgent"`
More int `json:"more"`
Items []item `json:"items"`
}{Date: date}
const limit = 60
for _, row := range rows {
out.Total++
if row.Done || row.Skipped {
out.Done++
}
if row.Urgent {
out.Urgent++
}
if len(out.Items) >= limit {
out.More++
continue
}
if sub.StartDate > ts || sub.EndDate == "" || sub.EndDate < ts {
continue
out.Items = append(out.Items, item{
Tail: row.CustomerTail,
Name: row.CustomerName,
Pkg: row.PackageName,
Color: row.PackageColor,
Remain: row.RemainText,
Urgent: row.Urgent,
Done: row.Done || row.Skipped,
Who: row.AssigneeLabel(),
WhoCol: row.AssigneeHex(),
})
}
cs := calSub{sub: sub, pauses: pauses[sub.ID], hasEnd: true, eval: sub.Eval(today, warn)}
if sub.Unit == domain.UnitCount && cs.eval.RemainingCount <= 0 {
continue
}
if cs.pausedOn(ts) {
continue
}
out = append(out, sub)
if ci, ok := checkins[sub.ID][ts]; ok && ci.Status == "done" {
done++
}
}
return out, done
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(out)
}
+202
View File
@@ -0,0 +1,202 @@
package web
import (
"fmt"
"net/http"
"strconv"
"strings"
"daili/internal/domain"
)
// pendingTasks 返回某天“应服务”的订阅行(day 型在区间内、count 型有剩余),
// customerIDs 非空时只保留这些客户;pkgID>0 时只保留该套餐。
func (s *Server) pendingTasks(date string, customerIDs map[int64]bool, pkgID int64) ([]TaskRow, error) {
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(date, date, pkgID)
if err != nil {
return nil, err
}
today := domain.Today()
warn := s.warnDays()
rows := []TaskRow{}
for _, sub := range subs {
if customerIDs != nil && !customerIDs[sub.CustomerID] {
continue
}
cs := calSub{sub: sub, pauses: pauses[sub.ID], eval: sub.Eval(today, warn)}
if sub.EndDate != "" {
cs.hasEnd = true
cs.end = domain.ParseDateOr(sub.EndDate, today)
}
if !cs.covers(date) {
continue
}
row := TaskRow{SubView: newSubView(sub, today, warn), Urgent: urgent[sub.ID][date]}
if ci, ok := checkins[sub.ID][date]; ok {
row.CheckinID = ci.ID
row.Done = ci.Status == "done"
row.Skipped = ci.Status == "skipped"
row.Note = ci.Note
row.Operator = ci.OperatorName
}
rows = append(rows, row)
}
sortTasks(rows)
return rows, nil
}
// handleQuickCheckin 输入手机尾号直接完成当日服务:
// 命中 1 项直接完成;命中多项则跳到日历的选择面板。
func (s *Server) handleQuickCheckin(w http.ResponseWriter, r *http.Request) {
tail := strings.TrimSpace(r.PostFormValue("tail"))
date := strings.TrimSpace(r.PostFormValue("date"))
pkgID := int64(s.formInt(r, "pkg", 0))
if _, err := domain.ParseDate(date); err != nil {
date = domain.Date(domain.Today())
}
back := backURL(r, "/calendar?d="+date)
if tail == "" {
s.redirect(w, r, back, "请输入手机尾号")
return
}
customers, err := s.db.CustomersByTail(tail)
if err != nil {
s.redirect(w, r, back, "查询客户失败:"+err.Error())
return
}
if len(customers) == 0 {
s.redirect(w, r, back, "没有找到尾号 "+tail+" 的客户")
return
}
ids := map[int64]bool{}
for _, c := range customers {
ids[c.ID] = true
}
rows, err := s.pendingTasks(date, ids, pkgID)
if err != nil {
s.redirect(w, r, back, "读取待完成失败:"+err.Error())
return
}
pending := make([]TaskRow, 0, len(rows))
for _, row := range rows {
if !row.Done && !row.Skipped {
pending = append(pending, row)
}
}
switch len(pending) {
case 0:
if len(rows) > 0 {
s.redirect(w, r, back, "尾号 "+tail+" 今天的服务已经处理过了")
return
}
s.redirect(w, r, back, "尾号 "+tail+" 今天没有待完成的服务")
return
case 1:
row := pending[0]
u := s.currentUser(r)
var op int64
if u != nil {
op = u.ID
}
if err := s.db.SaveCheckin(row.ID, date, "done", "", op); err != nil {
s.redirect(w, r, back, "完成失败:"+err.Error())
return
}
s.logOp(r, "快速完成", "subscription", row.ID,
fmt.Sprintf("尾号 %s:%s %s 完成 %s(%s)", tail, row.CustomerTail, row.CustomerName, row.PackageName, date))
s.redirect(w, r, back, "已为 "+row.CustomerTail+" "+row.CustomerName+" 完成「"+row.PackageName+"」")
return
default:
// 多项:回到日历并打开选择面板
target := stripParam(back, "pick")
sep := "?"
if strings.Contains(target, "?") {
sep = "&"
}
s.redirect(w, r, target+sep+"pick="+urlQueryEscape(tail), fmt.Sprintf("尾号 %s 有 %d 项待完成,请勾选", tail, len(pending)))
return
}
}
// handleCheckinBatch 批量完成选中的订阅(同日)。
func (s *Server) handleCheckinBatch(w http.ResponseWriter, r *http.Request) {
date := strings.TrimSpace(r.PostFormValue("date"))
if _, err := domain.ParseDate(date); err != nil {
date = domain.Date(domain.Today())
}
back := backURL(r, "/calendar?d="+date)
raw := r.PostForm["sub_ids"]
if len(raw) == 0 {
s.redirect(w, r, back, "没有选中任何项")
return
}
u := s.currentUser(r)
var op int64
if u != nil {
op = u.ID
}
done, failed := 0, 0
var lastErr string
for _, v := range raw {
id, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
failed++
continue
}
if err := s.db.SaveCheckin(id, date, "done", "", op); err != nil {
failed++
lastErr = err.Error()
continue
}
done++
}
if done > 0 {
s.logOp(r, "批量完成", "checkin", 0, fmt.Sprintf("%s 一次完成 %d 项", date, done))
}
msg := fmt.Sprintf("已完成 %d 项", done)
if failed > 0 {
msg += fmt.Sprintf(",%d 项失败(%s)", failed, lastErr)
}
s.redirect(w, r, stripParam(back, "pick"), msg)
}
// handleUrgentToggle 勾选/取消某天某订阅的加急。
func (s *Server) handleUrgentToggle(w http.ResponseWriter, r *http.Request) {
subID := int64(s.formInt(r, "sub_id", 0))
date := strings.TrimSpace(r.PostFormValue("date"))
urgent := r.PostFormValue("urgent") == "1"
back := backURL(r, "/calendar")
if subID <= 0 {
s.redirect(w, r, back, "参数不正确")
return
}
if err := s.db.SetUrgent(subID, date, urgent); err != nil {
s.redirect(w, r, back, "设置加急失败:"+err.Error())
return
}
word := "已取消加急"
if urgent {
word = "已标记加急"
}
if sub, err := s.db.Sub(subID); err == nil {
s.logOp(r, word, "subscription", subID, sub.SubTitle()+" "+date+" "+word)
}
s.redirect(w, r, back, word)
}
func urlQueryEscape(v string) string {
var b strings.Builder
for _, c := range []byte(v) {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~' {
b.WriteByte(c)
continue
}
b.WriteString("%")
const hex = "0123456789ABCDEF"
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&15])
}
return b.String()
}
+25
View File
@@ -31,6 +31,7 @@ var staticFS embed.FS
const (
cookieSession = "daili_sid"
cookieFlash = "daili_flash"
cookieTheme = "daili_theme"
sessionTTL = 7 * 24 * time.Hour
pageSize = 20
)
@@ -103,6 +104,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /customers/{id}", s.auth(s.handleCustomerDetail))
mux.HandleFunc("GET /customers/{id}/edit", s.auth(s.handleCustomerEditPage))
mux.HandleFunc("POST /customers/{id}/edit", s.auth(s.handleCustomerUpdate))
mux.HandleFunc("POST /customers/{id}/assignee", s.auth(s.handleCustomerAssignee))
mux.HandleFunc("POST /customers/{id}/subscriptions", s.auth(s.handleSubCreate))
mux.HandleFunc("GET /subscriptions", s.auth(s.handleSubscriptions))
mux.HandleFunc("POST /subscriptions/{id}/extend", s.auth(s.handleSubExtend))
@@ -112,6 +114,9 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /subscriptions/{id}/reopen", s.auth(s.handleSubAction("reopen")))
mux.HandleFunc("POST /subscriptions/{id}/checkin", s.auth(s.handleCheckinSave))
mux.HandleFunc("POST /checkins/{id}/delete", s.auth(s.handleCheckinDelete))
mux.HandleFunc("POST /checkins/batch", s.auth(s.handleCheckinBatch))
mux.HandleFunc("POST /checkins/quick", s.auth(s.handleQuickCheckin))
mux.HandleFunc("POST /tasks/urgent", s.auth(s.handleUrgentToggle))
mux.HandleFunc("GET /packages", s.auth(s.handlePackages))
mux.HandleFunc("GET /packages/new", s.auth(s.handlePackageNewPage))
mux.HandleFunc("POST /packages", s.auth(s.handlePackageCreate))
@@ -121,8 +126,10 @@ func (s *Server) routes() http.Handler {
if s.cfg.ScreenPublic {
// 大屏免登录:仅局域网部署时使用,内容为只读看板
mux.HandleFunc("GET /screen", s.handleScreen)
mux.HandleFunc("GET /api/screen/day", s.handleScreenDay)
} else {
mux.HandleFunc("GET /screen", s.auth(s.handleScreen))
mux.HandleFunc("GET /api/screen/day", s.auth(s.handleScreenDay))
}
mux.HandleFunc("GET /logs", s.auth(s.handleLogs))
mux.HandleFunc("GET /settings", s.auth(s.handleSettings))
@@ -130,6 +137,9 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /settings/password", s.auth(s.handlePasswordChange))
mux.HandleFunc("POST /settings/users", s.auth(s.handleUserCreate))
mux.HandleFunc("POST /settings/users/{id}/toggle", s.auth(s.handleUserToggle))
mux.HandleFunc("POST /settings/users/{id}/color", s.auth(s.handleUserColor))
mux.HandleFunc("GET /settings/users/{id}/edit", s.auth(s.handleUserEditPage))
mux.HandleFunc("POST /settings/users/{id}/edit", s.auth(s.handleUserEditSave))
mux.HandleFunc("POST /settings/backup", s.auth(s.handleBackupNow))
mux.HandleFunc("GET /export/{kind}", s.auth(s.handleExport))
@@ -325,6 +335,7 @@ type page struct {
DueToday int
Due3 int
Addr string
Theme string // "" = 跟随系统;light / dark
D any
}
@@ -357,6 +368,7 @@ func (s *Server) newPage(r *http.Request, title, nav string) *page {
Version: s.cfg.Version,
Query: r.URL.Query().Get("q"),
Next: r.URL.Query().Get("next"),
Theme: themeFromRequest(r),
}
if u != nil {
if due, due3, err := s.db.Badges(today); err == nil {
@@ -366,6 +378,19 @@ func (s *Server) newPage(r *http.Request, title, nav string) *page {
return p
}
// themeFromRequest 读取主题偏好:"" = 跟随系统,light / dark = 手动指定。
func themeFromRequest(r *http.Request) string {
c, err := r.Cookie(cookieTheme)
if err != nil {
return ""
}
switch c.Value {
case "light", "dark":
return c.Value
}
return ""
}
func (s *Server) popFlash(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie(cookieFlash)
if err != nil || c.Value == "" {
+255 -42
View File
@@ -1,11 +1,13 @@
/* ============================================================
代打台 · 管理端(浅色「接单台」)
纸感底色 + 墨色文字 + 松墨绿主色;色签(ticket)是唯一强装饰
代打台 · 管理端
主题::root 为亮色;暗色由系统偏好或 [data-theme] 覆盖
设计语汇:纸感底色 + 墨色文字 + 松墨绿主色;色签(ticket)是唯一强装饰
============================================================ */
:root {
--paper: #f7f6f2;
--surface: #ffffff;
--surface-2: #fcfcfa;
--ink: #1a1c21;
--ink-2: #43474f;
--muted: #7a7e86;
@@ -20,11 +22,60 @@
--warn-soft: #fbf1e3;
--info: #41556b;
--info-soft: #eaeef2;
--hover: #f2f7f4;
--shadow: 0 12px 32px -26px rgba(26, 28, 33, 0.8);
--radius: 3px;
--mono: "Bahnschrift", "Consolas", "SF Mono", ui-monospace, monospace;
--sans: "Microsoft YaHei UI", "PingFang SC", "Hiragino Sans GB", system-ui, -apple-system, "Segoe UI", sans-serif;
}
/* 暗色令牌:系统偏好 + 手动选择共用 */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--paper: #111317;
--surface: #181b20;
--surface-2: #1e2228;
--ink: #e8eaee;
--ink-2: #c2c7cf;
--muted: #8b929c;
--line: #2b3038;
--line-2: #23272e;
--accent: #6bb8a3;
--accent-ink: #8fd0bd;
--accent-soft: rgba(107, 184, 163, 0.16);
--danger: #e8776f;
--danger-soft: rgba(232, 119, 111, 0.16);
--warn: #e0a862;
--warn-soft: rgba(224, 168, 98, 0.16);
--info: #93a7bd;
--info-soft: rgba(147, 167, 189, 0.16);
--hover: #1f242b;
--shadow: 0 12px 32px -22px rgba(0, 0, 0, 0.9);
}
}
:root[data-theme="dark"] {
--paper: #111317;
--surface: #181b20;
--surface-2: #1e2228;
--ink: #e8eaee;
--ink-2: #c2c7cf;
--muted: #8b929c;
--line: #2b3038;
--line-2: #23272e;
--accent: #6bb8a3;
--accent-ink: #8fd0bd;
--accent-soft: rgba(107, 184, 163, 0.16);
--danger: #e8776f;
--danger-soft: rgba(232, 119, 111, 0.16);
--warn: #e0a862;
--warn-soft: rgba(224, 168, 98, 0.16);
--info: #93a7bd;
--info-soft: rgba(147, 167, 189, 0.16);
--hover: #1f242b;
--shadow: 0 12px 32px -22px rgba(0, 0, 0, 0.9);
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
@@ -45,15 +96,17 @@ body {
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; text-underline-offset: 2px; }
h1, h2, h3 { margin: 0; font-weight: 700; letter-spacing: -0.01em; }
h1, h2, h3, h4 { margin: 0; font-weight: 700; letter-spacing: -0.01em; }
h1 { font-size: 22px; }
h2 { font-size: 15px; }
h3 { font-size: 13px; }
h4 { font-size: 13px; }
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }
.small { font-size: 12px; }
.muted { color: var(--muted); }
.right { text-align: right; }
.text-urgent { color: var(--danger); }
/* ---------- 顶栏 ---------- */
@@ -61,7 +114,7 @@ h3 { font-size: 13px; }
position: sticky; top: 0; z-index: 40;
display: flex; align-items: center; gap: 14px;
padding: 0 20px; height: 56px;
background: rgba(247, 246, 242, 0.94);
background: color-mix(in srgb, var(--paper) 92%, transparent);
backdrop-filter: blur(6px);
border-bottom: 1px solid var(--line);
}
@@ -71,10 +124,10 @@ h3 { font-size: 13px; }
.brand-mark {
display: grid; place-items: center;
width: 26px; height: 26px;
background: var(--accent); color: #fff;
background: var(--accent); color: var(--paper);
font-size: 14px; font-weight: 700;
border-radius: 2px;
box-shadow: 2px 2px 0 rgba(47, 93, 80, 0.18);
box-shadow: 2px 2px 0 color-mix(in srgb, var(--accent) 22%, transparent);
}
.brand-name { font-weight: 700; letter-spacing: 0.06em; }
@@ -102,7 +155,7 @@ h3 { font-size: 13px; }
color: var(--ink-2); font-size: 13px;
}
.nav a:hover { background: var(--surface); text-decoration: none; }
.nav a.on { background: var(--accent); color: #fff; }
.nav a.on { background: var(--accent); color: var(--paper); }
.nav a.nav-screen { color: var(--accent); }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
@@ -119,6 +172,24 @@ h3 { font-size: 13px; }
.pill-due b { color: var(--danger); }
.pill-warn b { color: var(--warn); }
.theme-toggle {
display: inline-grid; place-items: center;
width: 30px; height: 28px; padding: 0;
border: 1px solid var(--line); border-radius: 999px;
background: var(--surface); color: var(--ink-2);
cursor: pointer; font-size: 14px; line-height: 1;
}
.theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
.theme-toggle .ic-sun { display: none; }
:root[data-theme="dark"] .theme-toggle .ic-sun { display: inline; }
:root[data-theme="dark"] .theme-toggle .ic-moon { display: none; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .theme-toggle .ic-sun { display: inline; }
:root:not([data-theme="light"]) .theme-toggle .ic-moon { display: none; }
}
.auth-theme { position: fixed; top: 14px; right: 14px; z-index: 50; background: var(--surface); }
.me { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.me a { color: var(--ink-2); }
.link-btn {
@@ -134,9 +205,9 @@ h3 { font-size: 13px; }
left: 50%; top: 68px; transform: translateX(-50%);
max-width: min(680px, calc(100vw - 32px));
padding: 9px 16px;
background: var(--accent); color: #fff;
background: var(--accent); color: var(--paper);
border-radius: var(--radius);
box-shadow: 0 6px 24px rgba(26, 28, 33, 0.16);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
font-size: 13px;
animation: flash-in 0.22s ease-out;
}
@@ -184,10 +255,11 @@ main.with-top { padding-top: 22px; }
}
.btn:hover { border-color: var(--ink-2); text-decoration: none; }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--paper); }
.btn-primary:hover { background: var(--accent-ink); border-color: var(--accent-ink); }
.btn-ghost { background: transparent; border-color: transparent; color: var(--ink-2); }
.btn-ghost:hover { background: var(--surface); border-color: var(--line); }
.btn-urgent { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
.btn-sm { height: 28px; padding: 0 10px; font-size: 12px; }
.btn-square { width: 34px; padding: 0; font-size: 18px; line-height: 1; }
.btn-block { width: 100%; }
@@ -210,7 +282,16 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
}
.field input[type="color"] { width: 100%; height: 34px; padding: 2px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); cursor: pointer; }
.fieldset { border: 0; padding: 0; }
.check-line {
display: flex; gap: 10px; align-items: flex-start;
padding: 10px 12px; margin-bottom: 14px;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); cursor: pointer;
}
.check-line input { margin-top: 3px; accent-color: var(--accent); }
.check-line span { font-size: 13px; font-weight: 600; color: var(--ink-2); }
.check-line small { display: block; font-weight: 400; color: var(--muted); font-size: 12px; margin-top: 2px; }
.radios { display: grid; gap: 8px; }
.radios label {
@@ -273,7 +354,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
background: var(--surface); border: 1px solid var(--line);
border-top: 3px solid var(--accent);
padding: 28px;
box-shadow: 0 18px 40px -32px rgba(26, 28, 33, 0.5);
box-shadow: var(--shadow);
}
.auth-brand { display: flex; align-items: center; gap: 8px; font-weight: 700; letter-spacing: 0.06em; margin-bottom: 18px; }
.auth-card h1 { font-size: 20px; margin-bottom: 4px; }
@@ -281,6 +362,32 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.auth-foot { margin-top: 16px !important; text-align: center; }
.form .btn-block { margin-top: 4px; }
/* ---------- 套餐标签(日历/大屏共用) ---------- */
.pkg-tabs {
display: flex; gap: 6px; margin-bottom: 14px; flex-wrap: wrap;
padding-bottom: 8px; border-bottom: 1px solid var(--line);
}
.pkg-tabs a {
display: inline-flex; align-items: center; gap: 6px;
height: 30px; padding: 0 12px;
border: 1px solid var(--line); border-radius: 999px;
background: var(--surface); color: var(--ink-2);
font-size: 13px; text-decoration: none;
}
.pkg-tabs a i {
width: 8px; height: 8px; border-radius: 50%;
background: var(--tab, var(--accent)); display: inline-block;
}
.pkg-tabs a em {
font-family: var(--mono); font-style: normal; font-size: 11px;
color: var(--muted); background: var(--paper);
border-radius: 999px; padding: 0 5px;
}
.pkg-tabs a:hover { border-color: var(--accent); text-decoration: none; }
.pkg-tabs a.on { background: var(--accent); border-color: var(--accent); color: var(--paper); }
.pkg-tabs a.on em { background: color-mix(in srgb, var(--paper) 25%, transparent); color: var(--paper); }
/* ---------- 日历 ---------- */
.cal-page { max-width: 1400px; margin: 0 auto; }
@@ -296,6 +403,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
font-family: var(--mono); font-style: normal; font-size: 16px;
color: var(--ink); margin-right: 4px;
}
.cal-stats .text-urgent em { color: var(--danger); }
.cal-body { display: grid; grid-template-columns: minmax(0, 1fr) 372px; gap: 16px; align-items: start; }
@@ -326,28 +434,34 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
transition: background 0.12s ease;
}
.cal-cell:nth-child(7n) { border-right: 0; }
.cal-cell:hover { background: #f2f7f4; text-decoration: none; }
.cal-cell:hover { background: var(--hover); text-decoration: none; }
.cal-cell.out { opacity: 0.4; }
.cal-cell.weekend .dnum { color: var(--muted); }
.cal-cell.sel { box-shadow: inset 0 0 0 2px var(--accent); z-index: 2; }
.cal-cell.today .dnum {
background: var(--accent); color: #fff;
background: var(--accent); color: var(--paper);
border-radius: 50%; width: 22px; height: 22px;
display: inline-grid; place-items: center;
}
.cal-cell.has-due::after {
content: ""; position: absolute; left: 6px; right: 6px; bottom: 3px;
border-bottom: 1px dashed rgba(180, 35, 31, 0.45);
border-bottom: 1px dashed color-mix(in srgb, var(--danger) 45%, transparent);
}
.cal-cell.heat-1 { background: rgba(47, 93, 80, 0.045); }
.cal-cell.heat-2 { background: rgba(47, 93, 80, 0.085); }
.cal-cell.heat-3 { background: rgba(47, 93, 80, 0.13); }
.cal-cell.heat-4 { background: rgba(47, 93, 80, 0.19); }
.cal-cell.heat-0:hover, .cal-cell.heat-1:hover { background: #f2f7f4; }
.cal-cell.heat-1 { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.cal-cell.heat-2 { background: color-mix(in srgb, var(--accent) 9%, transparent); }
.cal-cell.heat-3 { background: color-mix(in srgb, var(--accent) 14%, transparent); }
.cal-cell.heat-4 { background: color-mix(in srgb, var(--accent) 20%, transparent); }
.cal-cell.heat-0:hover, .cal-cell.heat-1:hover { background: var(--hover); }
.cell-top { display: flex; align-items: center; justify-content: space-between; gap: 4px; }
.cell-badges { display: inline-flex; align-items: center; gap: 4px; }
.dnum { font-family: var(--mono); font-size: 13px; font-weight: 700; line-height: 1.4; }
.urgent-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--danger); display: inline-block;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 22%, transparent);
}
.due-badge {
display: inline-flex; align-items: baseline; gap: 2px;
font-size: 10px; padding: 0 5px; line-height: 16px;
@@ -365,7 +479,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
padding: 1px 5px;
font-size: 10.5px; line-height: 16px;
border-left: 3px solid var(--tag, var(--accent));
background: color-mix(in srgb, var(--tag, var(--accent)) 11%, #fff);
background: color-mix(in srgb, var(--tag, var(--accent)) 13%, var(--surface));
border-radius: 1px 2px 2px 1px;
white-space: nowrap;
color: var(--ink-2);
@@ -377,6 +491,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.ticket.done::after { content: "✓"; color: var(--accent); font-size: 10px; }
.ticket.skipped { opacity: 0.4; text-decoration: line-through; }
.ticket.more { border-left-color: var(--line); background: var(--paper); color: var(--muted); }
.ticket.urgent { border-left-color: var(--danger); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--danger) 35%, transparent); }
/* ---------- 当日抽屉 ---------- */
@@ -400,7 +515,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.drawer-block h3 {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--ink-2); margin-bottom: 8px;
text-transform: none; letter-spacing: 0.02em;
letter-spacing: 0.02em;
}
.drawer-block h3 em {
font-family: var(--mono); font-style: normal; font-size: 11px;
@@ -409,6 +524,39 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
}
.drawer-block p { margin: 0; font-size: 12px; }
/* 快速完成 */
.quick-form { display: flex; gap: 6px; margin-bottom: 4px; }
.quick-form input[type="text"] {
flex: 1 1 auto; min-width: 0; height: 30px; padding: 0 9px;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); font: inherit; font-size: 13px; font-family: var(--mono);
color: var(--ink);
}
.quick-form input[type="text"]:focus {
outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
}
.quick-hint { margin: 0 0 10px !important; font-size: 11px; line-height: 1.4; }
/* 选择面板 */
.picker {
margin: 8px 0 12px; padding: 10px;
border: 1px solid var(--accent);
border-radius: var(--radius);
background: var(--surface-2);
}
.picker-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
.picker-head h4 { font-size: 13px; }
.pick-list { list-style: none; margin: 0 0 8px; padding: 0; }
.pick-list li { border-bottom: 1px dashed var(--line-2); }
.pick-list li:last-child { border-bottom: 0; }
.pick-list label {
display: flex; align-items: center; gap: 7px;
padding: 6px 0; cursor: pointer; font-size: 12px;
}
.pick-list input[type="checkbox"] { accent-color: var(--accent); }
.pick-pkg { color: var(--ink-2); }
.pick-remain { margin-left: auto; font-family: var(--mono); color: var(--muted); font-size: 11px; }
.due-list, .task-list, .mini-due, .checkin-list { list-style: none; margin: 0; padding: 0; }
.due-list li {
@@ -422,14 +570,23 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.task { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; padding: 8px 0; border-bottom: 1px dashed var(--line-2); }
.task:last-child { border-bottom: 0; }
.task.urgent { border-left: 3px solid var(--danger); padding-left: 8px; margin-left: -8px; border-radius: 2px; background: color-mix(in srgb, var(--danger) 5%, transparent); }
.task-main { min-width: 0; flex: 1 1 auto; }
.task-title { display: flex; align-items: center; gap: 6px; color: var(--ink); }
.task-title { display: flex; align-items: center; gap: 6px; color: var(--ink); flex-wrap: wrap; }
.task-meta { display: flex; gap: 6px; font-size: 11px; color: var(--muted); margin-top: 2px; }
.task-note { font-size: 11px; color: var(--ink-2); margin-top: 2px; }
.task-actions { display: flex; gap: 4px; align-items: center; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; max-width: 170px; }
.task-actions { display: flex; gap: 4px; align-items: center; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; max-width: 190px; }
.task.done .task-title, .task.skipped .task-title { opacity: 0.5; }
.task.skipped .task-title .ticket { text-decoration: line-through; }
.tag-urgent {
display: inline-flex; align-items: center;
height: 17px; padding: 0 5px;
font-size: 10px; line-height: 1;
color: #fff; background: var(--danger); border-radius: 2px;
letter-spacing: 0.02em; white-space: nowrap;
}
.mini-due li { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; align-items: center; padding: 4px 0; font-size: 12px; }
.sm-date { font-family: var(--mono); font-size: 11px; color: var(--muted); }
@@ -442,12 +599,12 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
border-radius: 2px; border: 1px solid transparent;
white-space: nowrap;
}
.chip-active { background: var(--accent-soft); color: var(--accent-ink); border-color: rgba(47, 93, 80, 0.2); }
.chip-pending { background: var(--info-soft); color: var(--info); border-color: rgba(65, 85, 107, 0.2); }
.chip-paused { background: var(--info-soft); color: var(--info); border-color: rgba(65, 85, 107, 0.2); }
.chip-expired { background: var(--danger-soft); color: var(--danger); border-color: rgba(180, 35, 31, 0.22); }
.chip-usedup { background: var(--warn-soft); color: var(--warn); border-color: rgba(168, 94, 18, 0.22); }
.chip-closed { background: #f0efeb; color: var(--muted); border-color: var(--line); }
.chip-active { background: var(--accent-soft); color: var(--accent-ink); border-color: color-mix(in srgb, var(--accent) 25%, transparent); }
.chip-pending { background: var(--info-soft); color: var(--info); border-color: color-mix(in srgb, var(--info) 25%, transparent); }
.chip-paused { background: var(--info-soft); color: var(--info); border-color: color-mix(in srgb, var(--info) 25%, transparent); }
.chip-expired { background: var(--danger-soft); color: var(--danger); border-color: color-mix(in srgb, var(--danger) 25%, transparent); }
.chip-usedup { background: var(--warn-soft); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 25%, transparent); }
.chip-closed { background: var(--paper); color: var(--muted); border-color: var(--line); }
.chip-plain { background: var(--paper); color: var(--ink-2); border-color: var(--line); }
.tagchip {
@@ -481,7 +638,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
}
.table td { padding: 9px 12px; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
.table tbody tr:last-child td { border-bottom: 0; }
.table tbody tr:hover { background: #fbfcfb; }
.table tbody tr:hover { background: var(--hover); }
.table.tight td, .table.tight th { padding: 7px 10px; }
.table .col-dot { width: 28px; }
.table tr.archived { opacity: 0.6; }
@@ -501,7 +658,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
display: flex; flex-direction: column; align-items: center; gap: 12px;
padding: 56px 20px; text-align: center;
border: 1px dashed var(--line); border-radius: var(--radius);
background: rgba(255, 255, 255, 0.55);
background: color-mix(in srgb, var(--surface) 60%, transparent);
}
.empty p { margin: 0; color: var(--muted); font-size: 13px; }
.empty-mark {
@@ -553,7 +710,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.subcard-foot {
display: flex; gap: 4px; flex-wrap: wrap;
padding: 8px 12px; border-top: 1px dashed var(--line);
background: #fcfcfa;
background: var(--surface-2);
}
.timeline { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); padding: 16px 16px 10px; }
@@ -567,7 +724,9 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.tl-bar.st-closed { background: var(--muted); opacity: 0.6; }
.tl-pause {
position: absolute; top: 4px; height: 12px;
background: repeating-linear-gradient(135deg, #c9c7c0 0 4px, #e6e4dd 4px 8px);
background: repeating-linear-gradient(135deg,
color-mix(in srgb, var(--muted) 45%, transparent) 0 4px,
color-mix(in srgb, var(--muted) 18%, transparent) 4px 8px);
border-radius: 1px;
}
.tl-due { position: absolute; top: 1px; bottom: 1px; width: 2px; background: var(--danger); }
@@ -583,7 +742,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
border: 1px solid var(--accent);
border-radius: var(--radius);
background: var(--surface);
box-shadow: 0 12px 32px -26px rgba(26, 28, 33, 0.8);
box-shadow: var(--shadow);
scroll-margin-top: 80px;
}
.drawer .extend { margin: 12px 14px; }
@@ -591,7 +750,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
padding: 12px 14px;
background: var(--accent-soft);
border-bottom: 1px solid rgba(47, 93, 80, 0.2);
border-bottom: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
}
.extend-head h3 { font-size: 14px; }
.extend-head p { margin: 2px 0 0; font-size: 11px; }
@@ -620,12 +779,12 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
background: transparent; font: inherit; font-size: 11px; font-family: var(--mono);
color: var(--ink-2); cursor: pointer; padding: 0;
}
.miniday:hover:not(:disabled) { background: var(--accent-soft); border-color: rgba(47, 93, 80, 0.3); }
.miniday.out { color: #c3c1ba; }
.miniday.old { background: var(--warn-soft); border-color: rgba(168, 94, 18, 0.35); font-weight: 700; }
.miniday:hover:not(:disabled) { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
.miniday.out { color: color-mix(in srgb, var(--muted) 55%, transparent); }
.miniday.old { background: var(--warn-soft); border-color: color-mix(in srgb, var(--warn) 35%, transparent); font-weight: 700; }
.miniday.today { outline: 1px solid var(--accent); }
.miniday.picked { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 700; }
.miniday:disabled { color: #d6d4cc; cursor: not-allowed; }
.miniday.picked { background: var(--accent); color: var(--paper); border-color: var(--accent); font-weight: 700; }
.miniday:disabled { color: color-mix(in srgb, var(--muted) 45%, transparent); cursor: not-allowed; }
/* ---------- 设置页杂项 ---------- */
@@ -660,7 +819,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.topbar-right { order: 2; margin-left: auto; }
main { padding: 0 12px 32px; }
.cal-grid { overflow-x: auto; }
.cal-week, .cal-dow { min-width: 680px; }
.cal-week, .cal-dow { min-width: 640px; }
.cal-cell { min-height: 96px; }
.grid-2, .grid-3 { grid-template-columns: 1fr; }
.cust-tail { font-size: 24px; }
@@ -686,3 +845,57 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
/* ---------- 打手颜色 ---------- */
/* 日历格左侧竖条:同一天有几种打手就分几段 */
.asg-strip {
position: absolute; left: 0; top: 0; bottom: 0;
display: flex; flex-direction: column;
width: 5px; overflow: hidden;
border-radius: 2px 0 0 2px;
z-index: 1;
}
.asg-strip i { display: block; flex: 1 1 0; min-height: 3px; background: var(--muted); }
.asg-strip.sm { width: 3px; }
.cal-cell { padding-left: 12px; }
/* 打手标签:小圆点 + 名字 */
.asg {
display: inline-flex; align-items: center; gap: 4px;
font-size: 11px; color: var(--ink-2); white-space: nowrap;
}
.asg i {
width: 8px; height: 8px; border-radius: 2px;
display: inline-block; flex: 0 0 auto;
}
.asg.none { color: var(--muted); }
.asg.none i { background: var(--muted); opacity: 0.55; }
/* 待办行左侧打手色(加急时红底仍在,色条让位给背景) */
.task { border-left: 3px solid var(--asg, transparent); padding-left: 8px; margin-left: -3px; }
.task.urgent { border-left-color: var(--danger); }
/* 设置页颜色表单 */
.color-form { display: flex; align-items: center; gap: 6px; }
.color-form input[type="color"] {
width: 34px; height: 26px; padding: 1px;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); cursor: pointer;
}
/* 客户详情里的快速指派 */
.assign-form { display: inline-flex; align-items: center; gap: 6px; margin-left: 8px; }
.assign-form select {
height: 26px; padding: 0 6px; font-size: 12px;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); color: var(--ink);
}
.cust-meta dd { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
@media (max-width: 560px) {
.asg { font-size: 10px; }
.assign-form { margin-left: 0; }
}
+24
View File
@@ -106,3 +106,27 @@
sync();
});
})();
/* ---------- 亮色 / 暗色切换 ----------
偏好写 cookie,服务端首屏直接带 data-theme,不闪。
没有 cookie 时跟随系统(由 CSS 的 prefers-color-scheme 决定)。 */
(function () {
'use strict';
var KEY = 'daili_theme';
var btns = document.querySelectorAll('[data-theme-toggle]');
if (!btns.length) return;
function effective() {
var attr = document.documentElement.getAttribute('data-theme');
if (attr === 'light' || attr === 'dark') return attr;
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
Array.prototype.forEach.call(btns, function (btn) {
btn.addEventListener('click', function () {
var next = effective() === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
document.cookie = KEY + '=' + next + '; path=/; max-age=31536000; SameSite=Lax';
});
});
})();
+359 -160
View File
@@ -1,212 +1,411 @@
/* ============================================================
代打台 · 可视化大屏(深色「记分板」)
代打台 · 可视化大屏(记分板)
默认深色;亮色由系统偏好或 [data-theme="light"] 覆盖
============================================================ */
:root {
--bg: #070a0f;
--panel: #0e141c;
--panel-2: #121a24;
--line: #1b2530;
--ink: #e8eef2;
--dim: #7d8b99;
--mint: #35e0c6;
--mint-dim: rgba(53, 224, 198, 0.14);
--amber: #f5a524;
--red: #f2555a;
--mono: "Bahnschrift", "Consolas", "SF Mono", ui-monospace, monospace;
--sans: "Microsoft YaHei UI", "PingFang SC", system-ui, sans-serif;
--bg: #0c0f12;
--panel: #12161b;
--panel-2: #171c22;
--line: #232a32;
--ink: #e9edf1;
--ink-2: #aab4bf;
--muted: #6f7b87;
--accent: #58c1a3;
--accent-2: #8bd9c4;
--danger: #ef6b62;
--warn: #e6ac55;
--ok: #58c1a3;
--glow: 0 0 24px rgba(88, 193, 163, 0.18);
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--bg: #f4f2ec;
--panel: #ffffff;
--panel-2: #faf9f5;
--line: #e0ddd2;
--ink: #1b1e23;
--ink-2: #4a4f57;
--muted: #8a8f97;
--accent: #2f7d68;
--accent-2: #245c4d;
--danger: #b3302a;
--warn: #a0600f;
--ok: #2f7d68;
--glow: 0 0 18px rgba(47, 125, 104, 0.12);
}
}
:root[data-theme="light"] {
--bg: #f4f2ec;
--panel: #ffffff;
--panel-2: #faf9f5;
--line: #e0ddd2;
--ink: #1b1e23;
--ink-2: #4a4f57;
--muted: #8a8f97;
--accent: #2f7d68;
--accent-2: #245c4d;
--danger: #b3302a;
--warn: #a0600f;
--ok: #2f7d68;
--glow: 0 0 18px rgba(47, 125, 104, 0.12);
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
html, body { height: 100%; margin: 0; }
body.screen-body {
background:
radial-gradient(1200px 600px at 15% -10%, rgba(53, 224, 198, 0.07), transparent 60%),
radial-gradient(900px 500px at 100% 0%, rgba(245, 165, 36, 0.05), transparent 55%),
var(--bg);
body {
background: var(--bg);
color: var(--ink);
font-family: var(--sans);
font-family: "Bahnschrift", "Microsoft YaHei UI", "PingFang SC", system-ui, sans-serif;
font-size: 14px;
min-height: 100vh;
overflow-x: hidden;
overflow: hidden;
}
a { color: var(--mint); text-decoration: none; }
a:hover { opacity: 0.8; }
/* 背景:极淡的网格与光晕 */
body::before {
content: "";
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background:
radial-gradient(1200px 600px at 20% -10%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 60%),
linear-gradient(var(--line) 1px, transparent 1px) 0 0 / 100% 40px,
linear-gradient(90deg, var(--line) 1px, transparent 1px) 0 0 / 40px 100%;
opacity: 0.5;
}
.scr {
display: flex; flex-direction: column;
min-height: 100vh;
padding: clamp(12px, 1.4vw, 22px) clamp(14px, 1.8vw, 28px);
gap: clamp(10px, 1.1vw, 16px);
position: relative; z-index: 1;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto auto auto;
gap: 10px;
height: 100vh;
padding: 12px 16px 8px;
}
.scr-head { display: flex; align-items: center; gap: 16px; }
.scr-brand {
display: flex; align-items: center; gap: 10px;
font-size: clamp(15px, 1.2vw, 19px); font-weight: 700; letter-spacing: 0.1em;
}
/* ---------- 头部 ---------- */
.scr-head { display: flex; align-items: center; gap: 16px; min-height: 40px; }
.scr-brand { display: flex; align-items: baseline; gap: 6px; font-size: 17px; font-weight: 700; letter-spacing: 0.08em; }
.scr-brand .mark {
display: grid; place-items: center;
width: 30px; height: 30px; border-radius: 3px;
background: var(--mint); color: #04211d; font-size: 15px;
box-shadow: 0 0 22px rgba(53, 224, 198, 0.45);
display: inline-grid; place-items: center;
width: 24px; height: 24px; margin-right: 4px;
background: var(--accent); color: var(--bg);
font-size: 13px; border-radius: 2px;
align-self: center;
}
.scr-brand em { font-style: normal; color: var(--dim); font-weight: 400; letter-spacing: 0.24em; font-size: 0.7em; }
.scr-brand em { font-style: normal; font-size: 11px; color: var(--muted); letter-spacing: 0.2em; }
.scr-clock { margin-left: auto; display: flex; flex-direction: column; align-items: flex-end; }
.scr-clock b {
font-family: var(--mono); font-size: clamp(24px, 2.4vw, 36px);
font-weight: 600; line-height: 1;
color: var(--ink); letter-spacing: 0.04em;
text-shadow: 0 0 22px rgba(53, 224, 198, 0.25);
.scr-tabs { display: flex; gap: 4px; flex-wrap: wrap; }
.scr-tabs a {
display: inline-flex; align-items: center; gap: 5px;
height: 28px; padding: 0 11px;
font-size: 12px; color: var(--ink-2); text-decoration: none;
border: 1px solid var(--line); border-radius: 999px;
background: var(--panel);
}
.scr-clock span { font-family: var(--mono); font-size: 11px; color: var(--dim); letter-spacing: 0.1em; }
.scr-links { font-size: 11px; color: var(--dim); }
.scr-tabs a::before {
content: ""; width: 6px; height: 6px; border-radius: 50%;
background: var(--tab, var(--accent));
}
.scr-tabs a.on { background: var(--accent); border-color: var(--accent); color: var(--bg); }
.scr-tabs a.on::before { background: var(--bg); }
/* KPI */
.scr-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: clamp(8px, 0.9vw, 14px); }
.scr-tools { display: flex; align-items: center; gap: 8px; }
.scr-switch {
height: 28px; padding: 0 10px;
display: inline-flex; align-items: center;
font-size: 12px; color: var(--accent); text-decoration: none;
border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--line));
border-radius: 999px; background: var(--panel);
}
.scr-switch:hover { background: color-mix(in srgb, var(--accent) 12%, var(--panel)); }
.scr-theme {
width: 28px; height: 28px; padding: 0;
display: inline-grid; place-items: center;
border: 1px solid var(--line); border-radius: 999px;
background: var(--panel); color: var(--ink-2);
font-size: 13px; cursor: pointer;
}
.scr-theme .ic-sun { display: none; }
:root[data-theme="light"] .scr-theme .ic-sun { display: inline; }
:root[data-theme="light"] .scr-theme .ic-moon { display: none; }
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) .scr-theme .ic-sun { display: inline; }
:root:not([data-theme="dark"]) .scr-theme .ic-moon { display: none; }
}
.scr-clock { margin-left: auto; text-align: right; line-height: 1.1; }
.scr-clock b { font-family: var(--mono, "Bahnschrift", monospace); font-size: 22px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.scr-clock span { display: block; font-size: 11px; color: var(--muted); }
.scr-links { font-size: 11px; color: var(--muted); }
.scr-links a { color: var(--accent); text-decoration: none; }
/* ---------- KPI ---------- */
.scr-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.kpi {
position: relative; overflow: hidden;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--line);
border-radius: 4px;
padding: clamp(10px, 1vw, 16px) clamp(12px, 1.2vw, 20px);
}
.kpi::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--mint); opacity: 0.75;
}
.kpi-warm::before { background: var(--amber); }
.kpi-hot::before { background: var(--red); }
.kpi span { display: block; font-size: 11px; color: var(--dim); letter-spacing: 0.16em; }
.kpi b {
font-family: var(--mono); font-size: clamp(28px, 3.4vw, 52px); font-weight: 700;
line-height: 1.15; letter-spacing: -0.01em;
}
.kpi i { font-style: normal; font-size: 11px; color: var(--dim); margin-left: 4px; }
.kpi-hot b { color: var(--red); }
.kpi-warm b { color: var(--amber); }
/* 主区 */
.scr-main { display: grid; grid-template-columns: 1.15fr 1fr; gap: clamp(10px, 1.1vw, 16px); flex: 1 1 auto; }
.panel {
position: relative;
display: flex; align-items: baseline; gap: 6px;
padding: 10px 14px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 4px;
padding: clamp(10px, 1vw, 16px);
display: flex; flex-direction: column; min-height: 0;
border-radius: 3px;
overflow: hidden;
}
.panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.panel-head h2 {
margin: 0; font-size: clamp(12px, 0.95vw, 14px); font-weight: 600; letter-spacing: 0.08em;
color: var(--ink);
.kpi::after {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--accent);
}
.panel-head h2::before {
content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 7px;
background: var(--mint); vertical-align: 2px;
}
.panel-head span { font-size: 11px; color: var(--dim); font-family: var(--mono); }
.kpi span { font-size: 12px; color: var(--muted); margin-right: auto; }
.kpi b { font-family: "Bahnschrift", monospace; font-size: 30px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; }
.kpi i { font-style: normal; font-size: 11px; color: var(--muted); }
.kpi-hot::after { background: var(--danger); }
.kpi-hot b { color: var(--danger); }
.kpi-warm::after { background: var(--warn); }
.kpi-warm b { color: var(--warn); }
/* 热力月历 */
.heat-grid { display: flex; flex-direction: column; gap: 4px; flex: 1 1 auto; }
.heat-dow, .heat-row { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.heat-row { flex: 1 1 0; min-height: 0; }
.heat-dow { flex: 0 0 auto; }
.heat-dow span { text-align: center; font-size: 10px; color: var(--dim); }
.heat-cell {
position: relative;
display: flex; flex-direction: column; justify-content: space-between;
min-height: 34px;
padding: 5px 6px;
/* ---------- 主区 ---------- */
.scr-main { display: grid; grid-template-columns: 1.5fr 1fr; gap: 10px; min-height: 0; }
.panel {
display: flex; flex-direction: column; min-height: 0;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 3px;
background: #0a1017;
overflow: hidden;
}
.heat-cell span { font-family: var(--mono); font-size: 10px; color: var(--dim); }
.heat-cell b {
align-self: flex-end; font-family: var(--mono); font-size: clamp(12px, 1.1vw, 16px);
color: var(--mint); font-weight: 700;
.panel-head {
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
padding: 9px 14px;
border-bottom: 1px solid var(--line);
background: var(--panel-2);
}
.panel-head h2 { margin: 0; font-size: 13px; letter-spacing: 0.08em; font-weight: 700; }
.panel-head span { font-size: 11px; color: var(--muted); }
/* 热力 */
.heat-grid { flex: 1 1 auto; display: flex; flex-direction: column; justify-content: center; padding: 8px 12px; min-height: 0; }
.heat-dow { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-bottom: 4px; }
.heat-dow span { text-align: center; font-size: 10px; color: var(--muted); }
.heat-row { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-bottom: 4px; }
.heat-cell {
position: relative;
display: flex; align-items: center; justify-content: space-between;
min-height: 0; aspect-ratio: 2.05 / 1;
padding: 4px 7px;
border: 1px solid var(--line);
border-radius: 2px;
background: var(--panel-2);
font-size: 11px; color: var(--muted);
transition: transform 0.1s ease, border-color 0.1s ease;
}
.heat-cell span { font-family: "Bahnschrift", monospace; font-size: 11px; }
.heat-cell b { font-family: "Bahnschrift", monospace; font-size: 12px; color: var(--ink-2); }
.heat-cell.out { opacity: 0.28; }
.heat-cell.today { border-color: var(--mint); box-shadow: 0 0 0 1px rgba(53, 224, 198, 0.35), 0 0 18px rgba(53, 224, 198, 0.16); }
.heat-cell.h1 { background: rgba(53, 224, 198, 0.08); border-color: rgba(53, 224, 198, 0.22); }
.heat-cell.h2 { background: rgba(53, 224, 198, 0.15); border-color: rgba(53, 224, 198, 0.3); }
.heat-cell.h3 { background: rgba(53, 224, 198, 0.24); border-color: rgba(53, 224, 198, 0.42); }
.heat-cell.h4 { background: rgba(53, 224, 198, 0.36); border-color: rgba(53, 224, 198, 0.6); }
.heat-cell.h3 b, .heat-cell.h4 b { color: #eafffb; }
.heat-legend { display: flex; align-items: center; gap: 5px; margin-top: 10px; font-size: 10px; color: var(--dim); flex: 0 0 auto; }
.heat-legend i { width: 18px; height: 9px; border-radius: 2px; border: 1px solid #26313d; }
.heat-legend .h0 { background: #0a1017; }
.heat-legend .h1 { background: rgba(53, 224, 198, 0.16); }
.heat-legend .h2 { background: rgba(53, 224, 198, 0.28); }
.heat-legend .h3 { background: rgba(53, 224, 198, 0.42); }
.heat-legend .h4 { background: rgba(53, 224, 198, 0.6); }
.heat-cell.past { opacity: 0.66; }
.heat-cell.today { outline: 1px solid var(--accent); outline-offset: -1px; }
.heat-cell.today span { color: var(--accent); font-weight: 700; }
.heat-cell:hover { transform: translateY(-1px); border-color: var(--accent); z-index: 3; }
.heat-cell.h1 { background: color-mix(in srgb, var(--accent) 12%, var(--panel-2)); border-color: color-mix(in srgb, var(--accent) 22%, var(--line)); }
.heat-cell.h2 { background: color-mix(in srgb, var(--accent) 24%, var(--panel-2)); }
.heat-cell.h3 { background: color-mix(in srgb, var(--accent) 38%, var(--panel-2)); }
.heat-cell.h4 { background: color-mix(in srgb, var(--accent) 56%, var(--panel-2)); }
.heat-cell.h3 b, .heat-cell.h4 b { color: var(--bg); }
.heat-cell.h3 span, .heat-cell.h4 span { color: color-mix(in srgb, var(--bg) 75%, var(--accent)); }
.cell-urgent {
position: absolute; top: 3px; right: 3px;
width: 5px; height: 5px; border-radius: 50%;
background: var(--danger);
box-shadow: 0 0 6px color-mix(in srgb, var(--danger) 70%, transparent);
}
.heat-legend {
display: flex; align-items: center; gap: 4px;
padding: 6px 14px 10px;
font-size: 10px; color: var(--muted);
}
.heat-legend span { margin-right: 6px; }
.heat-legend i {
width: 14px; height: 9px; border-radius: 1px;
border: 1px solid var(--line);
display: inline-block;
}
.heat-legend i.h0 { background: var(--panel-2); }
.heat-legend i.h1 { background: color-mix(in srgb, var(--accent) 12%, var(--panel-2)); }
.heat-legend i.h2 { background: color-mix(in srgb, var(--accent) 24%, var(--panel-2)); }
.heat-legend i.h3 { background: color-mix(in srgb, var(--accent) 38%, var(--panel-2)); }
.heat-legend i.h4 { background: color-mix(in srgb, var(--accent) 56%, var(--panel-2)); }
.heat-legend .legend-hint { margin-left: auto; font-style: normal; opacity: 0.7; }
/* TOP 10 */
.top-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; overflow: hidden; }
.top-list { list-style: none; margin: 0; padding: 6px 10px; overflow: hidden; flex: 1 1 auto; }
.top-item {
display: grid; grid-template-columns: 22px auto 1fr auto auto;
grid-template-areas: "rank tail name pkg remain" "rank bar bar bar bar";
column-gap: 8px; row-gap: 3px;
align-items: center;
padding: 5px 8px;
border-radius: 3px;
background: #0a1017;
border-left: 2px solid transparent;
display: grid;
grid-template-columns: 22px 54px minmax(0, 1fr) auto auto 56px;
gap: 8px; align-items: center;
padding: 5px 4px;
border-bottom: 1px dashed var(--line);
font-size: 12px;
}
.top-item .rank { grid-area: rank; font-family: var(--mono); font-size: 11px; color: var(--dim); }
.top-tail { grid-area: tail; font-family: var(--mono); font-size: clamp(13px, 1.15vw, 17px); font-weight: 700; letter-spacing: 0.03em; }
.top-name { grid-area: name; font-size: 12px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.top-item:last-child { border-bottom: 0; }
.top-item .rank { font-family: "Bahnschrift", monospace; font-size: 11px; color: var(--muted); }
.top-item:nth-child(-n+3) .rank { color: var(--accent); font-weight: 700; }
.top-tail { font-family: "Bahnschrift", monospace; font-size: 14px; font-weight: 700; letter-spacing: 0.03em; }
.top-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-2); }
.top-pkg {
grid-area: pkg; font-size: 10px; color: var(--dim);
border-left: 2px solid var(--tag); padding-left: 5px;
font-size: 10px; padding: 1px 6px; border-radius: 2px;
border-left: 2px solid var(--tag, var(--accent));
background: color-mix(in srgb, var(--tag, var(--accent)) 14%, transparent);
color: var(--ink-2); white-space: nowrap;
}
.top-remain { grid-area: remain; font-family: var(--mono); font-size: 12px; color: var(--mint); }
.top-bar { grid-area: bar; height: 3px; background: #16202b; border-radius: 999px; overflow: hidden; }
.top-bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--mint), rgba(53, 224, 198, 0.35)); }
.top-item.lv-warn { border-left-color: var(--amber); }
.top-item.lv-warn .top-remain { color: var(--amber); }
.top-item.lv-warn .top-bar i { background: linear-gradient(90deg, var(--amber), rgba(245, 165, 36, 0.3)); }
.top-item.lv-today, .top-item.lv-expired { border-left-color: var(--red); }
.top-item.lv-today .top-remain, .top-item.lv-expired .top-remain { color: var(--red); }
.top-item.lv-today .top-bar i, .top-item.lv-expired .top-bar i { background: linear-gradient(90deg, var(--red), rgba(242, 85, 90, 0.3)); }
.top-remain { font-family: "Bahnschrift", monospace; font-size: 11px; white-space: nowrap; }
.top-item.lv-today .top-remain { color: var(--danger); font-weight: 700; }
.top-item.lv-warn .top-remain { color: var(--warn); }
.top-bar { height: 4px; background: var(--line); border-radius: 999px; overflow: hidden; }
.top-bar i { display: block; height: 100%; background: var(--accent); }
.top-item.lv-today .top-bar i { background: var(--danger); }
.top-item.lv-warn .top-bar i { background: var(--warn); }
/* 底部 */
.scr-bottom { display: grid; grid-template-columns: 1.15fr 1fr; gap: clamp(10px, 1.1vw, 16px); }
.today-list, .recent-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; max-height: 22vh; overflow: hidden; }
/* ---------- 底部两块 ---------- */
.scr-bottom { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; min-height: 150px; max-height: 30vh; }
.today-list { list-style: none; margin: 0; padding: 4px 10px 8px; overflow: auto; }
.today-list li {
display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center;
padding: 4px 6px; border-bottom: 1px solid rgba(27, 37, 48, 0.6);
display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto;
gap: 8px; align-items: center;
padding: 5px 6px;
border-bottom: 1px dashed var(--line);
font-size: 12px;
}
.today-list li:last-child { border-bottom: 0; }
.today-list li.urgent {
background: color-mix(in srgb, var(--danger) 9%, transparent);
border-left: 2px solid var(--danger);
padding-left: 8px;
}
.tchip {
display: inline-flex; align-items: baseline; gap: 3px;
padding: 1px 6px; font-size: 11px;
border-left: 3px solid var(--tag);
background: rgba(255, 255, 255, 0.04);
border-radius: 1px 2px 2px 1px;
padding: 1px 6px;
border-left: 2px solid var(--tag, var(--accent));
background: color-mix(in srgb, var(--tag, var(--accent)) 14%, transparent);
border-radius: 1px; white-space: nowrap;
}
.tchip b { font-family: var(--mono); font-size: 12px; color: var(--ink); }
.today-list .pkg, .recent-list .pkg { font-size: 11px; color: var(--dim); }
.today-list .rem { font-family: var(--mono); font-size: 11px; color: var(--mint); }
.tchip b { font-family: "Bahnschrift", monospace; font-size: 13px; }
.today-list .pkg { font-size: 10px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.today-list .rem { font-family: "Bahnschrift", monospace; font-size: 11px; color: var(--ink-2); white-space: nowrap; }
.recent-list li { display: flex; align-items: center; gap: 8px; padding: 3px 4px; font-size: 11px; border-bottom: 1px solid rgba(27, 37, 48, 0.6); }
.recent-list .time { color: var(--dim); font-size: 10px; }
.recent-list .who b { font-family: var(--mono); font-size: 12px; margin-right: 4px; }
.recent-list .plus { margin-left: auto; color: var(--mint); font-family: var(--mono); font-style: normal; }
.recent-list .op { color: var(--dim); }
.tag-urgent {
font-size: 9px; line-height: 1; padding: 2px 4px;
background: var(--danger); color: #fff; border-radius: 2px;
white-space: nowrap;
}
.scr-foot { font-size: 10px; color: #47535f; text-align: right; letter-spacing: 0.12em; }
.scr-empty { margin: auto; color: var(--dim); font-size: 12px; text-align: center; }
.scr-empty, .scr-empty-inline { color: var(--muted); font-size: 12px; padding: 16px; text-align: center; margin: 0; }
.scr-empty-inline { padding: 0; text-align: left; }
@media (max-width: 1100px) {
.scr-kpis { grid-template-columns: repeat(2, 1fr); }
/* ---------- 最近追加 ---------- */
.scr-recent {
display: flex; align-items: center; gap: 10px;
padding: 5px 12px;
background: var(--panel);
border: 1px solid var(--line); border-radius: 3px;
font-size: 11px; color: var(--muted);
overflow: hidden;
}
.recent-label { flex: 0 0 auto; letter-spacing: 0.12em; color: var(--accent); font-size: 10px; }
.recent-track { display: flex; gap: 18px; overflow: hidden; white-space: nowrap; }
.recent-item { color: var(--ink-2); }
.recent-item b { font-family: "Bahnschrift", monospace; font-size: 12px; color: var(--ink); margin-right: 3px; }
.recent-item em { font-style: normal; color: var(--accent); margin-left: 3px; }
.scr-foot { text-align: center; font-size: 10px; color: var(--muted); letter-spacing: 0.06em; }
/* ---------- 当日明细浮层 ---------- */
.day-pop {
position: fixed; z-index: 60;
width: 340px; max-height: 62vh; overflow: auto;
padding: 10px 12px;
background: var(--panel);
border: 1px solid var(--accent);
border-radius: 3px;
box-shadow: 0 18px 40px -18px rgba(0, 0, 0, 0.7), var(--glow);
font-size: 12px;
pointer-events: none;
}
.day-pop[hidden] { display: none; }
.day-pop h4 {
margin: 0 0 6px; display: flex; align-items: baseline; justify-content: space-between;
font-size: 12px; color: var(--ink);
}
.day-pop h4 small { font-weight: 400; color: var(--muted); font-size: 10px; }
.day-pop .pop-meta { display: flex; gap: 10px; margin-bottom: 6px; font-size: 10px; color: var(--muted); }
.day-pop .pop-meta b { color: var(--ink-2); font-family: "Bahnschrift", monospace; }
.day-pop ul { list-style: none; margin: 0; padding: 0; }
.day-pop li {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
gap: 6px; align-items: center;
padding: 3px 0; border-bottom: 1px dashed var(--line);
}
.day-pop li:last-child { border-bottom: 0; }
.day-pop li.urgent { color: var(--danger); }
.day-pop li.done { opacity: 0.45; text-decoration: line-through; }
.day-pop .pop-tail { font-family: "Bahnschrift", monospace; font-size: 12px; font-weight: 700; }
.day-pop .pop-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-2); }
.day-pop .pop-rem { font-size: 10px; color: var(--muted); white-space: nowrap; }
.day-pop .pop-more { margin-top: 6px; font-size: 10px; color: var(--muted); text-align: right; }
.day-pop .pop-empty { color: var(--muted); text-align: center; padding: 8px 0; }
/* ---------- 小屏适配(笔记本投屏也常见) ---------- */
@media (max-width: 1200px) {
.scr-main { grid-template-columns: 1fr 1fr; }
.scr-kpis .kpi b { font-size: 24px; }
.top-item { grid-template-columns: 20px 48px minmax(0, 1fr) auto 48px; }
.top-item .top-pkg { display: none; }
}
@media (max-width: 860px) {
body { overflow: auto; }
.scr { height: auto; }
.scr-main, .scr-bottom { grid-template-columns: 1fr; }
.heat-cell { min-height: 34px; }
.scr-tabs, .scr-links { display: none; }
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
/* ---------- 打手颜色(大屏) ---------- */
.asg-strip {
position: absolute; left: 0; top: 0; bottom: 0;
display: flex; flex-direction: column;
width: 3px; overflow: hidden;
}
.asg-strip i { display: block; flex: 1 1 0; min-height: 3px; }
.heat-cell { padding-left: 9px; }
.asg {
display: inline-flex; align-items: center; gap: 4px;
font-size: 10px; color: var(--ink-2); white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
.asg i { width: 7px; height: 7px; border-radius: 1px; flex: 0 0 auto; }
.asg.none { color: var(--muted); }
.asg.none i { opacity: 0.5; }
.today-list li { grid-template-columns: auto minmax(0, 1fr) auto auto auto; }
.pop-who {
display: inline-block; width: 7px; height: 7px;
border-radius: 1px; margin-right: 4px; vertical-align: 0;
}
+104 -1
View File
@@ -1,4 +1,4 @@
/* 大屏:时钟 + 全屏快捷键 */
/* 大屏:时钟 + 全屏 + 主题 + 日历悬停明细 */
(function () {
'use strict';
@@ -26,4 +26,107 @@
}
}
});
/* ---------- 主题切换 ---------- */
var THEME_KEY = 'daili_theme';
function effectiveTheme() {
var attr = document.documentElement.getAttribute('data-theme');
if (attr === 'light' || attr === 'dark') return attr;
return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
document.querySelectorAll('[data-theme-toggle]').forEach(function (btn) {
btn.addEventListener('click', function () {
var next = effectiveTheme() === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
document.cookie = THEME_KEY + '=' + next + '; path=/; max-age=31536000; SameSite=Lax';
});
});
/* ---------- 日历格子悬停明细 ---------- */
var root = document.querySelector('.scr');
var pop = document.getElementById('day-pop');
if (!root || !pop) return;
var api = root.getAttribute('data-api') || '/api/screen/day';
var pkg = root.getAttribute('data-pkg') || '0';
var cache = {};
var hideTimer = null;
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function place(cell) {
var r = cell.getBoundingClientRect();
var w = 340, h = Math.min(360, pop.offsetHeight || 240);
var left = r.right + 8;
if (left + w > window.innerWidth - 8) left = Math.max(8, r.left - w - 8);
var top = r.top + r.height / 2 - h / 2;
top = Math.max(8, Math.min(top, window.innerHeight - h - 8));
pop.style.left = left + 'px';
pop.style.top = top + 'px';
}
function render(data, cell) {
if (!data) {
pop.innerHTML = '<div class="pop-empty">读取失败</div>';
return;
}
var d = new Date(data.date + 'T00:00:00');
var label = data.date + ' 周' + '日一二三四五六'.charAt(d.getDay());
var html = '<h4>' + esc(label) + '<small>' + data.total + ' 单 · 已完成 ' + data.done +
(data.urgent ? ' · <span style="color:var(--danger)">加急 ' + data.urgent + '</span>' : '') + '</small></h4>';
if (!data.items || !data.items.length) {
html += '<div class="pop-empty">这天没有排单</div>';
} else {
html += '<ul>';
data.items.forEach(function (it) {
html += '<li class="' + (it.urgent ? 'urgent ' : '') + (it.done ? 'done' : '') + '">' +
'<span class="pop-tail">' + esc(it.tail) + '</span>' +
'<span class="pop-name"><i class="pop-who" style="background:' + esc(it.who_color || '#8A8F98') + '"></i>' + esc(it.who || '未指派') + ' · ' + esc(it.pkg) + '</span>' +
'<span class="pop-rem">' + (it.urgent ? '加急 · ' : '') + esc(it.remain) + '</span>' +
'</li>';
});
html += '</ul>';
if (data.more > 0) html += '<div class="pop-more">另有 ' + data.more + ' 单未显示</div>';
}
pop.innerHTML = html;
pop.hidden = false;
place(cell);
}
function show(cell) {
clearTimeout(hideTimer);
var date = cell.getAttribute('data-date');
var key = date + '|' + pkg;
pop.hidden = false;
pop.innerHTML = '<div class="pop-empty">载入中…</div>';
place(cell);
if (cache[key]) {
render(cache[key], cell);
return;
}
fetch(api + '?date=' + encodeURIComponent(date) + '&pkg=' + encodeURIComponent(pkg), {
headers: { 'Accept': 'application/json' }
}).then(function (r) {
if (!r.ok) throw new Error('bad');
return r.json();
}).then(function (data) {
cache[key] = data;
render(data, cell);
}).catch(function () {
pop.innerHTML = '<div class="pop-empty">读取失败</div>';
});
}
function hideSoon() {
hideTimer = setTimeout(function () { pop.hidden = true; }, 160);
}
document.querySelectorAll('.heat-cell[data-date]').forEach(function (cell) {
cell.addEventListener('mouseenter', function () { show(cell); });
cell.addEventListener('mouseleave', hideSoon);
});
})();
+8 -1
View File
@@ -1,5 +1,5 @@
{{define "layout"}}<!doctype html>
<html lang="zh-CN">
<html lang="zh-CN"{{if .Theme}} data-theme="{{.Theme}}"{{end}}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -25,12 +25,19 @@
<div class="topbar-right">
<a class="pill pill-due" href="/subscriptions?state=due_today" title="今天到期的订阅">今日到期 <b>{{.DueToday}}</b></a>
<a class="pill pill-warn" href="/subscriptions?state=due3" title="3 天内到期">3 天内 <b>{{.Due3}}</b></a>
<button class="theme-toggle" type="button" data-theme-toggle aria-label="切换亮色/暗色" title="切换亮色/暗色">
<span class="ic-moon" aria-hidden="true">☾</span><span class="ic-sun" aria-hidden="true">☀</span>
</button>
<div class="me">
<a href="/settings">{{if .User.DisplayName}}{{.User.DisplayName}}{{else}}{{.User.Username}}{{end}}</a>
<form method="post" action="/logout"><button type="submit" class="link-btn">退出</button></form>
</div>
</div>
</header>
{{else}}
<button class="theme-toggle auth-theme" type="button" data-theme-toggle aria-label="切换亮色/暗色" title="切换亮色/暗色">
<span class="ic-moon" aria-hidden="true">☾</span><span class="ic-sun" aria-hidden="true">☀</span>
</button>
{{end}}
{{if .Flash}}<div class="flash" data-flash>{{.Flash}}</div>{{end}}
{{if .Error}}<div class="flash flash-error">{{.Error}}</div>{{end}}
+1 -1
View File
@@ -1,5 +1,5 @@
{{define "screenlayout"}}<!doctype html>
<html lang="zh-CN">
<html lang="zh-CN"{{if .Theme}} data-theme="{{.Theme}}"{{end}}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
+89 -20
View File
@@ -2,19 +2,31 @@
<div class="cal-page">
<header class="cal-head">
<div class="cal-title">
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.PrevY}}&m={{.D.PrevM}}" aria-label="上个月">‹</a>
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.PrevY}}&m={{.D.PrevM}}&pkg={{.D.PkgID}}" aria-label="上个月">‹</a>
<h1>{{.D.Title}}</h1>
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.NextY}}&m={{.D.NextM}}" aria-label="下个月">›</a>
<a class="btn btn-sm" href="/calendar">回到今天</a>
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.NextY}}&m={{.D.NextM}}&pkg={{.D.PkgID}}" aria-label="下个月">›</a>
<a class="btn btn-sm" href="/calendar?pkg={{.D.PkgID}}">回到今天</a>
</div>
<div class="cal-stats">
<span><em>{{.D.MonthDue}}</em>本月到期</span>
<span><em>{{.D.MonthTasks}}</em>本月待打</span>
<span><em>{{.D.MonthDone}}</em>已完成</span>
{{if gt .D.MonthUrgent 0}}<span class="text-urgent"><em>{{.D.MonthUrgent}}</em>加急</span>{{end}}
<a class="btn btn-primary btn-sm" href="/customers/new">+ 新建客户</a>
</div>
</header>
{{if .D.HasTabs}}
<nav class="pkg-tabs">
{{range .D.Tabs}}
<a class="{{if eq .ID $.D.PkgID}}on{{end}}" style="--tab:{{.Color}}"
href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&pkg={{.ID}}">
<i></i>{{.Name}}{{if gt .Count 0}}<em>{{.Count}}</em>{{end}}
</a>
{{end}}
</nav>
{{end}}
{{if .D.NoPackages}}
<div class="notice">还没有服务套餐,先去 <a href="/packages/new">新建套餐</a>,再给客户开通。</div>
{{else if .D.NoCustomers}}
@@ -30,17 +42,21 @@
<div class="cal-week" role="row">
{{range .}}
<a class="cal-cell heat-{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if .IsSelected}} sel{{end}}{{if .IsWeekend}} weekend{{end}}{{if .DueCount}} has-due{{end}}"
href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{.Date}}" role="gridcell">
href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{.Date}}&pkg={{$.D.PkgID}}" role="gridcell">
{{if .Strip}}<span class="asg-strip" aria-hidden="true">{{range .Strip}}<i style="background:{{.}}"></i>{{end}}</span>{{end}}
<div class="cell-top">
<span class="dnum">{{.Day}}</span>
<span class="cell-badges">
{{if gt .UrgentCnt 0}}<span class="urgent-dot" title="{{.UrgentCnt}} 项加急"></span>{{end}}
{{if .DueCount}}<span class="due-badge"><em>到期</em>{{.DueCount}}</span>{{end}}
</span>
</div>
{{if .TaskCount}}
<div class="cell-load">{{.DoneCount}}/{{.TaskCount}} 单</div>
{{end}}
<div class="cell-tickets">
{{range .Tickets}}
<span class="ticket{{if .Done}} done{{end}}{{if .Skipped}} skipped{{end}}" style="--tag:{{.Color}}"><b>{{.Tail}}</b><span class="tk-name">{{.Name}}</span><i>{{.Pkg}}</i></span>
<span class="ticket{{if .Done}} done{{end}}{{if .Skipped}} skipped{{end}}{{if .Urgent}} urgent{{end}}" style="--tag:{{.Color}}"><b>{{.Tail}}</b><span class="tk-name">{{.Name}}</span><i>{{.Pkg}}</i></span>
{{end}}
{{if gt .More 0}}<span class="ticket more">+{{.More}}</span>{{end}}
</div>
@@ -56,40 +72,68 @@
<div class="drawer-tags">
{{if .D.SelectedIsToday}}<span class="chip chip-active">今天</span>{{end}}
{{if .D.SelectedIsPast}}<span class="chip chip-closed">已过去</span>{{end}}
<span class="chip chip-plain">{{len .D.Tasks}} 待打</span>
<span class="chip chip-plain">{{.D.TaskPending}} 待完成</span>
</div>
</header>
<section class="drawer-block">
<h3>这天到期 <em>{{len .D.Due}}</em></h3>
{{if .D.Due}}
<ul class="due-list">
{{range .D.Due}}
<h3>当日待完成 <em>{{.D.TaskPending}}</em><span class="muted small">/ {{len .D.Tasks}} 单</span></h3>
<form class="quick-form" method="post" action="/checkins/quick">
<input type="hidden" name="date" value="{{.D.Selected}}">
<input type="hidden" name="pkg" value="{{.D.PkgID}}">
<input type="hidden" name="back" value="{{.D.Back}}">
<input type="text" name="tail" placeholder="输入手机尾号,回车即可完成" maxlength="8" autocomplete="off" inputmode="numeric" aria-label="手机尾号快速完成">
<button class="btn btn-sm btn-primary" type="submit">完成</button>
</form>
<p class="muted small quick-hint">唯一匹配直接完成;同尾号多人 / 一单多服务会列出让你勾选。</p>
{{if .D.PickTail}}
<section class="picker">
<header class="picker-head">
<h4>尾号 {{.D.PickTail}} 的待完成项</h4>
<a class="btn btn-sm btn-ghost" href="{{.D.Back}}">收起</a>
</header>
{{if .D.PickRows}}
<form method="post" action="/checkins/batch">
<input type="hidden" name="date" value="{{.D.Selected}}">
<input type="hidden" name="back" value="{{.D.Back}}">
<ul class="pick-list">
{{range .D.PickRows}}
<li>
<a class="due-main" href="/customers/{{.CustomerID}}">
<label>
<input type="checkbox" name="sub_ids" value="{{.ID}}"{{if not .Done}}{{if not .Skipped}} checked{{end}}{{end}}>
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
<span class="rem">{{.RemainShort}}</span>
</a>
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&extend={{.ID}}#extend">追加</a>
<span class="pick-pkg">{{.PackageName}}</span>
<span class="pick-remain">{{.RemainShort}}</span>
{{if .Done}}<span class="chip chip-active">已完成</span>{{end}}
{{if .Skipped}}<span class="chip chip-paused">已跳过</span>{{end}}
</label>
</li>
{{end}}
</ul>
{{else}}<p class="muted">没有到期订阅。</p>{{end}}
<div class="actions">
<button class="btn btn-primary btn-sm" type="submit">确认完成选中项</button>
<a class="btn btn-sm" href="{{.D.Back}}">取消</a>
</div>
</form>
{{else}}
<p class="muted">{{.D.PickMsg}}</p>
{{end}}
</section>
{{end}}
<section class="drawer-block">
<h3>当日待打 <em>{{len .D.Tasks}}</em></h3>
{{if .D.Tasks}}
<ul class="task-list">
{{range .D.Tasks}}
<li class="task{{if .Done}} done{{end}}{{if .Skipped}} skipped{{end}}">
<li class="task{{if .Done}} done{{end}}{{if .Skipped}} skipped{{end}}{{if .Urgent}} urgent{{end}}" style="--asg:{{.AssigneeHex}}">
<div class="task-main">
<a class="task-title" href="/customers/{{.CustomerID}}">
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
{{if .Urgent}}<span class="tag-urgent">加急</span>{{end}}
</a>
<div class="task-meta">
<span class="asg{{if .AssigneeNone}} none{{end}}"><i style="background:{{.AssigneeHex}}"></i>{{.AssigneeLabel}}</span>
<span>{{.RemainText}}</span>
{{if .DueText}}<span class="due due-{{.Eval.Level}}">{{.DueText}}</span>{{end}}
</div>
@@ -116,7 +160,14 @@
<button class="btn btn-sm btn-ghost">跳过</button>
</form>
{{end}}
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&extend={{.ID}}#extend">追加</a>
<form method="post" action="/tasks/urgent">
<input type="hidden" name="sub_id" value="{{.ID}}">
<input type="hidden" name="date" value="{{$.D.Selected}}">
<input type="hidden" name="urgent" value="{{if .Urgent}}0{{else}}1{{end}}">
<input type="hidden" name="back" value="{{$.D.Back}}">
<button class="btn btn-sm {{if .Urgent}}btn-urgent{{else}}btn-ghost{{end}}">{{if .Urgent}}取消加急{{else}}加急{{end}}</button>
</form>
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&pkg={{$.D.PkgID}}&extend={{.ID}}#extend">追加</a>
</div>
</li>
{{end}}
@@ -126,6 +177,24 @@
{{if .D.Extend}}{{template "extend-panel" .D.Extend}}{{end}}
<section class="drawer-block">
<h3>这天到期 <em>{{len .D.Due}}</em></h3>
{{if .D.Due}}
<ul class="due-list">
{{range .D.Due}}
<li>
<a class="due-main" href="/customers/{{.CustomerID}}">
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
<span class="rem">{{.RemainShort}}</span>
</a>
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&pkg={{$.D.PkgID}}&extend={{.ID}}#extend">追加</a>
</li>
{{end}}
</ul>
{{else}}<p class="muted">没有到期订阅。</p>{{end}}
</section>
<section class="drawer-block">
<h3>近 7 天到期</h3>
{{if .D.OtherDue}}
@@ -15,6 +15,16 @@
{{if .D.Customer.GameServer}}<div><dt>区服</dt><dd>{{.D.Customer.GameServer}}</dd></div>{{end}}
{{if .D.Customer.Contact}}<div><dt>联系</dt><dd>{{.D.Customer.Contact}}</dd></div>{{end}}
{{if .D.Customer.Source}}<div><dt>来源</dt><dd>{{.D.Customer.Source}}</dd></div>{{end}}
<div><dt>打手</dt><dd>
<span class="asg{{if eq .D.Customer.AssigneeID 0}} none{{end}}"><i style="background:{{if .D.Customer.AssigneeColor}}{{.D.Customer.AssigneeColor}}{{else}}#8A8F98{{end}}"></i>{{if .D.Customer.AssigneeName}}{{.D.Customer.AssigneeName}}{{else}}未指派{{end}}</span>
<form method="post" action="/customers/{{.D.Customer.ID}}/assignee" class="assign-form">
<select name="assignee_id">
<option value="0">未指派</option>
{{range .D.Users}}<option value="{{.ID}}"{{if eq $.D.Customer.AssigneeID .ID}} selected{{end}}>{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Username}}{{end}}</option>{{end}}
</select>
<button class="btn btn-sm" type="submit">保存</button>
</form>
</dd></div>
<div><dt>建档</dt><dd>{{.D.Customer.CreatedAt}}</dd></div>
</dl>
<div class="page-actions">
@@ -29,6 +29,16 @@
<label class="field"><span>联系方式</span>
<input name="contact" value="{{.D.Customer.Contact}}" maxlength="64" placeholder="微信 / QQ">
</label>
<label class="field"><span>归属打手</span>
<select name="assignee_id">
<option value="0"{{if eq .D.Customer.AssigneeID 0}} selected{{end}}>未指派</option>
{{range .D.Users}}
<option value="{{.ID}}"{{if eq $.D.Customer.AssigneeID .ID}} selected{{end}}>{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Username}}{{end}}</option>
{{end}}
</select>
<small class="muted">该客户之后的所有单子都记在这个打手名下</small>
</label>
<label class="field"><span>来源渠道</span>
<input name="source" value="{{.D.Customer.Source}}" maxlength="32" placeholder="抖音 / 朋友介绍">
</label>
@@ -44,6 +44,11 @@
</label>
</div>
<label class="check-line">
<input type="checkbox" name="calendar_separate" value="1"{{if .D.Package.CalendarSeparate}} checked{{end}}>
<span>在日历中独立显示<small>勾选后工作台和大屏的日历会为该套餐单独开一个标签页,便于把“每日任务”和其他委托分开看</small></span>
</label>
<label class="field"><span>说明</span>
<textarea name="note" rows="2" maxlength="200" placeholder="套餐包含什么、怎么个打法">{{.D.Package.Note}}</textarea>
</label>
@@ -20,6 +20,7 @@
<td><span class="dot big" style="background:{{.Color}}"></span></td>
<td>
<b>{{.Name}}</b>
{{if .CalendarSeparate}}<span class="tagchip">独立日历</span>{{end}}
{{if .Note}}<div class="muted small">{{.Note}}</div>{{end}}
</td>
<td>{{.UnitLabel}}</td>
+68 -23
View File
@@ -1,7 +1,27 @@
{{define "content"}}
<div class="scr">
<div class="scr" data-pkg="{{.D.PkgID}}" data-heat="{{.D.HeatMode}}" data-api="/api/screen/day">
<header class="scr-head">
<div class="scr-brand"><span class="mark">代</span>代打台<em>控制台</em></div>
{{if .D.HasTabs}}
<nav class="scr-tabs">
{{range .D.Tabs}}
<a class="{{if eq .ID $.D.PkgID}}on{{end}}" style="--tab:{{.Color}}"
href="/screen?pkg={{.ID}}&heat={{$.D.HeatMode}}">{{.Name}}</a>
{{end}}
</nav>
{{end}}
<div class="scr-tools">
<a class="scr-switch" href="/screen?pkg={{.D.PkgID}}&heat={{if eq .D.HeatMode "due"}}task{{else}}due{{end}}"
title="切换热力统计口径">
{{if eq .D.HeatMode "due"}}热力:到期{{else}}热力:待完成{{end}} ⇄
</a>
<button class="scr-theme" type="button" data-theme-toggle aria-label="切换亮色/暗色">
<span class="ic-moon">☾</span><span class="ic-sun">☀</span>
</button>
</div>
<div class="scr-clock"><b data-clock>--:--:--</b><span data-date></span></div>
<div class="scr-links">每 {{.D.Refresh}} 秒刷新 · <a href="/calendar">进入工作台</a></div>
</header>
@@ -24,24 +44,29 @@
<section class="scr-main">
<div class="panel panel-cal">
<div class="panel-head">
<h2>到期热力</h2>
<span>{{.D.MonthTitle}} · 本月 {{.D.Stats.MonthDue}} 条到期</span>
<h2>{{if eq .D.HeatMode "due"}}到期热力{{else}}每日待完成{{end}}</h2>
<span>{{.D.MonthTitle}} · 本月合计 {{.D.HeatTotal}} 单</span>
</div>
<div class="heat-grid">
<div class="heat-dow">{{range .D.Weekdays}}<span>{{.}}</span>{{end}}</div>
{{range .D.Weeks}}
<div class="heat-row">
{{range .}}
<div class="heat-cell h{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}">
<div class="heat-cell h{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if .IsPast}} past{{end}}"
data-date="{{.Date}}" title="{{.Date}}">
{{if .Strip}}<span class="asg-strip sm" aria-hidden="true">{{range .Strip}}<i style="background:{{.}}"></i>{{end}}</span>{{end}}
<span>{{.Day}}</span>
{{if .Due}}<b>{{.Due}}</b>{{end}}
{{if gt .Urgent 0}}<u class="cell-urgent"></u>{{end}}
{{if .Count}}<b>{{.Count}}</b>{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
<div class="heat-legend">
<span>到期数</span><i class="h0"></i><i class="h1"></i><i class="h2"></i><i class="h3"></i><i class="h4"></i>
<span>{{if eq .D.HeatMode "due"}}到期数{{else}}待完成单量{{end}}</span>
<i class="h0"></i><i class="h1"></i><i class="h2"></i><i class="h3"></i><i class="h4"></i>
<em class="legend-hint">悬停格子看当日明细</em>
</div>
</div>
@@ -69,12 +94,12 @@
<section class="scr-bottom">
<div class="panel">
<div class="panel-head">
<h2>今日待打</h2>
<span>{{.D.TodayDone}} / {{.D.TodayTasks}} 已完成</span>
<h2>今日到期</h2>
<span>{{len .D.DueToday}} 条</span>
</div>
{{if .D.TodayList}}
{{if .D.DueToday}}
<ul class="today-list">
{{range .D.TodayList}}
{{range .D.DueToday}}
<li class="lv-{{.Eval.Level}}">
<span class="tchip" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
@@ -83,31 +108,51 @@
{{end}}
</ul>
{{else}}
<p class="scr-empty">今天没有待打任务</p>
<p class="scr-empty">今天没有到期</p>
{{end}}
</div>
<div class="panel">
<div class="panel-head"><h2>最近追加</h2><span>服务时长变更流水</span></div>
{{if .D.Recent}}
<ul class="recent-list">
{{range .D.Recent}}
<li>
<span class="time mono">{{.CreatedAt}}</span>
<span class="who"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<div class="panel-head">
<h2>今日待打</h2>
<span>{{.D.TodayDone}} / {{.D.TodayTasks}} 已完成{{if gt .D.TodayUrgent 0}} · 加急 {{.D.TodayUrgent}}{{end}}</span>
</div>
{{if .D.TodayList}}
<ul class="today-list">
{{range .D.TodayList}}
<li class="{{if .Urgent}}urgent{{end}} lv-{{.Eval.Level}}">
<span class="tchip" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span>
{{if gt .DaysAdded 0}}<em class="plus">+{{.DaysAdded}} 天</em>{{end}}
{{if gt .CountAdded 0}}<em class="plus">+{{.CountAdded}} 次</em>{{end}}
<span class="op">{{.OperatorName}}</span>
<span class="asg{{if .AssigneeNone}} none{{end}}"><i style="background:{{.AssigneeHex}}"></i>{{.AssigneeLabel}}</span>
{{if .Urgent}}<span class="tag-urgent">加急</span>{{end}}
<span class="rem">{{.RemainShort}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="scr-empty">还没有追加记录</p>
<p class="scr-empty">今天没有待打任务</p>
{{end}}
</div>
</section>
<footer class="scr-foot">代打台 v{{.D.Version}} · 数据每 {{.D.Refresh}} 秒自动刷新 · 按 F 进入全屏</footer>
<section class="scr-recent">
<span class="recent-label">最近追加</span>
{{if .D.Recent}}
<div class="recent-track">
{{range .D.Recent}}
<span class="recent-item"><b>{{.CustomerTail}}</b>{{.CustomerName}} · {{.PackageName}}
{{if gt .DaysAdded 0}}<em>+{{.DaysAdded}} 天</em>{{end}}
{{if gt .CountAdded 0}}<em>+{{.CountAdded}} 次</em>{{end}}
</span>
{{end}}
</div>
{{else}}
<span class="scr-empty-inline">还没有追加记录</span>
{{end}}
</section>
<footer class="scr-foot">代打台 v{{.D.Version}} · 数据每 {{.D.Refresh}} 秒自动刷新 · 按 F 全屏</footer>
<div class="day-pop" id="day-pop" hidden></div>
</div>
{{end}}
+12 -2
View File
@@ -25,16 +25,23 @@
<section class="section card">
<h2>管理员</h2>
<p class="muted small">所有账户权限相同,任何操作都会记在日志里。</p>
<p class="muted small">所有账户权限相同,任何操作都会记在日志里。每个管理员的颜色用于日历左侧竖条和待办标识。</p>
<p class="muted small">{{if eq .User.ID 1}}你是初始管理员,可以点「编辑」修改其他管理员的用户名、昵称和密码。{{else}}只有初始管理员(首个账号)可以修改其他管理员的资料。{{end}}</p>
<div class="table-wrap">
<table class="table tight">
<thead><tr><th>用户名</th><th>显示名称</th><th>状态</th><th>创建时间</th><th>最近登录</th><th></th></tr></thead>
<thead><tr><th>用户名</th><th>显示名称</th><th>打手颜色</th><th>状态</th><th>创建时间</th><th>最近登录</th><th></th></tr></thead>
<tbody>
{{$me := .User}}
{{range .D.Users}}
<tr>
<td class="mono">{{.Username}}</td>
<td>{{.DisplayName}}</td>
<td>
<form method="post" action="/settings/users/{{.ID}}/color" class="color-form">
<input type="color" name="color" value="{{if .Color}}{{.Color}}{{else}}#8A8F98{{end}}" title="打手颜色">
<button class="btn btn-sm btn-ghost" type="submit">保存</button>
</form>
</td>
<td>{{if .IsActive}}<span class="chip chip-active">启用</span>{{else}}<span class="chip chip-closed">停用</span>{{end}}</td>
<td class="mono small">{{.CreatedAt}}</td>
<td class="mono small">{{if .LastLoginAt}}{{.LastLoginAt}}{{else}}—{{end}}</td>
@@ -42,6 +49,9 @@
{{if eq .ID $me.ID}}
<span class="muted small">当前账户</span>
{{else}}
{{if eq $me.ID 1}}
<a class="btn btn-sm" href="/settings/users/{{.ID}}/edit">编辑</a>
{{end}}
<form method="post" action="/settings/users/{{.ID}}/toggle">
<button class="btn btn-sm btn-ghost">{{if .IsActive}}停用{{else}}启用{{end}}</button>
</form>
@@ -0,0 +1,39 @@
{{define "content"}}
<div class="page narrow">
<header class="page-head">
<div>
<h1>编辑管理员</h1>
<p class="muted">修改 {{.D.Target.Username}} 的登录名、昵称,或重置密码。</p>
</div>
<div class="page-actions">
<a class="btn btn-ghost" href="/settings">返回设置</a>
</div>
</header>
<form method="post" action="/settings/users/{{.D.Target.ID}}/edit" class="form card">
<div class="grid-2">
<label class="field"><span>用户名 <i>*</i></span>
<input type="text" name="username" value="{{.D.Target.Username}}" required minlength="3" maxlength="32" autocomplete="off">
</label>
<label class="field"><span>显示名称</span>
<input type="text" name="display_name" value="{{.D.Target.DisplayName}}" maxlength="32" autocomplete="off">
</label>
</div>
<div class="grid-2">
<label class="field"><span>新密码</span>
<input type="password" name="password" minlength="6" autocomplete="new-password" placeholder="留空则不修改">
</label>
<label class="field"><span>再输一次</span>
<input type="password" name="password2" minlength="6" autocomplete="new-password" placeholder="与上面一致">
</label>
</div>
<p class="muted small">重置密码后该账户的登录会话会立即失效,需要用新密码重新登录。当前账户:{{.User.Username}}</p>
<div class="actions">
<button class="btn btn-primary" type="submit">保存修改</button>
<a class="btn btn-ghost" href="/settings">取消</a>
</div>
</form>
</div>
{{end}}
+19
View File
@@ -31,6 +31,25 @@ type SubView struct {
CanCheckinToday bool
}
// AssigneeLabel 返回打手显示名;未指派时给灰字提示。
func (v SubView) AssigneeLabel() string {
if v.AssigneeName == "" {
return "未指派"
}
return v.AssigneeName
}
// AssigneeHex 返回打手颜色;未指派用中性灰。
func (v SubView) AssigneeHex() string {
if v.AssigneeColor == "" {
return "#8A8F98"
}
return v.AssigneeColor
}
// AssigneeNone 是否未指派。
func (v SubView) AssigneeNone() bool { return v.AssigneeName == "" }
func stateLabel(state string) (string, string) {
switch state {
case domain.StateActive:
+1 -1
View File
@@ -26,7 +26,7 @@ import (
"daili/internal/web"
)
const version = "1.1.0"
const version = "1.4.0"
func main() {
cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")")
+5 -4
View File
@@ -410,7 +410,7 @@ $r = Send $admin 'GET' '/calendar?y=2020&m=1'
Check 'L-03' '「回到今天」入口存在' ((Has $r.Content '回到今天')) ""
$cal = (Send $admin 'GET' "/calendar?d=$today").Content
Check 'L-04' '格子显示色签与负荷' ((Has $cal '单测') -and (Has $cal '包月') -and (Has $cal 'heat-')) ""
Check 'L-05' '点日期切换抽屉' ((Has $cal ($T.ToString('M 月 d 日'))) -and (Has $cal '当日待打')) ""
Check 'L-05' '点日期切换抽屉' ((Has $cal ($T.ToString('M 月 d 日'))) -and (Has $cal '当日待完成')) ""
$r = Send $admin 'GET' "/calendar?d=$(D -1)"
Check 'L-06' '过去日期标记已过去' ((Has $r.Content '已过去')) ""
Check 'L-07' '近 7 天到期区块' ((Has $cal '近 7 天到期')) ""
@@ -433,11 +433,12 @@ $kpiCust = [regex]::Match($screen, '(?s)客户</span>\s*<b>(\d+)</b>').Groups[1]
$kpiDue = [regex]::Match($screen, '(?s)今日到期</span>\s*<b>(\d+)</b>').Groups[1].Value
$kpi7 = [regex]::Match($screen, '(?s)7 天内到期</span>\s*<b>(\d+)</b>').Groups[1].Value
Check 'D-02' '四张 KPI 与后台一致' (($kpiCust -eq $custN) -and ($kpiDue -eq $dueToday) -and ($kpi7 -eq $due7)) "cust=$kpiCust/$custN due=$kpiDue/$dueToday 7=$kpi7/$due7"
Check 'D-03' '热力月历渲染' ((Has $screen '到期热力') -and (Has $screen 'heat-cell')) ""
$screenDue = (Send $admin 'GET' '/screen?heat=due').Content
Check 'D-03' '热力月历渲染(默认待完成,可切到期)' ((Has $screen '每日待完成') -and (Has $screen 'heat-cell') -and (Has $screenDue '到期热力')) ""
$topN = [regex]::Matches($screen, 'class="top-item').Count
Check 'D-04' 'TOP 榜不超过 10 条' ((Has $screen '即将到期 TOP 10') -and ($topN -ge 1) -and ($topN -le 10)) "count=$topN"
$todayTasks = [regex]::Match($cal, '(\d+) 待打').Groups[1].Value
$screenTasks = [regex]::Match($screen, '<span>(\d+) / (\d+) 已完成</span>').Groups[2].Value
$todayTasks = [regex]::Match($cal, '(\d+) 待完成').Groups[1].Value
$screenTasks = [regex]::Match($screen, '(\d+) / (\d+) 已完成').Groups[2].Value
Check 'D-05' '今日待打数与日历一致' ($todayTasks -eq $screenTasks -and $todayTasks -ne '') "calendar=$todayTasks screen=$screenTasks"
Check 'D-06' '最近追加流水渲染' ((Has $screen '最近追加')) ""
Check 'D-07' '自动刷新秒数取自设置' ((Has $screen '<meta http-equiv="refresh" content="30">')) ""