diff --git a/README.md b/README.md index 943a92f..9cfe049 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # 代打台 · 代打管理平台 一个给代打工作室用的单文件管理程序:**套餐 → 订阅 → 追加时长 → 日历打卡 → 大屏**。 -整个程序就是一个 `daili.exe` + 一个 `daili.ini`,数据存在同目录的 `daili.db` 里,局域网内手机、电视都能打开。 +整个程序就是一个 `daili.exe` + 一个 `daili.ini`,数据存在同目录的 `daili.db` 里,局域网内手机、电视都能打开。界面支持亮色 / 暗色切换,默认跟随系统。 --- @@ -129,6 +129,17 @@ --- +## 4.1 工作台速用 + +- **按尾号快速完成**:日历右侧「当日待完成」顶部输入手机尾号 → 回车。 + - 只匹配到 1 项:直接完成并提示; + - 同一尾号有多个客户,或一个客户今天有多个服务:弹出勾选面板,默认全选,可多选后一次完成。 +- **加急**:每条任务右侧点「加急」,会加红色 `加急` 标签并排到最前;日历格子出现红点,大屏「今日待打」同步置顶。再点一次取消。 +- **按套餐分日历**:套餐编辑页勾选「在日历中独立显示」后,工作台与大屏顶部会出现套餐标签(全部 / 每日任务 / …),可以只看某类委托的排单。 +- **主题**:顶栏右上角 ☾ / ☀ 切换亮 / 暗,偏好记在浏览器里;不选时跟随系统。 + +--- + ## 5. 数据、备份与导出 | 文件 | 说明 | @@ -141,6 +152,7 @@ - **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。 - **重置**:关闭程序 → 删除 `daili.db` → 重新打开,会回到「创建第一个管理员」的全新状态。 +- 加急标记存在 `daili.db` 的 `task_flags` 表;套餐的「独立日历」是 `packages.calendar_separate` 列,老版本数据库打开时会**自动加列**,不用手工迁移; - `daili.ini`(监听/备份/日志配置)与 `backup/`、`logs/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。 - 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库; @@ -153,7 +165,12 @@ - 地址:`http://<本机IP>:17777/screen` - 默认 `screen_public = true`:**免登录**,电视/副屏直接打开即可(仅建议局域网使用); - 改成 `screen_public = false` 后大屏回到登录保护; -- 深色「记分板」风格:客户数 / 活跃订阅 / 今日到期 / 7 天内到期、到期热力月历、即将到期 TOP10、今日待打、最近追加; +- 「记分板」风格,亮 / 暗主题同样可切(右上角 ☾ / ☀): + - 顶部 KPI:客户 / 活跃订阅 / 今日到期 / 7 天内到期; + - 中部:**热力月历 + 即将到期 TOP10**。热力默认统计「每日待完成单量」,点右上角按钮可切回「到期热力」;鼠标悬停任意格子会浮出当日待办明细(含加急标记); + - 底部:**今日到期**(左)+ **今日待打**(右,加急置顶带红 tag); + - 最下方细条:最近追加流水; +- 套餐勾选「在日历中独立显示」后,大屏顶部会出现套餐标签,可切换只看某类委托; - 默认 30 秒自动刷新(设置页可改),按 `F` 全屏。 --- diff --git a/internal/store/customers.go b/internal/store/customers.go index 691ddc0..ee7fc03 100644 --- a/internal/store/customers.go +++ b/internal/store/customers.go @@ -199,3 +199,36 @@ func (db *DB) CustomerCount() (int, error) { err := db.sql.QueryRow(`SELECT COUNT(*) FROM customers`).Scan(&n) return n, err } + +// CustomersByTail 按手机尾号精确匹配客户;没有精确命中时退回模糊匹配。 +func (db *DB) CustomersByTail(tail string) ([]Customer, error) { + tail = strings.TrimSpace(tail) + if tail == "" { + return nil, nil + } + out, err := db.queryCustomers(`SELECT `+customerCols+` FROM customers WHERE tail = ? ORDER BY id`, tail) + if err != nil { + return nil, err + } + if len(out) > 0 { + return out, nil + } + return db.queryCustomers(`SELECT `+customerCols+` FROM customers WHERE tail LIKE ? ORDER BY id LIMIT 20`, "%"+tail+"%") +} + +func (db *DB) queryCustomers(q string, args ...any) ([]Customer, error) { + rows, err := db.sql.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Customer{} + for rows.Next() { + c, err := scanCustomer(rows) + if err != nil { + return nil, err + } + out = append(out, *c) + } + return out, rows.Err() +} diff --git a/internal/store/db.go b/internal/store/db.go index 3d5a61f..db5e6ce 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -116,6 +116,41 @@ func (db *DB) Migrate() error { return fmt.Errorf("迁移失败: %w\nSQL: %s", err, stmt) } } + // 增量列:老库升级时补上(SQLite 没有 ADD COLUMN IF NOT EXISTS) + if err := db.ensureColumn("packages", "calendar_separate", + "ALTER TABLE packages ADD COLUMN calendar_separate INTEGER NOT NULL DEFAULT 0"); err != nil { + return err + } + return nil +} + +// ensureColumn 在列不存在时执行 ddl,保证迁移可重复执行。 +func (db *DB) ensureColumn(table, column, ddl string) error { + rows, err := db.sql.Query("PRAGMA table_info(" + table + ")") + if err != nil { + return fmt.Errorf("读取 %s 表结构失败: %w", table, err) + } + found := false + for rows.Next() { + var cid int + var name, ctype string + var notnull, pk int + var dflt any + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + rows.Close() + return err + } + if strings.EqualFold(name, column) { + found = true + } + } + rows.Close() + if found { + return nil + } + if _, err := db.sql.Exec(ddl); err != nil { + return fmt.Errorf("迁移失败(%s.%s): %w", table, column, err) + } return nil } @@ -146,6 +181,7 @@ var schemaStatements = []string{ note TEXT NOT NULL DEFAULT '', archived INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, + calendar_separate INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, @@ -224,6 +260,16 @@ var schemaStatements = []string{ ip TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL )`, + `CREATE TABLE IF NOT EXISTS task_flags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sub_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE, + service_date TEXT NOT NULL, + urgent INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (sub_id, service_date) + )`, + `CREATE INDEX IF NOT EXISTS idx_task_flags_date ON task_flags(service_date)`, `CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL diff --git a/internal/store/packages.go b/internal/store/packages.go index 56a4c51..1b56873 100644 --- a/internal/store/packages.go +++ b/internal/store/packages.go @@ -20,10 +20,12 @@ type Package struct { Note string Archived bool SortOrder int - CreatedAt string - UpdatedAt string - SubCount int - ActiveCount int + // CalendarSeparate 勾选后在工作台/大屏日历中单独成页 + CalendarSeparate bool + CreatedAt string + UpdatedAt string + SubCount int + ActiveCount int } // UnitLabel 返回类型中文名。 @@ -37,9 +39,9 @@ func (p *Package) UnitLabel() string { func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) { var p Package var price sql.NullInt64 - var archived int + var archived, separate int err := row.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note, - &archived, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt) + &archived, &p.SortOrder, &separate, &p.CreatedAt, &p.UpdatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound @@ -47,6 +49,7 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) { return nil, err } p.Archived = archived == 1 + p.CalendarSeparate = separate == 1 if price.Valid { p.PriceCents = int(price.Int64) p.HasPrice = true @@ -56,7 +59,7 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) { // ListPackages 列出套餐(含订阅计数)。 func (db *DB) ListPackages(includeArchived bool) ([]Package, error) { - q := `SELECT p.id,p.name,p.unit,p.default_amount,p.price_cents,p.color,p.note,p.archived,p.sort_order,p.created_at,p.updated_at, + q := `SELECT p.id,p.name,p.unit,p.default_amount,p.price_cents,p.color,p.note,p.archived,p.sort_order,p.calendar_separate,p.created_at,p.updated_at, (SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id), (SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id AND s.status<>'closed') FROM packages p` @@ -73,12 +76,13 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) { for rows.Next() { var p Package var price sql.NullInt64 - var archived int + var archived, separate int if err := rows.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note, - &archived, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil { + &archived, &p.SortOrder, &separate, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil { return nil, err } p.Archived = archived == 1 + p.CalendarSeparate = separate == 1 if price.Valid { p.PriceCents = int(price.Int64) p.HasPrice = true @@ -90,7 +94,7 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) { // Package 取单个套餐。 func (db *DB) Package(id int64) (*Package, error) { - return scanPackage(db.sql.QueryRow(`SELECT id,name,unit,default_amount,price_cents,color,note,archived,sort_order,created_at,updated_at + return scanPackage(db.sql.QueryRow(`SELECT id,name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,created_at,updated_at FROM packages WHERE id=?`, id)) } @@ -108,9 +112,9 @@ func (db *DB) CreatePackage(p *Package) (int64, error) { if p.HasPrice { price = p.PriceCents } - res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,created_at,updated_at) - VALUES(?,?,?,?,?,?,0,?,?,?)`, - p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, now, now) + res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,created_at,updated_at) + VALUES(?,?,?,?,?,?,0,?,?,?,?)`, + p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), now, now) if err != nil { return 0, err } @@ -130,11 +134,19 @@ func (db *DB) UpdatePackage(p *Package) error { if p.HasPrice { price = p.PriceCents } - _, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,updated_at=? - WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, nowStamp(), p.ID) + _, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,calendar_separate=?,updated_at=? + WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), nowStamp(), p.ID) return err } +// b2i 把 bool 转成 SQLite 的 0/1。 +func b2i(v bool) int { + if v { + return 1 + } + return 0 +} + // SetPackageArchived 归档/取消归档。 func (db *DB) SetPackageArchived(id int64, archived bool) error { v := 0 diff --git a/internal/store/store_test.go b/internal/store/store_test.go index ebc0724..d2993dd 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -647,3 +647,163 @@ func TestOpenLegacyWALDatabaseKeepsData(t *testing.T) { t.Fatalf("旧库未切到单文件模式: %s", mode) } } + +func TestPackageCalendarSeparate(t *testing.T) { + db := newTestDB(t) + pkg := mustPackage(t, db, "每日任务", domain.UnitDay, 30) + if pkg.CalendarSeparate { + t.Fatal("新建套餐默认不应勾选独立日历") + } + pkg.CalendarSeparate = true + if err := db.UpdatePackage(pkg); err != nil { + t.Fatal(err) + } + got, err := db.Package(pkg.ID) + if err != nil || !got.CalendarSeparate { + t.Fatalf("独立日历标记未保存: %+v err=%v", got, err) + } + list, err := db.ListPackages(true) + if err != nil || len(list) != 1 || !list[0].CalendarSeparate { + t.Fatalf("列表未带独立日历标记: %+v err=%v", list, err) + } + got.CalendarSeparate = false + if err := db.UpdatePackage(got); err != nil { + t.Fatal(err) + } + if again, _ := db.Package(pkg.ID); again.CalendarSeparate { + t.Fatal("取消勾选失败") + } +} + +func TestTaskUrgentFlags(t *testing.T) { + db := newTestDB(t) + pkg := mustPackage(t, db, "包月日常", domain.UnitDay, 30) + cid := mustCustomer(t, db, "4321", "老王") + sub1, err := db.CreateSub(CreateSubInput{ + CustomerID: cid, PackageID: pkg.ID, StartDate: domain.Date(domain.Today()), Days: 30, + }) + if err != nil { + t.Fatal(err) + } + pkg2 := mustPackage(t, db, "按次", domain.UnitCount, 10) + sub2, err := db.CreateSub(CreateSubInput{ + CustomerID: cid, PackageID: pkg2.ID, StartDate: domain.Date(domain.Today()), + TotalCount: 10, EndDate: domain.Date(domain.AddDays(domain.Today(), 30)), + }) + if err != nil { + t.Fatal(err) + } + d1 := domain.Date(domain.Today()) + d2 := domain.Date(domain.AddDays(domain.Today(), 1)) + + if err := db.SetUrgent(sub1.ID, d1, true); err != nil { + t.Fatal(err) + } + if err := db.SetUrgent(sub2.ID, d2, true); err != nil { + t.Fatal(err) + } + flags, err := db.UrgentFlags(d1, d2) + if err != nil { + t.Fatal(err) + } + if !flags[sub1.ID][d1] || !flags[sub2.ID][d2] { + t.Fatalf("加急标记缺失: %+v", flags) + } + if ok, _ := db.IsUrgent(sub1.ID, d1); !ok { + t.Fatal("IsUrgent 应为 true") + } + if n, _ := db.CountUrgent(d1, d2); n != 2 { + t.Fatalf("加急计数=%d 期望 2", n) + } + // 重复设置不报错(UPSERT) + if err := db.SetUrgent(sub1.ID, d1, true); err != nil { + t.Fatalf("重复标记加急失败: %v", err) + } + // 取消 + if err := db.SetUrgent(sub1.ID, d1, false); err != nil { + t.Fatal(err) + } + if ok, _ := db.IsUrgent(sub1.ID, d1); ok { + t.Fatal("取消加急后仍为 true") + } + if n, _ := db.CountUrgent(d1, d2); n != 1 { + t.Fatalf("取消后计数=%d 期望 1", n) + } + // 非法日期 + if err := db.SetUrgent(sub1.ID, "2026-13-40", true); err == nil { + t.Fatal("非法日期应报错") + } + // 不存在的订阅 + if err := db.SetUrgent(99999, d1, true); err == nil { + t.Fatal("不存在的订阅应报错") + } +} + +// TestMigrateOldDatabaseAddsColumn 模拟 v1.1 的旧库:packages 表没有 calendar_separate。 +func TestMigrateOldDatabaseAddsColumn(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "old.db") + dsn := strings.ReplaceAll(path, "\\", "/") + old, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatal(err) + } + stmts := []string{ + `CREATE TABLE packages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + unit TEXT NOT NULL, + default_amount INTEGER NOT NULL DEFAULT 30, + price_cents INTEGER, + color TEXT NOT NULL DEFAULT '#2F5D50', + note TEXT NOT NULL DEFAULT '', + archived INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `INSERT INTO packages(name,unit,default_amount,color,note,archived,sort_order,created_at,updated_at) + VALUES('老套餐','day',30,'#2F5D50','',0,1,'2026-01-01 00:00:00','2026-01-01 00:00:00')`, + } + for _, s := range stmts { + if _, err := old.Exec(s); err != nil { + t.Fatal(err) + } + } + if err := old.Close(); err != nil { + t.Fatal(err) + } + + db, err := Open(path) + if err != nil { + t.Fatalf("打开旧库失败: %v", err) + } + defer db.Close() + if err := db.Migrate(); err != nil { + t.Fatalf("迁移失败: %v", err) + } + pkg, err := db.Package(1) + if err != nil { + t.Fatalf("读取老套餐失败: %v", err) + } + if pkg.Name != "老套餐" || pkg.CalendarSeparate { + t.Fatalf("老数据/默认值不对: %+v", pkg) + } + // 新列可写 + pkg.CalendarSeparate = true + if err := db.UpdatePackage(pkg); err != nil { + t.Fatal(err) + } + if got, _ := db.Package(1); !got.CalendarSeparate { + t.Fatal("迁移后新列不可写") + } + // 新表 task_flags 已建立 + var tbl string + if err := db.sql.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='task_flags'`).Scan(&tbl); err != nil { + t.Fatalf("task_flags 表未建立: %v", err) + } + // 迁移可重复执行 + if err := db.Migrate(); err != nil { + t.Fatalf("重复迁移失败: %v", err) + } +} diff --git a/internal/store/tasks.go b/internal/store/tasks.go new file mode 100644 index 0000000..bdbea15 --- /dev/null +++ b/internal/store/tasks.go @@ -0,0 +1,67 @@ +package store + +import ( + "errors" + + "daili/internal/domain" +) + +// UrgentFlags 返回 [from,to] 区间内的加急标记,索引为 [subID][date]。 +// 表里只存“加急=1”的行,未出现即未加急。 +func (db *DB) UrgentFlags(from, to string) (map[int64]map[string]bool, error) { + out := map[int64]map[string]bool{} + rows, err := db.sql.Query(`SELECT sub_id, service_date FROM task_flags + WHERE urgent=1 AND service_date BETWEEN ? AND ?`, from, to) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var subID int64 + var date string + if err := rows.Scan(&subID, &date); err != nil { + return nil, err + } + if out[subID] == nil { + out[subID] = map[string]bool{} + } + out[subID][date] = true + } + return out, rows.Err() +} + +// SetUrgent 设置或取消某天某订阅的加急标记。 +func (db *DB) SetUrgent(subID int64, date string, urgent bool) error { + if _, err := domain.ParseDate(date); err != nil { + return errors.New("日期格式不正确") + } + if _, err := db.Sub(subID); err != nil { + return errors.New("订阅不存在") + } + now := nowStamp() + if urgent { + _, err := db.sql.Exec(`INSERT INTO task_flags(sub_id, service_date, urgent, created_at, updated_at) + VALUES(?,?,1,?,?) + ON CONFLICT(sub_id, service_date) DO UPDATE SET urgent=1, updated_at=excluded.updated_at`, + subID, date, now, now) + return err + } + _, err := db.sql.Exec(`DELETE FROM task_flags WHERE sub_id=? AND service_date=?`, subID, date) + return err +} + +// IsUrgent 查询单条加急状态。 +func (db *DB) IsUrgent(subID int64, date string) (bool, error) { + var n int + err := db.sql.QueryRow(`SELECT COUNT(*) FROM task_flags WHERE sub_id=? AND service_date=? AND urgent=1`, + subID, date).Scan(&n) + return n > 0, err +} + +// CountUrgent 统计区间内加急任务数。 +func (db *DB) CountUrgent(from, to string) (int, error) { + var n int + err := db.sql.QueryRow(`SELECT COUNT(*) FROM task_flags WHERE urgent=1 AND service_date BETWEEN ? AND ?`, + from, to).Scan(&n) + return n, err +} diff --git a/internal/web/handlers_calendar.go b/internal/web/handlers_calendar.go index 1945ffe..a18ffe0 100644 --- a/internal/web/handlers_calendar.go +++ b/internal/web/handlers_calendar.go @@ -3,6 +3,7 @@ package web import ( "fmt" "net/http" + "net/url" "sort" "time" @@ -22,6 +23,7 @@ type DayCell struct { DueCount int TaskCount int DoneCount int + UrgentCnt int Heat int Tickets []Ticket More int @@ -37,6 +39,7 @@ type Ticket struct { Done bool Skipped bool IsDue bool + Urgent bool } // TaskRow 是当日待打清单的一行。 @@ -47,30 +50,51 @@ type TaskRow struct { Skipped bool Note string Operator string + Urgent bool +} + +// PkgTab 是日历上方的套餐标签。 +type PkgTab struct { + ID int64 + Name string + Color string + Count int } type calendarData struct { - Year, Month int - Title string - PrevY, PrevM int - NextY, NextM int - TodayStr string - Weekdays []string - Weeks [][]DayCell - Selected string - SelectedLabel string - SelectedIsToday bool - SelectedIsPast bool - Tasks []TaskRow - Due []SubView - OtherDue []SubView - Extend *ExtendPanel - MonthDue int - MonthTasks int - MonthDone int - NoPackages bool - NoCustomers bool - Back string + Year, Month int + Title string + PrevY, PrevM int + NextY, NextM int + TodayStr string + Weekdays []string + Weeks [][]DayCell + Selected string + SelectedLabel string + SelectedIsToday bool + SelectedIsPast bool + Tasks []TaskRow + TaskPending int + Due []SubView + OtherDue []SubView + Extend *ExtendPanel + MonthDue int + MonthTasks int + MonthDone int + MonthUrgent int + NoPackages bool + NoCustomers bool + Back string + + // 套餐标签(在日历中独立显示的套餐) + Tabs []PkgTab + PkgID int64 + HasTabs bool + + // 快速完成:按尾号筛选出的候选 + PickTail string + PickRows []TaskRow + PickMsg string } // calSub 是日历计算用的订阅上下文。 @@ -108,6 +132,40 @@ func (c calSub) covers(date string) bool { return !c.pausedOn(date) } +// loadCalendarSubs 读取区间内与日历相交的订阅;pkgID>0 时只保留该套餐。 +func (s *Server) loadCalendarSubs(from, to string, pkgID int64) ([]store.Subscription, map[int64][]store.Pause, map[int64]map[string]store.Checkin, map[int64]map[string]bool, error) { + subs, err := s.db.CalendarSubs(from, to) + if err != nil { + return nil, nil, nil, nil, err + } + if pkgID > 0 { + filtered := make([]store.Subscription, 0, len(subs)) + for _, sub := range subs { + if sub.PackageID == pkgID { + filtered = append(filtered, sub) + } + } + subs = filtered + } + ids := make([]int64, 0, len(subs)) + for _, sub := range subs { + ids = append(ids, sub.ID) + } + pauses, err := s.db.PausesForSubs(ids) + if err != nil { + return nil, nil, nil, nil, err + } + checkins, err := s.db.CheckinsBetween(from, to) + if err != nil { + return nil, nil, nil, nil, err + } + urgent, err := s.db.UrgentFlags(from, to) + if err != nil { + return nil, nil, nil, nil, err + } + return subs, pauses, checkins, urgent, nil +} + func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { today := domain.Today() y := s.queryInt(r, "y", today.Year()) @@ -118,6 +176,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { monthStart := domain.MonthStart(y, time.Month(m)) monthEnd := domain.MonthEnd(y, time.Month(m)) + pkgID := int64(s.queryInt(r, "pkg", 0)) sel := r.URL.Query().Get("d") selT, err := domain.ParseDate(sel) if sel != "" && err != nil { @@ -132,38 +191,23 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { selT = domain.ParseDateOr(sel, today) } - // 网格:以周一为首日,铺满整月所需的周数 gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart)) weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7 gridEnd := domain.AddDays(gridStart, weeks*7-1) - subs, err := s.db.CalendarSubs(domain.Date(gridStart), domain.Date(gridEnd)) + subs, pauses, checkins, urgent, err := s.loadCalendarSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID) if err != nil { http.Error(w, "读取订阅失败", http.StatusInternalServerError) return } - ids := make([]int64, 0, len(subs)) - for _, sub := range subs { - ids = append(ids, sub.ID) - } - pauses, err := s.db.PausesForSubs(ids) - if err != nil { - http.Error(w, "读取暂停记录失败", http.StatusInternalServerError) - return - } - checkins, err := s.db.CheckinsBetween(domain.Date(gridStart), domain.Date(gridEnd)) - if err != nil { - http.Error(w, "读取打卡记录失败", http.StatusInternalServerError) - return - } warn := s.warnDays() list := make([]calSub, 0, len(subs)) for _, sub := range subs { cs := calSub{ - sub: sub, - start: domain.ParseDateOr(sub.StartDate, today), - eval: sub.Eval(today, warn), + sub: sub, + start: domain.ParseDateOr(sub.StartDate, today), + eval: sub.Eval(today, warn), pauses: pauses[sub.ID], } if sub.EndDate != "" { @@ -173,6 +217,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { list = append(list, cs) } + back := r.URL.RequestURI() data := &calendarData{ Year: y, Month: m, Title: fmt.Sprintf("%d 年 %d 月", y, m), @@ -180,7 +225,8 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"}, Selected: sel, SelectedIsToday: sel == domain.Date(today), - Back: r.URL.RequestURI(), + Back: back, + PkgID: pkgID, } data.PrevY, data.PrevM = m-1, y if data.PrevM < 1 { @@ -192,8 +238,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { data.NextM = 1 data.NextY = y + 1 } - label := selT.Format("1 月 2 日") - data.SelectedLabel = label + " 周" + dowCN(sel) + data.SelectedLabel = selT.Format("1 月 2 日") + " 周" + dowCN(sel) data.SelectedIsPast = selT.Before(today) // 铺格子 @@ -221,6 +266,10 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { } cell.TaskCount++ ci := checkins[cs.sub.ID][ds] + isUrgent := urgent[cs.sub.ID][ds] + if isUrgent { + cell.UrgentCnt++ + } t := Ticket{ SubID: cs.sub.ID, Tail: cs.sub.CustomerTail, @@ -230,6 +279,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { Done: ci.Status == "done", Skipped: ci.Status == "skipped", IsDue: cs.sub.EndDate == ds, + Urgent: isUrgent, } if t.Done || t.Skipped { cell.DoneCount++ @@ -237,6 +287,9 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { tickets = append(tickets, t) } sort.SliceStable(tickets, func(i, j int) bool { + if tickets[i].Urgent != tickets[j].Urgent { + return tickets[i].Urgent + } if tickets[i].Done != tickets[j].Done { return !tickets[i].Done } @@ -252,6 +305,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { data.MonthDue += cell.DueCount data.MonthTasks += cell.TaskCount data.MonthDone += cell.DoneCount + data.MonthUrgent += cell.UrgentCnt } week = append(week, cell) } @@ -268,8 +322,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { if !cs.covers(sel) { continue } - v := newSubView(cs.sub, today, warn) - row := TaskRow{SubView: v} + row := TaskRow{SubView: newSubView(cs.sub, today, warn), Urgent: urgent[cs.sub.ID][sel]} if ci, ok := checkins[cs.sub.ID][sel]; ok { row.CheckinID = ci.ID row.Done = ci.Status == "done" @@ -279,15 +332,13 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { } tasks = append(tasks, row) } - sort.SliceStable(tasks, func(i, j int) bool { - ai := tasks[i].Done || tasks[i].Skipped - aj := tasks[j].Done || tasks[j].Skipped - if ai != aj { - return !ai - } - return tasks[i].EndDate < tasks[j].EndDate - }) + sortTasks(tasks) data.Tasks = tasks + for _, row := range tasks { + if !row.Done && !row.Skipped { + data.TaskPending++ + } + } data.Due = due // 其它日期到期(不在当天的 7 天内) @@ -296,25 +347,61 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { if u.EndDate == sel { continue } + if pkgID > 0 && u.PackageID != pkgID { + continue + } data.OtherDue = append(data.OtherDue, newSubView(u, today, warn)) } } + // 套餐标签 + pkgs, _ := s.db.ListPackages(true) + separate := []store.Package{} + for _, p := range pkgs { + if p.CalendarSeparate && !p.Archived { + separate = append(separate, p) + } + } + if len(separate) > 0 { + data.HasTabs = true + data.Tabs = append(data.Tabs, PkgTab{ID: 0, Name: "全部", Color: "#8A8F98", Count: len(list)}) + for _, p := range separate { + cnt := 0 + for _, sub := range subs { + if sub.PackageID == p.ID { + cnt++ + } + } + data.Tabs = append(data.Tabs, PkgTab{ID: p.ID, Name: p.Name, Color: p.Color, Count: cnt}) + } + } + + // 快速完成:按尾号列出候选 + if tail := r.URL.Query().Get("pick"); tail != "" { + data.PickTail = tail + for _, row := range tasks { + if row.CustomerTail == tail { + data.PickRows = append(data.PickRows, row) + } + } + if len(data.PickRows) == 0 { + data.PickMsg = "尾号 " + tail + " 在今天没有待完成的服务" + } + } + // 追加面板 if eid := s.queryInt(r, "extend", 0); eid > 0 { if sub, err := s.db.Sub(int64(eid)); err == nil { if pkg, err := s.db.Package(sub.PackageID); err == nil { - back := r.URL.Path + "?" + r.URL.RawQuery data.Extend = buildExtendPanel(sub, pkg, today, back, warn) } } } - // 空态提示 if n, _ := s.db.CustomerCount(); n == 0 { data.NoCustomers = true } - if pkgs, _ := s.db.ListPackages(false); len(pkgs) == 0 { + if len(pkgs) == 0 { data.NoPackages = true } @@ -323,6 +410,22 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { s.render(w, r, "calendar", p) } +// sortTasks 加急优先,其次未完成,最后按结束日。 +func sortTasks(tasks []TaskRow) { + sort.SliceStable(tasks, func(i, j int) bool { + ai, aj := tasks[i], tasks[j] + if ai.Urgent != aj.Urgent { + return ai.Urgent + } + adi := ai.Done || ai.Skipped + adj := aj.Done || aj.Skipped + if adi != adj { + return !adi + } + return ai.EndDate < aj.EndDate + }) +} + func heatLevel(n int) int { switch { case n <= 0: @@ -346,3 +449,15 @@ func shortName(s string) string { } return string(r[:2]) } + +// stripParam 去掉地址里的某个参数,用于拼“返回”链接。 +func stripParam(raw, key string) string { + u, err := url.Parse(raw) + if err != nil { + return raw + } + q := u.Query() + q.Del(key) + u.RawQuery = q.Encode() + return u.String() +} diff --git a/internal/web/handlers_packages.go b/internal/web/handlers_packages.go index 877e9f8..3af46e2 100644 --- a/internal/web/handlers_packages.go +++ b/internal/web/handlers_packages.go @@ -62,6 +62,7 @@ func readPackageForm(r *http.Request) *store.Package { if p.Unit == "" { p.Unit = domain.UnitDay } + p.CalendarSeparate = r.PostFormValue("calendar_separate") != "" return p } diff --git a/internal/web/handlers_screen.go b/internal/web/handlers_screen.go index c44c7d5..9861548 100644 --- a/internal/web/handlers_screen.go +++ b/internal/web/handlers_screen.go @@ -1,9 +1,9 @@ package web import ( + "encoding/json" "fmt" "net/http" - "time" "daili/internal/domain" "daili/internal/store" @@ -15,10 +15,15 @@ type screenDay struct { InMonth bool IsToday bool IsPast bool - Due int + Count int + Urgent int Heat int } +type screenTask struct { + TaskRow +} + type screenData struct { Stats *store.Stats MonthTitle string @@ -27,17 +32,51 @@ type screenData struct { Weekdays []string Top []SubView DueToday []SubView - TodayList []SubView + TodayList []screenTask Recent []store.Adjustment TodayTasks int TodayDone int + TodayUrgent int Refresh int Version string + + HeatMode string // task = 每日待完成;due = 到期 + HeatTotal int + Tabs []PkgTab + PkgID int64 + HasTabs bool + Theme string +} + +// screenCalSubs 读取大屏日历区间内的订阅,并带上暂停/打卡/加急。 +func (s *Server) screenCalSubs(from, to string, pkgID int64) ([]calSub, map[int64]map[string]store.Checkin, map[int64]map[string]bool, error) { + subs, pauses, checkins, urgent, err := s.loadCalendarSubs(from, to, pkgID) + if err != nil { + return nil, nil, nil, err + } + today := domain.Today() + warn := s.warnDays() + list := make([]calSub, 0, len(subs)) + for _, sub := range subs { + cs := calSub{sub: sub, pauses: pauses[sub.ID], eval: sub.Eval(today, warn)} + if sub.EndDate != "" { + cs.hasEnd = true + cs.end = domain.ParseDateOr(sub.EndDate, today) + } + list = append(list, cs) + } + return list, checkins, urgent, nil } func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) { today := domain.Today() warn := s.warnDays() + pkgID := int64(s.queryInt(r, "pkg", 0)) + heatMode := r.URL.Query().Get("heat") + if heatMode != "due" { + heatMode = "task" + } + stats, err := s.db.ScreenStats(today, warn) if err != nil { http.Error(w, "读取大屏数据失败", http.StatusInternalServerError) @@ -50,85 +89,176 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) { Month: int(today.Month()), Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"}, Top: subViews(stats.Top, today, warn), - DueToday: subViews(stats.DueTodaySubs, today, warn), Refresh: s.screenRefresh(), Version: s.cfg.Version, - } - if len(data.DueToday) > 6 { - data.DueToday = data.DueToday[:6] + HeatMode: heatMode, + PkgID: pkgID, } - // 本月热力 + // 今日到期(可按套餐过滤) + dueToday := []store.Subscription{} + for _, sub := range stats.DueTodaySubs { + if pkgID > 0 && sub.PackageID != pkgID { + continue + } + dueToday = append(dueToday, sub) + } + data.DueToday = subViews(dueToday, today, warn) + + // 本月热力:默认统计“每日待完成的单子”,可切换到“到期” monthStart := domain.MonthStart(today.Year(), today.Month()) monthEnd := domain.MonthEnd(today.Year(), today.Month()) gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart)) weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7 + gridEnd := domain.AddDays(gridStart, weeks*7-1) + + list, checkins, urgent, err := s.screenCalSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID) + if err != nil { + http.Error(w, "读取大屏日历失败", http.StatusInternalServerError) + return + } + for wi := 0; wi < weeks; wi++ { week := make([]screenDay, 0, 7) for di := 0; di < 7; di++ { d := domain.AddDays(gridStart, wi*7+di) ds := domain.Date(d) - due := stats.Heat[ds] + count, urgentN := 0, 0 + for _, cs := range list { + if heatMode == "due" { + if cs.sub.EndDate == ds { + count++ + } + continue + } + if !cs.covers(ds) { + continue + } + ci, has := checkins[cs.sub.ID][ds] + if has && (ci.Status == "done" || ci.Status == "skipped") { + continue // “待完成”只统计还没处理的 + } + count++ + if urgent[cs.sub.ID][ds] { + urgentN++ + } + } + if d.Month() == today.Month() { + data.HeatTotal += count + } week = append(week, screenDay{ Date: ds, Day: d.Day(), InMonth: d.Month() == today.Month(), IsToday: ds == domain.Date(today), IsPast: d.Before(today), - Due: due, - Heat: heatLevel(due), + Count: count, + Urgent: urgentN, + Heat: heatLevel(count), }) } data.Weeks = append(data.Weeks, week) } + // 今日待打(加急置顶) + rows, err := s.pendingTasks(domain.Date(today), nil, pkgID) + if err == nil { + for _, row := range rows { + if row.Done || row.Skipped { + continue + } + data.TodayList = append(data.TodayList, screenTask{TaskRow: row}) + if row.Urgent { + data.TodayUrgent++ + } + } + } + data.TodayTasks = len(data.TodayList) + for _, row := range rows { + if row.Done { + data.TodayDone++ + } + } + + // 最近追加(底部细条) if adj, err := s.db.RecentAdjustments(6); err == nil { data.Recent = adj } - list, done := s.todayTaskList(today) - data.TodayTasks = len(list) - data.TodayDone = done - data.TodayList = subViews(list, today, warn) + // 套餐标签 + pkgs, _ := s.db.ListPackages(true) + for _, p := range pkgs { + if p.CalendarSeparate && !p.Archived { + if !data.HasTabs { + data.HasTabs = true + data.Tabs = append(data.Tabs, PkgTab{ID: 0, Name: "全部", Color: "#8A8F98"}) + } + data.Tabs = append(data.Tabs, PkgTab{ID: p.ID, Name: p.Name, Color: p.Color}) + } + } + + data.Theme = themeFromRequest(r) p := s.newPage(r, "大屏", "screen") + p.Theme = data.Theme p.D = data s.render(w, r, "screen", p) } -// todayTaskList 返回今天应服务的订阅与已完成数量。 -func (s *Server) todayTaskList(today time.Time) ([]store.Subscription, int) { - ts := domain.Date(today) - subs, err := s.db.CalendarSubs(ts, ts) +// handleScreenDay 返回某天的待办明细,供大屏悬停浮层使用。 +func (s *Server) handleScreenDay(w http.ResponseWriter, r *http.Request) { + date := r.URL.Query().Get("date") + if _, err := domain.ParseDate(date); err != nil { + http.Error(w, "日期不正确", http.StatusBadRequest) + return + } + pkgID := int64(s.queryInt(r, "pkg", 0)) + rows, err := s.pendingTasks(date, nil, pkgID) if err != nil { - return nil, 0 + http.Error(w, "读取失败", http.StatusInternalServerError) + return } - ids := make([]int64, 0, len(subs)) - for _, sub := range subs { - ids = append(ids, sub.ID) + type item struct { + Tail string `json:"tail"` + Name string `json:"name"` + Pkg string `json:"pkg"` + Color string `json:"color"` + Remain string `json:"remain"` + Urgent bool `json:"urgent"` + Done bool `json:"done"` } - pauses, _ := s.db.PausesForSubs(ids) - checkins, _ := s.db.CheckinsBetween(ts, ts) - warn := s.warnDays() - out := []store.Subscription{} - done := 0 - for _, sub := range subs { - if sub.Status == domain.StatusClosed { + out := struct { + Date string `json:"date"` + Total int `json:"total"` + Done int `json:"done"` + Urgent int `json:"urgent"` + More int `json:"more"` + Items []item `json:"items"` + }{Date: date} + + const limit = 60 + for _, row := range rows { + out.Total++ + if row.Done || row.Skipped { + out.Done++ + } + if row.Urgent { + out.Urgent++ + } + if len(out.Items) >= limit { + out.More++ continue } - if sub.StartDate > ts || sub.EndDate == "" || sub.EndDate < ts { - continue - } - cs := calSub{sub: sub, pauses: pauses[sub.ID], hasEnd: true, eval: sub.Eval(today, warn)} - if sub.Unit == domain.UnitCount && cs.eval.RemainingCount <= 0 { - continue - } - if cs.pausedOn(ts) { - continue - } - out = append(out, sub) - if ci, ok := checkins[sub.ID][ts]; ok && ci.Status == "done" { - done++ - } + out.Items = append(out.Items, item{ + Tail: row.CustomerTail, + Name: row.CustomerName, + Pkg: row.PackageName, + Color: row.PackageColor, + Remain: row.RemainText, + Urgent: row.Urgent, + Done: row.Done || row.Skipped, + }) } - return out, done + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(out) } diff --git a/internal/web/handlers_tasks.go b/internal/web/handlers_tasks.go new file mode 100644 index 0000000..b3ebc1a --- /dev/null +++ b/internal/web/handlers_tasks.go @@ -0,0 +1,202 @@ +package web + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "daili/internal/domain" +) + +// pendingTasks 返回某天“应服务”的订阅行(day 型在区间内、count 型有剩余), +// customerIDs 非空时只保留这些客户;pkgID>0 时只保留该套餐。 +func (s *Server) pendingTasks(date string, customerIDs map[int64]bool, pkgID int64) ([]TaskRow, error) { + subs, pauses, checkins, urgent, err := s.loadCalendarSubs(date, date, pkgID) + if err != nil { + return nil, err + } + today := domain.Today() + warn := s.warnDays() + rows := []TaskRow{} + for _, sub := range subs { + if customerIDs != nil && !customerIDs[sub.CustomerID] { + continue + } + cs := calSub{sub: sub, pauses: pauses[sub.ID], eval: sub.Eval(today, warn)} + if sub.EndDate != "" { + cs.hasEnd = true + cs.end = domain.ParseDateOr(sub.EndDate, today) + } + if !cs.covers(date) { + continue + } + row := TaskRow{SubView: newSubView(sub, today, warn), Urgent: urgent[sub.ID][date]} + if ci, ok := checkins[sub.ID][date]; ok { + row.CheckinID = ci.ID + row.Done = ci.Status == "done" + row.Skipped = ci.Status == "skipped" + row.Note = ci.Note + row.Operator = ci.OperatorName + } + rows = append(rows, row) + } + sortTasks(rows) + return rows, nil +} + +// handleQuickCheckin 输入手机尾号直接完成当日服务: +// 命中 1 项直接完成;命中多项则跳到日历的选择面板。 +func (s *Server) handleQuickCheckin(w http.ResponseWriter, r *http.Request) { + tail := strings.TrimSpace(r.PostFormValue("tail")) + date := strings.TrimSpace(r.PostFormValue("date")) + pkgID := int64(s.formInt(r, "pkg", 0)) + if _, err := domain.ParseDate(date); err != nil { + date = domain.Date(domain.Today()) + } + back := backURL(r, "/calendar?d="+date) + if tail == "" { + s.redirect(w, r, back, "请输入手机尾号") + return + } + + customers, err := s.db.CustomersByTail(tail) + if err != nil { + s.redirect(w, r, back, "查询客户失败:"+err.Error()) + return + } + if len(customers) == 0 { + s.redirect(w, r, back, "没有找到尾号 "+tail+" 的客户") + return + } + ids := map[int64]bool{} + for _, c := range customers { + ids[c.ID] = true + } + + rows, err := s.pendingTasks(date, ids, pkgID) + if err != nil { + s.redirect(w, r, back, "读取待完成失败:"+err.Error()) + return + } + pending := make([]TaskRow, 0, len(rows)) + for _, row := range rows { + if !row.Done && !row.Skipped { + pending = append(pending, row) + } + } + switch len(pending) { + case 0: + if len(rows) > 0 { + s.redirect(w, r, back, "尾号 "+tail+" 今天的服务已经处理过了") + return + } + s.redirect(w, r, back, "尾号 "+tail+" 今天没有待完成的服务") + return + case 1: + row := pending[0] + u := s.currentUser(r) + var op int64 + if u != nil { + op = u.ID + } + if err := s.db.SaveCheckin(row.ID, date, "done", "", op); err != nil { + s.redirect(w, r, back, "完成失败:"+err.Error()) + return + } + s.logOp(r, "快速完成", "subscription", row.ID, + fmt.Sprintf("尾号 %s:%s %s 完成 %s(%s)", tail, row.CustomerTail, row.CustomerName, row.PackageName, date)) + s.redirect(w, r, back, "已为 "+row.CustomerTail+" "+row.CustomerName+" 完成「"+row.PackageName+"」") + return + default: + // 多项:回到日历并打开选择面板 + target := stripParam(back, "pick") + sep := "?" + if strings.Contains(target, "?") { + sep = "&" + } + s.redirect(w, r, target+sep+"pick="+urlQueryEscape(tail), fmt.Sprintf("尾号 %s 有 %d 项待完成,请勾选", tail, len(pending))) + return + } +} + +// handleCheckinBatch 批量完成选中的订阅(同日)。 +func (s *Server) handleCheckinBatch(w http.ResponseWriter, r *http.Request) { + date := strings.TrimSpace(r.PostFormValue("date")) + if _, err := domain.ParseDate(date); err != nil { + date = domain.Date(domain.Today()) + } + back := backURL(r, "/calendar?d="+date) + raw := r.PostForm["sub_ids"] + if len(raw) == 0 { + s.redirect(w, r, back, "没有选中任何项") + return + } + u := s.currentUser(r) + var op int64 + if u != nil { + op = u.ID + } + done, failed := 0, 0 + var lastErr string + for _, v := range raw { + id, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + failed++ + continue + } + if err := s.db.SaveCheckin(id, date, "done", "", op); err != nil { + failed++ + lastErr = err.Error() + continue + } + done++ + } + if done > 0 { + s.logOp(r, "批量完成", "checkin", 0, fmt.Sprintf("%s 一次完成 %d 项", date, done)) + } + msg := fmt.Sprintf("已完成 %d 项", done) + if failed > 0 { + msg += fmt.Sprintf(",%d 项失败(%s)", failed, lastErr) + } + s.redirect(w, r, stripParam(back, "pick"), msg) +} + +// handleUrgentToggle 勾选/取消某天某订阅的加急。 +func (s *Server) handleUrgentToggle(w http.ResponseWriter, r *http.Request) { + subID := int64(s.formInt(r, "sub_id", 0)) + date := strings.TrimSpace(r.PostFormValue("date")) + urgent := r.PostFormValue("urgent") == "1" + back := backURL(r, "/calendar") + if subID <= 0 { + s.redirect(w, r, back, "参数不正确") + return + } + if err := s.db.SetUrgent(subID, date, urgent); err != nil { + s.redirect(w, r, back, "设置加急失败:"+err.Error()) + return + } + word := "已取消加急" + if urgent { + word = "已标记加急" + } + if sub, err := s.db.Sub(subID); err == nil { + s.logOp(r, word, "subscription", subID, sub.SubTitle()+" "+date+" "+word) + } + s.redirect(w, r, back, word) +} + +func urlQueryEscape(v string) string { + var b strings.Builder + for _, c := range []byte(v) { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~' { + b.WriteByte(c) + continue + } + b.WriteString("%") + const hex = "0123456789ABCDEF" + b.WriteByte(hex[c>>4]) + b.WriteByte(hex[c&15]) + } + return b.String() +} diff --git a/internal/web/server.go b/internal/web/server.go index 84cdedf..82b0b58 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -31,6 +31,7 @@ var staticFS embed.FS const ( cookieSession = "daili_sid" cookieFlash = "daili_flash" + cookieTheme = "daili_theme" sessionTTL = 7 * 24 * time.Hour pageSize = 20 ) @@ -112,6 +113,9 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("POST /subscriptions/{id}/reopen", s.auth(s.handleSubAction("reopen"))) mux.HandleFunc("POST /subscriptions/{id}/checkin", s.auth(s.handleCheckinSave)) mux.HandleFunc("POST /checkins/{id}/delete", s.auth(s.handleCheckinDelete)) + mux.HandleFunc("POST /checkins/batch", s.auth(s.handleCheckinBatch)) + mux.HandleFunc("POST /checkins/quick", s.auth(s.handleQuickCheckin)) + mux.HandleFunc("POST /tasks/urgent", s.auth(s.handleUrgentToggle)) mux.HandleFunc("GET /packages", s.auth(s.handlePackages)) mux.HandleFunc("GET /packages/new", s.auth(s.handlePackageNewPage)) mux.HandleFunc("POST /packages", s.auth(s.handlePackageCreate)) @@ -119,11 +123,13 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("POST /packages/{id}/edit", s.auth(s.handlePackageUpdate)) mux.HandleFunc("POST /packages/{id}/archive", s.auth(s.handlePackageArchive)) if s.cfg.ScreenPublic { - // 大屏免登录:仅局域网部署时使用,内容为只读看板 - mux.HandleFunc("GET /screen", s.handleScreen) - } else { - mux.HandleFunc("GET /screen", s.auth(s.handleScreen)) - } + // 大屏免登录:仅局域网部署时使用,内容为只读看板 + mux.HandleFunc("GET /screen", s.handleScreen) + mux.HandleFunc("GET /api/screen/day", s.handleScreenDay) + } else { + mux.HandleFunc("GET /screen", s.auth(s.handleScreen)) + mux.HandleFunc("GET /api/screen/day", s.auth(s.handleScreenDay)) + } mux.HandleFunc("GET /logs", s.auth(s.handleLogs)) mux.HandleFunc("GET /settings", s.auth(s.handleSettings)) mux.HandleFunc("POST /settings", s.auth(s.handleSettingsSave)) @@ -325,6 +331,7 @@ type page struct { DueToday int Due3 int Addr string + Theme string // "" = 跟随系统;light / dark D any } @@ -357,6 +364,7 @@ func (s *Server) newPage(r *http.Request, title, nav string) *page { Version: s.cfg.Version, Query: r.URL.Query().Get("q"), Next: r.URL.Query().Get("next"), + Theme: themeFromRequest(r), } if u != nil { if due, due3, err := s.db.Badges(today); err == nil { @@ -366,6 +374,19 @@ func (s *Server) newPage(r *http.Request, title, nav string) *page { return p } +// themeFromRequest 读取主题偏好:"" = 跟随系统,light / dark = 手动指定。 +func themeFromRequest(r *http.Request) string { + c, err := r.Cookie(cookieTheme) + if err != nil { + return "" + } + switch c.Value { + case "light", "dark": + return c.Value + } + return "" +} + func (s *Server) popFlash(w http.ResponseWriter, r *http.Request) string { c, err := r.Cookie(cookieFlash) if err != nil || c.Value == "" { diff --git a/internal/web/static/app.css b/internal/web/static/app.css index bfcaf9b..0659391 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -1,11 +1,13 @@ /* ============================================================ - 代打台 · 管理端(浅色「接单台」) - 纸感底色 + 墨色文字 + 松墨绿主色;色签(ticket)是唯一强装饰 + 代打台 · 管理端 + 主题::root 为亮色;暗色由系统偏好或 [data-theme] 覆盖 + 设计语汇:纸感底色 + 墨色文字 + 松墨绿主色;色签(ticket)是唯一强装饰 ============================================================ */ :root { --paper: #f7f6f2; --surface: #ffffff; + --surface-2: #fcfcfa; --ink: #1a1c21; --ink-2: #43474f; --muted: #7a7e86; @@ -20,11 +22,60 @@ --warn-soft: #fbf1e3; --info: #41556b; --info-soft: #eaeef2; + --hover: #f2f7f4; + --shadow: 0 12px 32px -26px rgba(26, 28, 33, 0.8); --radius: 3px; --mono: "Bahnschrift", "Consolas", "SF Mono", ui-monospace, monospace; --sans: "Microsoft YaHei UI", "PingFang SC", "Hiragino Sans GB", system-ui, -apple-system, "Segoe UI", sans-serif; } +/* 暗色令牌:系统偏好 + 手动选择共用 */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --paper: #111317; + --surface: #181b20; + --surface-2: #1e2228; + --ink: #e8eaee; + --ink-2: #c2c7cf; + --muted: #8b929c; + --line: #2b3038; + --line-2: #23272e; + --accent: #6bb8a3; + --accent-ink: #8fd0bd; + --accent-soft: rgba(107, 184, 163, 0.16); + --danger: #e8776f; + --danger-soft: rgba(232, 119, 111, 0.16); + --warn: #e0a862; + --warn-soft: rgba(224, 168, 98, 0.16); + --info: #93a7bd; + --info-soft: rgba(147, 167, 189, 0.16); + --hover: #1f242b; + --shadow: 0 12px 32px -22px rgba(0, 0, 0, 0.9); + } +} + +:root[data-theme="dark"] { + --paper: #111317; + --surface: #181b20; + --surface-2: #1e2228; + --ink: #e8eaee; + --ink-2: #c2c7cf; + --muted: #8b929c; + --line: #2b3038; + --line-2: #23272e; + --accent: #6bb8a3; + --accent-ink: #8fd0bd; + --accent-soft: rgba(107, 184, 163, 0.16); + --danger: #e8776f; + --danger-soft: rgba(232, 119, 111, 0.16); + --warn: #e0a862; + --warn-soft: rgba(224, 168, 98, 0.16); + --info: #93a7bd; + --info-soft: rgba(147, 167, 189, 0.16); + --hover: #1f242b; + --shadow: 0 12px 32px -22px rgba(0, 0, 0, 0.9); +} + * { box-sizing: border-box; } [hidden] { display: none !important; } @@ -45,15 +96,17 @@ body { a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; text-underline-offset: 2px; } -h1, h2, h3 { margin: 0; font-weight: 700; letter-spacing: -0.01em; } +h1, h2, h3, h4 { margin: 0; font-weight: 700; letter-spacing: -0.01em; } h1 { font-size: 22px; } h2 { font-size: 15px; } h3 { font-size: 13px; } +h4 { font-size: 13px; } .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; letter-spacing: 0.02em; } .small { font-size: 12px; } .muted { color: var(--muted); } .right { text-align: right; } +.text-urgent { color: var(--danger); } /* ---------- 顶栏 ---------- */ @@ -61,7 +114,7 @@ h3 { font-size: 13px; } position: sticky; top: 0; z-index: 40; display: flex; align-items: center; gap: 14px; padding: 0 20px; height: 56px; - background: rgba(247, 246, 242, 0.94); + background: color-mix(in srgb, var(--paper) 92%, transparent); backdrop-filter: blur(6px); border-bottom: 1px solid var(--line); } @@ -71,10 +124,10 @@ h3 { font-size: 13px; } .brand-mark { display: grid; place-items: center; width: 26px; height: 26px; - background: var(--accent); color: #fff; + background: var(--accent); color: var(--paper); font-size: 14px; font-weight: 700; border-radius: 2px; - box-shadow: 2px 2px 0 rgba(47, 93, 80, 0.18); + box-shadow: 2px 2px 0 color-mix(in srgb, var(--accent) 22%, transparent); } .brand-name { font-weight: 700; letter-spacing: 0.06em; } @@ -102,7 +155,7 @@ h3 { font-size: 13px; } color: var(--ink-2); font-size: 13px; } .nav a:hover { background: var(--surface); text-decoration: none; } -.nav a.on { background: var(--accent); color: #fff; } +.nav a.on { background: var(--accent); color: var(--paper); } .nav a.nav-screen { color: var(--accent); } .topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; } @@ -119,6 +172,24 @@ h3 { font-size: 13px; } .pill-due b { color: var(--danger); } .pill-warn b { color: var(--warn); } +.theme-toggle { + display: inline-grid; place-items: center; + width: 30px; height: 28px; padding: 0; + border: 1px solid var(--line); border-radius: 999px; + background: var(--surface); color: var(--ink-2); + cursor: pointer; font-size: 14px; line-height: 1; +} +.theme-toggle:hover { border-color: var(--accent); color: var(--accent); } +.theme-toggle .ic-sun { display: none; } +:root[data-theme="dark"] .theme-toggle .ic-sun { display: inline; } +:root[data-theme="dark"] .theme-toggle .ic-moon { display: none; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .theme-toggle .ic-sun { display: inline; } + :root:not([data-theme="light"]) .theme-toggle .ic-moon { display: none; } +} + +.auth-theme { position: fixed; top: 14px; right: 14px; z-index: 50; background: var(--surface); } + .me { display: flex; align-items: center; gap: 8px; font-size: 13px; } .me a { color: var(--ink-2); } .link-btn { @@ -134,9 +205,9 @@ h3 { font-size: 13px; } left: 50%; top: 68px; transform: translateX(-50%); max-width: min(680px, calc(100vw - 32px)); padding: 9px 16px; - background: var(--accent); color: #fff; + background: var(--accent); color: var(--paper); border-radius: var(--radius); - box-shadow: 0 6px 24px rgba(26, 28, 33, 0.16); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18); font-size: 13px; animation: flash-in 0.22s ease-out; } @@ -184,10 +255,11 @@ main.with-top { padding-top: 22px; } } .btn:hover { border-color: var(--ink-2); text-decoration: none; } .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } -.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--paper); } .btn-primary:hover { background: var(--accent-ink); border-color: var(--accent-ink); } .btn-ghost { background: transparent; border-color: transparent; color: var(--ink-2); } .btn-ghost:hover { background: var(--surface); border-color: var(--line); } +.btn-urgent { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); } .btn-sm { height: 28px; padding: 0 10px; font-size: 12px; } .btn-square { width: 34px; padding: 0; font-size: 18px; line-height: 1; } .btn-block { width: 100%; } @@ -210,7 +282,16 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } .field input[type="color"] { width: 100%; height: 34px; padding: 2px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); cursor: pointer; } -.fieldset { border: 0; padding: 0; } + +.check-line { + display: flex; gap: 10px; align-items: flex-start; + padding: 10px 12px; margin-bottom: 14px; + border: 1px solid var(--line); border-radius: var(--radius); + background: var(--surface); cursor: pointer; +} +.check-line input { margin-top: 3px; accent-color: var(--accent); } +.check-line span { font-size: 13px; font-weight: 600; color: var(--ink-2); } +.check-line small { display: block; font-weight: 400; color: var(--muted); font-size: 12px; margin-top: 2px; } .radios { display: grid; gap: 8px; } .radios label { @@ -273,7 +354,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } background: var(--surface); border: 1px solid var(--line); border-top: 3px solid var(--accent); padding: 28px; - box-shadow: 0 18px 40px -32px rgba(26, 28, 33, 0.5); + box-shadow: var(--shadow); } .auth-brand { display: flex; align-items: center; gap: 8px; font-weight: 700; letter-spacing: 0.06em; margin-bottom: 18px; } .auth-card h1 { font-size: 20px; margin-bottom: 4px; } @@ -281,6 +362,32 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } .auth-foot { margin-top: 16px !important; text-align: center; } .form .btn-block { margin-top: 4px; } +/* ---------- 套餐标签(日历/大屏共用) ---------- */ + +.pkg-tabs { + display: flex; gap: 6px; margin-bottom: 14px; flex-wrap: wrap; + padding-bottom: 8px; border-bottom: 1px solid var(--line); +} +.pkg-tabs a { + display: inline-flex; align-items: center; gap: 6px; + height: 30px; padding: 0 12px; + border: 1px solid var(--line); border-radius: 999px; + background: var(--surface); color: var(--ink-2); + font-size: 13px; text-decoration: none; +} +.pkg-tabs a i { + width: 8px; height: 8px; border-radius: 50%; + background: var(--tab, var(--accent)); display: inline-block; +} +.pkg-tabs a em { + font-family: var(--mono); font-style: normal; font-size: 11px; + color: var(--muted); background: var(--paper); + border-radius: 999px; padding: 0 5px; +} +.pkg-tabs a:hover { border-color: var(--accent); text-decoration: none; } +.pkg-tabs a.on { background: var(--accent); border-color: var(--accent); color: var(--paper); } +.pkg-tabs a.on em { background: color-mix(in srgb, var(--paper) 25%, transparent); color: var(--paper); } + /* ---------- 日历 ---------- */ .cal-page { max-width: 1400px; margin: 0 auto; } @@ -296,6 +403,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } font-family: var(--mono); font-style: normal; font-size: 16px; color: var(--ink); margin-right: 4px; } +.cal-stats .text-urgent em { color: var(--danger); } .cal-body { display: grid; grid-template-columns: minmax(0, 1fr) 372px; gap: 16px; align-items: start; } @@ -326,28 +434,34 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } transition: background 0.12s ease; } .cal-cell:nth-child(7n) { border-right: 0; } -.cal-cell:hover { background: #f2f7f4; text-decoration: none; } +.cal-cell:hover { background: var(--hover); text-decoration: none; } .cal-cell.out { opacity: 0.4; } .cal-cell.weekend .dnum { color: var(--muted); } .cal-cell.sel { box-shadow: inset 0 0 0 2px var(--accent); z-index: 2; } .cal-cell.today .dnum { - background: var(--accent); color: #fff; + background: var(--accent); color: var(--paper); border-radius: 50%; width: 22px; height: 22px; display: inline-grid; place-items: center; } .cal-cell.has-due::after { content: ""; position: absolute; left: 6px; right: 6px; bottom: 3px; - border-bottom: 1px dashed rgba(180, 35, 31, 0.45); + border-bottom: 1px dashed color-mix(in srgb, var(--danger) 45%, transparent); } -.cal-cell.heat-1 { background: rgba(47, 93, 80, 0.045); } -.cal-cell.heat-2 { background: rgba(47, 93, 80, 0.085); } -.cal-cell.heat-3 { background: rgba(47, 93, 80, 0.13); } -.cal-cell.heat-4 { background: rgba(47, 93, 80, 0.19); } -.cal-cell.heat-0:hover, .cal-cell.heat-1:hover { background: #f2f7f4; } +.cal-cell.heat-1 { background: color-mix(in srgb, var(--accent) 5%, transparent); } +.cal-cell.heat-2 { background: color-mix(in srgb, var(--accent) 9%, transparent); } +.cal-cell.heat-3 { background: color-mix(in srgb, var(--accent) 14%, transparent); } +.cal-cell.heat-4 { background: color-mix(in srgb, var(--accent) 20%, transparent); } +.cal-cell.heat-0:hover, .cal-cell.heat-1:hover { background: var(--hover); } .cell-top { display: flex; align-items: center; justify-content: space-between; gap: 4px; } +.cell-badges { display: inline-flex; align-items: center; gap: 4px; } .dnum { font-family: var(--mono); font-size: 13px; font-weight: 700; line-height: 1.4; } +.urgent-dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--danger); display: inline-block; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 22%, transparent); +} .due-badge { display: inline-flex; align-items: baseline; gap: 2px; font-size: 10px; padding: 0 5px; line-height: 16px; @@ -365,7 +479,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } padding: 1px 5px; font-size: 10.5px; line-height: 16px; border-left: 3px solid var(--tag, var(--accent)); - background: color-mix(in srgb, var(--tag, var(--accent)) 11%, #fff); + background: color-mix(in srgb, var(--tag, var(--accent)) 13%, var(--surface)); border-radius: 1px 2px 2px 1px; white-space: nowrap; color: var(--ink-2); @@ -377,6 +491,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } .ticket.done::after { content: "✓"; color: var(--accent); font-size: 10px; } .ticket.skipped { opacity: 0.4; text-decoration: line-through; } .ticket.more { border-left-color: var(--line); background: var(--paper); color: var(--muted); } +.ticket.urgent { border-left-color: var(--danger); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--danger) 35%, transparent); } /* ---------- 当日抽屉 ---------- */ @@ -400,7 +515,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } .drawer-block h3 { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ink-2); margin-bottom: 8px; - text-transform: none; letter-spacing: 0.02em; + letter-spacing: 0.02em; } .drawer-block h3 em { font-family: var(--mono); font-style: normal; font-size: 11px; @@ -409,6 +524,39 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } } .drawer-block p { margin: 0; font-size: 12px; } +/* 快速完成 */ +.quick-form { display: flex; gap: 6px; margin-bottom: 4px; } +.quick-form input[type="text"] { + flex: 1 1 auto; min-width: 0; height: 30px; padding: 0 9px; + border: 1px solid var(--line); border-radius: var(--radius); + background: var(--surface); font: inherit; font-size: 13px; font-family: var(--mono); + color: var(--ink); +} +.quick-form input[type="text"]:focus { + outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); +} +.quick-hint { margin: 0 0 10px !important; font-size: 11px; line-height: 1.4; } + +/* 选择面板 */ +.picker { + margin: 8px 0 12px; padding: 10px; + border: 1px solid var(--accent); + border-radius: var(--radius); + background: var(--surface-2); +} +.picker-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; } +.picker-head h4 { font-size: 13px; } +.pick-list { list-style: none; margin: 0 0 8px; padding: 0; } +.pick-list li { border-bottom: 1px dashed var(--line-2); } +.pick-list li:last-child { border-bottom: 0; } +.pick-list label { + display: flex; align-items: center; gap: 7px; + padding: 6px 0; cursor: pointer; font-size: 12px; +} +.pick-list input[type="checkbox"] { accent-color: var(--accent); } +.pick-pkg { color: var(--ink-2); } +.pick-remain { margin-left: auto; font-family: var(--mono); color: var(--muted); font-size: 11px; } + .due-list, .task-list, .mini-due, .checkin-list { list-style: none; margin: 0; padding: 0; } .due-list li { @@ -422,14 +570,23 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } .task { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; padding: 8px 0; border-bottom: 1px dashed var(--line-2); } .task:last-child { border-bottom: 0; } +.task.urgent { border-left: 3px solid var(--danger); padding-left: 8px; margin-left: -8px; border-radius: 2px; background: color-mix(in srgb, var(--danger) 5%, transparent); } .task-main { min-width: 0; flex: 1 1 auto; } -.task-title { display: flex; align-items: center; gap: 6px; color: var(--ink); } +.task-title { display: flex; align-items: center; gap: 6px; color: var(--ink); flex-wrap: wrap; } .task-meta { display: flex; gap: 6px; font-size: 11px; color: var(--muted); margin-top: 2px; } .task-note { font-size: 11px; color: var(--ink-2); margin-top: 2px; } -.task-actions { display: flex; gap: 4px; align-items: center; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; max-width: 170px; } +.task-actions { display: flex; gap: 4px; align-items: center; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; max-width: 190px; } .task.done .task-title, .task.skipped .task-title { opacity: 0.5; } .task.skipped .task-title .ticket { text-decoration: line-through; } +.tag-urgent { + display: inline-flex; align-items: center; + height: 17px; padding: 0 5px; + font-size: 10px; line-height: 1; + color: #fff; background: var(--danger); border-radius: 2px; + letter-spacing: 0.02em; white-space: nowrap; +} + .mini-due li { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; align-items: center; padding: 4px 0; font-size: 12px; } .sm-date { font-family: var(--mono); font-size: 11px; color: var(--muted); } @@ -442,12 +599,12 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } border-radius: 2px; border: 1px solid transparent; white-space: nowrap; } -.chip-active { background: var(--accent-soft); color: var(--accent-ink); border-color: rgba(47, 93, 80, 0.2); } -.chip-pending { background: var(--info-soft); color: var(--info); border-color: rgba(65, 85, 107, 0.2); } -.chip-paused { background: var(--info-soft); color: var(--info); border-color: rgba(65, 85, 107, 0.2); } -.chip-expired { background: var(--danger-soft); color: var(--danger); border-color: rgba(180, 35, 31, 0.22); } -.chip-usedup { background: var(--warn-soft); color: var(--warn); border-color: rgba(168, 94, 18, 0.22); } -.chip-closed { background: #f0efeb; color: var(--muted); border-color: var(--line); } +.chip-active { background: var(--accent-soft); color: var(--accent-ink); border-color: color-mix(in srgb, var(--accent) 25%, transparent); } +.chip-pending { background: var(--info-soft); color: var(--info); border-color: color-mix(in srgb, var(--info) 25%, transparent); } +.chip-paused { background: var(--info-soft); color: var(--info); border-color: color-mix(in srgb, var(--info) 25%, transparent); } +.chip-expired { background: var(--danger-soft); color: var(--danger); border-color: color-mix(in srgb, var(--danger) 25%, transparent); } +.chip-usedup { background: var(--warn-soft); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 25%, transparent); } +.chip-closed { background: var(--paper); color: var(--muted); border-color: var(--line); } .chip-plain { background: var(--paper); color: var(--ink-2); border-color: var(--line); } .tagchip { @@ -481,7 +638,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } } .table td { padding: 9px 12px; border-bottom: 1px solid var(--line-2); vertical-align: middle; } .table tbody tr:last-child td { border-bottom: 0; } -.table tbody tr:hover { background: #fbfcfb; } +.table tbody tr:hover { background: var(--hover); } .table.tight td, .table.tight th { padding: 7px 10px; } .table .col-dot { width: 28px; } .table tr.archived { opacity: 0.6; } @@ -501,7 +658,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 56px 20px; text-align: center; border: 1px dashed var(--line); border-radius: var(--radius); - background: rgba(255, 255, 255, 0.55); + background: color-mix(in srgb, var(--surface) 60%, transparent); } .empty p { margin: 0; color: var(--muted); font-size: 13px; } .empty-mark { @@ -553,7 +710,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } .subcard-foot { display: flex; gap: 4px; flex-wrap: wrap; padding: 8px 12px; border-top: 1px dashed var(--line); - background: #fcfcfa; + background: var(--surface-2); } .timeline { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); padding: 16px 16px 10px; } @@ -567,7 +724,9 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } .tl-bar.st-closed { background: var(--muted); opacity: 0.6; } .tl-pause { position: absolute; top: 4px; height: 12px; - background: repeating-linear-gradient(135deg, #c9c7c0 0 4px, #e6e4dd 4px 8px); + background: repeating-linear-gradient(135deg, + color-mix(in srgb, var(--muted) 45%, transparent) 0 4px, + color-mix(in srgb, var(--muted) 18%, transparent) 4px 8px); border-radius: 1px; } .tl-due { position: absolute; top: 1px; bottom: 1px; width: 2px; background: var(--danger); } @@ -583,7 +742,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } border: 1px solid var(--accent); border-radius: var(--radius); background: var(--surface); - box-shadow: 0 12px 32px -26px rgba(26, 28, 33, 0.8); + box-shadow: var(--shadow); scroll-margin-top: 80px; } .drawer .extend { margin: 12px 14px; } @@ -591,7 +750,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; background: var(--accent-soft); - border-bottom: 1px solid rgba(47, 93, 80, 0.2); + border-bottom: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); } .extend-head h3 { font-size: 14px; } .extend-head p { margin: 2px 0 0; font-size: 11px; } @@ -620,12 +779,12 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } background: transparent; font: inherit; font-size: 11px; font-family: var(--mono); color: var(--ink-2); cursor: pointer; padding: 0; } -.miniday:hover:not(:disabled) { background: var(--accent-soft); border-color: rgba(47, 93, 80, 0.3); } -.miniday.out { color: #c3c1ba; } -.miniday.old { background: var(--warn-soft); border-color: rgba(168, 94, 18, 0.35); font-weight: 700; } +.miniday:hover:not(:disabled) { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 30%, transparent); } +.miniday.out { color: color-mix(in srgb, var(--muted) 55%, transparent); } +.miniday.old { background: var(--warn-soft); border-color: color-mix(in srgb, var(--warn) 35%, transparent); font-weight: 700; } .miniday.today { outline: 1px solid var(--accent); } -.miniday.picked { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 700; } -.miniday:disabled { color: #d6d4cc; cursor: not-allowed; } +.miniday.picked { background: var(--accent); color: var(--paper); border-color: var(--accent); font-weight: 700; } +.miniday:disabled { color: color-mix(in srgb, var(--muted) 45%, transparent); cursor: not-allowed; } /* ---------- 设置页杂项 ---------- */ @@ -660,7 +819,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } .topbar-right { order: 2; margin-left: auto; } main { padding: 0 12px 32px; } .cal-grid { overflow-x: auto; } - .cal-week, .cal-dow { min-width: 680px; } + .cal-week, .cal-dow { min-width: 640px; } .cal-cell { min-height: 96px; } .grid-2, .grid-3 { grid-template-columns: 1fr; } .cust-tail { font-size: 24px; } diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 6e86bbb..e645adf 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -106,3 +106,27 @@ sync(); }); })(); + +/* ---------- 亮色 / 暗色切换 ---------- + 偏好写 cookie,服务端首屏直接带 data-theme,不闪。 + 没有 cookie 时跟随系统(由 CSS 的 prefers-color-scheme 决定)。 */ +(function () { + 'use strict'; + var KEY = 'daili_theme'; + var btns = document.querySelectorAll('[data-theme-toggle]'); + if (!btns.length) return; + + function effective() { + var attr = document.documentElement.getAttribute('data-theme'); + if (attr === 'light' || attr === 'dark') return attr; + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + + Array.prototype.forEach.call(btns, function (btn) { + btn.addEventListener('click', function () { + var next = effective() === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + document.cookie = KEY + '=' + next + '; path=/; max-age=31536000; SameSite=Lax'; + }); + }); +})(); \ No newline at end of file diff --git a/internal/web/static/screen.css b/internal/web/static/screen.css index b35a604..9404c3a 100644 --- a/internal/web/static/screen.css +++ b/internal/web/static/screen.css @@ -1,212 +1,383 @@ /* ============================================================ - 代打台 · 可视化大屏(深色「记分板」) + 代打台 · 可视化大屏(记分板) + 默认深色;亮色由系统偏好或 [data-theme="light"] 覆盖 ============================================================ */ :root { - --bg: #070a0f; - --panel: #0e141c; - --panel-2: #121a24; - --line: #1b2530; - --ink: #e8eef2; - --dim: #7d8b99; - --mint: #35e0c6; - --mint-dim: rgba(53, 224, 198, 0.14); - --amber: #f5a524; - --red: #f2555a; - --mono: "Bahnschrift", "Consolas", "SF Mono", ui-monospace, monospace; - --sans: "Microsoft YaHei UI", "PingFang SC", system-ui, sans-serif; + --bg: #0c0f12; + --panel: #12161b; + --panel-2: #171c22; + --line: #232a32; + --ink: #e9edf1; + --ink-2: #aab4bf; + --muted: #6f7b87; + --accent: #58c1a3; + --accent-2: #8bd9c4; + --danger: #ef6b62; + --warn: #e6ac55; + --ok: #58c1a3; + --glow: 0 0 24px rgba(88, 193, 163, 0.18); +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { + --bg: #f4f2ec; + --panel: #ffffff; + --panel-2: #faf9f5; + --line: #e0ddd2; + --ink: #1b1e23; + --ink-2: #4a4f57; + --muted: #8a8f97; + --accent: #2f7d68; + --accent-2: #245c4d; + --danger: #b3302a; + --warn: #a0600f; + --ok: #2f7d68; + --glow: 0 0 18px rgba(47, 125, 104, 0.12); + } +} + +:root[data-theme="light"] { + --bg: #f4f2ec; + --panel: #ffffff; + --panel-2: #faf9f5; + --line: #e0ddd2; + --ink: #1b1e23; + --ink-2: #4a4f57; + --muted: #8a8f97; + --accent: #2f7d68; + --accent-2: #245c4d; + --danger: #b3302a; + --warn: #a0600f; + --ok: #2f7d68; + --glow: 0 0 18px rgba(47, 125, 104, 0.12); } * { box-sizing: border-box; } -html, body { margin: 0; height: 100%; } +html, body { height: 100%; margin: 0; } -body.screen-body { - background: - radial-gradient(1200px 600px at 15% -10%, rgba(53, 224, 198, 0.07), transparent 60%), - radial-gradient(900px 500px at 100% 0%, rgba(245, 165, 36, 0.05), transparent 55%), - var(--bg); +body { + background: var(--bg); color: var(--ink); - font-family: var(--sans); + font-family: "Bahnschrift", "Microsoft YaHei UI", "PingFang SC", system-ui, sans-serif; font-size: 14px; - min-height: 100vh; - overflow-x: hidden; + overflow: hidden; } -a { color: var(--mint); text-decoration: none; } -a:hover { opacity: 0.8; } +/* 背景:极淡的网格与光晕 */ +body::before { + content: ""; + position: fixed; inset: 0; z-index: 0; pointer-events: none; + background: + radial-gradient(1200px 600px at 20% -10%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 60%), + linear-gradient(var(--line) 1px, transparent 1px) 0 0 / 100% 40px, + linear-gradient(90deg, var(--line) 1px, transparent 1px) 0 0 / 40px 100%; + opacity: 0.5; +} .scr { - display: flex; flex-direction: column; - min-height: 100vh; - padding: clamp(12px, 1.4vw, 22px) clamp(14px, 1.8vw, 28px); - gap: clamp(10px, 1.1vw, 16px); + position: relative; z-index: 1; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto auto auto; + gap: 10px; + height: 100vh; + padding: 12px 16px 8px; } -.scr-head { display: flex; align-items: center; gap: 16px; } -.scr-brand { - display: flex; align-items: center; gap: 10px; - font-size: clamp(15px, 1.2vw, 19px); font-weight: 700; letter-spacing: 0.1em; -} +/* ---------- 头部 ---------- */ + +.scr-head { display: flex; align-items: center; gap: 16px; min-height: 40px; } + +.scr-brand { display: flex; align-items: baseline; gap: 6px; font-size: 17px; font-weight: 700; letter-spacing: 0.08em; } .scr-brand .mark { - display: grid; place-items: center; - width: 30px; height: 30px; border-radius: 3px; - background: var(--mint); color: #04211d; font-size: 15px; - box-shadow: 0 0 22px rgba(53, 224, 198, 0.45); + display: inline-grid; place-items: center; + width: 24px; height: 24px; margin-right: 4px; + background: var(--accent); color: var(--bg); + font-size: 13px; border-radius: 2px; + align-self: center; } -.scr-brand em { font-style: normal; color: var(--dim); font-weight: 400; letter-spacing: 0.24em; font-size: 0.7em; } +.scr-brand em { font-style: normal; font-size: 11px; color: var(--muted); letter-spacing: 0.2em; } -.scr-clock { margin-left: auto; display: flex; flex-direction: column; align-items: flex-end; } -.scr-clock b { - font-family: var(--mono); font-size: clamp(24px, 2.4vw, 36px); - font-weight: 600; line-height: 1; - color: var(--ink); letter-spacing: 0.04em; - text-shadow: 0 0 22px rgba(53, 224, 198, 0.25); +.scr-tabs { display: flex; gap: 4px; flex-wrap: wrap; } +.scr-tabs a { + display: inline-flex; align-items: center; gap: 5px; + height: 28px; padding: 0 11px; + font-size: 12px; color: var(--ink-2); text-decoration: none; + border: 1px solid var(--line); border-radius: 999px; + background: var(--panel); } -.scr-clock span { font-family: var(--mono); font-size: 11px; color: var(--dim); letter-spacing: 0.1em; } -.scr-links { font-size: 11px; color: var(--dim); } +.scr-tabs a::before { + content: ""; width: 6px; height: 6px; border-radius: 50%; + background: var(--tab, var(--accent)); +} +.scr-tabs a.on { background: var(--accent); border-color: var(--accent); color: var(--bg); } +.scr-tabs a.on::before { background: var(--bg); } -/* KPI */ -.scr-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: clamp(8px, 0.9vw, 14px); } +.scr-tools { display: flex; align-items: center; gap: 8px; } +.scr-switch { + height: 28px; padding: 0 10px; + display: inline-flex; align-items: center; + font-size: 12px; color: var(--accent); text-decoration: none; + border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--line)); + border-radius: 999px; background: var(--panel); +} +.scr-switch:hover { background: color-mix(in srgb, var(--accent) 12%, var(--panel)); } + +.scr-theme { + width: 28px; height: 28px; padding: 0; + display: inline-grid; place-items: center; + border: 1px solid var(--line); border-radius: 999px; + background: var(--panel); color: var(--ink-2); + font-size: 13px; cursor: pointer; +} +.scr-theme .ic-sun { display: none; } +:root[data-theme="light"] .scr-theme .ic-sun { display: inline; } +:root[data-theme="light"] .scr-theme .ic-moon { display: none; } +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) .scr-theme .ic-sun { display: inline; } + :root:not([data-theme="dark"]) .scr-theme .ic-moon { display: none; } +} + +.scr-clock { margin-left: auto; text-align: right; line-height: 1.1; } +.scr-clock b { font-family: var(--mono, "Bahnschrift", monospace); font-size: 22px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; } +.scr-clock span { display: block; font-size: 11px; color: var(--muted); } + +.scr-links { font-size: 11px; color: var(--muted); } +.scr-links a { color: var(--accent); text-decoration: none; } + +/* ---------- KPI ---------- */ + +.scr-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; } .kpi { - position: relative; overflow: hidden; - background: linear-gradient(180deg, var(--panel-2), var(--panel)); - border: 1px solid var(--line); - border-radius: 4px; - padding: clamp(10px, 1vw, 16px) clamp(12px, 1.2vw, 20px); -} -.kpi::before { - content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; - background: var(--mint); opacity: 0.75; -} -.kpi-warm::before { background: var(--amber); } -.kpi-hot::before { background: var(--red); } -.kpi span { display: block; font-size: 11px; color: var(--dim); letter-spacing: 0.16em; } -.kpi b { - font-family: var(--mono); font-size: clamp(28px, 3.4vw, 52px); font-weight: 700; - line-height: 1.15; letter-spacing: -0.01em; -} -.kpi i { font-style: normal; font-size: 11px; color: var(--dim); margin-left: 4px; } -.kpi-hot b { color: var(--red); } -.kpi-warm b { color: var(--amber); } - -/* 主区 */ -.scr-main { display: grid; grid-template-columns: 1.15fr 1fr; gap: clamp(10px, 1.1vw, 16px); flex: 1 1 auto; } -.panel { + position: relative; + display: flex; align-items: baseline; gap: 6px; + padding: 10px 14px; background: var(--panel); border: 1px solid var(--line); - border-radius: 4px; - padding: clamp(10px, 1vw, 16px); - display: flex; flex-direction: column; min-height: 0; + border-radius: 3px; + overflow: hidden; } -.panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 10px; } -.panel-head h2 { - margin: 0; font-size: clamp(12px, 0.95vw, 14px); font-weight: 600; letter-spacing: 0.08em; - color: var(--ink); +.kpi::after { + content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; + background: var(--accent); } -.panel-head h2::before { - content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 7px; - background: var(--mint); vertical-align: 2px; -} -.panel-head span { font-size: 11px; color: var(--dim); font-family: var(--mono); } +.kpi span { font-size: 12px; color: var(--muted); margin-right: auto; } +.kpi b { font-family: "Bahnschrift", monospace; font-size: 30px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; } +.kpi i { font-style: normal; font-size: 11px; color: var(--muted); } +.kpi-hot::after { background: var(--danger); } +.kpi-hot b { color: var(--danger); } +.kpi-warm::after { background: var(--warn); } +.kpi-warm b { color: var(--warn); } -/* 热力月历 */ -.heat-grid { display: flex; flex-direction: column; gap: 4px; flex: 1 1 auto; } -.heat-dow, .heat-row { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; } -.heat-row { flex: 1 1 0; min-height: 0; } -.heat-dow { flex: 0 0 auto; } -.heat-dow span { text-align: center; font-size: 10px; color: var(--dim); } -.heat-cell { - position: relative; - display: flex; flex-direction: column; justify-content: space-between; - min-height: 34px; - padding: 5px 6px; +/* ---------- 主区 ---------- */ + +.scr-main { display: grid; grid-template-columns: 1.5fr 1fr; gap: 10px; min-height: 0; } + +.panel { + display: flex; flex-direction: column; min-height: 0; + background: var(--panel); border: 1px solid var(--line); border-radius: 3px; - background: #0a1017; + overflow: hidden; } -.heat-cell span { font-family: var(--mono); font-size: 10px; color: var(--dim); } -.heat-cell b { - align-self: flex-end; font-family: var(--mono); font-size: clamp(12px, 1.1vw, 16px); - color: var(--mint); font-weight: 700; +.panel-head { + display: flex; align-items: baseline; justify-content: space-between; gap: 10px; + padding: 9px 14px; + border-bottom: 1px solid var(--line); + background: var(--panel-2); } +.panel-head h2 { margin: 0; font-size: 13px; letter-spacing: 0.08em; font-weight: 700; } +.panel-head span { font-size: 11px; color: var(--muted); } + +/* 热力 */ +.heat-grid { flex: 1 1 auto; display: flex; flex-direction: column; justify-content: center; padding: 8px 12px; min-height: 0; } +.heat-dow { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-bottom: 4px; } +.heat-dow span { text-align: center; font-size: 10px; color: var(--muted); } +.heat-row { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-bottom: 4px; } +.heat-cell { + position: relative; + display: flex; align-items: center; justify-content: space-between; + min-height: 0; aspect-ratio: 2.05 / 1; + padding: 4px 7px; + border: 1px solid var(--line); + border-radius: 2px; + background: var(--panel-2); + font-size: 11px; color: var(--muted); + transition: transform 0.1s ease, border-color 0.1s ease; +} +.heat-cell span { font-family: "Bahnschrift", monospace; font-size: 11px; } +.heat-cell b { font-family: "Bahnschrift", monospace; font-size: 12px; color: var(--ink-2); } .heat-cell.out { opacity: 0.28; } -.heat-cell.today { border-color: var(--mint); box-shadow: 0 0 0 1px rgba(53, 224, 198, 0.35), 0 0 18px rgba(53, 224, 198, 0.16); } -.heat-cell.h1 { background: rgba(53, 224, 198, 0.08); border-color: rgba(53, 224, 198, 0.22); } -.heat-cell.h2 { background: rgba(53, 224, 198, 0.15); border-color: rgba(53, 224, 198, 0.3); } -.heat-cell.h3 { background: rgba(53, 224, 198, 0.24); border-color: rgba(53, 224, 198, 0.42); } -.heat-cell.h4 { background: rgba(53, 224, 198, 0.36); border-color: rgba(53, 224, 198, 0.6); } -.heat-cell.h3 b, .heat-cell.h4 b { color: #eafffb; } -.heat-legend { display: flex; align-items: center; gap: 5px; margin-top: 10px; font-size: 10px; color: var(--dim); flex: 0 0 auto; } -.heat-legend i { width: 18px; height: 9px; border-radius: 2px; border: 1px solid #26313d; } -.heat-legend .h0 { background: #0a1017; } -.heat-legend .h1 { background: rgba(53, 224, 198, 0.16); } -.heat-legend .h2 { background: rgba(53, 224, 198, 0.28); } -.heat-legend .h3 { background: rgba(53, 224, 198, 0.42); } -.heat-legend .h4 { background: rgba(53, 224, 198, 0.6); } +.heat-cell.past { opacity: 0.66; } +.heat-cell.today { outline: 1px solid var(--accent); outline-offset: -1px; } +.heat-cell.today span { color: var(--accent); font-weight: 700; } +.heat-cell:hover { transform: translateY(-1px); border-color: var(--accent); z-index: 3; } -/* TOP10 */ -.top-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; overflow: hidden; } +.heat-cell.h1 { background: color-mix(in srgb, var(--accent) 12%, var(--panel-2)); border-color: color-mix(in srgb, var(--accent) 22%, var(--line)); } +.heat-cell.h2 { background: color-mix(in srgb, var(--accent) 24%, var(--panel-2)); } +.heat-cell.h3 { background: color-mix(in srgb, var(--accent) 38%, var(--panel-2)); } +.heat-cell.h4 { background: color-mix(in srgb, var(--accent) 56%, var(--panel-2)); } +.heat-cell.h3 b, .heat-cell.h4 b { color: var(--bg); } +.heat-cell.h3 span, .heat-cell.h4 span { color: color-mix(in srgb, var(--bg) 75%, var(--accent)); } + +.cell-urgent { + position: absolute; top: 3px; right: 3px; + width: 5px; height: 5px; border-radius: 50%; + background: var(--danger); + box-shadow: 0 0 6px color-mix(in srgb, var(--danger) 70%, transparent); +} + +.heat-legend { + display: flex; align-items: center; gap: 4px; + padding: 6px 14px 10px; + font-size: 10px; color: var(--muted); +} +.heat-legend span { margin-right: 6px; } +.heat-legend i { + width: 14px; height: 9px; border-radius: 1px; + border: 1px solid var(--line); + display: inline-block; +} +.heat-legend i.h0 { background: var(--panel-2); } +.heat-legend i.h1 { background: color-mix(in srgb, var(--accent) 12%, var(--panel-2)); } +.heat-legend i.h2 { background: color-mix(in srgb, var(--accent) 24%, var(--panel-2)); } +.heat-legend i.h3 { background: color-mix(in srgb, var(--accent) 38%, var(--panel-2)); } +.heat-legend i.h4 { background: color-mix(in srgb, var(--accent) 56%, var(--panel-2)); } +.heat-legend .legend-hint { margin-left: auto; font-style: normal; opacity: 0.7; } + +/* TOP 10 */ +.top-list { list-style: none; margin: 0; padding: 6px 10px; overflow: hidden; flex: 1 1 auto; } .top-item { - display: grid; grid-template-columns: 22px auto 1fr auto auto; - grid-template-areas: "rank tail name pkg remain" "rank bar bar bar bar"; - column-gap: 8px; row-gap: 3px; - align-items: center; - padding: 5px 8px; - border-radius: 3px; - background: #0a1017; - border-left: 2px solid transparent; + display: grid; + grid-template-columns: 22px 54px minmax(0, 1fr) auto auto 56px; + gap: 8px; align-items: center; + padding: 5px 4px; + border-bottom: 1px dashed var(--line); + font-size: 12px; } -.top-item .rank { grid-area: rank; font-family: var(--mono); font-size: 11px; color: var(--dim); } -.top-tail { grid-area: tail; font-family: var(--mono); font-size: clamp(13px, 1.15vw, 17px); font-weight: 700; letter-spacing: 0.03em; } -.top-name { grid-area: name; font-size: 12px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.top-item:last-child { border-bottom: 0; } +.top-item .rank { font-family: "Bahnschrift", monospace; font-size: 11px; color: var(--muted); } +.top-item:nth-child(-n+3) .rank { color: var(--accent); font-weight: 700; } +.top-tail { font-family: "Bahnschrift", monospace; font-size: 14px; font-weight: 700; letter-spacing: 0.03em; } +.top-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-2); } .top-pkg { - grid-area: pkg; font-size: 10px; color: var(--dim); - border-left: 2px solid var(--tag); padding-left: 5px; + font-size: 10px; padding: 1px 6px; border-radius: 2px; + border-left: 2px solid var(--tag, var(--accent)); + background: color-mix(in srgb, var(--tag, var(--accent)) 14%, transparent); + color: var(--ink-2); white-space: nowrap; } -.top-remain { grid-area: remain; font-family: var(--mono); font-size: 12px; color: var(--mint); } -.top-bar { grid-area: bar; height: 3px; background: #16202b; border-radius: 999px; overflow: hidden; } -.top-bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--mint), rgba(53, 224, 198, 0.35)); } -.top-item.lv-warn { border-left-color: var(--amber); } -.top-item.lv-warn .top-remain { color: var(--amber); } -.top-item.lv-warn .top-bar i { background: linear-gradient(90deg, var(--amber), rgba(245, 165, 36, 0.3)); } -.top-item.lv-today, .top-item.lv-expired { border-left-color: var(--red); } -.top-item.lv-today .top-remain, .top-item.lv-expired .top-remain { color: var(--red); } -.top-item.lv-today .top-bar i, .top-item.lv-expired .top-bar i { background: linear-gradient(90deg, var(--red), rgba(242, 85, 90, 0.3)); } +.top-remain { font-family: "Bahnschrift", monospace; font-size: 11px; white-space: nowrap; } +.top-item.lv-today .top-remain { color: var(--danger); font-weight: 700; } +.top-item.lv-warn .top-remain { color: var(--warn); } +.top-bar { height: 4px; background: var(--line); border-radius: 999px; overflow: hidden; } +.top-bar i { display: block; height: 100%; background: var(--accent); } +.top-item.lv-today .top-bar i { background: var(--danger); } +.top-item.lv-warn .top-bar i { background: var(--warn); } -/* 底部 */ -.scr-bottom { display: grid; grid-template-columns: 1.15fr 1fr; gap: clamp(10px, 1.1vw, 16px); } -.today-list, .recent-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; max-height: 22vh; overflow: hidden; } +/* ---------- 底部两块 ---------- */ + +.scr-bottom { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; min-height: 150px; max-height: 30vh; } +.today-list { list-style: none; margin: 0; padding: 4px 10px 8px; overflow: auto; } .today-list li { - display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; - padding: 4px 6px; border-bottom: 1px solid rgba(27, 37, 48, 0.6); + display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; + gap: 8px; align-items: center; + padding: 5px 6px; + border-bottom: 1px dashed var(--line); + font-size: 12px; +} +.today-list li:last-child { border-bottom: 0; } +.today-list li.urgent { + background: color-mix(in srgb, var(--danger) 9%, transparent); + border-left: 2px solid var(--danger); + padding-left: 8px; } .tchip { display: inline-flex; align-items: baseline; gap: 3px; - padding: 1px 6px; font-size: 11px; - border-left: 3px solid var(--tag); - background: rgba(255, 255, 255, 0.04); - border-radius: 1px 2px 2px 1px; + padding: 1px 6px; + border-left: 2px solid var(--tag, var(--accent)); + background: color-mix(in srgb, var(--tag, var(--accent)) 14%, transparent); + border-radius: 1px; white-space: nowrap; } -.tchip b { font-family: var(--mono); font-size: 12px; color: var(--ink); } -.today-list .pkg, .recent-list .pkg { font-size: 11px; color: var(--dim); } -.today-list .rem { font-family: var(--mono); font-size: 11px; color: var(--mint); } +.tchip b { font-family: "Bahnschrift", monospace; font-size: 13px; } +.today-list .pkg { font-size: 10px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.today-list .rem { font-family: "Bahnschrift", monospace; font-size: 11px; color: var(--ink-2); white-space: nowrap; } -.recent-list li { display: flex; align-items: center; gap: 8px; padding: 3px 4px; font-size: 11px; border-bottom: 1px solid rgba(27, 37, 48, 0.6); } -.recent-list .time { color: var(--dim); font-size: 10px; } -.recent-list .who b { font-family: var(--mono); font-size: 12px; margin-right: 4px; } -.recent-list .plus { margin-left: auto; color: var(--mint); font-family: var(--mono); font-style: normal; } -.recent-list .op { color: var(--dim); } +.tag-urgent { + font-size: 9px; line-height: 1; padding: 2px 4px; + background: var(--danger); color: #fff; border-radius: 2px; + white-space: nowrap; +} -.scr-foot { font-size: 10px; color: #47535f; text-align: right; letter-spacing: 0.12em; } -.scr-empty { margin: auto; color: var(--dim); font-size: 12px; text-align: center; } +.scr-empty, .scr-empty-inline { color: var(--muted); font-size: 12px; padding: 16px; text-align: center; margin: 0; } +.scr-empty-inline { padding: 0; text-align: left; } -@media (max-width: 1100px) { - .scr-kpis { grid-template-columns: repeat(2, 1fr); } +/* ---------- 最近追加 ---------- */ + +.scr-recent { + display: flex; align-items: center; gap: 10px; + padding: 5px 12px; + background: var(--panel); + border: 1px solid var(--line); border-radius: 3px; + font-size: 11px; color: var(--muted); + overflow: hidden; +} +.recent-label { flex: 0 0 auto; letter-spacing: 0.12em; color: var(--accent); font-size: 10px; } +.recent-track { display: flex; gap: 18px; overflow: hidden; white-space: nowrap; } +.recent-item { color: var(--ink-2); } +.recent-item b { font-family: "Bahnschrift", monospace; font-size: 12px; color: var(--ink); margin-right: 3px; } +.recent-item em { font-style: normal; color: var(--accent); margin-left: 3px; } + +.scr-foot { text-align: center; font-size: 10px; color: var(--muted); letter-spacing: 0.06em; } + +/* ---------- 当日明细浮层 ---------- */ + +.day-pop { + position: fixed; z-index: 60; + width: 340px; max-height: 62vh; overflow: auto; + padding: 10px 12px; + background: var(--panel); + border: 1px solid var(--accent); + border-radius: 3px; + box-shadow: 0 18px 40px -18px rgba(0, 0, 0, 0.7), var(--glow); + font-size: 12px; + pointer-events: none; +} +.day-pop[hidden] { display: none; } +.day-pop h4 { + margin: 0 0 6px; display: flex; align-items: baseline; justify-content: space-between; + font-size: 12px; color: var(--ink); +} +.day-pop h4 small { font-weight: 400; color: var(--muted); font-size: 10px; } +.day-pop .pop-meta { display: flex; gap: 10px; margin-bottom: 6px; font-size: 10px; color: var(--muted); } +.day-pop .pop-meta b { color: var(--ink-2); font-family: "Bahnschrift", monospace; } +.day-pop ul { list-style: none; margin: 0; padding: 0; } +.day-pop li { + display: grid; grid-template-columns: auto minmax(0, 1fr) auto; + gap: 6px; align-items: center; + padding: 3px 0; border-bottom: 1px dashed var(--line); +} +.day-pop li:last-child { border-bottom: 0; } +.day-pop li.urgent { color: var(--danger); } +.day-pop li.done { opacity: 0.45; text-decoration: line-through; } +.day-pop .pop-tail { font-family: "Bahnschrift", monospace; font-size: 12px; font-weight: 700; } +.day-pop .pop-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-2); } +.day-pop .pop-rem { font-size: 10px; color: var(--muted); white-space: nowrap; } +.day-pop .pop-more { margin-top: 6px; font-size: 10px; color: var(--muted); text-align: right; } +.day-pop .pop-empty { color: var(--muted); text-align: center; padding: 8px 0; } + +/* ---------- 小屏适配(笔记本投屏也常见) ---------- */ + +@media (max-width: 1200px) { + .scr-main { grid-template-columns: 1fr 1fr; } + .scr-kpis .kpi b { font-size: 24px; } + .top-item { grid-template-columns: 20px 48px minmax(0, 1fr) auto 48px; } + .top-item .top-pkg { display: none; } +} + +@media (max-width: 860px) { + body { overflow: auto; } + .scr { height: auto; } .scr-main, .scr-bottom { grid-template-columns: 1fr; } - .heat-cell { min-height: 34px; } -} - -@media (prefers-reduced-motion: reduce) { - * { animation: none !important; transition: none !important; } + .scr-tabs, .scr-links { display: none; } } diff --git a/internal/web/static/screen.js b/internal/web/static/screen.js index c351133..e86749e 100644 --- a/internal/web/static/screen.js +++ b/internal/web/static/screen.js @@ -1,4 +1,4 @@ -/* 大屏:时钟 + 全屏快捷键 */ +/* 大屏:时钟 + 全屏 + 主题 + 日历悬停明细 */ (function () { 'use strict'; @@ -26,4 +26,107 @@ } } }); + + /* ---------- 主题切换 ---------- */ + var THEME_KEY = 'daili_theme'; + function effectiveTheme() { + var attr = document.documentElement.getAttribute('data-theme'); + if (attr === 'light' || attr === 'dark') return attr; + return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; + } + document.querySelectorAll('[data-theme-toggle]').forEach(function (btn) { + btn.addEventListener('click', function () { + var next = effectiveTheme() === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + document.cookie = THEME_KEY + '=' + next + '; path=/; max-age=31536000; SameSite=Lax'; + }); + }); + + /* ---------- 日历格子悬停明细 ---------- */ + var root = document.querySelector('.scr'); + var pop = document.getElementById('day-pop'); + if (!root || !pop) return; + + var api = root.getAttribute('data-api') || '/api/screen/day'; + var pkg = root.getAttribute('data-pkg') || '0'; + var cache = {}; + var hideTimer = null; + + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + + function place(cell) { + var r = cell.getBoundingClientRect(); + var w = 340, h = Math.min(360, pop.offsetHeight || 240); + var left = r.right + 8; + if (left + w > window.innerWidth - 8) left = Math.max(8, r.left - w - 8); + var top = r.top + r.height / 2 - h / 2; + top = Math.max(8, Math.min(top, window.innerHeight - h - 8)); + pop.style.left = left + 'px'; + pop.style.top = top + 'px'; + } + + function render(data, cell) { + if (!data) { + pop.innerHTML = '
读取失败
'; + return; + } + var d = new Date(data.date + 'T00:00:00'); + var label = data.date + ' 周' + '日一二三四五六'.charAt(d.getDay()); + var html = '

' + esc(label) + '' + data.total + ' 单 · 已完成 ' + data.done + + (data.urgent ? ' · 加急 ' + data.urgent + '' : '') + '

'; + if (!data.items || !data.items.length) { + html += '
这天没有排单
'; + } else { + html += ''; + if (data.more > 0) html += '
另有 ' + data.more + ' 单未显示
'; + } + pop.innerHTML = html; + pop.hidden = false; + place(cell); + } + + function show(cell) { + clearTimeout(hideTimer); + var date = cell.getAttribute('data-date'); + var key = date + '|' + pkg; + pop.hidden = false; + pop.innerHTML = '
载入中…
'; + place(cell); + if (cache[key]) { + render(cache[key], cell); + return; + } + fetch(api + '?date=' + encodeURIComponent(date) + '&pkg=' + encodeURIComponent(pkg), { + headers: { 'Accept': 'application/json' } + }).then(function (r) { + if (!r.ok) throw new Error('bad'); + return r.json(); + }).then(function (data) { + cache[key] = data; + render(data, cell); + }).catch(function () { + pop.innerHTML = '
读取失败
'; + }); + } + + function hideSoon() { + hideTimer = setTimeout(function () { pop.hidden = true; }, 160); + } + + document.querySelectorAll('.heat-cell[data-date]').forEach(function (cell) { + cell.addEventListener('mouseenter', function () { show(cell); }); + cell.addEventListener('mouseleave', hideSoon); + }); })(); diff --git a/internal/web/templates/_layout.html b/internal/web/templates/_layout.html index ce34e4a..0a9f1cc 100644 --- a/internal/web/templates/_layout.html +++ b/internal/web/templates/_layout.html @@ -1,5 +1,5 @@ {{define "layout"}} - + @@ -25,12 +25,19 @@
今日到期 {{.DueToday}} 3 天内 {{.Due3}} +
{{if .User.DisplayName}}{{.User.DisplayName}}{{else}}{{.User.Username}}{{end}}
+{{else}} + {{end}} {{if .Flash}}
{{.Flash}}
{{end}} {{if .Error}}
{{.Error}}
{{end}} diff --git a/internal/web/templates/_screen_layout.html b/internal/web/templates/_screen_layout.html index 951bc7a..6190aa5 100644 --- a/internal/web/templates/_screen_layout.html +++ b/internal/web/templates/_screen_layout.html @@ -1,5 +1,5 @@ {{define "screenlayout"}} - + diff --git a/internal/web/templates/pages/calendar.html b/internal/web/templates/pages/calendar.html index 88d4202..823120a 100644 --- a/internal/web/templates/pages/calendar.html +++ b/internal/web/templates/pages/calendar.html @@ -2,19 +2,31 @@
- ‹ + ‹

{{.D.Title}}

- › - 回到今天 + › + 回到今天
{{.D.MonthDue}}本月到期 {{.D.MonthTasks}}本月待打 {{.D.MonthDone}}已完成 + {{if gt .D.MonthUrgent 0}}{{.D.MonthUrgent}}加急{{end}} + 新建客户
+ {{if .D.HasTabs}} + + {{end}} + {{if .D.NoPackages}}
还没有服务套餐,先去 新建套餐,再给客户开通。
{{else if .D.NoCustomers}} @@ -30,17 +42,20 @@
{{range .}} + href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{.Date}}&pkg={{$.D.PkgID}}" role="gridcell">
{{.Day}} - {{if .DueCount}}到期{{.DueCount}}{{end}} + + {{if gt .UrgentCnt 0}}{{end}} + {{if .DueCount}}到期{{.DueCount}}{{end}} +
{{if .TaskCount}}
{{.DoneCount}}/{{.TaskCount}} 单
{{end}}
{{range .Tickets}} - {{.Tail}}{{.Name}}{{.Pkg}} + {{.Tail}}{{.Name}}{{.Pkg}} {{end}} {{if gt .More 0}}+{{.More}}{{end}}
@@ -56,38 +71,65 @@
{{if .D.SelectedIsToday}}今天{{end}} {{if .D.SelectedIsPast}}已过去{{end}} - {{len .D.Tasks}} 待打 + {{.D.TaskPending}} 待完成
-

这天到期 {{len .D.Due}}

- {{if .D.Due}} -
- {{else}}

没有到期订阅。

{{end}} -
+

当日待完成 {{.D.TaskPending}}/ {{len .D.Tasks}} 单

+
+ + + + + +
+

唯一匹配直接完成;同尾号多人 / 一单多服务会列出让你勾选。

+ + {{if .D.PickTail}} +
+
+

尾号 {{.D.PickTail}} 的待完成项

+ 收起 +
+ {{if .D.PickRows}} +
+ + +
    + {{range .D.PickRows}} +
  • + +
  • + {{end}} +
+
+ + 取消 +
+
+ {{else}} +

{{.D.PickMsg}}

+ {{end}} +
+ {{end}} -
-

当日待打 {{len .D.Tasks}}

{{if .D.Tasks}}
+ + diff --git a/internal/web/templates/pages/packages.html b/internal/web/templates/pages/packages.html index 95352a7..cf2b185 100644 --- a/internal/web/templates/pages/packages.html +++ b/internal/web/templates/pages/packages.html @@ -20,6 +20,7 @@ {{.Name}} + {{if .CalendarSeparate}}独立日历{{end}} {{if .Note}}
{{.Note}}
{{end}} {{.UnitLabel}} diff --git a/internal/web/templates/pages/screen.html b/internal/web/templates/pages/screen.html index c413ce7..162d9d2 100644 --- a/internal/web/templates/pages/screen.html +++ b/internal/web/templates/pages/screen.html @@ -1,7 +1,27 @@ {{define "content"}} -
+
代代打台控制台
+ + {{if .D.HasTabs}} + + {{end}} + + +
--:--:--
@@ -24,24 +44,28 @@
-

到期热力

- {{.D.MonthTitle}} · 本月 {{.D.Stats.MonthDue}} 条到期 +

{{if eq .D.HeatMode "due"}}到期热力{{else}}每日待完成{{end}}

+ {{.D.MonthTitle}} · 本月合计 {{.D.HeatTotal}} 单
{{range .D.Weekdays}}{{.}}{{end}}
{{range .D.Weeks}}
{{range .}} -
+
{{.Day}} - {{if .Due}}{{.Due}}{{end}} + {{if gt .Urgent 0}}{{end}} + {{if .Count}}{{.Count}}{{end}}
{{end}}
{{end}}
- 到期数 + {{if eq .D.HeatMode "due"}}到期数{{else}}待完成单量{{end}} + + 悬停格子看当日明细
@@ -69,12 +93,12 @@
-

今日待打

- {{.D.TodayDone}} / {{.D.TodayTasks}} 已完成 +

今日到期

+ {{len .D.DueToday}} 条
- {{if .D.TodayList}} + {{if .D.DueToday}}
    - {{range .D.TodayList}} + {{range .D.DueToday}}
  • {{.CustomerTail}}{{.CustomerName}} {{.PackageName}} @@ -83,31 +107,50 @@ {{end}}
{{else}} -

今天没有待打任务

+

今天没有到期

{{end}}
-

最近追加

服务时长变更流水
- {{if .D.Recent}} -
    - {{range .D.Recent}} -
  • - {{.CreatedAt}} - {{.CustomerTail}}{{.CustomerName}} +
    +

    今日待打

    + {{.D.TodayDone}} / {{.D.TodayTasks}} 已完成{{if gt .D.TodayUrgent 0}} · 加急 {{.D.TodayUrgent}}{{end}} +
    + {{if .D.TodayList}} +
      + {{range .D.TodayList}} +
    • + {{.CustomerTail}}{{.CustomerName}} {{.PackageName}} - {{if gt .DaysAdded 0}}+{{.DaysAdded}} 天{{end}} - {{if gt .CountAdded 0}}+{{.CountAdded}} 次{{end}} - {{.OperatorName}} + {{if .Urgent}}加急{{end}} + {{.RemainShort}}
    • {{end}}
    {{else}} -

    还没有追加记录

    +

    今天没有待打任务

    {{end}}
-
代打台 v{{.D.Version}} · 数据每 {{.D.Refresh}} 秒自动刷新 · 按 F 进入全屏
+
+ 最近追加 + {{if .D.Recent}} +
+ {{range .D.Recent}} + {{.CustomerTail}}{{.CustomerName}} · {{.PackageName}} + {{if gt .DaysAdded 0}}+{{.DaysAdded}} 天{{end}} + {{if gt .CountAdded 0}}+{{.CountAdded}} 次{{end}} + + {{end}} +
+ {{else}} + 还没有追加记录 + {{end}} +
+ +
代打台 v{{.D.Version}} · 数据每 {{.D.Refresh}} 秒自动刷新 · 按 F 全屏
+ +
{{end}} diff --git a/main.go b/main.go index c038830..9d8fc71 100644 --- a/main.go +++ b/main.go @@ -26,7 +26,7 @@ import ( "daili/internal/web" ) -const version = "1.1.0" +const version = "1.2.0" func main() { cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")") diff --git a/scripts/acceptance.ps1 b/scripts/acceptance.ps1 index d1df9cf..13d4156 100644 --- a/scripts/acceptance.ps1 +++ b/scripts/acceptance.ps1 @@ -410,7 +410,7 @@ $r = Send $admin 'GET' '/calendar?y=2020&m=1' Check 'L-03' '「回到今天」入口存在' ((Has $r.Content '回到今天')) "" $cal = (Send $admin 'GET' "/calendar?d=$today").Content Check 'L-04' '格子显示色签与负荷' ((Has $cal '单测') -and (Has $cal '包月') -and (Has $cal 'heat-')) "" -Check 'L-05' '点日期切换抽屉' ((Has $cal ($T.ToString('M 月 d 日'))) -and (Has $cal '当日待打')) "" +Check 'L-05' '点日期切换抽屉' ((Has $cal ($T.ToString('M 月 d 日'))) -and (Has $cal '当日待完成')) "" $r = Send $admin 'GET' "/calendar?d=$(D -1)" Check 'L-06' '过去日期标记已过去' ((Has $r.Content '已过去')) "" Check 'L-07' '近 7 天到期区块' ((Has $cal '近 7 天到期')) "" @@ -433,11 +433,12 @@ $kpiCust = [regex]::Match($screen, '(?s)客户\s*(\d+)').Groups[1] $kpiDue = [regex]::Match($screen, '(?s)今日到期\s*(\d+)').Groups[1].Value $kpi7 = [regex]::Match($screen, '(?s)7 天内到期\s*(\d+)').Groups[1].Value Check 'D-02' '四张 KPI 与后台一致' (($kpiCust -eq $custN) -and ($kpiDue -eq $dueToday) -and ($kpi7 -eq $due7)) "cust=$kpiCust/$custN due=$kpiDue/$dueToday 7=$kpi7/$due7" -Check 'D-03' '热力月历渲染' ((Has $screen '到期热力') -and (Has $screen 'heat-cell')) "" +$screenDue = (Send $admin 'GET' '/screen?heat=due').Content +Check 'D-03' '热力月历渲染(默认待完成,可切到期)' ((Has $screen '每日待完成') -and (Has $screen 'heat-cell') -and (Has $screenDue '到期热力')) "" $topN = [regex]::Matches($screen, 'class="top-item').Count Check 'D-04' 'TOP 榜不超过 10 条' ((Has $screen '即将到期 TOP 10') -and ($topN -ge 1) -and ($topN -le 10)) "count=$topN" -$todayTasks = [regex]::Match($cal, '(\d+) 待打').Groups[1].Value -$screenTasks = [regex]::Match($screen, '(\d+) / (\d+) 已完成').Groups[2].Value +$todayTasks = [regex]::Match($cal, '(\d+) 待完成').Groups[1].Value +$screenTasks = [regex]::Match($screen, '(\d+) / (\d+) 已完成').Groups[2].Value Check 'D-05' '今日待打数与日历一致' ($todayTasks -eq $screenTasks -and $todayTasks -ne '') "calendar=$todayTasks screen=$screenTasks" Check 'D-06' '最近追加流水渲染' ((Has $screen '最近追加')) "" Check 'D-07' '自动刷新秒数取自设置' ((Has $screen '')) ""