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
This commit is contained in:
subtrack
2026-10-19 00:16:34 +08:00
parent 85f98dff5c
commit 8cfc9f5891
21 changed files with 542 additions and 43 deletions
+5 -1
View File
@@ -136,6 +136,10 @@
- 同一尾号有多个客户,或一个客户今天有多个服务:弹出勾选面板,默认全选,可多选后一次完成。 - 同一尾号有多个客户,或一个客户今天有多个服务:弹出勾选面板,默认全选,可多选后一次完成。
- **加急**:每条任务右侧点「加急」,会加红色 `加急` 标签并排到最前;日历格子出现红点,大屏「今日待打」同步置顶。再点一次取消。 - **加急**:每条任务右侧点「加急」,会加红色 `加急` 标签并排到最前;日历格子出现红点,大屏「今日待打」同步置顶。再点一次取消。
- **按套餐分日历**:套餐编辑页勾选「在日历中独立显示」后,工作台与大屏顶部会出现套餐标签(全部 / 每日任务 / …),可以只看某类委托的排单。 - **按套餐分日历**:套餐编辑页勾选「在日历中独立显示」后,工作台与大屏顶部会出现套餐标签(全部 / 每日任务 / …),可以只看某类委托的排单。
- **打手颜色**:`设置 → 管理员` 里给每个管理员选一个颜色。客户详情或编辑页可以把客户指派给某个打手,之后这个客户的所有单子都用该颜色标识:
- 日历格左侧竖条 = 当天涉及的所有打手颜色(几种人几条,等分显示);
- 「当日待完成」每条前面有打手色条 + 名字,未指派的显示灰色「未指派」;
- 大屏的今日待打、悬停浮层、热力格竖条同样带颜色。
- **主题**:顶栏右上角 ☾ / ☀ 切换亮 / 暗,偏好记在浏览器里;不选时跟随系统。 - **主题**:顶栏右上角 ☾ / ☀ 切换亮 / 暗,偏好记在浏览器里;不选时跟随系统。
--- ---
@@ -152,7 +156,7 @@
- **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。 - **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。
- **重置**:关闭程序 → 删除 `daili.db` → 重新打开,会回到「创建第一个管理员」的全新状态。 - **重置**:关闭程序 → 删除 `daili.db` → 重新打开,会回到「创建第一个管理员」的全新状态。
- 加急标记存在 `daili.db` 的 `task_flags` 表;套餐的「独立日历」是 `packages.calendar_separate` 列,老版本数据库打开时会**自动加列**,不用手工迁移; - 打手颜色存在 `users.color`,客户归属打手是 `customers.assignee_id`;加急标记存在 `daili.db` 的 `task_flags` 表;套餐的「独立日历」是 `packages.calendar_separate` 列,老版本数据库打开时会**自动加列**,不用手工迁移;
- `daili.ini`(监听/备份/日志配置)与 `backup/`、`logs/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。 - `daili.ini`(监听/备份/日志配置)与 `backup/`、`logs/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。
- 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库; - 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库;
+13 -7
View File
@@ -23,6 +23,11 @@ type Customer struct {
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
// 归属打手(管理员)
AssigneeID int64
AssigneeName string
AssigneeColor string
// 列表聚合 // 列表聚合
SubCount int SubCount int
Summary string Summary string
@@ -41,12 +46,13 @@ func (c *Customer) TagList() []string {
return out 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) { func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) {
var c Customer var c Customer
err := row.Scan(&c.ID, &c.Tail, &c.Name, &c.GameName, &c.GameServer, &c.Contact, 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 err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
@@ -59,10 +65,10 @@ func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) {
// CreateCustomer 新建客户(尾号+姓名唯一)。 // CreateCustomer 新建客户(尾号+姓名唯一)。
func (db *DB) CreateCustomer(c *Customer) (int64, error) { func (db *DB) CreateCustomer(c *Customer) (int64, error) {
now := nowStamp() now := nowStamp()
res, err := db.sql.Exec(`INSERT INTO customers(tail,name,game_name,game_server,contact,tags,source,note,created_at,updated_at) 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(?,?,?,?,?,?,?,?,?,?)`, VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact, 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 err != nil {
if isUnique(err) { if isUnique(err) {
return 0, ErrDuplicate return 0, ErrDuplicate
@@ -74,10 +80,10 @@ func (db *DB) CreateCustomer(c *Customer) (int64, error) {
// UpdateCustomer 更新客户。 // UpdateCustomer 更新客户。
func (db *DB) UpdateCustomer(c *Customer) error { 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=?`, WHERE id=?`,
strings.TrimSpace(c.Tail), strings.TrimSpace(c.Name), c.GameName, c.GameServer, c.Contact, 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) { if err != nil && isUnique(err) {
return ErrDuplicate return ErrDuplicate
} }
+11
View File
@@ -121,6 +121,15 @@ func (db *DB) Migrate() error {
"ALTER TABLE packages ADD COLUMN calendar_separate INTEGER NOT NULL DEFAULT 0"); err != nil { "ALTER TABLE packages ADD COLUMN calendar_separate INTEGER NOT NULL DEFAULT 0"); err != nil {
return err 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 return nil
} }
@@ -161,6 +170,7 @@ var schemaStatements = []string{
display_name TEXT NOT NULL DEFAULT '', display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1,
color TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
last_login_at TEXT last_login_at TEXT
)`, )`,
@@ -195,6 +205,7 @@ var schemaStatements = []string{
tags TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
assignee_id INTEGER REFERENCES users(id),
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
UNIQUE (tail, name) UNIQUE (tail, name)
+150
View File
@@ -764,6 +764,33 @@ func TestMigrateOldDatabaseAddsColumn(t *testing.T) {
)`, )`,
`INSERT INTO packages(name,unit,default_amount,color,note,archived,sort_order,created_at,updated_at) `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')`, 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 { for _, s := range stmts {
if _, err := old.Exec(s); err != nil { if _, err := old.Exec(s); err != nil {
@@ -797,6 +824,28 @@ func TestMigrateOldDatabaseAddsColumn(t *testing.T) {
if got, _ := db.Package(1); !got.CalendarSeparate { if got, _ := db.Package(1); !got.CalendarSeparate {
t.Fatal("迁移后新列不可写") 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 已建立 // 新表 task_flags 已建立
var tbl string var tbl string
if err := db.sql.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='task_flags'`).Scan(&tbl); err != nil { if err := db.sql.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='task_flags'`).Scan(&tbl); err != nil {
@@ -807,3 +856,104 @@ func TestMigrateOldDatabaseAddsColumn(t *testing.T) {
t.Fatalf("重复迁移失败: %v", err) 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("停用账户不应出现在打手候选里")
}
}
}
+11 -2
View File
@@ -33,6 +33,11 @@ type Subscription struct {
CustomerTail string CustomerTail string
CustomerName string CustomerName string
CustomerContact string CustomerContact string
// 客户归属的打手(管理员)
AssigneeID int64
AssigneeName string
AssigneeColor string
} }
// Eval 计算展示状态。 // 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,''), 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,''), 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 FROM subscriptions s
JOIN packages p ON p.id=s.package_id JOIN packages p ON p.id=s.package_id
JOIN customers c ON c.id=s.customer_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 var s Subscription
err := row.Scan(&s.ID, &s.CustomerID, &s.PackageID, &s.Unit, &s.Status, &s.StartDate, &s.EndDate, 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.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 err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
+90 -5
View File
@@ -3,6 +3,7 @@ package store
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"strings"
"time" "time"
"daili/internal/pw" "daili/internal/pw"
@@ -14,17 +15,41 @@ type User struct {
Username string Username string
DisplayName string DisplayName string
IsActive bool IsActive bool
Color string
CreatedAt string CreatedAt string
LastLoginAt 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 { func scanUser(row interface {
Scan(dest ...any) error Scan(dest ...any) error
}) (*User, error) { }) (*User, error) {
var u User var u User
var active int var active int
var last sql.NullString 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -35,7 +60,7 @@ func scanUser(row interface {
return &u, nil 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 返回账户数量。 // CountUsers 返回账户数量。
func (db *DB) CountUsers() (int, error) { func (db *DB) CountUsers() (int, error) {
@@ -51,8 +76,9 @@ func (db *DB) CreateUser(username, displayName, password string) (*User, error)
return nil, err return nil, err
} }
now := nowStamp() now := nowStamp()
res, err := db.sql.Exec(`INSERT INTO users(username, display_name, password_hash, is_active, created_at) color := db.nextAssigneeColor()
VALUES(?,?,?,1,?)`, username, displayName, hash, now) 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 err != nil {
if isUnique(err) { if isUnique(err) {
return nil, ErrDuplicate return nil, ErrDuplicate
@@ -60,7 +86,66 @@ func (db *DB) CreateUser(username, displayName, password string) (*User, error)
return nil, err return nil, err
} }
id, _ := res.LastInsertId() 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()
} }
// Authenticate 校验用户名口令。 // Authenticate 校验用户名口令。
+28
View File
@@ -27,6 +27,8 @@ type DayCell struct {
Heat int Heat int
Tickets []Ticket Tickets []Ticket
More int More int
// Strip 是左侧的打手色竖条(按出现顺序去重,未指派为灰)
Strip []string
} }
// Ticket 是日历格里的色签。 // Ticket 是日历格里的色签。
@@ -40,6 +42,8 @@ type Ticket struct {
Skipped bool Skipped bool
IsDue bool IsDue bool
Urgent bool Urgent bool
// AssigneeColor 打手颜色(未指派为空,模板用 spanAssignee 兜底)
AssigneeColor string
} }
// TaskRow 是当日待打清单的一行。 // TaskRow 是当日待打清单的一行。
@@ -280,6 +284,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
Skipped: ci.Status == "skipped", Skipped: ci.Status == "skipped",
IsDue: cs.sub.EndDate == ds, IsDue: cs.sub.EndDate == ds,
Urgent: isUrgent, Urgent: isUrgent,
AssigneeColor: cs.sub.AssigneeColor,
} }
if t.Done || t.Skipped { if t.Done || t.Skipped {
cell.DoneCount++ cell.DoneCount++
@@ -295,6 +300,8 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
} }
return tickets[i].SubID < tickets[j].SubID return tickets[i].SubID < tickets[j].SubID
}) })
// 竖条按“当天全部任务”算,不能被色签的 3 条上限截断
cell.Strip = assigneeStrip(tickets)
if len(tickets) > 3 { if len(tickets) > 3 {
cell.More = len(tickets) - 3 cell.More = len(tickets) - 3
tickets = tickets[:3] tickets = tickets[:3]
@@ -426,6 +433,27 @@ func sortTasks(tasks []TaskRow) {
}) })
} }
// 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 { func heatLevel(n int) int {
switch { switch {
case n <= 0: case n <= 0:
+39 -6
View File
@@ -51,15 +51,17 @@ func (s *Server) handleCustomers(w http.ResponseWriter, r *http.Request) {
type customerFormData struct { type customerFormData struct {
Customer *store.Customer Customer *store.Customer
IsNew bool IsNew bool
Users []store.User
} }
func (s *Server) handleCustomerNewPage(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCustomerNewPage(w http.ResponseWriter, r *http.Request) {
p := s.newPage(r, "新建客户", "customers") 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) 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{ return &store.Customer{
Tail: strings.TrimSpace(r.PostFormValue("tail")), Tail: strings.TrimSpace(r.PostFormValue("tail")),
Name: strings.TrimSpace(r.PostFormValue("name")), Name: strings.TrimSpace(r.PostFormValue("name")),
@@ -69,6 +71,7 @@ func readCustomerForm(r *http.Request) *store.Customer {
Tags: normalizeTags(r.PostFormValue("tags")), Tags: normalizeTags(r.PostFormValue("tags")),
Source: strings.TrimSpace(r.PostFormValue("source")), Source: strings.TrimSpace(r.PostFormValue("source")),
Note: strings.TrimSpace(r.PostFormValue("note")), 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) { func (s *Server) handleCustomerCreate(w http.ResponseWriter, r *http.Request) {
c := readCustomerForm(r) c := s.readCustomerForm(r)
p := s.newPage(r, "新建客户", "customers") p := s.newPage(r, "新建客户", "customers")
if msg := validateCustomer(c); msg != "" { 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) s.fail(w, r, "customer_form", msg, p)
return return
} }
@@ -117,7 +121,8 @@ func (s *Server) handleCustomerCreate(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, store.ErrDuplicate) { if errors.Is(err, store.ErrDuplicate) {
msg = "该「尾号 + 姓名」的客户已存在,请直接搜索后追加订阅" 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) s.fail(w, r, "customer_form", msg, p)
return return
} }
@@ -143,7 +148,7 @@ func (s *Server) handleCustomerUpdate(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
c := readCustomerForm(r) c := s.readCustomerForm(r)
c.ID = id c.ID = id
p := s.newPage(r, "编辑客户", "customers") p := s.newPage(r, "编辑客户", "customers")
if msg := validateCustomer(c); msg != "" { if msg := validateCustomer(c); msg != "" {
@@ -179,6 +184,7 @@ type customerDetailData struct {
AdjSubColor map[int64]string AdjSubColor map[int64]string
Checkins []CheckinRow Checkins []CheckinRow
Packages []store.Package Packages []store.Package
Users []store.User
Timeline *Timeline Timeline *Timeline
Extend *ExtendPanel Extend *ExtendPanel
TotalRemain string TotalRemain string
@@ -208,7 +214,9 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
} }
pauses, _ := s.db.PausesForSubs(ids) pauses, _ := s.db.PausesForSubs(ids)
users, _ := s.db.ActiveUsers()
data := &customerDetailData{ data := &customerDetailData{
Users: users,
Customer: c, Customer: c,
Subs: subViews(subs, today, warn), Subs: subViews(subs, today, warn),
Packages: pkgs, Packages: pkgs,
@@ -247,6 +255,31 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "customer_detail", p) 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) { func (s *Server) handleSubCreate(w http.ResponseWriter, r *http.Request) {
customerID := pathID(r) customerID := pathID(r)
c, err := s.db.Customer(customerID) c, err := s.db.Customer(customerID)
+23
View File
@@ -152,6 +152,29 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "settings", p) s.render(w, r, "settings", p)
} }
// 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) { func (s *Server) handleSettingsSave(w http.ResponseWriter, r *http.Request) {
warn := s.formInt(r, "warn_days", 3) warn := s.formInt(r, "warn_days", 3)
if warn < 0 { if warn < 0 {
+17
View File
@@ -18,6 +18,8 @@ type screenDay struct {
Count int Count int
Urgent int Urgent int
Heat int Heat int
// Strip 打手色竖条(去重)
Strip []string
} }
type screenTask struct { type screenTask struct {
@@ -124,6 +126,8 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
d := domain.AddDays(gridStart, wi*7+di) d := domain.AddDays(gridStart, wi*7+di)
ds := domain.Date(d) ds := domain.Date(d)
count, urgentN := 0, 0 count, urgentN := 0, 0
strip := []string{}
seenColor := map[string]bool{}
for _, cs := range list { for _, cs := range list {
if heatMode == "due" { if heatMode == "due" {
if cs.sub.EndDate == ds { if cs.sub.EndDate == ds {
@@ -142,6 +146,14 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
if urgent[cs.sub.ID][ds] { if urgent[cs.sub.ID][ds] {
urgentN++ urgentN++
} }
color := cs.sub.AssigneeColor
if color == "" {
color = "#8A8F98"
}
if !seenColor[color] {
seenColor[color] = true
strip = append(strip, color)
}
} }
if d.Month() == today.Month() { if d.Month() == today.Month() {
data.HeatTotal += count data.HeatTotal += count
@@ -155,6 +167,7 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
Count: count, Count: count,
Urgent: urgentN, Urgent: urgentN,
Heat: heatLevel(count), Heat: heatLevel(count),
Strip: strip,
}) })
} }
data.Weeks = append(data.Weeks, week) data.Weeks = append(data.Weeks, week)
@@ -225,6 +238,8 @@ func (s *Server) handleScreenDay(w http.ResponseWriter, r *http.Request) {
Remain string `json:"remain"` Remain string `json:"remain"`
Urgent bool `json:"urgent"` Urgent bool `json:"urgent"`
Done bool `json:"done"` Done bool `json:"done"`
Who string `json:"who"`
WhoCol string `json:"who_color"`
} }
out := struct { out := struct {
Date string `json:"date"` Date string `json:"date"`
@@ -256,6 +271,8 @@ func (s *Server) handleScreenDay(w http.ResponseWriter, r *http.Request) {
Remain: row.RemainText, Remain: row.RemainText,
Urgent: row.Urgent, Urgent: row.Urgent,
Done: row.Done || row.Skipped, Done: row.Done || row.Skipped,
Who: row.AssigneeLabel(),
WhoCol: row.AssigneeHex(),
}) })
} }
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
+2
View File
@@ -104,6 +104,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /customers/{id}", s.auth(s.handleCustomerDetail)) mux.HandleFunc("GET /customers/{id}", s.auth(s.handleCustomerDetail))
mux.HandleFunc("GET /customers/{id}/edit", s.auth(s.handleCustomerEditPage)) 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}/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("POST /customers/{id}/subscriptions", s.auth(s.handleSubCreate))
mux.HandleFunc("GET /subscriptions", s.auth(s.handleSubscriptions)) mux.HandleFunc("GET /subscriptions", s.auth(s.handleSubscriptions))
mux.HandleFunc("POST /subscriptions/{id}/extend", s.auth(s.handleSubExtend)) mux.HandleFunc("POST /subscriptions/{id}/extend", s.auth(s.handleSubExtend))
@@ -136,6 +137,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /settings/password", s.auth(s.handlePasswordChange)) mux.HandleFunc("POST /settings/password", s.auth(s.handlePasswordChange))
mux.HandleFunc("POST /settings/users", s.auth(s.handleUserCreate)) 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}/toggle", s.auth(s.handleUserToggle))
mux.HandleFunc("POST /settings/users/{id}/color", s.auth(s.handleUserColor))
mux.HandleFunc("POST /settings/backup", s.auth(s.handleBackupNow)) mux.HandleFunc("POST /settings/backup", s.auth(s.handleBackupNow))
mux.HandleFunc("GET /export/{kind}", s.auth(s.handleExport)) mux.HandleFunc("GET /export/{kind}", s.auth(s.handleExport))
+54
View File
@@ -845,3 +845,57 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } * { 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; }
}
+28
View File
@@ -381,3 +381,31 @@ body::before {
.scr-main, .scr-bottom { grid-template-columns: 1fr; } .scr-main, .scr-bottom { grid-template-columns: 1fr; }
.scr-tabs, .scr-links { display: none; } .scr-tabs, .scr-links { display: none; }
} }
/* ---------- 打手颜色(大屏) ---------- */
.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;
}
+1 -1
View File
@@ -85,7 +85,7 @@
data.items.forEach(function (it) { data.items.forEach(function (it) {
html += '<li class="' + (it.urgent ? 'urgent ' : '') + (it.done ? 'done' : '') + '">' + html += '<li class="' + (it.urgent ? 'urgent ' : '') + (it.done ? 'done' : '') + '">' +
'<span class="pop-tail">' + esc(it.tail) + '</span>' + '<span class="pop-tail">' + esc(it.tail) + '</span>' +
'<span class="pop-name">' + esc(it.name) + ' · ' + esc(it.pkg) + '</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>' + '<span class="pop-rem">' + (it.urgent ? '加急 · ' : '') + esc(it.remain) + '</span>' +
'</li>'; '</li>';
}); });
+3 -1
View File
@@ -43,6 +43,7 @@
{{range .}} {{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}}" <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}}&pkg={{$.D.PkgID}}" 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"> <div class="cell-top">
<span class="dnum">{{.Day}}</span> <span class="dnum">{{.Day}}</span>
<span class="cell-badges"> <span class="cell-badges">
@@ -124,7 +125,7 @@
{{if .D.Tasks}} {{if .D.Tasks}}
<ul class="task-list"> <ul class="task-list">
{{range .D.Tasks}} {{range .D.Tasks}}
<li class="task{{if .Done}} done{{end}}{{if .Skipped}} skipped{{end}}{{if .Urgent}} urgent{{end}}"> <li class="task{{if .Done}} done{{end}}{{if .Skipped}} skipped{{end}}{{if .Urgent}} urgent{{end}}" style="--asg:{{.AssigneeHex}}">
<div class="task-main"> <div class="task-main">
<a class="task-title" href="/customers/{{.CustomerID}}"> <a class="task-title" href="/customers/{{.CustomerID}}">
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span> <span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
@@ -132,6 +133,7 @@
{{if .Urgent}}<span class="tag-urgent">加急</span>{{end}} {{if .Urgent}}<span class="tag-urgent">加急</span>{{end}}
</a> </a>
<div class="task-meta"> <div class="task-meta">
<span class="asg{{if .AssigneeNone}} none{{end}}"><i style="background:{{.AssigneeHex}}"></i>{{.AssigneeLabel}}</span>
<span>{{.RemainText}}</span> <span>{{.RemainText}}</span>
{{if .DueText}}<span class="due due-{{.Eval.Level}}">{{.DueText}}</span>{{end}} {{if .DueText}}<span class="due due-{{.Eval.Level}}">{{.DueText}}</span>{{end}}
</div> </div>
@@ -15,6 +15,16 @@
{{if .D.Customer.GameServer}}<div><dt>区服</dt><dd>{{.D.Customer.GameServer}}</dd></div>{{end}} {{if .D.Customer.GameServer}}<div><dt>区服</dt><dd>{{.D.Customer.GameServer}}</dd></div>{{end}}
{{if .D.Customer.Contact}}<div><dt>联系</dt><dd>{{.D.Customer.Contact}}</dd></div>{{end}} {{if .D.Customer.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}} {{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> <div><dt>建档</dt><dd>{{.D.Customer.CreatedAt}}</dd></div>
</dl> </dl>
<div class="page-actions"> <div class="page-actions">
@@ -29,7 +29,17 @@
<label class="field"><span>联系方式</span> <label class="field"><span>联系方式</span>
<input name="contact" value="{{.D.Customer.Contact}}" maxlength="64" placeholder="微信 / QQ"> <input name="contact" value="{{.D.Customer.Contact}}" maxlength="64" placeholder="微信 / QQ">
</label> </label>
<label class="field"><span>来源渠道</span> <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="抖音 / 朋友介绍"> <input name="source" value="{{.D.Customer.Source}}" maxlength="32" placeholder="抖音 / 朋友介绍">
</label> </label>
</div> </div>
+2
View File
@@ -54,6 +54,7 @@
{{range .}} {{range .}}
<div class="heat-cell h{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if .IsPast}} past{{end}}" <div class="heat-cell h{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if .IsPast}} past{{end}}"
data-date="{{.Date}}" title="{{.Date}}"> 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> <span>{{.Day}}</span>
{{if gt .Urgent 0}}<u class="cell-urgent"></u>{{end}} {{if gt .Urgent 0}}<u class="cell-urgent"></u>{{end}}
{{if .Count}}<b>{{.Count}}</b>{{end}} {{if .Count}}<b>{{.Count}}</b>{{end}}
@@ -122,6 +123,7 @@
<li class="{{if .Urgent}}urgent{{end}} lv-{{.Eval.Level}}"> <li class="{{if .Urgent}}urgent{{end}} lv-{{.Eval.Level}}">
<span class="tchip" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span> <span class="tchip" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
<span class="pkg">{{.PackageName}}</span> <span class="pkg">{{.PackageName}}</span>
<span class="asg{{if .AssigneeNone}} none{{end}}"><i style="background:{{.AssigneeHex}}"></i>{{.AssigneeLabel}}</span>
{{if .Urgent}}<span class="tag-urgent">加急</span>{{end}} {{if .Urgent}}<span class="tag-urgent">加急</span>{{end}}
<span class="rem">{{.RemainShort}}</span> <span class="rem">{{.RemainShort}}</span>
</li> </li>
+8 -2
View File
@@ -25,16 +25,22 @@
<section class="section card"> <section class="section card">
<h2>管理员</h2> <h2>管理员</h2>
<p class="muted small">所有账户权限相同,任何操作都会记在日志里。</p> <p class="muted small">所有账户权限相同,任何操作都会记在日志里。每个管理员的颜色用于日历左侧竖条和待办标识。</p>
<div class="table-wrap"> <div class="table-wrap">
<table class="table tight"> <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> <tbody>
{{$me := .User}} {{$me := .User}}
{{range .D.Users}} {{range .D.Users}}
<tr> <tr>
<td class="mono">{{.Username}}</td> <td class="mono">{{.Username}}</td>
<td>{{.DisplayName}}</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>{{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">{{.CreatedAt}}</td>
<td class="mono small">{{if .LastLoginAt}}{{.LastLoginAt}}{{else}}—{{end}}</td> <td class="mono small">{{if .LastLoginAt}}{{.LastLoginAt}}{{else}}—{{end}}</td>
+19
View File
@@ -31,6 +31,25 @@ type SubView struct {
CanCheckinToday bool 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) { func stateLabel(state string) (string, string) {
switch state { switch state {
case domain.StateActive: case domain.StateActive:
+1 -1
View File
@@ -26,7 +26,7 @@ import (
"daili/internal/web" "daili/internal/web"
) )
const version = "1.2.0" const version = "1.3.0"
func main() { func main() {
cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")") cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")")