diff --git a/README.md b/README.md index 9cfe049..3e6e23d 100644 --- a/README.md +++ b/README.md @@ -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/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。 - 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库; diff --git a/internal/store/customers.go b/internal/store/customers.go index ee7fc03..ec57150 100644 --- a/internal/store/customers.go +++ b/internal/store/customers.go @@ -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 } diff --git a/internal/store/db.go b/internal/store/db.go index db5e6ce..5b140b5 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -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) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index d2993dd..864e657 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -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("停用账户不应出现在打手候选里") + } + } +} diff --git a/internal/store/subscriptions.go b/internal/store/subscriptions.go index 01984c1..f32782a 100644 --- a/internal/store/subscriptions.go +++ b/internal/store/subscriptions.go @@ -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 diff --git a/internal/store/users.go b/internal/store/users.go index fcba725..56453ac 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -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 校验用户名口令。 diff --git a/internal/web/handlers_calendar.go b/internal/web/handlers_calendar.go index a18ffe0..65a4f2d 100644 --- a/internal/web/handlers_calendar.go +++ b/internal/web/handlers_calendar.go @@ -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: diff --git a/internal/web/handlers_customers.go b/internal/web/handlers_customers.go index 4984ac1..619c2e6 100644 --- a/internal/web/handlers_customers.go +++ b/internal/web/handlers_customers.go @@ -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) diff --git a/internal/web/handlers_misc.go b/internal/web/handlers_misc.go index b3c5718..271b700 100644 --- a/internal/web/handlers_misc.go +++ b/internal/web/handlers_misc.go @@ -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 { diff --git a/internal/web/handlers_screen.go b/internal/web/handlers_screen.go index 9861548..ae9320c 100644 --- a/internal/web/handlers_screen.go +++ b/internal/web/handlers_screen.go @@ -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") diff --git a/internal/web/server.go b/internal/web/server.go index 82b0b58..6820fcc 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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)) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 0659391..830ec76 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -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; } +} diff --git a/internal/web/static/screen.css b/internal/web/static/screen.css index 9404c3a..798f17a 100644 --- a/internal/web/static/screen.css +++ b/internal/web/static/screen.css @@ -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; +} diff --git a/internal/web/static/screen.js b/internal/web/static/screen.js index e86749e..72393c7 100644 --- a/internal/web/static/screen.js +++ b/internal/web/static/screen.js @@ -85,7 +85,7 @@ data.items.forEach(function (it) { html += '
  • ' + '' + esc(it.tail) + '' + - '' + esc(it.name) + ' · ' + esc(it.pkg) + '' + + '' + esc(it.who || '未指派') + ' · ' + esc(it.pkg) + '' + '' + (it.urgent ? '加急 · ' : '') + esc(it.remain) + '' + '
  • '; }); diff --git a/internal/web/templates/pages/calendar.html b/internal/web/templates/pages/calendar.html index 823120a..43e860f 100644 --- a/internal/web/templates/pages/calendar.html +++ b/internal/web/templates/pages/calendar.html @@ -43,6 +43,7 @@ {{range .}} + {{if .Strip}}{{end}}
    {{.Day}} @@ -124,7 +125,7 @@ {{if .D.Tasks}}