feat(v1.7): 计数改已完成/总数、客户月历、套餐标签合并、批量开通、触控模式、按次到期
build / build-and-test (push) Successful in 3m36s

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:
subtrack
2026-10-19 16:23:01 +08:00
parent fb771bc013
commit a2542e1135
19 changed files with 723 additions and 103 deletions
+6
View File
@@ -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
View File
@@ -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()
}
+36
View File
@@ -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)
}
}
}
+103 -31
View File
@@ -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
}
// 快速完成:按尾号列出候选
+104
View File
@@ -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,
+119 -5
View File
@@ -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
}
+51 -32
View File
@@ -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
+5 -3
View File
@@ -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, "没有选中任何项")
+2
View File
@@ -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)
+49
View File
@@ -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);
}
+55
View File
@@ -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(); }
}
});
});
})();
+12 -11
View File
@@ -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>
+3 -2
View File
@@ -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>
+3 -4
View File
@@ -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>