From 1f7d0f0cc431467340fc83c03271166b62cd862b Mon Sep 17 00:00:00 2001 From: subtrack Date: Mon, 19 Oct 2026 02:12:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(v1.5):=20=E8=A7=A6=E6=91=B8=E9=94=AE?= =?UTF-8?q?=E7=9B=98=E3=80=81=E6=A0=BC=E5=AD=90=E5=A4=A7=E5=AD=97=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E3=80=81=E5=AE=A2=E6=88=B7=E5=81=9C=E7=94=A8/?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E3=80=81=E6=8C=89=E9=92=AE=E6=94=B6=E7=BA=B3?= =?UTF-8?q?=E3=80=817=20=E5=A4=A9=E5=BE=BD=E6=A0=87=E3=80=81=E6=8A=BD?= =?UTF-8?q?=E5=B1=89=E6=97=B6=E9=92=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) 工作台速录 - 手机尾号输入框下方新增 3×4 触摸小键盘(1-9、0、⌫、✓),点 ✓ 直接提交;实体键盘仍可输入 - 抽屉顶部新增秒级时钟(HH:MM:SS),位于日期标题上方 2) 日历格子 - 移除排单缩略信息(客户名/套餐色签/“x/y 单”小字) - 保留左侧打手颜色竖条,中间改为大字「待完成 / 总数」,例如 1/2 = 还剩 1 单、共 2 单 - 大屏格子按此前约定保持原样 3) 客户停用 / 恢复 / 彻底删除 - customers 新增 archived 列(0/1);停用后默认从客户列表、日历、待办、大屏、到期提醒、徽标中隐藏,订阅与打卡记录原样保留 - 恢复后立即回到日历;列表右上角可切换「显示已停用」,已停用行带标记 - 彻底删除为二次确认操作,依赖外键级联清理订阅、打卡、暂停、追加记录与加急标记 - 停用/恢复/删除都会写入操作日志 4) 按钮收纳 - 任务行只露出「完成」,跳过 / 加急(取消加急)/ 追加收进「⋯」菜单;已完成行只露「撤销打卡」等,点空白处自动收起 - 加急、跳过等原有能力不变,仅入口折叠 5) 顶栏 - 在「今日到期」「3 天内到期」右侧新增「7 天内到期」徽标,固定统计未来 7 天 6) 视觉 - 移除管理端背景的横线装饰,改为纯底色;大屏网格背景保留 数据库兼容性: - 只新增 customers.archived 一列,老库启动时自动 ALTER,可重复迁移;原有表结构与接口不变 测试: - 新增单测 TestCustomerArchiveFilters:停用后日历/到期/徽标/搜索/列表全部过滤、恢复回日历、彻底删除级联清理订阅与打卡 - 功能验证 20 项(键盘、格子计数、折叠菜单、7 天徽标、时钟、停用/恢复/删除、背景)全部通过 - 原有 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.5.0 --- README.md | 8 +- internal/store/customers.go | 61 ++++++++++-- internal/store/db.go | 5 + internal/store/stats.go | 33 ++++--- internal/store/store_test.go | 93 ++++++++++++++++++- internal/store/subscriptions.go | 7 +- internal/web/handlers_calendar.go | 7 +- internal/web/handlers_customers.go | 75 +++++++++++++-- internal/web/server.go | 8 +- internal/web/static/app.css | 82 +++++++++++++++- internal/web/static/app.js | 48 +++++++++- internal/web/templates/_layout.html | 1 + internal/web/templates/pages/calendar.html | 69 +++++++++----- .../web/templates/pages/customer_detail.html | 11 ++- internal/web/templates/pages/customers.html | 9 +- main.go | 2 +- 16 files changed, 447 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 93aa7c5..3c8f106 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,12 @@ - 同一尾号有多个客户,或一个客户今天有多个服务:弹出勾选面板,默认全选,可多选后一次完成。 - **加急**:每条任务右侧点「加急」,会加红色 `加急` 标签并排到最前;日历格子出现红点,大屏「今日待打」同步置顶。再点一次取消。 - **按套餐分日历**:套餐编辑页勾选「在日历中独立显示」后,工作台与大屏顶部会出现套餐标签(全部 / 每日任务 / …),可以只看某类委托的排单。 +- **触摸小键盘**:日历右侧输入框下面有 3×4 数字键盘(1-9、0、⌫、✓),点 ✓ 直接提交,适合触摸屏/收银机; +- **日历格子**:不再显示排单缩略信息,只保留左侧打手色竖条 + 大字「待完成 / 总数」(如 1/2 = 还剩 1 单,共 2 单); +- **按钮收纳**:任务行只露出「完成」,跳过 / 加急 / 追加收进「⋯」菜单,点空白处自动收起; +- **停用客户**:客户详情页可「停用」(默认从列表、日历、大屏隐藏,订阅保留)或「彻底删除」(连订阅/打卡/追加记录一起删,需二次确认);列表右上角可切换到「显示已停用」再恢复; +- **7 天内到期**:顶栏新增徽标,固定统计未来 7 天内到期的订阅; +- **抽屉时钟**:右侧当日面板顶部有时钟,精确到秒; - **打手颜色**:`设置 → 管理员` 里给每个管理员选一个颜色。客户详情或编辑页可以把客户指派给某个打手,之后这个客户的所有单子都用该颜色标识: - 日历格左侧竖条 = 当天涉及的所有打手颜色(几种人几条,等分显示); - 「当日待完成」每条前面有打手色条 + 名字,未指派的显示灰色「未指派」; @@ -156,7 +162,7 @@ - **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。 - **重置**:关闭程序 → 删除 `daili.db` → 重新打开,会回到「创建第一个管理员」的全新状态。 -- 打手颜色存在 `users.color`,客户归属打手是 `customers.assignee_id`;加急标记存在 `daili.db` 的 `task_flags` 表;套餐的「独立日历」是 `packages.calendar_separate` 列,老版本数据库打开时会**自动加列**,不用手工迁移; +- 客户停用是 `customers.archived`(0/1);打手颜色存在 `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 ec57150..f3de72b 100644 --- a/internal/store/customers.go +++ b/internal/store/customers.go @@ -28,6 +28,9 @@ type Customer struct { AssigneeName string AssigneeColor string + // Archived 停用(软删除):默认隐藏,订阅保留但不上日历 + Archived bool + // 列表聚合 SubCount int Summary string @@ -46,19 +49,21 @@ func (c *Customer) TagList() []string { return out } -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),'')` +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),''),IFNULL(archived,0)` func scanCustomer(row interface{ Scan(dest ...any) error }) (*Customer, error) { var c Customer + var archived int 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.AssigneeID, &c.AssigneeName, &c.AssigneeColor) + &c.AssigneeID, &c.AssigneeName, &c.AssigneeColor, &archived) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, err } + c.Archived = archived == 1 return &c, nil } @@ -96,12 +101,19 @@ func (db *DB) Customer(id int64) (*Customer, error) { } // ListCustomers 分页查询客户,并聚合订阅摘要。 -func (db *DB) ListCustomers(q string, limit, offset int, today string, warnDays int) ([]Customer, int, error) { +func (db *DB) ListCustomers(q string, limit, offset int, today string, warnDays int, includeArchived bool) ([]Customer, int, error) { where := "1=1" args := []any{} + if !includeArchived { + where = "IFNULL(archived,0)=0" + } if q = strings.TrimSpace(q); q != "" { like := "%" + q + "%" - where = "(tail LIKE ? OR name LIKE ? OR game_name LIKE ? OR contact LIKE ? OR tags LIKE ?)" + if includeArchived { + where = "(tail LIKE ? OR name LIKE ? OR game_name LIKE ? OR contact LIKE ? OR tags LIKE ?)" + } else { + where = "IFNULL(archived,0)=0 AND (tail LIKE ? OR name LIKE ? OR game_name LIKE ? OR contact LIKE ? OR tags LIKE ?)" + } args = append(args, like, like, like, like, like) } var total int @@ -202,7 +214,7 @@ func (db *DB) FindCustomerByTailName(tail, name string) (*Customer, error) { // CustomerCount 客户总数。 func (db *DB) CustomerCount() (int, error) { var n int - err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers`).Scan(&n) + err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers WHERE IFNULL(archived,0)=0`).Scan(&n) return n, err } @@ -212,14 +224,14 @@ func (db *DB) CustomersByTail(tail string) ([]Customer, error) { if tail == "" { return nil, nil } - out, err := db.queryCustomers(`SELECT `+customerCols+` FROM customers WHERE tail = ? ORDER BY id`, tail) + out, err := db.queryCustomers(`SELECT `+customerCols+` FROM customers WHERE IFNULL(archived,0)=0 AND tail = ? ORDER BY id`, tail) if err != nil { return nil, err } if len(out) > 0 { return out, nil } - return db.queryCustomers(`SELECT `+customerCols+` FROM customers WHERE tail LIKE ? ORDER BY id LIMIT 20`, "%"+tail+"%") + return db.queryCustomers(`SELECT `+customerCols+` FROM customers WHERE IFNULL(archived,0)=0 AND tail LIKE ? ORDER BY id LIMIT 20`, "%"+tail+"%") } func (db *DB) queryCustomers(q string, args ...any) ([]Customer, error) { @@ -238,3 +250,38 @@ func (db *DB) queryCustomers(q string, args ...any) ([]Customer, error) { } return out, rows.Err() } + +// SetCustomerArchived 停用/恢复客户。停用后其订阅保留,但不再上日历与大屏。 +func (db *DB) SetCustomerArchived(id int64, archived bool) error { + v := 0 + if archived { + v = 1 + } + res, err := db.sql.Exec(`UPDATE customers SET archived=?, updated_at=? WHERE id=?`, v, nowStamp(), id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// DeleteCustomer 彻底删除客户;订阅、打卡、暂停、追加记录随外键级联删除。 +func (db *DB) DeleteCustomer(id int64) error { + res, err := db.sql.Exec(`DELETE FROM customers WHERE id=?`, id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// CountArchivedCustomers 统计已停用客户数。 +func (db *DB) CountArchivedCustomers() (int, error) { + var n int + err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers WHERE IFNULL(archived,0)=1`).Scan(&n) + return n, err +} diff --git a/internal/store/db.go b/internal/store/db.go index 5b140b5..4560cbf 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -130,6 +130,10 @@ func (db *DB) Migrate() error { "ALTER TABLE customers ADD COLUMN assignee_id INTEGER REFERENCES users(id)"); err != nil { return err } + if err := db.ensureColumn("customers", "archived", + "ALTER TABLE customers ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"); err != nil { + return err + } return nil } @@ -206,6 +210,7 @@ var schemaStatements = []string{ source TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '', assignee_id INTEGER REFERENCES users(id), + archived INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (tail, name) diff --git a/internal/store/stats.go b/internal/store/stats.go index f3010af..31e2fa5 100644 --- a/internal/store/stats.go +++ b/internal/store/stats.go @@ -22,13 +22,18 @@ type Stats struct { } // Badges 顶栏常驻徽标。 -func (db *DB) Badges(today time.Time) (dueToday, due3 int, err error) { +func (db *DB) Badges(today time.Time) (dueToday, due3, due7 int, err error) { t := domain.Date(today) - if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date=?`, t).Scan(&dueToday); err != nil { + const notArchived = ` AND IFNULL((SELECT archived FROM customers c WHERE c.id=customer_id),0)=0` + if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date=?`+notArchived, t).Scan(&dueToday); err != nil { return } - err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date BETWEEN ? AND ?`, - t, domain.Date(domain.AddDays(today, 2))).Scan(&due3) + if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date BETWEEN ? AND ?`+notArchived, + t, domain.Date(domain.AddDays(today, 2))).Scan(&due3); err != nil { + return + } + err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date BETWEEN ? AND ?`+notArchived, + t, domain.Date(domain.AddDays(today, 6))).Scan(&due7) return } @@ -42,14 +47,17 @@ func (db *DB) ScreenStats(today time.Time, warnDays int) (*Stats, error) { if st.ActiveSubs, err = db.CountActiveSubs(domain.Date(today)); err != nil { return nil, err } - if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status='paused'`).Scan(&st.PausedSubs); err != nil { + if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status='paused' + AND IFNULL((SELECT archived FROM customers c WHERE c.id=customer_id),0)=0`).Scan(&st.PausedSubs); err != nil { return nil, err } - if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date=?`, + if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date=? + AND IFNULL((SELECT archived FROM customers c WHERE c.id=customer_id),0)=0`, domain.Date(today)).Scan(&st.DueToday); err != nil { return nil, err } - if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date BETWEEN ? AND ?`, + if err = db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE status<>'closed' AND end_date BETWEEN ? AND ? + AND IFNULL((SELECT archived FROM customers c WHERE c.id=customer_id),0)=0`, domain.Date(today), domain.Date(domain.AddDays(today, 6))).Scan(&st.Due7); err != nil { return nil, err } @@ -60,8 +68,9 @@ func (db *DB) ScreenStats(today time.Time, warnDays int) (*Stats, error) { // 本月到期热力 ms := domain.MonthStart(today.Year(), today.Month()) me := domain.MonthEnd(today.Year(), today.Month()) - rows, err := db.sql.Query(`SELECT end_date, COUNT(*) FROM subscriptions - WHERE status<>'closed' AND end_date BETWEEN ? AND ? GROUP BY end_date`, + rows, err := db.sql.Query(`SELECT end_date, COUNT(*) FROM subscriptions s + JOIN customers c ON c.id=s.customer_id + WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0 AND s.end_date BETWEEN ? AND ? GROUP BY s.end_date`, domain.Date(ms), domain.Date(me)) if err != nil { return nil, err @@ -80,12 +89,12 @@ func (db *DB) ScreenStats(today time.Time, warnDays int) (*Stats, error) { // TOP:按日按剩余天数、按次按剩余次数,各自排序后交替取前 10 t := domain.Date(today) - daySubs, err := db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.unit='day' AND s.end_date>=? + daySubs, err := db.querySubs(subSelect+` WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0 AND s.unit='day' AND s.end_date>=? ORDER BY s.end_date ASC LIMIT 12`, t) if err != nil { return nil, err } - cntSubs, err := db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.unit='count' AND s.used_count < s.total_count + cntSubs, err := db.querySubs(subSelect+` WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0 AND s.unit='count' AND s.used_count < s.total_count AND (IFNULL(s.end_date,'')='' OR s.end_date>=?) ORDER BY (s.total_count - s.used_count) ASC LIMIT 12`, t) if err != nil { @@ -109,6 +118,6 @@ func (db *DB) ScreenStats(today time.Time, warnDays int) (*Stats, error) { // RecentDue 返回未来 n 天内到期的订阅(按到期日排序)。 func (db *DB) RecentDue(from string, days int) ([]Subscription, error) { to := domain.Date(domain.AddDays(domain.ParseDateOr(from, domain.Today()), days-1)) - return db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.end_date BETWEEN ? AND ? + return db.querySubs(subSelect+` WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0 AND s.end_date BETWEEN ? AND ? ORDER BY s.end_date, s.id`, from, to) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index bb58a78..09bdca4 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -343,7 +343,7 @@ func TestSettingsAndBadges(t *testing.T) { _, _ = db.CreateSub(CreateSubInput{ CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.AddDays(today, -30)), Days: 31, }) - due, due3, err := db.Badges(today) + due, due3, _, err := db.Badges(today) if err != nil { t.Fatal(err) } @@ -357,14 +357,14 @@ func TestSearchByTail(t *testing.T) { mustCustomer(t, db, "8899", "阿飞") mustCustomer(t, db, "8890", "阿飞二号") today := domain.Today() - list, total, err := db.ListCustomers("8899", 20, 0, domain.Date(today), 3) + list, total, err := db.ListCustomers("8899", 20, 0, domain.Date(today), 3, false) if err != nil { t.Fatal(err) } if total != 1 || len(list) != 1 || list[0].Name != "阿飞" { t.Fatalf("搜索命中 %d 条", total) } - list, total, _ = db.ListCustomers("889", 20, 0, domain.Date(today), 3) + list, total, _ = db.ListCustomers("889", 20, 0, domain.Date(today), 3, false) if total != 2 { t.Fatalf("前缀搜索应命中 2 条,实际 %d", total) } @@ -1019,3 +1019,90 @@ func TestUpdateUserByAdmin(t *testing.T) { t.Fatalf("首个账户 id 应为 1,实际 %d", admin.ID) } } + +func TestCustomerArchiveFilters(t *testing.T) { + db := newTestDB(t) + pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) + + // 一个正常客户 + 一个按次客户 + cid := mustCustomer(t, db, "7001", "小张") + sub, err := db.CreateSub(CreateSubInput{ + CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 10, + }) + if err != nil { + t.Fatal(err) + } + // 打卡 + 加急,稍后验证级联删除 + if err := db.SaveCheckin(sub.ID, domain.Date(domain.Today()), "done", "", 0); err != nil { + t.Fatal(err) + } + if err := db.SetUrgent(sub.ID, domain.Date(domain.Today()), true); err != nil { + t.Fatal(err) + } + + today := domain.Date(domain.Today()) + subs, err := db.CalendarSubs(today, today) + if err != nil || len(subs) != 1 { + t.Fatalf("停用前日历应有 1 条: %v err=%v", len(subs), err) + } + if n, _ := db.CustomerCount(); n != 1 { + t.Fatalf("停用前客户数=1, got %d", n) + } + + // 停用 + if err := db.SetCustomerArchived(cid, true); err != nil { + t.Fatal(err) + } + if subs, _ := db.CalendarSubs(today, today); len(subs) != 0 { + t.Fatalf("停用后不应上日历: %d", len(subs)) + } + if due, _ := db.DueOn(domain.Date(domain.AddDays(domain.Today(), 9))); len(due) != 0 { + t.Fatalf("停用后不应提醒到期: %d", len(due)) + } + if n, _ := db.CustomerCount(); n != 0 { + t.Fatalf("停用后客户数应为 0, got %d", n) + } + if list, total, _ := db.ListCustomers("", 50, 0, today, 3, false); total != 0 || len(list) != 0 { + t.Fatalf("默认列表不应包含停用客户: total=%d", total) + } + if list, total, _ := db.ListCustomers("", 50, 0, today, 3, true); total != 1 || len(list) != 1 || !list[0].Archived { + t.Fatalf("已停用视图应包含: total=%d list=%+v", total, list) + } + if c, _ := db.Customer(cid); !c.Archived { + t.Fatal("Customer() 应返回 Archived=true") + } + if due, _, _, _ := db.Badges(domain.Today()); due != 0 { + t.Fatalf("停用后徽标应为 0, got %d", due) + } + if all, _ := db.CustomersByTail("7001"); len(all) != 0 { + t.Fatalf("停用客户不应被尾号搜到: %d", len(all)) + } + + // 恢复 + if err := db.SetCustomerArchived(cid, false); err != nil { + t.Fatal(err) + } + if subs, _ := db.CalendarSubs(today, today); len(subs) != 1 { + t.Fatalf("恢复后应回到日历: %d", len(subs)) + } + + // 彻底删除:订阅/打卡/加急一起消失 + if err := db.DeleteCustomer(cid); err != nil { + t.Fatal(err) + } + if _, err := db.Customer(cid); err == nil { + t.Fatal("删除后不应还能查到客户") + } + if subs, _ := db.CalendarSubs(today, today); len(subs) != 0 { + t.Fatalf("删除后订阅应级联消失: %d", len(subs)) + } + var flags, checkins int + _ = db.sql.QueryRow(`SELECT COUNT(*) FROM task_flags`).Scan(&flags) + _ = db.sql.QueryRow(`SELECT COUNT(*) FROM checkins`).Scan(&checkins) + if flags != 0 || checkins != 0 { + t.Fatalf("删除后打卡/加急应级联删除: flags=%d checkins=%d", flags, checkins) + } + if err := db.DeleteCustomer(9999); err == nil { + t.Fatal("删除不存在的客户应报错") + } +} diff --git a/internal/store/subscriptions.go b/internal/store/subscriptions.go index f32782a..f39ad2e 100644 --- a/internal/store/subscriptions.go +++ b/internal/store/subscriptions.go @@ -232,14 +232,14 @@ func (db *DB) ListSubs(f SubFilter) ([]Subscription, int, error) { // CalendarSubs 取与 [from,to] 日期区间相交的订阅(用于日历)。 func (db *DB) CalendarSubs(from, to string) ([]Subscription, error) { - return db.querySubs(subSelect+` WHERE s.status<>'closed' + return db.querySubs(subSelect+` WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0 AND s.start_date <= ? AND IFNULL(s.end_date,'')<>'' AND s.end_date >= ? ORDER BY s.id`, to, from) } // DueOn 某天到期的订阅。 func (db *DB) DueOn(date string) ([]Subscription, error) { - return db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.end_date=? ORDER BY s.id`, date) + return db.querySubs(subSelect+` WHERE s.status<>'closed' AND IFNULL(c.archived,0)=0 AND s.end_date=? ORDER BY s.id`, date) } // CreateSubInput 新建订阅入参。 @@ -593,7 +593,8 @@ func (db *DB) PausesForSubs(ids []int64) (map[int64][]Pause, error) { // CountActiveSubs 活跃订阅数。 func (db *DB) CountActiveSubs(today string) (int, error) { var n int - err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s WHERE s.status='active' AND ( + err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s WHERE s.status='active' + AND IFNULL((SELECT archived FROM customers c WHERE c.id=s.customer_id),0)=0 AND ( (s.unit='day' AND s.end_date >= ?) OR (s.unit='count' AND (IFNULL(s.end_date,'')='' OR s.end_date >= ?) AND s.used_count < s.total_count) )`, today, today).Scan(&n) diff --git a/internal/web/handlers_calendar.go b/internal/web/handlers_calendar.go index 65a4f2d..b5422bc 100644 --- a/internal/web/handlers_calendar.go +++ b/internal/web/handlers_calendar.go @@ -25,8 +25,10 @@ type DayCell struct { DoneCount int UrgentCnt int Heat int - Tickets []Ticket - More int + // PendingCount 当日还没完成的单数(= TaskCount - DoneCount) + PendingCount int + Tickets []Ticket + More int // Strip 是左侧的打手色竖条(按出现顺序去重,未指派为灰) Strip []string } @@ -307,6 +309,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { tickets = tickets[:3] } cell.Tickets = tickets + cell.PendingCount = cell.TaskCount - cell.DoneCount cell.Heat = heatLevel(cell.TaskCount + cell.DueCount) if cell.InMonth { data.MonthDue += cell.DueCount diff --git a/internal/web/handlers_customers.go b/internal/web/handlers_customers.go index 619c2e6..6771944 100644 --- a/internal/web/handlers_customers.go +++ b/internal/web/handlers_customers.go @@ -12,12 +12,14 @@ import ( ) type customersData struct { - List []store.Customer - Pager Pager - Q string - Total int - PackageCount int - NoPackages bool + List []store.Customer + Pager Pager + Q string + Total int + PackageCount int + NoPackages bool + ShowArchived bool + ArchivedCount int } func (s *Server) handleCustomers(w http.ResponseWriter, r *http.Request) { @@ -28,21 +30,24 @@ func (s *Server) handleCustomers(w http.ResponseWriter, r *http.Request) { if pageNo < 1 { pageNo = 1 } - list, total, err := s.db.ListCustomers(q, pageSize, (pageNo-1)*pageSize, domain.Date(today), warn) + showArchived := r.URL.Query().Get("archived") == "1" + list, total, err := s.db.ListCustomers(q, pageSize, (pageNo-1)*pageSize, domain.Date(today), warn, showArchived) if err != nil { http.Error(w, "读取客户失败", http.StatusInternalServerError) return } pager := newPager(r, pageNo, pageSize, total) if pager.Page != pageNo { - list, total, err = s.db.ListCustomers(q, pageSize, (pager.Page-1)*pageSize, domain.Date(today), warn) + list, total, err = s.db.ListCustomers(q, pageSize, (pager.Page-1)*pageSize, domain.Date(today), warn, showArchived) if err != nil { http.Error(w, "读取客户失败", http.StatusInternalServerError) return } } pkgs, _ := s.db.ListPackages(false) - data := &customersData{List: list, Pager: pager, Q: q, Total: total, PackageCount: len(pkgs), NoPackages: len(pkgs) == 0} + archivedN, _ := s.db.CountArchivedCustomers() + data := &customersData{List: list, Pager: pager, Q: q, Total: total, PackageCount: len(pkgs), NoPackages: len(pkgs) == 0, + ShowArchived: showArchived, ArchivedCount: archivedN} p := s.newPage(r, "客户", "customers") p.D = data s.render(w, r, "customers", p) @@ -255,6 +260,58 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) { s.render(w, r, "customer_detail", p) } +// handleCustomerArchive 停用客户:默认从列表、日历、大屏隐藏,订阅保留。 +func (s *Server) handleCustomerArchive(w http.ResponseWriter, r *http.Request) { + id := pathID(r) + c, err := s.db.Customer(id) + if err != nil { + http.NotFound(w, r) + return + } + if err := s.db.SetCustomerArchived(id, true); err != nil { + s.redirect(w, r, "/customers/"+itoa(id), "停用失败:"+err.Error()) + return + } + s.logOp(r, "停用客户", "customer", id, c.Tail+" "+c.Name+"(订阅保留,不再上日历)") + s.redirect(w, r, "/customers", "已停用客户 "+c.Tail+" "+c.Name+",可在「已停用」里恢复") +} + +// handleCustomerRestore 恢复客户,订单一并回到日历。 +func (s *Server) handleCustomerRestore(w http.ResponseWriter, r *http.Request) { + id := pathID(r) + c, err := s.db.Customer(id) + if err != nil { + http.NotFound(w, r) + return + } + if err := s.db.SetCustomerArchived(id, false); err != nil { + s.redirect(w, r, "/customers/"+itoa(id), "恢复失败:"+err.Error()) + return + } + s.logOp(r, "恢复客户", "customer", id, c.Tail+" "+c.Name) + s.redirect(w, r, "/customers/"+itoa(id), "已恢复客户 "+c.Tail+" "+c.Name) +} + +// handleCustomerDelete 彻底删除客户及其订阅/打卡/追加记录(外键级联)。 +func (s *Server) handleCustomerDelete(w http.ResponseWriter, r *http.Request) { + id := pathID(r) + c, err := s.db.Customer(id) + if err != nil { + http.NotFound(w, r) + return + } + if r.PostFormValue("confirm") != "delete" { + s.redirect(w, r, "/customers/"+itoa(id), "请在弹窗里确认删除") + return + } + if err := s.db.DeleteCustomer(id); err != nil { + s.redirect(w, r, "/customers/"+itoa(id), "删除失败:"+err.Error()) + return + } + s.logOp(r, "删除客户", "customer", id, c.Tail+" "+c.Name+"(含订阅与打卡记录)") + s.redirect(w, r, "/customers", "已彻底删除客户 "+c.Tail+" "+c.Name) +} + // handleCustomerAssignee 从客户详情快速指派/取消打手。 func (s *Server) handleCustomerAssignee(w http.ResponseWriter, r *http.Request) { id := pathID(r) diff --git a/internal/web/server.go b/internal/web/server.go index ecbec7a..74f50b5 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -105,6 +105,9 @@ func (s *Server) routes() http.Handler { 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}/archive", s.auth(s.handleCustomerArchive)) + mux.HandleFunc("POST /customers/{id}/restore", s.auth(s.handleCustomerRestore)) + mux.HandleFunc("POST /customers/{id}/delete", s.auth(s.handleCustomerDelete)) 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)) @@ -334,6 +337,7 @@ type page struct { Next string DueToday int Due3 int + Due7 int Addr string Theme string // "" = 跟随系统;light / dark D any @@ -371,8 +375,8 @@ func (s *Server) newPage(r *http.Request, title, nav string) *page { Theme: themeFromRequest(r), } if u != nil { - if due, due3, err := s.db.Badges(today); err == nil { - p.DueToday, p.Due3 = due, due3 + if due, due3, due7, err := s.db.Badges(today); err == nil { + p.DueToday, p.Due3, p.Due7 = due, due3, due7 } } return p diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 830ec76..35b9c30 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -83,9 +83,7 @@ html, body { margin: 0; padding: 0; } body { - background: - linear-gradient(var(--line-2) 1px, transparent 1px) 0 0 / 100% 32px, - var(--paper); + background: var(--paper); color: var(--ink); font-family: var(--sans); font-size: 14px; @@ -899,3 +897,81 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } .asg { font-size: 10px; } .assign-form { margin-left: 0; } } + + +/* ---------- 日历格子:大字待完成/总数 ---------- */ + +.cal-cell { justify-content: flex-start; } +.cell-count { + flex: 1 1 auto; + display: flex; align-items: baseline; justify-content: center; gap: 4px; + padding-top: 4px; + font-family: var(--mono); + color: var(--muted); +} +.cell-count b { font-size: 26px; line-height: 1; font-weight: 700; color: var(--ink); } +.cell-count span { font-size: 12px; color: var(--muted); } +.cell-count b:first-child { color: var(--accent); } +.cal-cell.today .cell-count b { color: var(--accent-ink); } +.cal-cell.out .cell-count b { color: var(--muted); } + +/* ---------- 抽屉时钟 ---------- */ + +.drawer-clock { + position: sticky; top: 0; z-index: 3; + display: flex; align-items: baseline; justify-content: space-between; gap: 8px; + padding: 10px 14px 6px; + background: var(--surface); + border-bottom: 1px solid var(--line-2); +} +.drawer-clock b { + font-family: var(--mono); font-size: 22px; letter-spacing: 0.06em; + font-variant-numeric: tabular-nums; +} +.drawer-clock span { font-size: 11px; color: var(--muted); font-family: var(--mono); } +.drawer-head { top: 44px; } + +/* ---------- 数字小键盘 ---------- */ + +.keypad { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; + margin: 8px 0 12px; +} +.keypad button { + height: 44px; + border: 1px solid var(--line); border-radius: var(--radius); + background: var(--surface); color: var(--ink); + font-family: var(--mono); font-size: 17px; font-weight: 600; + cursor: pointer; user-select: none; + -webkit-tap-highlight-color: transparent; +} +.keypad button:hover { border-color: var(--accent); } +.keypad button:active { background: var(--accent-soft); } +.keypad .key-back { color: var(--ink-2); font-size: 15px; } +.keypad .key-ok { background: var(--accent); border-color: var(--accent); color: var(--paper); } +.keypad .key-ok:hover { background: var(--accent-ink); border-color: var(--accent-ink); } + +/* ---------- 任务行“更多”菜单 ---------- */ + +.more-actions { position: relative; } +.more-actions > summary { + list-style: none; cursor: pointer; + width: 28px; padding: 0; justify-content: center; + font-size: 16px; line-height: 1; +} +.more-actions > summary::-webkit-details-marker { display: none; } +.more-actions[open] > summary { background: var(--accent-soft); border-color: var(--accent); } +.more-menu { + position: absolute; right: 0; top: calc(100% + 4px); z-index: 20; + display: flex; flex-direction: column; gap: 2px; + min-width: 124px; padding: 5px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); +} +.more-menu .btn { + justify-content: flex-start; width: 100%; + height: 30px; border-color: transparent; background: transparent; +} +.more-menu .btn:hover { background: var(--accent-soft); border-color: transparent; } diff --git a/internal/web/static/app.js b/internal/web/static/app.js index e645adf..ac03c1d 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -129,4 +129,50 @@ document.cookie = KEY + '=' + next + '; path=/; max-age=31536000; SameSite=Lax'; }); }); -})(); \ No newline at end of file +})(); + +/* ---------- 触摸小键盘 + 抽屉时钟 + 更多菜单 ---------- */ +(function () { + 'use strict'; + + /* 时钟:精确到秒 */ + var clocks = document.querySelectorAll('[data-clock]'); + function tick() { + var d = new Date(); + var pad = function (n) { return n < 10 ? '0' + n : '' + n; }; + var t = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); + Array.prototype.forEach.call(clocks, function (el) { el.textContent = t; }); + } + if (clocks.length) { tick(); setInterval(tick, 1000); } + + /* 小键盘:输入手机尾号 */ + Array.prototype.forEach.call(document.querySelectorAll('[data-keypad]'), function (pad) { + var form = pad.closest('form'); + if (!form) return; + var input = form.querySelector('input[name="tail"]'); + if (!input) return; + pad.addEventListener('click', function (e) { + var btn = e.target.closest('button[data-key]'); + if (!btn) return; + var key = btn.getAttribute('data-key'); + if (key === 'back') { + input.value = input.value.slice(0, -1); + } else if (key === 'ok') { + if (input.value) { + if (form.requestSubmit) { form.requestSubmit(); } else { form.submit(); } + } + } else { + if (input.value.length < 8) { input.value += key; } + } + input.focus(); + }); + }); + + /* 更多菜单:点开一个时收起其它,点空白处关闭 */ + document.addEventListener('click', function (e) { + var opened = document.querySelectorAll('.more-actions[open]'); + Array.prototype.forEach.call(opened, function (d) { + if (!d.contains(e.target)) { d.removeAttribute('open'); } + }); + }); +})(); diff --git a/internal/web/templates/_layout.html b/internal/web/templates/_layout.html index 0a9f1cc..a564588 100644 --- a/internal/web/templates/_layout.html +++ b/internal/web/templates/_layout.html @@ -25,6 +25,7 @@
今日到期 {{.DueToday}} 3 天内 {{.Due3}} + 7 天内 {{.Due7}} diff --git a/internal/web/templates/pages/calendar.html b/internal/web/templates/pages/calendar.html index 43e860f..f1fd5e6 100644 --- a/internal/web/templates/pages/calendar.html +++ b/internal/web/templates/pages/calendar.html @@ -52,14 +52,10 @@
{{if .TaskCount}} -
{{.DoneCount}}/{{.TaskCount}} 单
- {{end}} -
- {{range .Tickets}} - {{.Tail}}{{.Name}}{{.Pkg}} - {{end}} - {{if gt .More 0}}+{{.More}}{{end}} +
+ {{.PendingCount}}/ {{.TaskCount}}
+ {{end}} {{end}}
@@ -67,6 +63,7 @@