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 天内到期的订阅;
|
- **7 天内到期**:顶栏新增徽标,固定统计未来 7 天内到期的订阅;
|
||||||
- **抽屉时钟**:右侧当日面板顶部有时钟,30px 大写、水平垂直居中,24 小时制精确到秒,下面一行是当天日期;
|
- **抽屉时钟**:右侧当日面板顶部有时钟,30px 大写、水平垂直居中,24 小时制精确到秒,下面一行是当天日期;
|
||||||
|
- **触控 / PC 模式**:尾号输入框右侧有「⌨ / 触控」开关。切到触控时展开 3×4 小键盘、输入框变只读并关闭系统输入法(`inputmode=none`),点键直接写值;切回 PC 收起键盘。选择记在浏览器里。
|
||||||
|
- **格子计数**:工作台和大屏的日历格都显示「已完成 / 总数」,例如 2/5 = 5 单里做完 2 单。
|
||||||
- **已完成折叠**:当日清单里已完成/已跳过的单子自动折叠成一行(尾号 + 姓名 + 套餐 + 状态),点这一行展开看剩余、打手,以及撤销/加急/追加;未完成的行不受影响;
|
- **已完成折叠**:当日清单里已完成/已跳过的单子自动折叠成一行(尾号 + 姓名 + 套餐 + 状态),点这一行展开看剩余、打手,以及撤销/加急/追加;未完成的行不受影响;
|
||||||
- **打手颜色**:`设置 → 管理员` 里给每个管理员选一个颜色。客户详情或编辑页可以把客户指派给某个打手,之后这个客户的所有单子都用该颜色标识:
|
- **打手颜色**:`设置 → 管理员` 里给每个管理员选一个颜色。客户详情或编辑页可以把客户指派给某个打手,之后这个客户的所有单子都用该颜色标识:
|
||||||
- 日历格左侧竖条 = 当天涉及的所有打手颜色(几种人几条,等分显示);
|
- 日历格左侧竖条 = 当天涉及的所有打手颜色(几种人几条,等分显示);
|
||||||
@@ -152,6 +154,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 4.2 套餐标签与批量开通
|
||||||
|
|
||||||
|
- **套餐标签**:套餐编辑页可以填一个标签(如「包月」)。日历/大屏顶部页签会**按标签合并**:同标签的套餐合成一个页签;勾了「在日历中独立显示」的套餐**额外**再开一个自己的页签。
|
||||||
|
- **批量开通**:`套餐 → 详情/批量`,勾选客户(表头有全选,已订阅该套餐的客户置灰),填开始日期与天数/次数后一次开通;重复的会自动跳过并提示。
|
||||||
|
- **客户月历**:客户详情页新增「当月排单」,显示该客户当月每天几单、完成几单,可翻月,点某天直接跳到日历那天。
|
||||||
|
- **按次到期**:按次订阅的「到期日」仍是选填;填了到期后状态显示「已到期」、不再进日历待排。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 5. 数据、备份与导出
|
## 5. 数据、备份与导出
|
||||||
|
|
||||||
| 文件 | 说明 |
|
| 文件 | 说明 |
|
||||||
@@ -164,7 +175,7 @@
|
|||||||
|
|
||||||
- **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。
|
- **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。
|
||||||
- **重置**:关闭程序 → 删除 `daili.db` → 重新打开,会回到「创建第一个管理员」的全新状态。
|
- **重置**:关闭程序 → 删除 `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/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。
|
- `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 {
|
"ALTER TABLE customers ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +201,7 @@ var schemaStatements = []string{
|
|||||||
archived INTEGER NOT NULL DEFAULT 0,
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
calendar_separate INTEGER NOT NULL DEFAULT 0,
|
calendar_separate INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tag TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
)`,
|
)`,
|
||||||
|
|||||||
+51
-13
@@ -22,10 +22,12 @@ type Package struct {
|
|||||||
SortOrder int
|
SortOrder int
|
||||||
// CalendarSeparate 勾选后在工作台/大屏日历中单独成页
|
// CalendarSeparate 勾选后在工作台/大屏日历中单独成页
|
||||||
CalendarSeparate bool
|
CalendarSeparate bool
|
||||||
CreatedAt string
|
// Tag 套餐标签:相同标签的套餐在日历里合并显示
|
||||||
UpdatedAt string
|
Tag string
|
||||||
SubCount int
|
CreatedAt string
|
||||||
ActiveCount int
|
UpdatedAt string
|
||||||
|
SubCount int
|
||||||
|
ActiveCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnitLabel 返回类型中文名。
|
// UnitLabel 返回类型中文名。
|
||||||
@@ -41,7 +43,7 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
|
|||||||
var price sql.NullInt64
|
var price sql.NullInt64
|
||||||
var archived, separate int
|
var archived, separate int
|
||||||
err := row.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
|
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 err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -59,7 +61,7 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
|
|||||||
|
|
||||||
// ListPackages 列出套餐(含订阅计数)。
|
// ListPackages 列出套餐(含订阅计数)。
|
||||||
func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
|
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),
|
||||||
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id AND s.status<>'closed')
|
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id AND s.status<>'closed')
|
||||||
FROM packages p`
|
FROM packages p`
|
||||||
@@ -78,7 +80,7 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
|
|||||||
var price sql.NullInt64
|
var price sql.NullInt64
|
||||||
var archived, separate int
|
var archived, separate int
|
||||||
if err := rows.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
p.Archived = archived == 1
|
p.Archived = archived == 1
|
||||||
@@ -94,7 +96,7 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
|
|||||||
|
|
||||||
// Package 取单个套餐。
|
// Package 取单个套餐。
|
||||||
func (db *DB) Package(id int64) (*Package, error) {
|
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))
|
FROM packages WHERE id=?`, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,9 +114,9 @@ func (db *DB) CreatePackage(p *Package) (int64, error) {
|
|||||||
if p.HasPrice {
|
if p.HasPrice {
|
||||||
price = p.PriceCents
|
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)
|
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,?,?,?,?)`,
|
VALUES(?,?,?,?,?,?,0,?,?,?,?,?)`,
|
||||||
p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), now, now)
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -134,8 +136,8 @@ func (db *DB) UpdatePackage(p *Package) error {
|
|||||||
if p.HasPrice {
|
if p.HasPrice {
|
||||||
price = p.PriceCents
|
price = p.PriceCents
|
||||||
}
|
}
|
||||||
_, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,calendar_separate=?,updated_at=?
|
_, 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), nowStamp(), p.ID)
|
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
|
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)
|
_, err := db.sql.Exec(`UPDATE packages SET archived=?, updated_at=? WHERE id=?`, v, nowStamp(), id)
|
||||||
return err
|
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("删除不存在的客户应报错")
|
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/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"daili/internal/domain"
|
"daili/internal/domain"
|
||||||
@@ -61,10 +62,13 @@ type TaskRow struct {
|
|||||||
|
|
||||||
// PkgTab 是日历上方的套餐标签。
|
// PkgTab 是日历上方的套餐标签。
|
||||||
type PkgTab struct {
|
type PkgTab struct {
|
||||||
ID int64
|
ID int64
|
||||||
Name string
|
Tag string
|
||||||
Color string
|
Name string
|
||||||
Count int
|
Color string
|
||||||
|
Count int
|
||||||
|
Href string
|
||||||
|
Active bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type calendarData struct {
|
type calendarData struct {
|
||||||
@@ -93,9 +97,12 @@ type calendarData struct {
|
|||||||
Back string
|
Back string
|
||||||
|
|
||||||
// 套餐标签(在日历中独立显示的套餐)
|
// 套餐标签(在日历中独立显示的套餐)
|
||||||
Tabs []PkgTab
|
Tabs []PkgTab
|
||||||
PkgID int64
|
PkgID int64
|
||||||
HasTabs bool
|
Tag string
|
||||||
|
// FilterQuery 当前筛选条件(pkg=1 或 tag=xxx),拼链接用
|
||||||
|
FilterQuery string
|
||||||
|
HasTabs bool
|
||||||
|
|
||||||
// 快速完成:按尾号筛选出的候选
|
// 快速完成:按尾号筛选出的候选
|
||||||
PickTail string
|
PickTail string
|
||||||
@@ -138,18 +145,76 @@ func (c calSub) covers(date string) bool {
|
|||||||
return !c.pausedOn(date)
|
return !c.pausedOn(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadCalendarSubs 读取区间内与日历相交的订阅;pkgID>0 时只保留该套餐。
|
// buildCalendarTabs 生成日历页签:先按套餐标签合并,再给「独立显示」的套餐各开一个。
|
||||||
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) {
|
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)
|
subs, err := s.db.CalendarSubs(from, to)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, nil, err
|
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))
|
filtered := make([]store.Subscription, 0, len(subs))
|
||||||
for _, sub := range subs {
|
for _, sub := range subs {
|
||||||
if sub.PackageID == pkgID {
|
if pkgID > 0 && sub.PackageID != pkgID {
|
||||||
filtered = append(filtered, sub)
|
continue
|
||||||
}
|
}
|
||||||
|
if pkgID <= 0 && tag != "" && tags[sub.PackageID] != tag {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, sub)
|
||||||
}
|
}
|
||||||
subs = filtered
|
subs = filtered
|
||||||
}
|
}
|
||||||
@@ -183,6 +248,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
|||||||
monthEnd := domain.MonthEnd(y, time.Month(m))
|
monthEnd := domain.MonthEnd(y, time.Month(m))
|
||||||
|
|
||||||
pkgID := int64(s.queryInt(r, "pkg", 0))
|
pkgID := int64(s.queryInt(r, "pkg", 0))
|
||||||
|
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
|
||||||
sel := r.URL.Query().Get("d")
|
sel := r.URL.Query().Get("d")
|
||||||
selT, err := domain.ParseDate(sel)
|
selT, err := domain.ParseDate(sel)
|
||||||
if sel != "" && err != nil {
|
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
|
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
|
||||||
gridEnd := domain.AddDays(gridStart, weeks*7-1)
|
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 {
|
if err != nil {
|
||||||
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
|
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -233,6 +299,11 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
|||||||
SelectedIsToday: sel == domain.Date(today),
|
SelectedIsToday: sel == domain.Date(today),
|
||||||
Back: back,
|
Back: back,
|
||||||
PkgID: pkgID,
|
PkgID: pkgID,
|
||||||
|
Tag: tag,
|
||||||
|
FilterQuery: "pkg=" + itoa(pkgID),
|
||||||
|
}
|
||||||
|
if tag != "" {
|
||||||
|
data.FilterQuery = "tag=" + url.QueryEscape(tag)
|
||||||
}
|
}
|
||||||
data.PrevY, data.PrevM = m-1, y
|
data.PrevY, data.PrevM = m-1, y
|
||||||
if data.PrevM < 1 {
|
if data.PrevM < 1 {
|
||||||
@@ -360,30 +431,31 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
|||||||
if pkgID > 0 && u.PackageID != pkgID {
|
if pkgID > 0 && u.PackageID != pkgID {
|
||||||
continue
|
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))
|
data.OtherDue = append(data.OtherDue, newSubView(u, today, warn))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 套餐标签
|
// 页签:先按「套餐标签」合并,再给勾了独立显示的套餐各开一个
|
||||||
pkgs, _ := s.db.ListPackages(true)
|
pkgs, _ := s.db.ListPackages(true)
|
||||||
separate := []store.Package{}
|
data.Tabs, data.HasTabs = buildCalendarTabs(pkgs, subs, len(list))
|
||||||
for _, p := range pkgs {
|
for i := range data.Tabs {
|
||||||
if p.CalendarSeparate && !p.Archived {
|
t := &data.Tabs[i]
|
||||||
separate = append(separate, p)
|
href := fmt.Sprintf("/calendar?y=%d&m=%d&d=%s", y, m, sel)
|
||||||
}
|
if t.Tag != "" {
|
||||||
}
|
href += "&tag=" + url.QueryEscape(t.Tag)
|
||||||
if len(separate) > 0 {
|
t.Active = t.Tag == tag
|
||||||
data.HasTabs = true
|
} else if t.ID > 0 {
|
||||||
data.Tabs = append(data.Tabs, PkgTab{ID: 0, Name: "全部", Color: "#8A8F98", Count: len(list)})
|
href += "&pkg=" + itoa(t.ID)
|
||||||
for _, p := range separate {
|
t.Active = t.ID == pkgID
|
||||||
cnt := 0
|
} else {
|
||||||
for _, sub := range subs {
|
t.Active = tag == "" && pkgID == 0
|
||||||
if sub.PackageID == p.ID {
|
|
||||||
cnt++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data.Tabs = append(data.Tabs, PkgTab{ID: p.ID, Name: p.Name, Color: p.Color, Count: cnt})
|
|
||||||
}
|
}
|
||||||
|
t.Href = href
|
||||||
}
|
}
|
||||||
|
|
||||||
// 快速完成:按尾号列出候选
|
// 快速完成:按尾号列出候选
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"daili/internal/domain"
|
"daili/internal/domain"
|
||||||
"daili/internal/store"
|
"daili/internal/store"
|
||||||
@@ -190,11 +192,112 @@ type customerDetailData struct {
|
|||||||
Checkins []CheckinRow
|
Checkins []CheckinRow
|
||||||
Packages []store.Package
|
Packages []store.Package
|
||||||
Users []store.User
|
Users []store.User
|
||||||
|
CustCal *custCal
|
||||||
Timeline *Timeline
|
Timeline *Timeline
|
||||||
Extend *ExtendPanel
|
Extend *ExtendPanel
|
||||||
TotalRemain string
|
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) {
|
func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
id := pathID(r)
|
id := pathID(r)
|
||||||
c, err := s.db.Customer(id)
|
c, err := s.db.Customer(id)
|
||||||
@@ -222,6 +325,7 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
users, _ := s.db.ActiveUsers()
|
users, _ := s.db.ActiveUsers()
|
||||||
data := &customerDetailData{
|
data := &customerDetailData{
|
||||||
Users: users,
|
Users: users,
|
||||||
|
CustCal: s.buildCustomerCal(c, r.URL.Query().Get("cm"), today),
|
||||||
Customer: c,
|
Customer: c,
|
||||||
Subs: subViews(subs, today, warn),
|
Subs: subViews(subs, today, warn),
|
||||||
Packages: pkgs,
|
Packages: pkgs,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -27,13 +28,16 @@ func (s *Server) handlePackages(w http.ResponseWriter, r *http.Request) {
|
|||||||
type packageFormData struct {
|
type packageFormData struct {
|
||||||
Package *store.Package
|
Package *store.Package
|
||||||
IsNew bool
|
IsNew bool
|
||||||
|
Tags []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handlePackageNewPage(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePackageNewPage(w http.ResponseWriter, r *http.Request) {
|
||||||
p := s.newPage(r, "新增套餐", "packages")
|
p := s.newPage(r, "新增套餐", "packages")
|
||||||
|
tags, _ := s.db.PackageTags()
|
||||||
p.D = &packageFormData{
|
p.D = &packageFormData{
|
||||||
Package: &store.Package{Unit: domain.UnitDay, DefaultAmount: 30, Color: "#2F5D50"},
|
Package: &store.Package{Unit: domain.UnitDay, DefaultAmount: 30, Color: "#2F5D50"},
|
||||||
IsNew: true,
|
IsNew: true,
|
||||||
|
Tags: tags,
|
||||||
}
|
}
|
||||||
s.render(w, r, "package_form", p)
|
s.render(w, r, "package_form", p)
|
||||||
}
|
}
|
||||||
@@ -63,6 +67,7 @@ func readPackageForm(r *http.Request) *store.Package {
|
|||||||
p.Unit = domain.UnitDay
|
p.Unit = domain.UnitDay
|
||||||
}
|
}
|
||||||
p.CalendarSeparate = r.PostFormValue("calendar_separate") != ""
|
p.CalendarSeparate = r.PostFormValue("calendar_separate") != ""
|
||||||
|
p.Tag = strings.TrimSpace(r.PostFormValue("tag"))
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,17 +90,123 @@ func validatePackage(p *store.Package) string {
|
|||||||
return ""
|
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) {
|
func (s *Server) handlePackageCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
pkg := readPackageForm(r)
|
pkg := readPackageForm(r)
|
||||||
p := s.newPage(r, "新增套餐", "packages")
|
p := s.newPage(r, "新增套餐", "packages")
|
||||||
if msg := validatePackage(pkg); msg != "" {
|
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)
|
s.fail(w, r, "package_form", msg, p)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, err := s.db.CreatePackage(pkg)
|
id, err := s.db.CreatePackage(pkg)
|
||||||
if err != nil {
|
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)
|
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -111,7 +222,8 @@ func (s *Server) handlePackageEditPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
p := s.newPage(r, "编辑套餐", "packages")
|
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)
|
s.render(w, r, "package_form", p)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,12 +233,14 @@ func (s *Server) handlePackageUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
pkg.ID = id
|
pkg.ID = id
|
||||||
p := s.newPage(r, "编辑套餐", "packages")
|
p := s.newPage(r, "编辑套餐", "packages")
|
||||||
if msg := validatePackage(pkg); msg != "" {
|
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)
|
s.fail(w, r, "package_form", msg, p)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.UpdatePackage(pkg); err != nil {
|
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)
|
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"daili/internal/domain"
|
"daili/internal/domain"
|
||||||
"daili/internal/store"
|
"daili/internal/store"
|
||||||
@@ -16,6 +18,7 @@ type screenDay struct {
|
|||||||
IsToday bool
|
IsToday bool
|
||||||
IsPast bool
|
IsPast bool
|
||||||
Count int
|
Count int
|
||||||
|
Done int
|
||||||
Urgent int
|
Urgent int
|
||||||
Heat int
|
Heat int
|
||||||
// Strip 打手色竖条(去重)
|
// Strip 打手色竖条(去重)
|
||||||
@@ -42,17 +45,19 @@ type screenData struct {
|
|||||||
Refresh int
|
Refresh int
|
||||||
Version string
|
Version string
|
||||||
|
|
||||||
HeatMode string // task = 每日待完成;due = 到期
|
HeatMode string // task = 每日待完成;due = 到期
|
||||||
HeatTotal int
|
Tag string
|
||||||
Tabs []PkgTab
|
HeatTotal int
|
||||||
PkgID int64
|
Tabs []PkgTab
|
||||||
HasTabs bool
|
PkgID int64
|
||||||
Theme string
|
HasTabs bool
|
||||||
|
FilterQuery string
|
||||||
|
Theme string
|
||||||
}
|
}
|
||||||
|
|
||||||
// screenCalSubs 读取大屏日历区间内的订阅,并带上暂停/打卡/加急。
|
// screenCalSubs 读取大屏日历区间内的订阅,并带上暂停/打卡/加急。
|
||||||
func (s *Server) screenCalSubs(from, to string, pkgID int64) ([]calSub, map[int64]map[string]store.Checkin, map[int64]map[string]bool, error) {
|
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)
|
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(from, to, pkgID, tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -74,6 +79,7 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
|||||||
today := domain.Today()
|
today := domain.Today()
|
||||||
warn := s.warnDays()
|
warn := s.warnDays()
|
||||||
pkgID := int64(s.queryInt(r, "pkg", 0))
|
pkgID := int64(s.queryInt(r, "pkg", 0))
|
||||||
|
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
|
||||||
heatMode := r.URL.Query().Get("heat")
|
heatMode := r.URL.Query().Get("heat")
|
||||||
if heatMode != "due" {
|
if heatMode != "due" {
|
||||||
heatMode = "task"
|
heatMode = "task"
|
||||||
@@ -85,16 +91,18 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := &screenData{
|
data := &screenData{
|
||||||
Stats: stats,
|
Stats: stats,
|
||||||
MonthTitle: fmt.Sprintf("%d 年 %d 月", today.Year(), int(today.Month())),
|
MonthTitle: fmt.Sprintf("%d 年 %d 月", today.Year(), int(today.Month())),
|
||||||
Year: today.Year(),
|
Year: today.Year(),
|
||||||
Month: int(today.Month()),
|
Month: int(today.Month()),
|
||||||
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
|
Weekdays: []string{"一", "二", "三", "四", "五", "六", "日"},
|
||||||
Top: subViews(stats.Top, today, warn),
|
Top: subViews(stats.Top, today, warn),
|
||||||
Refresh: s.screenRefresh(),
|
Refresh: s.screenRefresh(),
|
||||||
Version: s.cfg.Version,
|
Version: s.cfg.Version,
|
||||||
HeatMode: heatMode,
|
HeatMode: heatMode,
|
||||||
PkgID: pkgID,
|
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
|
weeks := (domain.WeekdayIndex(monthStart) + monthEnd.Day() + 6) / 7
|
||||||
gridEnd := domain.AddDays(gridStart, weeks*7-1)
|
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 {
|
if err != nil {
|
||||||
http.Error(w, "读取大屏日历失败", http.StatusInternalServerError)
|
http.Error(w, "读取大屏日历失败", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -125,7 +133,7 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
|||||||
for di := 0; di < 7; di++ {
|
for di := 0; di < 7; di++ {
|
||||||
d := domain.AddDays(gridStart, wi*7+di)
|
d := domain.AddDays(gridStart, wi*7+di)
|
||||||
ds := domain.Date(d)
|
ds := domain.Date(d)
|
||||||
count, urgentN := 0, 0
|
count, doneN, urgentN := 0, 0, 0
|
||||||
strip := []string{}
|
strip := []string{}
|
||||||
seenColor := map[string]bool{}
|
seenColor := map[string]bool{}
|
||||||
for _, cs := range list {
|
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]
|
ci, has := checkins[cs.sub.ID][ds]
|
||||||
if has && (ci.Status == "done" || ci.Status == "skipped") {
|
if has && (ci.Status == "done" || ci.Status == "skipped") {
|
||||||
continue // “待完成”只统计还没处理的
|
doneN++
|
||||||
|
continue // “待完成”热力只统计还没处理的
|
||||||
}
|
}
|
||||||
count++
|
count++
|
||||||
if urgent[cs.sub.ID][ds] {
|
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(),
|
InMonth: d.Month() == today.Month(),
|
||||||
IsToday: ds == domain.Date(today),
|
IsToday: ds == domain.Date(today),
|
||||||
IsPast: d.Before(today),
|
IsPast: d.Before(today),
|
||||||
Count: count,
|
Count: count + doneN,
|
||||||
|
Done: doneN,
|
||||||
Urgent: urgentN,
|
Urgent: urgentN,
|
||||||
Heat: heatLevel(count),
|
Heat: heatLevel(count),
|
||||||
Strip: strip,
|
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 {
|
if err == nil {
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
if row.Done || row.Skipped {
|
if row.Done || row.Skipped {
|
||||||
@@ -198,18 +208,27 @@ func (s *Server) handleScreen(w http.ResponseWriter, r *http.Request) {
|
|||||||
data.Recent = adj
|
data.Recent = adj
|
||||||
}
|
}
|
||||||
|
|
||||||
// 套餐标签
|
// 页签:按标签合并 + 独立显示另开
|
||||||
pkgs, _ := s.db.ListPackages(true)
|
pkgs, _ := s.db.ListPackages(true)
|
||||||
for _, p := range pkgs {
|
data.Tabs, data.HasTabs = buildCalendarTabs(pkgs, nil, 0)
|
||||||
if p.CalendarSeparate && !p.Archived {
|
for i := range data.Tabs {
|
||||||
if !data.HasTabs {
|
t := &data.Tabs[i]
|
||||||
data.HasTabs = true
|
href := "/screen?heat=" + heatMode
|
||||||
data.Tabs = append(data.Tabs, PkgTab{ID: 0, Name: "全部", Color: "#8A8F98"})
|
if t.Tag != "" {
|
||||||
}
|
href += "&tag=" + url.QueryEscape(t.Tag)
|
||||||
data.Tabs = append(data.Tabs, PkgTab{ID: p.ID, Name: p.Name, Color: p.Color})
|
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)
|
data.Theme = themeFromRequest(r)
|
||||||
p := s.newPage(r, "大屏", "screen")
|
p := s.newPage(r, "大屏", "screen")
|
||||||
p.Theme = data.Theme
|
p.Theme = data.Theme
|
||||||
@@ -225,7 +244,7 @@ func (s *Server) handleScreenDay(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
pkgID := int64(s.queryInt(r, "pkg", 0))
|
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 {
|
if err != nil {
|
||||||
http.Error(w, "读取失败", http.StatusInternalServerError)
|
http.Error(w, "读取失败", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
|
|
||||||
// pendingTasks 返回某天“应服务”的订阅行(day 型在区间内、count 型有剩余),
|
// pendingTasks 返回某天“应服务”的订阅行(day 型在区间内、count 型有剩余),
|
||||||
// customerIDs 非空时只保留这些客户;pkgID>0 时只保留该套餐。
|
// customerIDs 非空时只保留这些客户;pkgID>0 时只保留该套餐。
|
||||||
func (s *Server) pendingTasks(date string, customerIDs map[int64]bool, pkgID int64) ([]TaskRow, error) {
|
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)
|
subs, pauses, checkins, urgent, err := s.loadCalendarSubs(date, date, pkgID, tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -51,6 +51,7 @@ func (s *Server) handleQuickCheckin(w http.ResponseWriter, r *http.Request) {
|
|||||||
tail := strings.TrimSpace(r.PostFormValue("tail"))
|
tail := strings.TrimSpace(r.PostFormValue("tail"))
|
||||||
date := strings.TrimSpace(r.PostFormValue("date"))
|
date := strings.TrimSpace(r.PostFormValue("date"))
|
||||||
pkgID := int64(s.formInt(r, "pkg", 0))
|
pkgID := int64(s.formInt(r, "pkg", 0))
|
||||||
|
tag := strings.TrimSpace(r.PostFormValue("tag"))
|
||||||
if _, err := domain.ParseDate(date); err != nil {
|
if _, err := domain.ParseDate(date); err != nil {
|
||||||
date = domain.Date(domain.Today())
|
date = domain.Date(domain.Today())
|
||||||
}
|
}
|
||||||
@@ -74,7 +75,7 @@ func (s *Server) handleQuickCheckin(w http.ResponseWriter, r *http.Request) {
|
|||||||
ids[c.ID] = true
|
ids[c.ID] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.pendingTasks(date, ids, pkgID)
|
rows, err := s.pendingTasks(date, ids, pkgID, tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.redirect(w, r, back, "读取待完成失败:"+err.Error())
|
s.redirect(w, r, back, "读取待完成失败:"+err.Error())
|
||||||
return
|
return
|
||||||
@@ -127,6 +128,7 @@ func (s *Server) handleCheckinBatch(w http.ResponseWriter, r *http.Request) {
|
|||||||
date = domain.Date(domain.Today())
|
date = domain.Date(domain.Today())
|
||||||
}
|
}
|
||||||
back := backURL(r, "/calendar?d="+date)
|
back := backURL(r, "/calendar?d="+date)
|
||||||
|
_ = r.ParseForm()
|
||||||
raw := r.PostForm["sub_ids"]
|
raw := r.PostForm["sub_ids"]
|
||||||
if len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
s.redirect(w, r, back, "没有选中任何项")
|
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("GET /packages/{id}/edit", s.auth(s.handlePackageEditPage))
|
||||||
mux.HandleFunc("POST /packages/{id}/edit", s.auth(s.handlePackageUpdate))
|
mux.HandleFunc("POST /packages/{id}/edit", s.auth(s.handlePackageUpdate))
|
||||||
mux.HandleFunc("POST /packages/{id}/archive", s.auth(s.handlePackageArchive))
|
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 {
|
if s.cfg.ScreenPublic {
|
||||||
// 大屏免登录:仅局域网部署时使用,内容为只读看板
|
// 大屏免登录:仅局域网部署时使用,内容为只读看板
|
||||||
mux.HandleFunc("GET /screen", s.handleScreen)
|
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;
|
margin: 6px 0 2px; padding-left: 9px;
|
||||||
border-left: 1px dashed var(--line);
|
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">
|
<div class="cal-page">
|
||||||
<header class="cal-head">
|
<header class="cal-head">
|
||||||
<div class="cal-title">
|
<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>
|
<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-ghost btn-square" href="/calendar?y={{.D.NextY}}&m={{.D.NextM}}&{{.D.FilterQuery}}" aria-label="下个月">›</a>
|
||||||
<a class="btn btn-sm" href="/calendar?pkg={{.D.PkgID}}">回到今天</a>
|
<a class="btn btn-sm" href="/calendar?{{.D.FilterQuery}}">回到今天</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="cal-stats">
|
<div class="cal-stats">
|
||||||
<span><em>{{.D.MonthDue}}</em>本月到期</span>
|
<span><em>{{.D.MonthDue}}</em>本月到期</span>
|
||||||
@@ -19,8 +19,7 @@
|
|||||||
{{if .D.HasTabs}}
|
{{if .D.HasTabs}}
|
||||||
<nav class="pkg-tabs">
|
<nav class="pkg-tabs">
|
||||||
{{range .D.Tabs}}
|
{{range .D.Tabs}}
|
||||||
<a class="{{if eq .ID $.D.PkgID}}on{{end}}" style="--tab:{{.Color}}"
|
<a class="{{if .Active}}on{{end}}" style="--tab:{{.Color}}" href="{{.Href}}">
|
||||||
href="/calendar?y={{$.D.Year}}&m={{$.D.Month}}&d={{$.D.Selected}}&pkg={{.ID}}">
|
|
||||||
<i></i>{{.Name}}{{if gt .Count 0}}<em>{{.Count}}</em>{{end}}
|
<i></i>{{.Name}}{{if gt .Count 0}}<em>{{.Count}}</em>{{end}}
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -42,7 +41,7 @@
|
|||||||
<div class="cal-week" role="row">
|
<div class="cal-week" role="row">
|
||||||
{{range .}}
|
{{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}}"
|
<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}}
|
{{if .Strip}}<span class="asg-strip" aria-hidden="true">{{range .Strip}}<i style="background:{{.}}"></i>{{end}}</span>{{end}}
|
||||||
<div class="cell-top">
|
<div class="cell-top">
|
||||||
<span class="dnum">{{.Day}}</span>
|
<span class="dnum">{{.Day}}</span>
|
||||||
@@ -53,7 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{if .TaskCount}}
|
{{if .TaskCount}}
|
||||||
<div class="cell-count">
|
<div class="cell-count">
|
||||||
<b>{{.PendingCount}}</b><span>/ {{.TaskCount}}</span>
|
<b>{{.DoneCount}}</b><span>/ {{.TaskCount}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</a>
|
</a>
|
||||||
@@ -81,12 +80,14 @@
|
|||||||
<form class="quick-form" method="post" action="/checkins/quick">
|
<form class="quick-form" method="post" action="/checkins/quick">
|
||||||
<input type="hidden" name="date" value="{{.D.Selected}}">
|
<input type="hidden" name="date" value="{{.D.Selected}}">
|
||||||
<input type="hidden" name="pkg" value="{{.D.PkgID}}">
|
<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="hidden" name="back" value="{{.D.Back}}">
|
||||||
<input type="text" name="tail" placeholder="输入手机尾号,回车即可完成" maxlength="8" autocomplete="off" inputmode="numeric" aria-label="手机尾号快速完成">
|
<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 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>
|
</form>
|
||||||
<p class="muted small quick-hint">唯一匹配直接完成;同尾号多人 / 一单多服务会列出让你勾选。</p>
|
<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="1">1</button>
|
||||||
<button type="button" class="key" data-key="2">2</button>
|
<button type="button" class="key" data-key="2">2</button>
|
||||||
<button type="button" class="key" data-key="3">3</button>
|
<button type="button" class="key" data-key="3">3</button>
|
||||||
@@ -171,7 +172,7 @@
|
|||||||
<input type="hidden" name="back" value="{{$.D.Back}}">
|
<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>
|
<button class="btn btn-sm {{if .Urgent}}btn-urgent{{else}}btn-ghost{{end}}" type="submit">{{if .Urgent}}取消加急{{else}}标记加急{{end}}</button>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,7 +217,7 @@
|
|||||||
<input type="hidden" name="back" value="{{$.D.Back}}">
|
<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>
|
<button class="btn btn-sm {{if .Urgent}}btn-urgent{{else}}btn-ghost{{end}}" type="submit">{{if .Urgent}}取消加急{{else}}标记加急{{end}}</button>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,7 +241,7 @@
|
|||||||
<span class="pkg">{{.PackageName}}</span>
|
<span class="pkg">{{.PackageName}}</span>
|
||||||
<span class="rem">{{.RemainShort}}</span>
|
<span class="rem">{{.RemainShort}}</span>
|
||||||
</a>
|
</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>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -44,6 +44,33 @@
|
|||||||
|
|
||||||
{{if .D.Customer.Note}}<div class="notice note">{{.D.Customer.Note}}</div>{{end}}
|
{{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">
|
<section class="section">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>订阅</h2>
|
<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>
|
<label class="field"><span>日历色签</span>
|
||||||
<input type="color" name="color" value="{{.D.Package.Color}}">
|
<input type="color" name="color" value="{{.D.Package.Color}}">
|
||||||
</label>
|
</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>
|
<label class="field"><span>排序(小的靠前)</span>
|
||||||
<input type="number" name="sort_order" value="{{.D.Package.SortOrder}}">
|
<input type="number" name="sort_order" value="{{.D.Package.SortOrder}}">
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<td><span class="dot big" style="background:{{.Color}}"></span></td>
|
<td><span class="dot big" style="background:{{.Color}}"></span></td>
|
||||||
<td>
|
<td>
|
||||||
<b>{{.Name}}</b>
|
<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}}
|
{{if .Note}}<div class="muted small">{{.Note}}</div>{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td>{{.UnitLabel}}</td>
|
<td>{{.UnitLabel}}</td>
|
||||||
@@ -29,7 +29,8 @@
|
|||||||
<td class="mono">{{.ActiveCount}} / {{.SubCount}}</td>
|
<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>{{if .Archived}}<span class="chip chip-closed">已归档</span>{{else}}<span class="chip chip-active">启用中</span>{{end}}</td>
|
||||||
<td class="actions-cell">
|
<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}}>
|
<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>
|
<button class="btn btn-sm btn-ghost">{{if .Archived}}恢复{{else}}归档{{end}}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -6,14 +6,13 @@
|
|||||||
{{if .D.HasTabs}}
|
{{if .D.HasTabs}}
|
||||||
<nav class="scr-tabs">
|
<nav class="scr-tabs">
|
||||||
{{range .D.Tabs}}
|
{{range .D.Tabs}}
|
||||||
<a class="{{if eq .ID $.D.PkgID}}on{{end}}" style="--tab:{{.Color}}"
|
<a class="{{if .Active}}on{{end}}" style="--tab:{{.Color}}" href="{{.Href}}">{{.Name}}</a>
|
||||||
href="/screen?pkg={{.ID}}&heat={{$.D.HeatMode}}">{{.Name}}</a>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<div class="scr-tools">
|
<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="切换热力统计口径">
|
title="切换热力统计口径">
|
||||||
{{if eq .D.HeatMode "due"}}热力:到期{{else}}热力:待完成{{end}} ⇄
|
{{if eq .D.HeatMode "due"}}热力:到期{{else}}热力:待完成{{end}} ⇄
|
||||||
</a>
|
</a>
|
||||||
@@ -57,7 +56,7 @@
|
|||||||
{{if .Strip}}<span class="asg-strip sm" aria-hidden="true">{{range .Strip}}<i style="background:{{.}}"></i>{{end}}</span>{{end}}
|
{{if .Strip}}<span class="asg-strip sm" aria-hidden="true">{{range .Strip}}<i style="background:{{.}}"></i>{{end}}</span>{{end}}
|
||||||
<span>{{.Day}}</span>
|
<span>{{.Day}}</span>
|
||||||
{{if gt .Urgent 0}}<u class="cell-urgent"></u>{{end}}
|
{{if gt .Urgent 0}}<u class="cell-urgent"></u>{{end}}
|
||||||
{{if .Count}}<b>{{.Count}}</b>{{end}}
|
{{if .Count}}<b>{{.Done}}/{{.Count}}</b>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import (
|
|||||||
"daili/internal/web"
|
"daili/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "1.6.1"
|
const version = "1.7.0"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")")
|
cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")")
|
||||||
|
|||||||
Reference in New Issue
Block a user