diff --git a/README.md b/README.md index 8b6cad9..eb4e6e9 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,15 @@ --- +## 4.3 包赛季与按次有效期 + +- **包赛季**:套餐计费方式新增第三种「包赛季」,套餐上直接填赛季开始日/结束日。客户开通后,日历上用一条横向色带标出整段赛季;赛季内任意一天点「完成」即整段完成,完成后不再出现在日历与待打,客户详情显示「赛季已完成 + 日期」。 +- **赛季过期不可选**:今天已过赛季结束日的套餐,在客户开通下拉里会置灰并标「赛季已结束」,服务端也会拒绝开通。 +- **按次默认有效期**:按次套餐可设「默认有效期(天)」,开单时自动算出到期日(开始日 + 天数 − 1),仍可手动改。 +- **删除订阅**:已结束的订阅卡片上,在「重新启用」旁新增「删除」,二次确认后彻底删除该订阅及其打卡/追加记录。 + +--- + ## 4.2 套餐标签与批量开通 - **套餐标签**:套餐编辑页可以填一个标签(如「包月」)。日历/大屏顶部页签会**按标签合并**:同标签的套餐合成一个页签;勾了「在日历中独立显示」的套餐**额外**再开一个自己的页签。 @@ -175,7 +184,7 @@ - **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。 - **重置**:关闭程序 → 删除 `daili.db` → 重新打开,会回到「创建第一个管理员」的全新状态。 -- 套餐标签是 `packages.tag`;客户停用是 `customers.archived`(0/1);打手颜色存在 `users.color`,客户归属打手是 `customers.assignee_id`;加急标记存在 `daili.db` 的 `task_flags` 表;套餐的「独立日历」是 `packages.calendar_separate` 列,老版本数据库打开时会**自动加列**,不用手工迁移; +- 包赛季是 `packages.season/season_start/season_end` 与 `subscriptions.season/season_done`;套餐标签是 `packages.tag`;客户停用是 `customers.archived`(0/1);打手颜色存在 `users.color`,客户归属打手是 `customers.assignee_id`;加急标记存在 `daili.db` 的 `task_flags` 表;套餐的「独立日历」是 `packages.calendar_separate` 列,老版本数据库打开时会**自动加列**,不用手工迁移; - `daili.ini`(监听/备份/日志配置)与 `backup/`、`logs/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。 - 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库; diff --git a/internal/store/db.go b/internal/store/db.go index af8dad0..f3f60d7 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -139,6 +139,31 @@ func (db *DB) Migrate() error { "ALTER TABLE packages ADD COLUMN tag TEXT NOT NULL DEFAULT ''"); err != nil { return err } + // v1.8:包赛季套餐 + 按次默认有效期 + if err := db.ensureColumn("packages", "season", + "ALTER TABLE packages ADD COLUMN season INTEGER NOT NULL DEFAULT 0"); err != nil { + return err + } + if err := db.ensureColumn("packages", "default_validity_days", + "ALTER TABLE packages ADD COLUMN default_validity_days INTEGER NOT NULL DEFAULT 0"); err != nil { + return err + } + if err := db.ensureColumn("packages", "season_start", + "ALTER TABLE packages ADD COLUMN season_start TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + if err := db.ensureColumn("packages", "season_end", + "ALTER TABLE packages ADD COLUMN season_end TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + if err := db.ensureColumn("subscriptions", "season", + "ALTER TABLE subscriptions ADD COLUMN season INTEGER NOT NULL DEFAULT 0"); err != nil { + return err + } + if err := db.ensureColumn("subscriptions", "season_done", + "ALTER TABLE subscriptions ADD COLUMN season_done TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } return nil } @@ -202,6 +227,10 @@ var schemaStatements = []string{ sort_order INTEGER NOT NULL DEFAULT 0, calendar_separate INTEGER NOT NULL DEFAULT 0, tag TEXT NOT NULL DEFAULT '', + season INTEGER NOT NULL DEFAULT 0, + default_validity_days INTEGER NOT NULL DEFAULT 0, + season_start TEXT NOT NULL DEFAULT '', + season_end TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, @@ -231,6 +260,8 @@ var schemaStatements = []string{ end_date TEXT, total_count INTEGER NOT NULL DEFAULT 0, used_count INTEGER NOT NULL DEFAULT 0, + season INTEGER NOT NULL DEFAULT 0, + season_done TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '', created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL, diff --git a/internal/store/packages.go b/internal/store/packages.go index d2bb1d0..26ed56a 100644 --- a/internal/store/packages.go +++ b/internal/store/packages.go @@ -23,7 +23,14 @@ type Package struct { // CalendarSeparate 勾选后在工作台/大屏日历中单独成页 CalendarSeparate bool // Tag 套餐标签:相同标签的套餐在日历里合并显示 - Tag string + Tag string + // Season 包赛季模式:开始~结束之间任意一天可整段完成 + Season bool + // DefaultValidityDays 按次套餐默认有效期(天),0 不限 + DefaultValidityDays int + // 包赛季的固定区间(套餐级) + SeasonStart string + SeasonEnd string CreatedAt string UpdatedAt string SubCount int @@ -32,6 +39,9 @@ type Package struct { // UnitLabel 返回类型中文名。 func (p *Package) UnitLabel() string { + if p.Season { + return "包赛季" + } if p.Unit == domain.UnitCount { return "按次" } @@ -41,9 +51,9 @@ func (p *Package) UnitLabel() string { func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) { var p Package var price sql.NullInt64 - var archived, separate int + var archived, separate, season, validity int err := row.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note, - &archived, &p.SortOrder, &separate, &p.Tag, &p.CreatedAt, &p.UpdatedAt) + &archived, &p.SortOrder, &separate, &p.Tag, &season, &validity, &p.SeasonStart, &p.SeasonEnd, &p.CreatedAt, &p.UpdatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound @@ -52,6 +62,8 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) { } p.Archived = archived == 1 p.CalendarSeparate = separate == 1 + p.Season = season == 1 + p.DefaultValidityDays = validity if price.Valid { p.PriceCents = int(price.Int64) p.HasPrice = true @@ -61,7 +73,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.tag,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.season,p.default_validity_days,p.season_start,p.season_end,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,13 +90,15 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) { for rows.Next() { var p Package var price sql.NullInt64 - var archived, separate int + var archived, separate, season, validity int if err := rows.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note, - &archived, &p.SortOrder, &separate, &p.Tag, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil { + &archived, &p.SortOrder, &separate, &p.Tag, &season, &validity, &p.SeasonStart, &p.SeasonEnd, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil { return nil, err } p.Archived = archived == 1 p.CalendarSeparate = separate == 1 + p.Season = season == 1 + p.DefaultValidityDays = validity if price.Valid { p.PriceCents = int(price.Int64) p.HasPrice = true @@ -96,7 +110,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,tag,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,season,default_validity_days,season_start,season_end,created_at,updated_at FROM packages WHERE id=?`, id)) } @@ -114,9 +128,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,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) + res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,season,default_validity_days,season_start,season_end,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), b2i(p.Season), p.DefaultValidityDays, p.SeasonStart, p.SeasonEnd, now, now) if err != nil { return 0, err } @@ -136,8 +150,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=?,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) + _, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,calendar_separate=?,tag=?,season=?,default_validity_days=?,season_start=?,season_end=?,updated_at=? + WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), b2i(p.Season), p.DefaultValidityDays, p.SeasonStart, p.SeasonEnd, nowStamp(), p.ID) return err } @@ -194,3 +208,11 @@ func (db *DB) PackageTags() ([]string, error) { } return out, rows.Err() } + +// SeasonExpired 判断包赛季套餐在 today 是否已结束。 +func (p Package) SeasonExpired(today string) bool { + if !p.Season || p.SeasonEnd == "" { + return false + } + return today > p.SeasonEnd +} diff --git a/internal/store/subscriptions.go b/internal/store/subscriptions.go index f39ad2e..7bfa03b 100644 --- a/internal/store/subscriptions.go +++ b/internal/store/subscriptions.go @@ -26,6 +26,9 @@ type Subscription struct { CreatedAt string UpdatedAt string ClosedAt string + // Season 包赛季;SeasonDone 标记整段完成的日期(空=未完成) + Season bool + SeasonDone string // 联表 PackageName string @@ -98,6 +101,7 @@ func (a *Adjustment) KindLabel() string { const subSelect = `SELECT s.id,s.customer_id,s.package_id,s.unit,s.status,s.start_date,IFNULL(s.end_date,''), s.total_count,s.used_count,s.note,IFNULL(s.created_by,0),s.created_at,s.updated_at,IFNULL(s.closed_at,''), + IFNULL(s.season,0),IFNULL(s.season_done,''), p.name,p.color,c.tail,c.name,IFNULL(c.contact,''), IFNULL(c.assignee_id,0), IFNULL((SELECT u.display_name FROM users u WHERE u.id=c.assignee_id),''), @@ -108,8 +112,10 @@ const subSelect = `SELECT s.id,s.customer_id,s.package_id,s.unit,s.status,s.star func scanSub(row interface{ Scan(dest ...any) error }) (*Subscription, error) { var s Subscription + var seasonInt int err := row.Scan(&s.ID, &s.CustomerID, &s.PackageID, &s.Unit, &s.Status, &s.StartDate, &s.EndDate, &s.TotalCount, &s.UsedCount, &s.Note, &s.CreatedBy, &s.CreatedAt, &s.UpdatedAt, &s.ClosedAt, + &seasonInt, &s.SeasonDone, &s.PackageName, &s.PackageColor, &s.CustomerTail, &s.CustomerName, &s.CustomerContact, &s.AssigneeID, &s.AssigneeName, &s.AssigneeColor) if err != nil { @@ -118,6 +124,7 @@ func scanSub(row interface{ Scan(dest ...any) error }) (*Subscription, error) { } return nil, err } + s.Season = seasonInt == 1 return &s, nil } @@ -263,6 +270,9 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) { if pkg.Archived { return nil, errors.New("该套餐已归档,不能新建订阅") } + if pkg.Season && strings.TrimSpace(in.StartDate) == "" { + in.StartDate = pkg.SeasonStart + } start, err := domain.ParseDate(in.StartDate) if err != nil { return nil, errors.New("开始日期格式不正确") @@ -309,8 +319,28 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) { return nil, errors.New("有效期不能早于开始日期") } endDate = domain.Date(end) + } else if pkg.DefaultValidityDays > 0 { + endDate = domain.Date(domain.AddDays(start, pkg.DefaultValidityDays-1)) } } + if pkg.Season { + if pkg.SeasonStart == "" || pkg.SeasonEnd == "" { + return nil, errors.New("该赛季套餐未设置起止日期") + } + end, err := domain.ParseDate(pkg.SeasonEnd) + if err != nil { + return nil, errors.New("赛季结束日期不正确") + } + if domain.DayDiff(domain.Today(), end) < 0 { + return nil, errors.New("该赛季已结束,不能开通") + } + if domain.DayDiff(start, end) < 0 { + return nil, errors.New("赛季结束日期早于开始日期") + } + endDate = domain.Date(end) + unit = domain.UnitDay + days = domain.DayDiff(start, end) + 1 + } now := nowStamp() var id int64 @@ -324,9 +354,9 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) { if !errors.Is(err, sql.ErrNoRows) { return err } - res, err := tx.Exec(`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,total_count,used_count,note,created_by,created_at,updated_at) - VALUES(?,?,?,'active',?,?,?,0,?,?,?,?)`, - in.CustomerID, in.PackageID, unit, domain.Date(start), nullIfEmpty(endDate), total, in.Note, nullIfZero(in.OperatorID), now, now) + res, err := tx.Exec(`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,total_count,used_count,note,created_by,created_at,updated_at,season) + VALUES(?,?,?,'active',?,?,?,0,?,?,?,?,?)`, + in.CustomerID, in.PackageID, unit, domain.Date(start), nullIfEmpty(endDate), total, in.Note, nullIfZero(in.OperatorID), now, now, b2i(pkg.Season)) if err != nil { if isUnique(err) { return errors.New("该客户已订阅此套餐,请改用「追加时长」") @@ -605,3 +635,29 @@ func (db *DB) CountActiveSubs(today string) (int, error) { func (s *Subscription) SubTitle() string { return fmt.Sprintf("%s %s · %s", s.CustomerTail, s.CustomerName, s.PackageName) } + +// SeasonComplete 把包赛季订阅整段标记为已完成。 +func (db *DB) SeasonComplete(subID int64, date string) error { + sub, err := db.Sub(subID) + if err != nil { + return err + } + if !sub.Season { + return errors.New("不是包赛季订阅") + } + _, err = db.sql.Exec(`UPDATE subscriptions SET season_done=?, updated_at=? WHERE id=?`, + date, nowStamp(), subID) + return err +} + +// DeleteSubscription 彻底删除订阅;打卡、暂停、追加、加急随外键级联删除。 +func (db *DB) DeleteSubscription(subID int64) error { + res, err := db.sql.Exec(`DELETE FROM subscriptions WHERE id=?`, subID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/web/handlers_calendar.go b/internal/web/handlers_calendar.go index 5b6bc44..6668658 100644 --- a/internal/web/handlers_calendar.go +++ b/internal/web/handlers_calendar.go @@ -26,6 +26,11 @@ type DayCell struct { DoneCount int UrgentCnt int Heat int + // 赛季色带 + HasSeason bool + SeasonColor string + SeasonStart bool + SeasonEnd bool // PendingCount 当日还没完成的单数(= TaskCount - DoneCount) PendingCount int Tickets []Ticket @@ -136,6 +141,12 @@ func (c calSub) covers(date string) bool { if c.sub.Status == domain.StatusClosed || !c.hasEnd { return false } + if c.sub.Season { + if c.sub.SeasonDone != "" { + return false + } + return date >= c.sub.StartDate && date <= c.sub.EndDate + } if date < c.sub.StartDate || date > c.sub.EndDate { return false } @@ -363,6 +374,16 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { cell.DoneCount++ } tickets = append(tickets, t) + if cs.sub.Season && cs.sub.SeasonDone == "" { + cell.HasSeason = true + cell.SeasonColor = cs.sub.PackageColor + if ds == cs.sub.StartDate { + cell.SeasonStart = true + } + if ds == cs.sub.EndDate { + cell.SeasonEnd = true + } + } } sort.SliceStable(tickets, func(i, j int) bool { if tickets[i].Urgent != tickets[j].Urgent { diff --git a/internal/web/handlers_packages.go b/internal/web/handlers_packages.go index cc83023..e1fa50b 100644 --- a/internal/web/handlers_packages.go +++ b/internal/web/handlers_packages.go @@ -68,10 +68,27 @@ func readPackageForm(r *http.Request) *store.Package { } p.CalendarSeparate = r.PostFormValue("calendar_separate") != "" p.Tag = strings.TrimSpace(r.PostFormValue("tag")) + if p.Unit == "season" { + p.Season = true + p.Unit = domain.UnitDay + } + if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("default_validity_days"))); err == nil { + p.DefaultValidityDays = v + } + p.SeasonStart = strings.TrimSpace(r.PostFormValue("season_start")) + p.SeasonEnd = strings.TrimSpace(r.PostFormValue("season_end")) return p } func validatePackage(p *store.Package) string { + if p.Season { + if p.SeasonStart == "" || p.SeasonEnd == "" { + return "包赛季套餐必须填写赛季开始日和结束日" + } + if p.SeasonEnd < p.SeasonStart { + return "赛季结束日不能早于开始日" + } + } if p.Name == "" { return "套餐名称不能为空" } diff --git a/internal/web/handlers_subs.go b/internal/web/handlers_subs.go index dd8dc4d..eb6e6a8 100644 --- a/internal/web/handlers_subs.go +++ b/internal/web/handlers_subs.go @@ -160,6 +160,19 @@ func (s *Server) handleCheckinSave(w http.ResponseWriter, r *http.Request) { if u != nil { opID = u.ID } + if sub.Season { + if status == "skipped" { + s.redirect(w, r, back, "包赛季不能跳过,请直接完成或结束") + return + } + if err := s.db.SeasonComplete(id, date); err != nil { + s.redirect(w, r, back, "完成赛季失败:"+err.Error()) + return + } + s.logOp(r, "赛季完成", "subscription", id, fmt.Sprintf("%s 赛季整段完成(%s)", sub.SubTitle(), date)) + s.redirect(w, r, back, "已把 "+sub.CustomerTail+" "+sub.CustomerName+" 的赛季标记为完成") + return + } if err := s.db.SaveCheckin(id, date, status, note, opID); err != nil { s.redirect(w, r, back, "打卡失败:"+err.Error()) return @@ -183,3 +196,43 @@ func (s *Server) handleCheckinDelete(w http.ResponseWriter, r *http.Request) { s.logOp(r, "撤销打卡", "subscription", subID, "撤销打卡记录 #"+itoa(id)) s.redirect(w, r, back, "已撤销打卡") } + +// handleSubDelete 彻底删除订阅。 +func (s *Server) handleSubDelete(w http.ResponseWriter, r *http.Request) { + id := pathID(r) + sub, err := s.db.Sub(id) + if err != nil { + http.NotFound(w, r) + return + } + if r.PostFormValue("confirm") != "delete" { + s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "请在弹窗里确认删除") + return + } + if err := s.db.DeleteSubscription(id); err != nil { + s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "删除失败:"+err.Error()) + return + } + s.logOp(r, "删除订阅", "subscription", id, sub.SubTitle()+"(含打卡与追加记录)") + s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "已删除订阅「"+sub.PackageName+"」") +} + +// handleSeasonDone 从订阅卡片直接完成整段赛季。 +func (s *Server) handleSeasonDone(w http.ResponseWriter, r *http.Request) { + id := pathID(r) + sub, err := s.db.Sub(id) + if err != nil { + http.NotFound(w, r) + return + } + date := strings.TrimSpace(r.PostFormValue("date")) + if _, err := domain.ParseDate(date); err != nil { + date = domain.Date(domain.Today()) + } + if err := s.db.SeasonComplete(id, date); err != nil { + s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "完成失败:"+err.Error()) + return + } + s.logOp(r, "赛季完成", "subscription", id, sub.SubTitle()+" 整段完成("+date+")") + s.redirect(w, r, "/customers/"+itoa(sub.CustomerID), "已把赛季标记为完成") +} diff --git a/internal/web/server.go b/internal/web/server.go index 0d407df..d534f7a 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -115,6 +115,8 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("POST /subscriptions/{id}/resume", s.auth(s.handleSubAction("resume"))) mux.HandleFunc("POST /subscriptions/{id}/close", s.auth(s.handleSubAction("close"))) mux.HandleFunc("POST /subscriptions/{id}/reopen", s.auth(s.handleSubAction("reopen"))) + mux.HandleFunc("POST /subscriptions/{id}/delete", s.auth(s.handleSubDelete)) + mux.HandleFunc("POST /subscriptions/{id}/season-done", s.auth(s.handleSeasonDone)) mux.HandleFunc("POST /subscriptions/{id}/checkin", s.auth(s.handleCheckinSave)) mux.HandleFunc("POST /checkins/{id}/delete", s.auth(s.handleCheckinDelete)) mux.HandleFunc("POST /checkins/batch", s.auth(s.handleCheckinBatch)) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index d382734..bddb53b 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -1093,3 +1093,19 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } .keypad-toggle.on { background: var(--accent); border-color: var(--accent); color: var(--paper); } + + +/* ---------- v1.8:包赛季色带 ---------- */ + +.cal-cell { padding-bottom: 10px; } +.season-band { + position: absolute; + left: 0; right: 0; bottom: 2px; + height: 5px; + background: var(--sc, var(--accent)); + opacity: 0.55; + pointer-events: none; +} +.season-band.start { border-top-left-radius: 3px; border-bottom-left-radius: 3px; left: 2px; } +.season-band.end { border-top-right-radius: 3px; border-bottom-right-radius: 3px; right: 2px; } +.cal-cell.has-due .season-band { bottom: 4px; } diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 7454160..d6d23fc 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -79,6 +79,10 @@ if (daysWrap) daysWrap.hidden = true; if (countWrap) { countWrap.hidden = false; if (amount) countWrap.querySelector('input').value = amount; } if (endWrap) endWrap.hidden = true; + } else if (unit === 'season') { + if (daysWrap) daysWrap.hidden = true; + if (countWrap) countWrap.hidden = true; + if (endWrap) endWrap.hidden = true; } else if (unit === 'day') { if (daysWrap) { daysWrap.hidden = false; if (amount) daysWrap.querySelector('input').value = amount; } if (countWrap) countWrap.hidden = true; @@ -101,6 +105,13 @@ var v = 'day'; radios.forEach(function (r) { if (r.checked) v = r.value; }); if (label) label.textContent = v === 'count' ? '次' : '天'; + var seasonWrap = form.querySelector('[data-season-wrap]'); + var validityWrap = form.querySelector('[data-validity-wrap]'); + if (seasonWrap) { + seasonWrap.hidden = v !== 'season'; + seasonWrap.querySelectorAll('input').forEach(function (i) { i.required = v === 'season'; }); + } + if (validityWrap) { validityWrap.hidden = v !== 'count'; } } radios.forEach(function (r) { r.addEventListener('change', sync); }); sync(); diff --git a/internal/web/templates/_partials.html b/internal/web/templates/_partials.html index 370f5d4..1961daf 100644 --- a/internal/web/templates/_partials.html +++ b/internal/web/templates/_partials.html @@ -41,7 +41,18 @@ {{end}} + {{if .Season}} + {{if eq .SeasonDone ""}} +
+ {{else}} + 赛季已完成 {{.SeasonDone}} + {{end}} + {{else}} {{if eq .Unit "day"}}追加时长{{else}}追加次数{{end}} + {{end}} {{if eq .Status "active"}} + {{end}} diff --git a/internal/web/templates/pages/calendar.html b/internal/web/templates/pages/calendar.html index e6739b6..ecee533 100644 --- a/internal/web/templates/pages/calendar.html +++ b/internal/web/templates/pages/calendar.html @@ -42,6 +42,7 @@ {{range .}} + {{if .HasSeason}}{{end}} {{if .Strip}}{{end}}