1) 日历计数口径
- 工作台与大屏的格子统一改为「已完成 / 总数」(如 2/5 = 共 5 单、做完 2 单)
2) 客户详情·当月排单
- 新增客户专属月历:每天显示 已完成/总数,可翻月,点某天跳转到日历对应日期
- 没有排单的日期淡化显示
3) 套餐标签与页签合并
- packages 新增 tag 列(可空),套餐编辑页可填
- 日历/大屏页签按标签合并:同标签套餐合成一个页签;勾了「独立显示」的套餐额外再开一个自己的页签
- 大屏同样支持标签筛选,链接参数为 tag=xxx
4) 批量开通
- 新增套餐详情页 /packages/{id}:显示套餐信息 + 客户列表
- 表头「全选」置顶,已订阅该套餐的客户置灰不可选;开始日期默认今天、天数/次数默认取套餐设置,均可改
- POST /packages/{id}/subscribe 批量开通,重复的自动跳过并汇总提示
- 修复 r.PostForm 未 ParseForm 导致拿不到勾选项的问题(批量打卡同源问题一并修正)
5) 触控 / PC 模式
- 尾号输入框右侧新增切换按钮,触控模式展开 3×4 键盘、输入框只读 + inputmode=none(不唤起系统输入法),按键直接写值
- 偏好记在 localStorage,默认 PC 模式(键盘收起)
6) 按次到期
- 按次订阅的到期日保持选填;填了到期后状态显示「已到期」且不再进日历待排(复用既有 end_date,无新列)
数据库兼容性:
- 只新增 packages.tag 一列,老库启动时自动 ALTER,可重复迁移;原有表结构与接口不变
测试:
- 无头 Chrome 实拍 5 张页面(日历/触控模式/客户月历/套餐批量/大屏)
- 功能验证 18 项全过(计数口径、标签页签、标签筛选、批量开通与重复跳过、月历翻月、触控开关、按次过期不上日历)
- 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.7.0
This commit is contained in:
@@ -142,6 +142,8 @@
|
||||
- **停用客户**:客户详情页可「停用」(默认从列表、日历、大屏隐藏,订阅保留)或「彻底删除」(连订阅/打卡/追加记录一起删,需二次确认);列表右上角可切换到「显示已停用」再恢复;
|
||||
- **7 天内到期**:顶栏新增徽标,固定统计未来 7 天内到期的订阅;
|
||||
- **抽屉时钟**:右侧当日面板顶部有时钟,30px 大写、水平垂直居中,24 小时制精确到秒,下面一行是当天日期;
|
||||
- **触控 / PC 模式**:尾号输入框右侧有「⌨ / 触控」开关。切到触控时展开 3×4 小键盘、输入框变只读并关闭系统输入法(`inputmode=none`),点键直接写值;切回 PC 收起键盘。选择记在浏览器里。
|
||||
- **格子计数**:工作台和大屏的日历格都显示「已完成 / 总数」,例如 2/5 = 5 单里做完 2 单。
|
||||
- **已完成折叠**:当日清单里已完成/已跳过的单子自动折叠成一行(尾号 + 姓名 + 套餐 + 状态),点这一行展开看剩余、打手,以及撤销/加急/追加;未完成的行不受影响;
|
||||
- **打手颜色**:`设置 → 管理员` 里给每个管理员选一个颜色。客户详情或编辑页可以把客户指派给某个打手,之后这个客户的所有单子都用该颜色标识:
|
||||
- 日历格左侧竖条 = 当天涉及的所有打手颜色(几种人几条,等分显示);
|
||||
@@ -152,6 +154,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 4.2 套餐标签与批量开通
|
||||
|
||||
- **套餐标签**:套餐编辑页可以填一个标签(如「包月」)。日历/大屏顶部页签会**按标签合并**:同标签的套餐合成一个页签;勾了「在日历中独立显示」的套餐**额外**再开一个自己的页签。
|
||||
- **批量开通**:`套餐 → 详情/批量`,勾选客户(表头有全选,已订阅该套餐的客户置灰),填开始日期与天数/次数后一次开通;重复的会自动跳过并提示。
|
||||
- **客户月历**:客户详情页新增「当月排单」,显示该客户当月每天几单、完成几单,可翻月,点某天直接跳到日历那天。
|
||||
- **按次到期**:按次订阅的「到期日」仍是选填;填了到期后状态显示「已到期」、不再进日历待排。
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据、备份与导出
|
||||
|
||||
| 文件 | 说明 |
|
||||
@@ -164,7 +175,7 @@
|
||||
|
||||
- **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。
|
||||
- **重置**:关闭程序 → 删除 `daili.db` → 重新打开,会回到「创建第一个管理员」的全新状态。
|
||||
- 客户停用是 `customers.archived`(0/1);打手颜色存在 `users.color`,客户归属打手是 `customers.assignee_id`;加急标记存在 `daili.db` 的 `task_flags` 表;套餐的「独立日历」是 `packages.calendar_separate` 列,老版本数据库打开时会**自动加列**,不用手工迁移;
|
||||
- 套餐标签是 `packages.tag`;客户停用是 `customers.archived`(0/1);打手颜色存在 `users.color`,客户归属打手是 `customers.assignee_id`;加急标记存在 `daili.db` 的 `task_flags` 表;套餐的「独立日历」是 `packages.calendar_separate` 列,老版本数据库打开时会**自动加列**,不用手工迁移;
|
||||
- `daili.ini`(监听/备份/日志配置)与 `backup/`、`logs/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。
|
||||
|
||||
- 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库;
|
||||
|
||||
@@ -134,6 +134,11 @@ func (db *DB) Migrate() error {
|
||||
"ALTER TABLE customers ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
// v1.7:套餐标签(同标签的套餐在日历里合并成一个页签)
|
||||
if err := db.ensureColumn("packages", "tag",
|
||||
"ALTER TABLE packages ADD COLUMN tag TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -196,6 +201,7 @@ var schemaStatements = []string{
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
calendar_separate INTEGER NOT NULL DEFAULT 0,
|
||||
tag TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
|
||||
+51
-13
@@ -22,10 +22,12 @@ type Package struct {
|
||||
SortOrder int
|
||||
// CalendarSeparate 勾选后在工作台/大屏日历中单独成页
|
||||
CalendarSeparate bool
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
SubCount int
|
||||
ActiveCount int
|
||||
// Tag 套餐标签:相同标签的套餐在日历里合并显示
|
||||
Tag string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
SubCount int
|
||||
ActiveCount int
|
||||
}
|
||||
|
||||
// UnitLabel 返回类型中文名。
|
||||
@@ -41,7 +43,7 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
|
||||
var price sql.NullInt64
|
||||
var archived, separate int
|
||||
err := row.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
|
||||
&archived, &p.SortOrder, &separate, &p.CreatedAt, &p.UpdatedAt)
|
||||
&archived, &p.SortOrder, &separate, &p.Tag, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -59,7 +61,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.calendar_separate,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.tag,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`
|
||||
@@ -78,7 +80,7 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
|
||||
var price sql.NullInt64
|
||||
var archived, separate int
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
|
||||
&archived, &p.SortOrder, &separate, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
|
||||
&archived, &p.SortOrder, &separate, &p.Tag, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Archived = archived == 1
|
||||
@@ -94,7 +96,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,calendar_separate,created_at,updated_at
|
||||
return scanPackage(db.sql.QueryRow(`SELECT id,name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,created_at,updated_at
|
||||
FROM packages WHERE id=?`, id))
|
||||
}
|
||||
|
||||
@@ -112,9 +114,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,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)
|
||||
res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,0,?,?,?,?,?)`,
|
||||
p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), now, now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -134,8 +136,8 @@ 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=?,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)
|
||||
_, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,calendar_separate=?,tag=?,updated_at=?
|
||||
WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), nowStamp(), p.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -156,3 +158,39 @@ func (db *DB) SetPackageArchived(id int64, archived bool) error {
|
||||
_, err := db.sql.Exec(`UPDATE packages SET archived=?, updated_at=? WHERE id=?`, v, nowStamp(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SubscribedCustomerIDs 返回已订阅某套餐的客户 ID 集合(含已关闭,用于批量订阅时置灰)。
|
||||
func (db *DB) SubscribedCustomerIDs(packageID int64) (map[int64]bool, error) {
|
||||
rows, err := db.sql.Query(`SELECT customer_id FROM subscriptions WHERE package_id=?`, packageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[int64]bool{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PackageTags 返回所有非空标签(去重,按字典序)。
|
||||
func (db *DB) PackageTags() ([]string, error) {
|
||||
rows, err := db.sql.Query(`SELECT DISTINCT tag FROM packages WHERE IFNULL(tag,'')<>'' ORDER BY tag`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var t string
|
||||
if err := rows.Scan(&t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -1106,3 +1106,39 @@ func TestCustomerArchiveFilters(t *testing.T) {
|
||||
t.Fatal("删除不存在的客户应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageTag(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
|
||||
p1 := mustPackage(t, db, "包月活动托", domain.UnitDay, 30)
|
||||
if p1.Tag != "" {
|
||||
t.Fatalf("默认标签应为空: %q", p1.Tag)
|
||||
}
|
||||
p1.Tag = "包月"
|
||||
if err := db.UpdatePackage(p1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := db.Package(p1.ID)
|
||||
if err != nil || got.Tag != "包月" {
|
||||
t.Fatalf("标签未保存: %+v err=%v", got, err)
|
||||
}
|
||||
|
||||
p2 := &Package{Name: "深渊委托", Unit: domain.UnitCount, DefaultAmount: 10, Color: "#8C4A3C", Tag: " 包月 "}
|
||||
if _, err := db.CreatePackage(p2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tags, err := db.PackageTags()
|
||||
if err != nil || len(tags) != 1 || tags[0] != "包月" {
|
||||
t.Fatalf("标签去重/去空格失败: %+v err=%v", tags, err)
|
||||
}
|
||||
|
||||
list, err := db.ListPackages(true)
|
||||
if err != nil || len(list) != 2 {
|
||||
t.Fatalf("列表应有 2 个套餐: %d err=%v", len(list), err)
|
||||
}
|
||||
for _, p := range list {
|
||||
if p.Tag != "包月" {
|
||||
t.Fatalf("列表标签不对: %+v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"daili/internal/domain"
|
||||
@@ -61,10 +62,13 @@ type TaskRow struct {
|
||||
|
||||
// PkgTab 是日历上方的套餐标签。
|
||||
type PkgTab struct {
|
||||
ID int64
|
||||
Name string
|
||||
Color string
|
||||
Count int
|
||||
ID int64
|
||||
Tag string
|
||||
Name string
|
||||
Color string
|
||||
Count int
|
||||
Href string
|
||||
Active bool
|
||||
}
|
||||
|
||||
type calendarData struct {
|
||||
@@ -93,9 +97,12 @@ type calendarData struct {
|
||||
Back string
|
||||
|
||||
// 套餐标签(在日历中独立显示的套餐)
|
||||
Tabs []PkgTab
|
||||
PkgID int64
|
||||
HasTabs bool
|
||||
Tabs []PkgTab
|
||||
PkgID int64
|
||||
Tag string
|
||||
// FilterQuery 当前筛选条件(pkg=1 或 tag=xxx),拼链接用
|
||||
FilterQuery string
|
||||
HasTabs bool
|
||||
|
||||
// 快速完成:按尾号筛选出的候选
|
||||
PickTail string
|
||||
@@ -138,18 +145,76 @@ 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) {
|
||||
// buildCalendarTabs 生成日历页签:先按套餐标签合并,再给「独立显示」的套餐各开一个。
|
||||
func buildCalendarTabs(pkgs []store.Package, subs []store.Subscription, total int) ([]PkgTab, bool) {
|
||||
tagOrder := []string{}
|
||||
tagColor := map[string]string{}
|
||||
tagCount := map[string]int{}
|
||||
pkgTag := map[int64]string{}
|
||||
sep := []store.Package{}
|
||||
for _, p := range pkgs {
|
||||
if p.Archived {
|
||||
continue
|
||||
}
|
||||
pkgTag[p.ID] = p.Tag
|
||||
if p.Tag != "" {
|
||||
if _, ok := tagColor[p.Tag]; !ok {
|
||||
tagOrder = append(tagOrder, p.Tag)
|
||||
tagColor[p.Tag] = p.Color
|
||||
}
|
||||
}
|
||||
if p.CalendarSeparate {
|
||||
sep = append(sep, p)
|
||||
}
|
||||
}
|
||||
for _, sub := range subs {
|
||||
if t := pkgTag[sub.PackageID]; t != "" {
|
||||
tagCount[t]++
|
||||
}
|
||||
}
|
||||
if len(tagOrder) == 0 && len(sep) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
tabs := []PkgTab{{ID: 0, Name: "全部", Color: "#8A8F98", Count: total}}
|
||||
for _, t := range tagOrder {
|
||||
tabs = append(tabs, PkgTab{Tag: t, Name: t, Color: tagColor[t], Count: tagCount[t]})
|
||||
}
|
||||
for _, p := range sep {
|
||||
cnt := 0
|
||||
for _, sub := range subs {
|
||||
if sub.PackageID == p.ID {
|
||||
cnt++
|
||||
}
|
||||
}
|
||||
tabs = append(tabs, PkgTab{ID: p.ID, Name: p.Name, Color: p.Color, Count: cnt})
|
||||
}
|
||||
return tabs, true
|
||||
}
|
||||
|
||||
// loadCalendarSubs 读取区间内与日历相交的订阅;pkgID>0 取该套餐,tag 非空取该标签下的所有套餐。
|
||||
func (s *Server) loadCalendarSubs(from, to string, pkgID int64, tag string) ([]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 {
|
||||
if pkgID > 0 || tag != "" {
|
||||
pkgs, err := s.db.ListPackages(true)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
tags := make(map[int64]string, len(pkgs))
|
||||
for _, p := range pkgs {
|
||||
tags[p.ID] = p.Tag
|
||||
}
|
||||
filtered := make([]store.Subscription, 0, len(subs))
|
||||
for _, sub := range subs {
|
||||
if sub.PackageID == pkgID {
|
||||
filtered = append(filtered, sub)
|
||||
if pkgID > 0 && sub.PackageID != pkgID {
|
||||
continue
|
||||
}
|
||||
if pkgID <= 0 && tag != "" && tags[sub.PackageID] != tag {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, sub)
|
||||
}
|
||||
subs = filtered
|
||||
}
|
||||
@@ -183,6 +248,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
monthEnd := domain.MonthEnd(y, time.Month(m))
|
||||
|
||||
pkgID := int64(s.queryInt(r, "pkg", 0))
|
||||
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
|
||||
sel := r.URL.Query().Get("d")
|
||||
selT, err := domain.ParseDate(sel)
|
||||
if sel != "" && err != nil {
|
||||
@@ -201,7 +267,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
|
||||
gridEnd := domain.AddDays(gridStart, weeks*7-1)
|
||||
|
||||
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID)
|
||||
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID, tag)
|
||||
if err != nil {
|
||||
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -233,6 +299,11 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
SelectedIsToday: sel == domain.Date(today),
|
||||
Back: back,
|
||||
PkgID: pkgID,
|
||||
Tag: tag,
|
||||
FilterQuery: "pkg=" + itoa(pkgID),
|
||||
}
|
||||
if tag != "" {
|
||||
data.FilterQuery = "tag=" + url.QueryEscape(tag)
|
||||
}
|
||||
data.PrevY, data.PrevM = m-1, y
|
||||
if data.PrevM < 1 {
|
||||
@@ -360,30 +431,31 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
if pkgID > 0 && u.PackageID != pkgID {
|
||||
continue
|
||||
}
|
||||
if pkgID <= 0 && tag != "" {
|
||||
if p, err := s.db.Package(u.PackageID); err != nil || p.Tag != tag {
|
||||
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})
|
||||
data.Tabs, data.HasTabs = buildCalendarTabs(pkgs, subs, len(list))
|
||||
for i := range data.Tabs {
|
||||
t := &data.Tabs[i]
|
||||
href := fmt.Sprintf("/calendar?y=%d&m=%d&d=%s", y, m, sel)
|
||||
if t.Tag != "" {
|
||||
href += "&tag=" + url.QueryEscape(t.Tag)
|
||||
t.Active = t.Tag == tag
|
||||
} else if t.ID > 0 {
|
||||
href += "&pkg=" + itoa(t.ID)
|
||||
t.Active = t.ID == pkgID
|
||||
} else {
|
||||
t.Active = tag == "" && pkgID == 0
|
||||
}
|
||||
t.Href = href
|
||||
}
|
||||
|
||||
// 快速完成:按尾号列出候选
|
||||
|
||||
@@ -2,10 +2,12 @@ package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"daili/internal/domain"
|
||||
"daili/internal/store"
|
||||
@@ -190,11 +192,112 @@ type customerDetailData struct {
|
||||
Checkins []CheckinRow
|
||||
Packages []store.Package
|
||||
Users []store.User
|
||||
CustCal *custCal
|
||||
Timeline *Timeline
|
||||
Extend *ExtendPanel
|
||||
TotalRemain string
|
||||
}
|
||||
|
||||
// custCalDay 客户月历的一格。
|
||||
type custCalDay struct {
|
||||
Date string
|
||||
Day int
|
||||
InMonth bool
|
||||
IsToday bool
|
||||
Count int
|
||||
Done int
|
||||
AllDone bool
|
||||
}
|
||||
|
||||
// custCal 客户详情页的当月排单日历。
|
||||
type custCal struct {
|
||||
Year, Month int
|
||||
Title string
|
||||
Prev, Next string
|
||||
Weekdays []string
|
||||
Weeks [][]custCalDay
|
||||
TaskDays int
|
||||
TotalTasks int
|
||||
}
|
||||
|
||||
func (s *Server) buildCustomerCal(c *store.Customer, cm string, today time.Time) *custCal {
|
||||
y, m := today.Year(), int(today.Month())
|
||||
if t, err := time.Parse("2006-01", cm); err == nil {
|
||||
y, m = t.Year(), int(t.Month())
|
||||
}
|
||||
monthStart := domain.MonthStart(y, time.Month(m))
|
||||
monthEnd := domain.MonthEnd(y, time.Month(m))
|
||||
gridStart := domain.AddDays(monthStart, -domain.WeekdayIndex(monthStart))
|
||||
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
|
||||
|
||||
subs, err := s.db.SubsByCustomer(c.ID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ids := make([]int64, 0, len(subs))
|
||||
for _, sub := range subs {
|
||||
ids = append(ids, sub.ID)
|
||||
}
|
||||
pauses, _ := s.db.PausesForSubs(ids)
|
||||
checkins, _ := s.db.CheckinsBetween(domain.Date(gridStart), domain.Date(domain.AddDays(gridStart, weeks*7-1)))
|
||||
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)
|
||||
}
|
||||
|
||||
cal := &custCal{
|
||||
Year: y, Month: m,
|
||||
Title: fmt.Sprintf("%d 年 %d 月", y, m),
|
||||
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
|
||||
}
|
||||
py, pm := y, m-1
|
||||
if pm < 1 {
|
||||
pm = 12
|
||||
py--
|
||||
}
|
||||
ny, nm := y, m+1
|
||||
if nm > 12 {
|
||||
nm = 1
|
||||
ny++
|
||||
}
|
||||
cal.Prev = fmt.Sprintf("/customers/%d?cm=%04d-%02d", c.ID, py, pm)
|
||||
cal.Next = fmt.Sprintf("/customers/%d?cm=%04d-%02d", c.ID, ny, nm)
|
||||
|
||||
for wi := 0; wi < weeks; wi++ {
|
||||
week := make([]custCalDay, 0, 7)
|
||||
for di := 0; di < 7; di++ {
|
||||
d := domain.AddDays(gridStart, wi*7+di)
|
||||
ds := domain.Date(d)
|
||||
cell := custCalDay{Date: ds, Day: d.Day(), InMonth: d.Month() == time.Month(m), IsToday: ds == domain.Date(today)}
|
||||
for _, cs := range list {
|
||||
if !cs.covers(ds) {
|
||||
continue
|
||||
}
|
||||
cell.Count++
|
||||
if ci, ok := checkins[cs.sub.ID][ds]; ok && (ci.Status == "done" || ci.Status == "skipped") {
|
||||
cell.Done++
|
||||
}
|
||||
}
|
||||
if cell.Count > 0 {
|
||||
cell.AllDone = cell.Done >= cell.Count
|
||||
if cell.InMonth {
|
||||
cal.TaskDays++
|
||||
cal.TotalTasks += cell.Count
|
||||
}
|
||||
}
|
||||
week = append(week, cell)
|
||||
}
|
||||
cal.Weeks = append(cal.Weeks, week)
|
||||
}
|
||||
return cal
|
||||
}
|
||||
|
||||
func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathID(r)
|
||||
c, err := s.db.Customer(id)
|
||||
@@ -222,6 +325,7 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
|
||||
users, _ := s.db.ActiveUsers()
|
||||
data := &customerDetailData{
|
||||
Users: users,
|
||||
CustCal: s.buildCustomerCal(c, r.URL.Query().Get("cm"), today),
|
||||
Customer: c,
|
||||
Subs: subViews(subs, today, warn),
|
||||
Packages: pkgs,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -27,13 +28,16 @@ func (s *Server) handlePackages(w http.ResponseWriter, r *http.Request) {
|
||||
type packageFormData struct {
|
||||
Package *store.Package
|
||||
IsNew bool
|
||||
Tags []string
|
||||
}
|
||||
|
||||
func (s *Server) handlePackageNewPage(w http.ResponseWriter, r *http.Request) {
|
||||
p := s.newPage(r, "新增套餐", "packages")
|
||||
tags, _ := s.db.PackageTags()
|
||||
p.D = &packageFormData{
|
||||
Package: &store.Package{Unit: domain.UnitDay, DefaultAmount: 30, Color: "#2F5D50"},
|
||||
IsNew: true,
|
||||
Tags: tags,
|
||||
}
|
||||
s.render(w, r, "package_form", p)
|
||||
}
|
||||
@@ -63,6 +67,7 @@ func readPackageForm(r *http.Request) *store.Package {
|
||||
p.Unit = domain.UnitDay
|
||||
}
|
||||
p.CalendarSeparate = r.PostFormValue("calendar_separate") != ""
|
||||
p.Tag = strings.TrimSpace(r.PostFormValue("tag"))
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -85,17 +90,123 @@ func validatePackage(p *store.Package) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// packageDetailData 套餐详情页数据。
|
||||
type packageDetailData struct {
|
||||
Package *store.Package
|
||||
Customers []store.Customer
|
||||
Subscribed map[int64]bool
|
||||
Q string
|
||||
Tags []string
|
||||
Today string
|
||||
}
|
||||
|
||||
// handlePackageDetail 套餐详情:查看信息 + 勾选客户批量开通。
|
||||
func (s *Server) handlePackageDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathID(r)
|
||||
pkg, err := s.db.Package(id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
today := domain.Today()
|
||||
warn := s.warnDays()
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
list, _, err := s.db.ListCustomers(q, 300, 0, domain.Date(today), warn, false)
|
||||
if err != nil {
|
||||
http.Error(w, "读取客户失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
subscribed, err := s.db.SubscribedCustomerIDs(id)
|
||||
if err != nil {
|
||||
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tags, _ := s.db.PackageTags()
|
||||
p := s.newPage(r, "套餐详情", "packages")
|
||||
p.D = &packageDetailData{Package: pkg, Customers: list, Subscribed: subscribed, Q: q, Tags: tags, Today: domain.Date(today)}
|
||||
s.render(w, r, "package_detail", p)
|
||||
}
|
||||
|
||||
// handlePackageBatchSubscribe 给勾选的客户批量开通该套餐。
|
||||
func (s *Server) handlePackageBatchSubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathID(r)
|
||||
pkg, err := s.db.Package(id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
back := "/packages/" + itoa(id)
|
||||
raw := r.PostForm["customer_ids"]
|
||||
if len(raw) == 0 {
|
||||
s.redirect(w, r, back, "没有选中任何客户")
|
||||
return
|
||||
}
|
||||
start := strings.TrimSpace(r.PostFormValue("start_date"))
|
||||
if start == "" {
|
||||
start = domain.Date(domain.Today())
|
||||
}
|
||||
if _, err := domain.ParseDate(start); err != nil {
|
||||
s.redirect(w, r, back, "开始日期格式不正确")
|
||||
return
|
||||
}
|
||||
end := strings.TrimSpace(r.PostFormValue("end_date"))
|
||||
days := s.formInt(r, "days", 0)
|
||||
count := s.formInt(r, "total_count", 0)
|
||||
u := s.currentUser(r)
|
||||
var op int64
|
||||
if u != nil {
|
||||
op = u.ID
|
||||
}
|
||||
|
||||
done, skipped, failed := 0, 0, 0
|
||||
var lastErr string
|
||||
for _, v := range raw {
|
||||
cid, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
if err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
_, err = s.db.CreateSub(store.CreateSubInput{
|
||||
CustomerID: cid, PackageID: id, StartDate: start, EndDate: end,
|
||||
Days: days, TotalCount: count, OperatorID: op,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "已订阅") {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
failed++
|
||||
lastErr = err.Error()
|
||||
continue
|
||||
}
|
||||
done++
|
||||
}
|
||||
s.logOp(r, "批量开通", "package", id,
|
||||
fmt.Sprintf("%s:成功 %d、跳过 %d、失败 %d", pkg.Name, done, skipped, failed))
|
||||
msg := fmt.Sprintf("已为 %d 位客户开通「%s」", done, pkg.Name)
|
||||
if skipped > 0 {
|
||||
msg += fmt.Sprintf(",%d 位已有该套餐已跳过", skipped)
|
||||
}
|
||||
if failed > 0 {
|
||||
msg += fmt.Sprintf(",%d 位失败(%s)", failed, lastErr)
|
||||
}
|
||||
s.redirect(w, r, back, msg)
|
||||
}
|
||||
|
||||
func (s *Server) handlePackageCreate(w http.ResponseWriter, r *http.Request) {
|
||||
pkg := readPackageForm(r)
|
||||
p := s.newPage(r, "新增套餐", "packages")
|
||||
if msg := validatePackage(pkg); msg != "" {
|
||||
p.D = &packageFormData{Package: pkg, IsNew: true}
|
||||
tags, _ := s.db.PackageTags()
|
||||
p.D = &packageFormData{Package: pkg, IsNew: true, Tags: tags}
|
||||
s.fail(w, r, "package_form", msg, p)
|
||||
return
|
||||
}
|
||||
id, err := s.db.CreatePackage(pkg)
|
||||
if err != nil {
|
||||
p.D = &packageFormData{Package: pkg, IsNew: true}
|
||||
tags, _ := s.db.PackageTags()
|
||||
p.D = &packageFormData{Package: pkg, IsNew: true, Tags: tags}
|
||||
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
|
||||
return
|
||||
}
|
||||
@@ -111,7 +222,8 @@ func (s *Server) handlePackageEditPage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
p := s.newPage(r, "编辑套餐", "packages")
|
||||
p.D = &packageFormData{Package: pkg, IsNew: false}
|
||||
tags, _ := s.db.PackageTags()
|
||||
p.D = &packageFormData{Package: pkg, IsNew: false, Tags: tags}
|
||||
s.render(w, r, "package_form", p)
|
||||
}
|
||||
|
||||
@@ -121,12 +233,14 @@ func (s *Server) handlePackageUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
pkg.ID = id
|
||||
p := s.newPage(r, "编辑套餐", "packages")
|
||||
if msg := validatePackage(pkg); msg != "" {
|
||||
p.D = &packageFormData{Package: pkg, IsNew: false}
|
||||
tags, _ := s.db.PackageTags()
|
||||
p.D = &packageFormData{Package: pkg, IsNew: false, Tags: tags}
|
||||
s.fail(w, r, "package_form", msg, p)
|
||||
return
|
||||
}
|
||||
if err := s.db.UpdatePackage(pkg); err != nil {
|
||||
p.D = &packageFormData{Package: pkg, IsNew: false}
|
||||
tags, _ := s.db.PackageTags()
|
||||
p.D = &packageFormData{Package: pkg, IsNew: false, Tags: tags}
|
||||
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"daili/internal/domain"
|
||||
"daili/internal/store"
|
||||
@@ -16,6 +18,7 @@ type screenDay struct {
|
||||
IsToday bool
|
||||
IsPast bool
|
||||
Count int
|
||||
Done int
|
||||
Urgent int
|
||||
Heat int
|
||||
// Strip 打手色竖条(去重)
|
||||
@@ -42,17 +45,19 @@ type screenData struct {
|
||||
Refresh int
|
||||
Version string
|
||||
|
||||
HeatMode string // task = 每日待完成;due = 到期
|
||||
HeatTotal int
|
||||
Tabs []PkgTab
|
||||
PkgID int64
|
||||
HasTabs bool
|
||||
Theme string
|
||||
HeatMode string // task = 每日待完成;due = 到期
|
||||
Tag string
|
||||
HeatTotal int
|
||||
Tabs []PkgTab
|
||||
PkgID int64
|
||||
HasTabs bool
|
||||
FilterQuery string
|
||||
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)
|
||||
func (s *Server) screenCalSubs(from, to string, pkgID int64, tag string) ([]calSub, map[int64]map[string]store.Checkin, map[int64]map[string]bool, error) {
|
||||
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(from, to, pkgID, tag)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
@@ -74,6 +79,7 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
||||
today := domain.Today()
|
||||
warn := s.warnDays()
|
||||
pkgID := int64(s.queryInt(r, "pkg", 0))
|
||||
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
|
||||
heatMode := r.URL.Query().Get("heat")
|
||||
if heatMode != "due" {
|
||||
heatMode = "task"
|
||||
@@ -85,16 +91,18 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
data := &screenData{
|
||||
Stats: stats,
|
||||
MonthTitle: fmt.Sprintf("%d 年 %d 月", today.Year(), int(today.Month())),
|
||||
Year: today.Year(),
|
||||
Month: int(today.Month()),
|
||||
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
|
||||
Top: subViews(stats.Top, today, warn),
|
||||
Refresh: s.screenRefresh(),
|
||||
Version: s.cfg.Version,
|
||||
HeatMode: heatMode,
|
||||
PkgID: pkgID,
|
||||
Stats: stats,
|
||||
MonthTitle: fmt.Sprintf("%d 年 %d 月", today.Year(), int(today.Month())),
|
||||
Year: today.Year(),
|
||||
Month: int(today.Month()),
|
||||
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
|
||||
Top: subViews(stats.Top, today, warn),
|
||||
Refresh: s.screenRefresh(),
|
||||
Version: s.cfg.Version,
|
||||
HeatMode: heatMode,
|
||||
PkgID: pkgID,
|
||||
Tag: tag,
|
||||
FilterQuery: "pkg=" + itoa(pkgID),
|
||||
}
|
||||
|
||||
// 今日到期(可按套餐过滤)
|
||||
@@ -114,7 +122,7 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
list, checkins, urgent, err := s.screenCalSubs(domain.Date(gridStart), domain.Date(gridEnd), pkgID, tag)
|
||||
if err != nil {
|
||||
http.Error(w, "读取大屏日历失败", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -125,7 +133,7 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
||||
for di := 0; di < 7; di++ {
|
||||
d := domain.AddDays(gridStart, wi*7+di)
|
||||
ds := domain.Date(d)
|
||||
count, urgentN := 0, 0
|
||||
count, doneN, urgentN := 0, 0, 0
|
||||
strip := []string{}
|
||||
seenColor := map[string]bool{}
|
||||
for _, cs := range list {
|
||||
@@ -140,7 +148,8 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
ci, has := checkins[cs.sub.ID][ds]
|
||||
if has && (ci.Status == "done" || ci.Status == "skipped") {
|
||||
continue // “待完成”只统计还没处理的
|
||||
doneN++
|
||||
continue // “待完成”热力只统计还没处理的
|
||||
}
|
||||
count++
|
||||
if urgent[cs.sub.ID][ds] {
|
||||
@@ -164,7 +173,8 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
||||
InMonth: d.Month() == today.Month(),
|
||||
IsToday: ds == domain.Date(today),
|
||||
IsPast: d.Before(today),
|
||||
Count: count,
|
||||
Count: count + doneN,
|
||||
Done: doneN,
|
||||
Urgent: urgentN,
|
||||
Heat: heatLevel(count),
|
||||
Strip: strip,
|
||||
@@ -174,7 +184,7 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 今日待打(加急置顶)
|
||||
rows, err := s.pendingTasks(domain.Date(today), nil, pkgID)
|
||||
rows, err := s.pendingTasks(domain.Date(today), nil, pkgID, tag)
|
||||
if err == nil {
|
||||
for _, row := range rows {
|
||||
if row.Done || row.Skipped {
|
||||
@@ -198,18 +208,27 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
||||
data.Recent = adj
|
||||
}
|
||||
|
||||
// 套餐标签
|
||||
// 页签:按标签合并 + 独立显示另开
|
||||
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.Tabs, data.HasTabs = buildCalendarTabs(pkgs, nil, 0)
|
||||
for i := range data.Tabs {
|
||||
t := &data.Tabs[i]
|
||||
href := "/screen?heat=" + heatMode
|
||||
if t.Tag != "" {
|
||||
href += "&tag=" + url.QueryEscape(t.Tag)
|
||||
t.Active = t.Tag == tag
|
||||
} else if t.ID > 0 {
|
||||
href += "&pkg=" + itoa(t.ID)
|
||||
t.Active = t.ID == pkgID
|
||||
} else {
|
||||
t.Active = tag == "" && pkgID == 0
|
||||
}
|
||||
t.Href = href
|
||||
}
|
||||
|
||||
if tag != "" {
|
||||
data.FilterQuery = "tag=" + url.QueryEscape(tag)
|
||||
}
|
||||
data.Theme = themeFromRequest(r)
|
||||
p := s.newPage(r, "大屏", "screen")
|
||||
p.Theme = data.Theme
|
||||
@@ -225,7 +244,7 @@ func (s *Server) handleScreenDay(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
pkgID := int64(s.queryInt(r, "pkg", 0))
|
||||
rows, err := s.pendingTasks(date, nil, pkgID)
|
||||
rows, err := s.pendingTasks(date, nil, pkgID, strings.TrimSpace(r.URL.Query().Get("tag")))
|
||||
if err != nil {
|
||||
http.Error(w, "读取失败", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
|
||||
// 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)
|
||||
func (s *Server) pendingTasks(date string, customerIDs map[int64]bool, pkgID int64, tag string) ([]TaskRow, error) {
|
||||
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(date, date, pkgID, tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -51,6 +51,7 @@ 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))
|
||||
tag := strings.TrimSpace(r.PostFormValue("tag"))
|
||||
if _, err := domain.ParseDate(date); err != nil {
|
||||
date = domain.Date(domain.Today())
|
||||
}
|
||||
@@ -74,7 +75,7 @@ func (s *Server) handleQuickCheckin(w http.ResponseWriter, r *http.Request) {
|
||||
ids[c.ID] = true
|
||||
}
|
||||
|
||||
rows, err := s.pendingTasks(date, ids, pkgID)
|
||||
rows, err := s.pendingTasks(date, ids, pkgID, tag)
|
||||
if err != nil {
|
||||
s.redirect(w, r, back, "读取待完成失败:"+err.Error())
|
||||
return
|
||||
@@ -127,6 +128,7 @@ func (s *Server) handleCheckinBatch(w http.ResponseWriter, r *http.Request) {
|
||||
date = domain.Date(domain.Today())
|
||||
}
|
||||
back := backURL(r, "/calendar?d="+date)
|
||||
_ = r.ParseForm()
|
||||
raw := r.PostForm["sub_ids"]
|
||||
if len(raw) == 0 {
|
||||
s.redirect(w, r, back, "没有选中任何项")
|
||||
|
||||
@@ -126,6 +126,8 @@ func (s *Server) routes() http.Handler {
|
||||
mux.HandleFunc("GET /packages/{id}/edit", s.auth(s.handlePackageEditPage))
|
||||
mux.HandleFunc("POST /packages/{id}/edit", s.auth(s.handlePackageUpdate))
|
||||
mux.HandleFunc("POST /packages/{id}/archive", s.auth(s.handlePackageArchive))
|
||||
mux.HandleFunc("GET /packages/{id}", s.auth(s.handlePackageDetail))
|
||||
mux.HandleFunc("POST /packages/{id}/subscribe", s.auth(s.handlePackageBatchSubscribe))
|
||||
if s.cfg.ScreenPublic {
|
||||
// 大屏免登录:仅局域网部署时使用,内容为只读看板
|
||||
mux.HandleFunc("GET /screen", s.handleScreen)
|
||||
|
||||
@@ -1044,3 +1044,52 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
|
||||
margin: 6px 0 2px; padding-left: 9px;
|
||||
border-left: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
|
||||
/* ---------- v1.7:客户详情月历 ---------- */
|
||||
|
||||
.minical-nav { display: flex; align-items: center; gap: 8px; }
|
||||
.minical-nav b { font-family: var(--mono); font-size: 13px; }
|
||||
|
||||
.minical {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
max-width: 560px;
|
||||
}
|
||||
.minical-dow {
|
||||
display: grid; grid-template-columns: repeat(7, 1fr);
|
||||
background: var(--paper); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.minical-dow span { padding: 5px 0; text-align: center; font-size: 11px; color: var(--muted); }
|
||||
.minical-row { display: grid; grid-template-columns: repeat(7, 1fr); grid-auto-rows: 54px; }
|
||||
|
||||
.minical-cell {
|
||||
position: relative;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 1px; min-height: 0;
|
||||
border-right: 1px solid var(--line-2);
|
||||
border-bottom: 1px solid var(--line-2);
|
||||
color: var(--ink); text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
.minical-cell:nth-child(7n) { border-right: 0; }
|
||||
.minical-cell:hover { background: var(--hover); text-decoration: none; }
|
||||
.minical-cell.out { opacity: 0.35; }
|
||||
.minical-cell.today { box-shadow: inset 0 0 0 2px var(--accent); }
|
||||
.minical-cell.no-task { color: var(--muted); }
|
||||
.minical-cell .mc-day { font-family: var(--mono); font-size: 12px; font-weight: 600; }
|
||||
.minical-cell .mc-count {
|
||||
font-family: var(--mono); font-size: 10px;
|
||||
color: var(--accent); line-height: 1;
|
||||
}
|
||||
.minical-cell.all-done .mc-count { color: var(--muted); }
|
||||
.minical-cell.all-done .mc-day { color: var(--muted); }
|
||||
.minical-cell.today .mc-day { color: var(--accent); }
|
||||
|
||||
/* 触控模式按钮 */
|
||||
.keypad-toggle { min-width: 40px; padding: 0 8px; font-size: 12px; }
|
||||
.keypad-toggle.on {
|
||||
background: var(--accent); border-color: var(--accent); color: var(--paper);
|
||||
}
|
||||
|
||||
@@ -198,3 +198,58 @@
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
/* ---------- 全选 + 触控/PC 小键盘模式 ---------- */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* 批量开通:表头全选 */
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-select-all]'), function (box) {
|
||||
box.addEventListener('change', function () {
|
||||
var form = box.closest('form') || document;
|
||||
Array.prototype.forEach.call(form.querySelectorAll('input[name="customer_ids"]'), function (c) {
|
||||
if (!c.disabled) { c.checked = box.checked; }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* 触控 / PC 模式:触控时显示键盘并禁用输入框(不唤起系统输入法) */
|
||||
var KEY = 'daili_keypad_mode';
|
||||
|
||||
function applyMode(touch) {
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-keypad]'), function (pad) {
|
||||
pad.hidden = !touch;
|
||||
var block = pad.closest('.drawer-block') || document;
|
||||
var input = block.querySelector('input[name="tail"]');
|
||||
if (input) {
|
||||
input.readOnly = touch;
|
||||
input.setAttribute('inputmode', touch ? 'none' : 'numeric');
|
||||
input.setAttribute('placeholder', touch ? '点下方键盘输入尾号' : '输入手机尾号,回车即可完成');
|
||||
}
|
||||
var btn = block.querySelector('[data-keypad-toggle]');
|
||||
if (btn) {
|
||||
btn.textContent = touch ? '触控' : '⌨';
|
||||
btn.classList.toggle('on', touch);
|
||||
btn.title = touch ? '当前触控模式,点此切回 PC' : '当前 PC 模式,点此打开触控键盘';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var touchMode = false;
|
||||
try { touchMode = localStorage.getItem(KEY) === 'touch'; } catch (err) { }
|
||||
applyMode(touchMode);
|
||||
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-keypad-toggle]'), function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
touchMode = !touchMode;
|
||||
try { localStorage.setItem(KEY, touchMode ? 'touch' : 'pc'); } catch (err) { }
|
||||
applyMode(touchMode);
|
||||
if (!touchMode) {
|
||||
var block = btn.closest('.drawer-block') || document;
|
||||
var input = block.querySelector('input[name="tail"]');
|
||||
if (input) { input.focus(); }
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<div class="cal-page">
|
||||
<header class="cal-head">
|
||||
<div class="cal-title">
|
||||
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.PrevY}}&m={{.D.PrevM}}&pkg={{.D.PkgID}}" aria-label="上个月">‹</a>
|
||||
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.PrevY}}&m={{.D.PrevM}}&{{.D.FilterQuery}}" aria-label="上个月">‹</a>
|
||||
<h1>{{.D.Title}}</h1>
|
||||
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.NextY}}&m={{.D.NextM}}&pkg={{.D.PkgID}}" aria-label="下个月">›</a>
|
||||
<a class="btn btn-sm" href="/calendar?pkg={{.D.PkgID}}">回到今天</a>
|
||||
<a class="btn btn-ghost btn-square" href="/calendar?y={{.D.NextY}}&m={{.D.NextM}}&{{.D.FilterQuery}}" aria-label="下个月">›</a>
|
||||
<a class="btn btn-sm" href="/calendar?{{.D.FilterQuery}}">回到今天</a>
|
||||
</div>
|
||||
<div class="cal-stats">
|
||||
<span><em>{{.D.MonthDue}}</em>本月到期</span>
|
||||
@@ -19,8 +19,7 @@
|
||||
{{if .D.HasTabs}}
|
||||
<nav class="pkg-tabs">
|
||||
{{range .D.Tabs}}
|
||||
<a class="{{if eq .ID $.D.PkgID}}on{{end}}" style="--tab:{{.Color}}"
|
||||
href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&pkg={{.ID}}">
|
||||
<a class="{{if .Active}}on{{end}}" style="--tab:{{.Color}}" href="{{.Href}}">
|
||||
<i></i>{{.Name}}{{if gt .Count 0}}<em>{{.Count}}</em>{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
@@ -42,7 +41,7 @@
|
||||
<div class="cal-week" role="row">
|
||||
{{range .}}
|
||||
<a class="cal-cell heat-{{.Heat}}{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if .IsSelected}} sel{{end}}{{if .IsWeekend}} weekend{{end}}{{if .DueCount}} has-due{{end}}"
|
||||
href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{.Date}}&pkg={{$.D.PkgID}}" role="gridcell">
|
||||
href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{.Date}}&{{$.D.FilterQuery}}" role="gridcell">
|
||||
{{if .Strip}}<span class="asg-strip" aria-hidden="true">{{range .Strip}}<i style="background:{{.}}"></i>{{end}}</span>{{end}}
|
||||
<div class="cell-top">
|
||||
<span class="dnum">{{.Day}}</span>
|
||||
@@ -53,7 +52,7 @@
|
||||
</div>
|
||||
{{if .TaskCount}}
|
||||
<div class="cell-count">
|
||||
<b>{{.PendingCount}}</b><span>/ {{.TaskCount}}</span>
|
||||
<b>{{.DoneCount}}</b><span>/ {{.TaskCount}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</a>
|
||||
@@ -81,12 +80,14 @@
|
||||
<form class="quick-form" method="post" action="/checkins/quick">
|
||||
<input type="hidden" name="date" value="{{.D.Selected}}">
|
||||
<input type="hidden" name="pkg" value="{{.D.PkgID}}">
|
||||
<input type="hidden" name="tag" value="{{.D.Tag}}">
|
||||
<input type="hidden" name="back" value="{{.D.Back}}">
|
||||
<input type="text" name="tail" placeholder="输入手机尾号,回车即可完成" maxlength="8" autocomplete="off" inputmode="numeric" aria-label="手机尾号快速完成">
|
||||
<button class="btn btn-sm btn-primary" type="submit">完成</button>
|
||||
<button type="button" class="btn btn-sm btn-ghost keypad-toggle" data-keypad-toggle title="切换触控/PC 模式" aria-label="切换触控/PC 模式">⌨</button>
|
||||
</form>
|
||||
<p class="muted small quick-hint">唯一匹配直接完成;同尾号多人 / 一单多服务会列出让你勾选。</p>
|
||||
<div class="keypad" data-keypad role="group" aria-label="数字小键盘">
|
||||
<div class="keypad" data-keypad role="group" aria-label="数字小键盘" hidden>
|
||||
<button type="button" class="key" data-key="1">1</button>
|
||||
<button type="button" class="key" data-key="2">2</button>
|
||||
<button type="button" class="key" data-key="3">3</button>
|
||||
@@ -171,7 +172,7 @@
|
||||
<input type="hidden" name="back" value="{{$.D.Back}}">
|
||||
<button class="btn btn-sm {{if .Urgent}}btn-urgent{{else}}btn-ghost{{end}}" type="submit">{{if .Urgent}}取消加急{{else}}标记加急{{end}}</button>
|
||||
</form>
|
||||
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&pkg={{$.D.PkgID}}&extend={{.ID}}#extend">追加时长</a>
|
||||
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&{{$.D.FilterQuery}}&extend={{.ID}}#extend">追加时长</a>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -216,7 +217,7 @@
|
||||
<input type="hidden" name="back" value="{{$.D.Back}}">
|
||||
<button class="btn btn-sm {{if .Urgent}}btn-urgent{{else}}btn-ghost{{end}}" type="submit">{{if .Urgent}}取消加急{{else}}标记加急{{end}}</button>
|
||||
</form>
|
||||
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&pkg={{$.D.PkgID}}&extend={{.ID}}#extend">追加时长</a>
|
||||
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&{{$.D.FilterQuery}}&extend={{.ID}}#extend">追加时长</a>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -240,7 +241,7 @@
|
||||
<span class="pkg">{{.PackageName}}</span>
|
||||
<span class="rem">{{.RemainShort}}</span>
|
||||
</a>
|
||||
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&pkg={{$.D.PkgID}}&extend={{.ID}}#extend">追加</a>
|
||||
<a class="btn btn-sm" href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&{{$.D.FilterQuery}}&extend={{.ID}}#extend">追加</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
@@ -44,6 +44,33 @@
|
||||
|
||||
{{if .D.Customer.Note}}<div class="notice note">{{.D.Customer.Note}}</div>{{end}}
|
||||
|
||||
{{if .D.CustCal}}
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<h2>当月排单</h2>
|
||||
<div class="minical-nav">
|
||||
<a class="btn btn-sm btn-ghost" href="{{.D.CustCal.Prev}}">‹</a>
|
||||
<b>{{.D.CustCal.Title}}</b>
|
||||
<a class="btn btn-sm btn-ghost" href="{{.D.CustCal.Next}}">›</a>
|
||||
<span class="muted small">{{.D.CustCal.TaskDays}} 天有单 · {{.D.CustCal.TotalTasks}} 单</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="minical">
|
||||
<div class="minical-dow">{{range .D.CustCal.Weekdays}}<span>{{.}}</span>{{end}}</div>
|
||||
{{range .D.CustCal.Weeks}}
|
||||
<div class="minical-row">
|
||||
{{range .}}
|
||||
<a class="minical-cell{{if not .InMonth}} out{{end}}{{if .IsToday}} today{{end}}{{if eq .Count 0}} no-task{{end}}{{if .AllDone}} all-done{{end}}" href="/calendar?d={{.Date}}" title="{{.Date}}">
|
||||
<span class="mc-day">{{.Day}}</span>
|
||||
{{if gt .Count 0}}<span class="mc-count">{{.Done}}/{{.Count}}</span>{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<h2>订阅</h2>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
{{define "content"}}
|
||||
<div class="page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1>{{.D.Package.Name}}</h1>
|
||||
<p class="muted">
|
||||
{{if eq .D.Package.Unit "day"}}按日{{else}}按次{{end}} ·
|
||||
默认 {{.D.Package.DefaultAmount}}{{if eq .D.Package.Unit "day"}} 天{{else}} 次{{end}} ·
|
||||
{{if .D.Package.Tag}}标签「{{.D.Package.Tag}}」 · {{end}}{{if .D.Package.CalendarSeparate}}独立日历 · {{end}}
|
||||
{{if .D.Package.Archived}}<span class="chip chip-closed">已归档</span>{{end}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<a class="btn btn-ghost" href="/packages">返回套餐</a>
|
||||
<a class="btn" href="/packages/{{.D.Package.ID}}/edit">编辑套餐</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{if .D.Package.Note}}<div class="notice note">{{.D.Package.Note}}</div>{{end}}
|
||||
|
||||
<form method="post" action="/packages/{{.D.Package.ID}}/subscribe" class="section card">
|
||||
<h2>批量开通</h2>
|
||||
<p class="muted small">勾选客户后一次开通;已经是该套餐的客户会自动跳过。开始日期默认今天,时长/次数默认取套餐设置,可改。</p>
|
||||
|
||||
<div class="grid-3">
|
||||
<label class="field"><span>开始日期</span>
|
||||
<input type="date" name="start_date" value="{{.D.Today}}" required>
|
||||
</label>
|
||||
{{if eq .D.Package.Unit "day"}}
|
||||
<label class="field"><span>天数</span>
|
||||
<input type="number" name="days" min="1" max="3650" value="{{.D.Package.DefaultAmount}}">
|
||||
</label>
|
||||
{{else}}
|
||||
<label class="field"><span>次数</span>
|
||||
<input type="number" name="total_count" min="1" max="10000" value="{{.D.Package.DefaultAmount}}">
|
||||
</label>
|
||||
<label class="field"><span>到期日(可选)</span>
|
||||
<input type="date" name="end_date">
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="filterbar">
|
||||
<input type="search" name="q" value="{{.D.Q}}" placeholder="按尾号 / 姓名筛选客户">
|
||||
<button class="btn" type="submit" formmethod="get" formaction="/packages/{{.D.Package.ID}}">筛选</button>
|
||||
<span class="muted small">共 {{len .D.Customers}} 位客户</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="table tight">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:44px"><input type="checkbox" data-select-all aria-label="全选"></th>
|
||||
<th>尾号</th><th>客户</th><th>当前订阅</th><th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .D.Customers}}
|
||||
{{$subscribed := index $.D.Subscribed .ID}}
|
||||
<tr{{if $subscribed}} class="archived"{{end}}>
|
||||
<td><input type="checkbox" name="customer_ids" value="{{.ID}}"{{if $subscribed}} disabled{{else}} checked{{end}}></td>
|
||||
<td><a class="tail" href="/customers/{{.ID}}">{{.Tail}}</a></td>
|
||||
<td>{{.Name}}{{if .AssigneeName}} <span class="asg"><i style="background:{{.AssigneeColor}}"></i>{{.AssigneeName}}</span>{{end}}</td>
|
||||
<td class="summary due-{{.Level}}">{{.Summary}}</td>
|
||||
<td>{{if $subscribed}}<span class="chip chip-closed">已是此套餐</span>{{else}}<span class="chip chip-plain">可开通</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" type="submit">开通选中的客户</button>
|
||||
<span class="muted small">勾选框默认已按「未订阅」预选,可自行取消</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -39,6 +39,12 @@
|
||||
<label class="field"><span>日历色签</span>
|
||||
<input type="color" name="color" value="{{.D.Package.Color}}">
|
||||
</label>
|
||||
<label class="field"><span>标签(可选)</span>
|
||||
<input type="text" name="tag" value="{{.D.Package.Tag}}" maxlength="20" list="pkg-tags" placeholder="例如:包月活动托">
|
||||
<datalist id="pkg-tags">{{range .D.Tags}}<option value="{{.}}"></option>{{end}}</datalist>
|
||||
<small class="muted">相同标签的套餐在日历里会合并成同一个页签</small>
|
||||
</label>
|
||||
|
||||
<label class="field"><span>排序(小的靠前)</span>
|
||||
<input type="number" name="sort_order" value="{{.D.Package.SortOrder}}">
|
||||
</label>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<td><span class="dot big" style="background:{{.Color}}"></span></td>
|
||||
<td>
|
||||
<b>{{.Name}}</b>
|
||||
{{if .CalendarSeparate}}<span class="tagchip">独立日历</span>{{end}}
|
||||
{{if .Tag}}<span class="tagchip">{{.Tag}}</span>{{end}}{{if .CalendarSeparate}}<span class="tagchip">独立日历</span>{{end}}
|
||||
{{if .Note}}<div class="muted small">{{.Note}}</div>{{end}}
|
||||
</td>
|
||||
<td>{{.UnitLabel}}</td>
|
||||
@@ -29,7 +29,8 @@
|
||||
<td class="mono">{{.ActiveCount}} / {{.SubCount}}</td>
|
||||
<td>{{if .Archived}}<span class="chip chip-closed">已归档</span>{{else}}<span class="chip chip-active">启用中</span>{{end}}</td>
|
||||
<td class="actions-cell">
|
||||
<a class="btn btn-sm" href="/packages/{{.ID}}/edit">编辑</a>
|
||||
<a class="btn btn-sm" href="/packages/{{.ID}}">详情/批量</a>
|
||||
<a class="btn btn-sm btn-ghost" href="/packages/{{.ID}}/edit">编辑</a>
|
||||
<form method="post" action="/packages/{{.ID}}/archive"{{if not .Archived}} data-confirm="归档后不能再新建该套餐的订阅,已有订阅不受影响。继续?"{{end}}>
|
||||
<button class="btn btn-sm btn-ghost">{{if .Archived}}恢复{{else}}归档{{end}}</button>
|
||||
</form>
|
||||
|
||||
@@ -6,14 +6,13 @@
|
||||
{{if .D.HasTabs}}
|
||||
<nav class="scr-tabs">
|
||||
{{range .D.Tabs}}
|
||||
<a class="{{if eq .ID $.D.PkgID}}on{{end}}" style="--tab:{{.Color}}"
|
||||
href="/screen?pkg={{.ID}}&heat={{$.D.HeatMode}}">{{.Name}}</a>
|
||||
<a class="{{if .Active}}on{{end}}" style="--tab:{{.Color}}" href="{{.Href}}">{{.Name}}</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
<div class="scr-tools">
|
||||
<a class="scr-switch" href="/screen?pkg={{.D.PkgID}}&heat={{if eq .D.HeatMode "due"}}task{{else}}due{{end}}"
|
||||
<a class="scr-switch" href="/screen?{{.D.FilterQuery}}&heat={{if eq .D.HeatMode "due"}}task{{else}}due{{end}}"
|
||||
title="切换热力统计口径">
|
||||
{{if eq .D.HeatMode "due"}}热力:到期{{else}}热力:待完成{{end}} ⇄
|
||||
</a>
|
||||
@@ -57,7 +56,7 @@
|
||||
{{if .Strip}}<span class="asg-strip sm" aria-hidden="true">{{range .Strip}}<i style="background:{{.}}"></i>{{end}}</span>{{end}}
|
||||
<span>{{.Day}}</span>
|
||||
{{if gt .Urgent 0}}<u class="cell-urgent"></u>{{end}}
|
||||
{{if .Count}}<b>{{.Count}}</b>{{end}}
|
||||
{{if .Count}}<b>{{.Done}}/{{.Count}}</b>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user