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:
@@ -136,6 +136,10 @@
|
||||
- 同一尾号有多个客户,或一个客户今天有多个服务:弹出勾选面板,默认全选,可多选后一次完成。
|
||||
- **加急**:每条任务右侧点「加急」,会加红色 `加急` 标签并排到最前;日历格子出现红点,大屏「今日待打」同步置顶。再点一次取消。
|
||||
- **按套餐分日历**:套餐编辑页勾选「在日历中独立显示」后,工作台与大屏顶部会出现套餐标签(全部 / 每日任务 / …),可以只看某类委托的排单。
|
||||
- **打手颜色**:`设置 → 管理员` 里给每个管理员选一个颜色。客户详情或编辑页可以把客户指派给某个打手,之后这个客户的所有单子都用该颜色标识:
|
||||
- 日历格左侧竖条 = 当天涉及的所有打手颜色(几种人几条,等分显示);
|
||||
- 「当日待完成」每条前面有打手色条 + 名字,未指派的显示灰色「未指派」;
|
||||
- 大屏的今日待打、悬停浮层、热力格竖条同样带颜色。
|
||||
- **主题**:顶栏右上角 ☾ / ☀ 切换亮 / 暗,偏好记在浏览器里;不选时跟随系统。
|
||||
|
||||
---
|
||||
@@ -152,7 +156,7 @@
|
||||
|
||||
- **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。
|
||||
- **重置**:关闭程序 → 删除 `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/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。
|
||||
|
||||
- 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -121,6 +121,15 @@ func (db *DB) Migrate() error {
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -161,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
|
||||
)`,
|
||||
@@ -195,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)
|
||||
|
||||
@@ -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)
|
||||
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 {
|
||||
@@ -797,6 +824,28 @@ func TestMigrateOldDatabaseAddsColumn(t *testing.T) {
|
||||
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 {
|
||||
@@ -807,3 +856,104 @@ func TestMigrateOldDatabaseAddsColumn(t *testing.T) {
|
||||
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("停用账户不应出现在打手候选里")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+90
-5
@@ -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,66 @@ 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()
|
||||
}
|
||||
|
||||
// Authenticate 校验用户名口令。
|
||||
|
||||
@@ -27,6 +27,8 @@ type DayCell struct {
|
||||
Heat int
|
||||
Tickets []Ticket
|
||||
More int
|
||||
// Strip 是左侧的打手色竖条(按出现顺序去重,未指派为灰)
|
||||
Strip []string
|
||||
}
|
||||
|
||||
// Ticket 是日历格里的色签。
|
||||
@@ -40,6 +42,8 @@ type Ticket struct {
|
||||
Skipped bool
|
||||
IsDue bool
|
||||
Urgent bool
|
||||
// AssigneeColor 打手颜色(未指派为空,模板用 spanAssignee 兜底)
|
||||
AssigneeColor string
|
||||
}
|
||||
|
||||
// TaskRow 是当日待打清单的一行。
|
||||
@@ -271,15 +275,16 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
cell.UrgentCnt++
|
||||
}
|
||||
t := Ticket{
|
||||
SubID: cs.sub.ID,
|
||||
Tail: cs.sub.CustomerTail,
|
||||
Name: cs.sub.CustomerName,
|
||||
Pkg: shortName(cs.sub.PackageName),
|
||||
Color: cs.sub.PackageColor,
|
||||
Done: ci.Status == "done",
|
||||
Skipped: ci.Status == "skipped",
|
||||
IsDue: cs.sub.EndDate == ds,
|
||||
Urgent: isUrgent,
|
||||
SubID: cs.sub.ID,
|
||||
Tail: cs.sub.CustomerTail,
|
||||
Name: cs.sub.CustomerName,
|
||||
Pkg: shortName(cs.sub.PackageName),
|
||||
Color: cs.sub.PackageColor,
|
||||
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++
|
||||
@@ -295,6 +300,8 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return tickets[i].SubID < tickets[j].SubID
|
||||
})
|
||||
// 竖条按“当天全部任务”算,不能被色签的 3 条上限截断
|
||||
cell.Strip = assigneeStrip(tickets)
|
||||
if len(tickets) > 3 {
|
||||
cell.More = len(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 {
|
||||
switch {
|
||||
case n <= 0:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -152,6 +152,29 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
warn := s.formInt(r, "warn_days", 3)
|
||||
if warn < 0 {
|
||||
|
||||
@@ -18,6 +18,8 @@ type screenDay struct {
|
||||
Count int
|
||||
Urgent int
|
||||
Heat int
|
||||
// Strip 打手色竖条(去重)
|
||||
Strip []string
|
||||
}
|
||||
|
||||
type screenTask struct {
|
||||
@@ -124,6 +126,8 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
||||
d := domain.AddDays(gridStart, wi*7+di)
|
||||
ds := domain.Date(d)
|
||||
count, urgentN := 0, 0
|
||||
strip := []string{}
|
||||
seenColor := map[string]bool{}
|
||||
for _, cs := range list {
|
||||
if heatMode == "due" {
|
||||
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] {
|
||||
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
|
||||
@@ -155,6 +167,7 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
||||
Count: count,
|
||||
Urgent: urgentN,
|
||||
Heat: heatLevel(count),
|
||||
Strip: strip,
|
||||
})
|
||||
}
|
||||
data.Weeks = append(data.Weeks, week)
|
||||
@@ -225,6 +238,8 @@ func (s *Server) handleScreenDay(w http.ResponseWriter, r *http.Request) {
|
||||
Remain string `json:"remain"`
|
||||
Urgent bool `json:"urgent"`
|
||||
Done bool `json:"done"`
|
||||
Who string `json:"who"`
|
||||
WhoCol string `json:"who_color"`
|
||||
}
|
||||
out := struct {
|
||||
Date string `json:"date"`
|
||||
@@ -256,6 +271,8 @@ func (s *Server) handleScreenDay(w http.ResponseWriter, r *http.Request) {
|
||||
Remain: row.RemainText,
|
||||
Urgent: row.Urgent,
|
||||
Done: row.Done || row.Skipped,
|
||||
Who: row.AssigneeLabel(),
|
||||
WhoCol: row.AssigneeHex(),
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
@@ -104,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))
|
||||
@@ -136,6 +137,7 @@ 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("POST /settings/backup", s.auth(s.handleBackupNow))
|
||||
mux.HandleFunc("GET /export/{kind}", s.auth(s.handleExport))
|
||||
|
||||
|
||||
@@ -845,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; }
|
||||
}
|
||||
|
||||
@@ -381,3 +381,31 @@ body::before {
|
||||
.scr-main, .scr-bottom { grid-template-columns: 1fr; }
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
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">' + 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>' +
|
||||
'</li>';
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
{{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}}&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">
|
||||
@@ -124,7 +125,7 @@
|
||||
{{if .D.Tasks}}
|
||||
<ul class="task-list">
|
||||
{{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">
|
||||
<a class="task-title" href="/customers/{{.CustomerID}}">
|
||||
<span class="ticket" style="--tag:{{.PackageColor}}"><b>{{.CustomerTail}}</b>{{.CustomerName}}</span>
|
||||
@@ -132,6 +133,7 @@
|
||||
{{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>
|
||||
|
||||
@@ -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,7 +29,17 @@
|
||||
<label class="field"><span>联系方式</span>
|
||||
<input name="contact" value="{{.D.Customer.Contact}}" maxlength="64" placeholder="微信 / QQ">
|
||||
</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="抖音 / 朋友介绍">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
{{range .}}
|
||||
<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 gt .Urgent 0}}<u class="cell-urgent"></u>{{end}}
|
||||
{{if .Count}}<b>{{.Count}}</b>{{end}}
|
||||
@@ -122,6 +123,7 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -25,16 +25,22 @@
|
||||
|
||||
<section class="section card">
|
||||
<h2>管理员</h2>
|
||||
<p class="muted small">所有账户权限相同,任何操作都会记在日志里。</p>
|
||||
<p class="muted small">所有账户权限相同,任何操作都会记在日志里。每个管理员的颜色用于日历左侧竖条和待办标识。</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>
|
||||
|
||||
+26
-7
@@ -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:
|
||||
@@ -329,13 +348,13 @@ func buildTimeline(subs []store.Subscription, pauses map[int64][]store.Pause, to
|
||||
en = st
|
||||
}
|
||||
b := Bar{
|
||||
SubID: s.ID,
|
||||
Name: s.PackageName,
|
||||
Color: s.PackageColor,
|
||||
Left: pos(st),
|
||||
Width: float64(domain.DayDiff(st, en)+1) / float64(totalDays) * 100,
|
||||
Label: domain.Date(st) + " → " + domain.Date(en),
|
||||
State: s.Status,
|
||||
SubID: s.ID,
|
||||
Name: s.PackageName,
|
||||
Color: s.PackageColor,
|
||||
Left: pos(st),
|
||||
Width: float64(domain.DayDiff(st, en)+1) / float64(totalDays) * 100,
|
||||
Label: domain.Date(st) + " → " + domain.Date(en),
|
||||
State: s.Status,
|
||||
HasDue: s.EndDate != "",
|
||||
}
|
||||
b.DueLeft = pos(en)
|
||||
|
||||
Reference in New Issue
Block a user