feat(v1.8): 包赛季模式、按次默认有效期、订阅删除
1) 包赛季(第三种计费方式) - 套餐表单新增「包赛季」,套餐上直接填赛季开始日/结束日(必填,结束日不能早于开始日) - 客户开通时自动带出赛季起止,不用再手填日期 - 日历上用横向色带标出整段赛季;赛季内任意一天点「完成」即整段完成 - 完成后不再进日历/待打,客户详情显示「赛季已完成 + 完成日期」 - 已过赛季结束日的套餐在客户开通下拉里置灰标「赛季已结束」,服务端同样拒绝开通 2) 按次默认有效期 - 套餐新增「按次默认有效期(天)」,开单时自动算出到期日(开始日 + 天数 − 1),仍可改 3) 订阅删除 - 已结束的订阅卡片在「重新启用」旁新增「删除」,二次确认后彻底删除该订阅及其打卡/追加/加急记录 4) 客户月历、日历与大屏同步识别赛季(色带、完成状态) 数据库兼容性: - 仅新增列:packages.season / default_validity_days / season_start / season_end,subscriptions.season / season_done - 没有改动原有 unit 的 CHECK 约束、没有重建表;老库启动自动 ALTER,可重复迁移 测试: - 功能验证 13 项:赛季套餐创建、过期套餐不可选、开通带出日期、日历色带、完成整段后隐藏、按次自动到期、删除订阅 - 119 项验收回归全绿;go test ./... 通过;无头 Chrome 实拍 3 张(日历色带 / 客户赛季卡片 / 套餐表单) - 版本号 1.7.0 → 1.8.0
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 "套餐名称不能为空"
|
||||
}
|
||||
|
||||
@@ -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), "已把赛季标记为完成")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -41,7 +41,18 @@
|
||||
<button class="btn btn-sm">今日打卡</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .Season}}
|
||||
{{if eq .SeasonDone ""}}
|
||||
<form method="post" action="/subscriptions/{{.ID}}/season-done" data-confirm="标记后整段赛季完成,确认?">
|
||||
<input type="hidden" name="date" value="{{.Today}}">
|
||||
<button class="btn btn-primary btn-sm">完成赛季</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<span class="chip chip-active">赛季已完成 {{.SeasonDone}}</span>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<a class="btn btn-primary btn-sm" href="?extend={{.ID}}#extend">{{if eq .Unit "day"}}追加时长{{else}}追加次数{{end}}</a>
|
||||
{{end}}
|
||||
{{if eq .Status "active"}}
|
||||
<form method="post" action="/subscriptions/{{.ID}}/pause" data-confirm="暂停后剩余时长冻结,确认暂停?">
|
||||
<button class="btn btn-sm">暂停</button>
|
||||
@@ -58,6 +69,10 @@
|
||||
<form method="post" action="/subscriptions/{{.ID}}/reopen">
|
||||
<button class="btn btn-sm">重新启用</button>
|
||||
</form>
|
||||
<form method="post" action="/subscriptions/{{.ID}}/delete" data-confirm="将永久删除该订阅及其打卡/追加记录,无法恢复。确定删除?">
|
||||
<input type="hidden" name="confirm" value="delete">
|
||||
<button class="btn btn-sm btn-ghost" style="color:var(--danger)">删除</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
{{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}}&{{$.D.FilterQuery}}" role="gridcell">
|
||||
{{if .HasSeason}}<span class="season-band{{if .SeasonStart}} start{{end}}{{if .SeasonEnd}} end{{end}}" style="--sc:{{.SeasonColor}}" aria-hidden="true"></span>{{end}}
|
||||
{{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>
|
||||
|
||||
@@ -89,8 +89,8 @@
|
||||
<select name="package_id" required data-pkg-select>
|
||||
<option value="">选择套餐…</option>
|
||||
{{range .D.Packages}}
|
||||
<option value="{{.ID}}" data-unit="{{.Unit}}" data-amount="{{.DefaultAmount}}"{{if .Archived}} disabled{{end}}>
|
||||
{{.Name}} · {{.UnitLabel}} {{.DefaultAmount}}{{if eq .Unit "day"}}天{{else}}次{{end}}{{if .Archived}}(已归档){{end}}
|
||||
<option value="{{.ID}}" data-unit="{{if .Season}}season{{else}}{{.Unit}}{{end}}" data-amount="{{.DefaultAmount}}"{{if .Archived}} disabled{{end}}{{if .SeasonExpired $.TodayStr}} disabled{{end}}>
|
||||
{{.Name}} · {{.UnitLabel}} {{if .Season}}{{.SeasonStart}}~{{.SeasonEnd}}{{else}}{{.DefaultAmount}}{{if eq .Unit "day"}}天{{else}}次{{end}}{{end}}{{if .SeasonExpired $.TodayStr}}(赛季已结束){{end}}{{if .Archived}}(已归档){{end}}
|
||||
</option>
|
||||
{{end}}
|
||||
</select>
|
||||
|
||||
@@ -18,11 +18,23 @@
|
||||
<div class="radios">
|
||||
<label><input type="radio" name="unit" value="day"{{if eq .D.Package.Unit "day"}} checked{{end}}>
|
||||
<b>按日</b><small>有开始日与结束日,每天一条待打任务,可打卡</small></label>
|
||||
<label><input type="radio" name="unit" value="count"{{if eq .D.Package.Unit "count"}} checked{{end}}>
|
||||
<label><input type="radio" name="unit" value="count"{{if and (eq .D.Package.Unit "count") (not .D.Package.Season)}} checked{{end}}>
|
||||
<b>按次</b><small>记录剩余次数,排期完成时自动扣 1 次,有效期可选</small></label>
|
||||
<label><input type="radio" name="unit" value="season"{{if .D.Package.Season}} checked{{end}}>
|
||||
<b>包赛季</b><small>整段赛季一次完成;赛季结束日过后不能再开通</small></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="grid-2" data-season-wrap hidden>
|
||||
<label class="field"><span>赛季开始日 <i>*</i></span><input type="date" name="season_start" value="{{.D.Package.SeasonStart}}"></label>
|
||||
<label class="field"><span>赛季结束日 <i>*</i></span><input type="date" name="season_end" value="{{.D.Package.SeasonEnd}}"></label>
|
||||
</div>
|
||||
<div class="grid-2" data-validity-wrap hidden>
|
||||
<label class="field"><span>按次默认有效期(天,0=不限)</span>
|
||||
<input type="number" name="default_validity_days" min="0" max="3650" value="{{.D.Package.DefaultValidityDays}}">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<label class="field"><span>默认量 <i>*</i></span>
|
||||
<div class="input-suffix">
|
||||
|
||||
@@ -78,6 +78,20 @@ func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
|
||||
StateClass: class,
|
||||
Today: domain.Date(today),
|
||||
}
|
||||
if s.Season {
|
||||
v.UnitLabel = "赛季"
|
||||
if s.SeasonDone != "" {
|
||||
v.RemainText = "赛季已完成"
|
||||
v.RemainShort = "已完成"
|
||||
v.StateLabel = "赛季已完成"
|
||||
v.StateClass = "active"
|
||||
} else {
|
||||
v.RemainText = fmt.Sprintf("剩 %d 天", e.RemainingDays)
|
||||
v.RemainShort = fmt.Sprintf("%d 天", e.RemainingDays)
|
||||
}
|
||||
v.PeriodText = s.StartDate + " → " + s.EndDate
|
||||
return v
|
||||
}
|
||||
if s.Unit == domain.UnitDay {
|
||||
v.UnitLabel = "按日"
|
||||
v.RemainText = fmt.Sprintf("剩 %d 天", e.RemainingDays)
|
||||
|
||||
Reference in New Issue
Block a user