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
272 lines
7.5 KiB
Go
272 lines
7.5 KiB
Go
package web
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"daili/internal/domain"
|
||
"daili/internal/store"
|
||
)
|
||
|
||
type packagesData struct {
|
||
List []store.Package
|
||
}
|
||
|
||
func (s *Server) handlePackages(w http.ResponseWriter, r *http.Request) {
|
||
list, err := s.db.ListPackages(true)
|
||
if err != nil {
|
||
http.Error(w, "读取套餐失败", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
p := s.newPage(r, "套餐", "packages")
|
||
p.D = &packagesData{List: list}
|
||
s.render(w, r, "packages", p)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
func readPackageForm(r *http.Request) *store.Package {
|
||
p := &store.Package{
|
||
Name: strings.TrimSpace(r.PostFormValue("name")),
|
||
Unit: strings.TrimSpace(r.PostFormValue("unit")),
|
||
DefaultAmount: 0,
|
||
Color: strings.TrimSpace(r.PostFormValue("color")),
|
||
Note: strings.TrimSpace(r.PostFormValue("note")),
|
||
SortOrder: 0,
|
||
}
|
||
if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("default_amount"))); err == nil {
|
||
p.DefaultAmount = v
|
||
}
|
||
if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("sort_order"))); err == nil {
|
||
p.SortOrder = v
|
||
}
|
||
if price := strings.TrimSpace(r.PostFormValue("price")); price != "" {
|
||
if f, err := strconv.ParseFloat(price, 64); err == nil {
|
||
p.PriceCents = int(f*100 + 0.5)
|
||
p.HasPrice = true
|
||
}
|
||
}
|
||
if p.Unit == "" {
|
||
p.Unit = domain.UnitDay
|
||
}
|
||
p.CalendarSeparate = r.PostFormValue("calendar_separate") != ""
|
||
p.Tag = strings.TrimSpace(r.PostFormValue("tag"))
|
||
return p
|
||
}
|
||
|
||
func validatePackage(p *store.Package) string {
|
||
if p.Name == "" {
|
||
return "套餐名称不能为空"
|
||
}
|
||
if len([]rune(p.Name)) > 32 {
|
||
return "套餐名称最长 32 字"
|
||
}
|
||
if p.Unit != domain.UnitDay && p.Unit != domain.UnitCount {
|
||
return "套餐类型不正确"
|
||
}
|
||
if p.DefaultAmount <= 0 {
|
||
return "默认天数/次数必须大于 0"
|
||
}
|
||
if p.Color == "" || !strings.HasPrefix(p.Color, "#") {
|
||
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) {
|
||
pkg := readPackageForm(r)
|
||
p := s.newPage(r, "新增套餐", "packages")
|
||
if msg := validatePackage(pkg); msg != "" {
|
||
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 {
|
||
tags, _ := s.db.PackageTags()
|
||
p.D = &packageFormData{Package: pkg, IsNew: true, Tags: tags}
|
||
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
|
||
return
|
||
}
|
||
s.logOp(r, "新增套餐", "package", id, "新增套餐 "+pkg.Name)
|
||
s.redirect(w, r, "/packages", "套餐已创建,现在可以给客户开通了")
|
||
}
|
||
|
||
func (s *Server) handlePackageEditPage(w http.ResponseWriter, r *http.Request) {
|
||
id := pathID(r)
|
||
pkg, err := s.db.Package(id)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
p := s.newPage(r, "编辑套餐", "packages")
|
||
tags, _ := s.db.PackageTags()
|
||
p.D = &packageFormData{Package: pkg, IsNew: false, Tags: tags}
|
||
s.render(w, r, "package_form", p)
|
||
}
|
||
|
||
func (s *Server) handlePackageUpdate(w http.ResponseWriter, r *http.Request) {
|
||
id := pathID(r)
|
||
pkg := readPackageForm(r)
|
||
pkg.ID = id
|
||
p := s.newPage(r, "编辑套餐", "packages")
|
||
if msg := validatePackage(pkg); msg != "" {
|
||
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 {
|
||
tags, _ := s.db.PackageTags()
|
||
p.D = &packageFormData{Package: pkg, IsNew: false, Tags: tags}
|
||
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
|
||
return
|
||
}
|
||
s.logOp(r, "编辑套餐", "package", id, "修改套餐 "+pkg.Name)
|
||
s.redirect(w, r, "/packages", "套餐已保存")
|
||
}
|
||
|
||
func (s *Server) handlePackageArchive(w http.ResponseWriter, r *http.Request) {
|
||
id := pathID(r)
|
||
pkg, err := s.db.Package(id)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
archived := !pkg.Archived
|
||
if err := s.db.SetPackageArchived(id, archived); err != nil {
|
||
s.redirect(w, r, "/packages", "操作失败:"+err.Error())
|
||
return
|
||
}
|
||
action := "归档套餐"
|
||
msg := "套餐已归档,不再允许新订阅(已有订阅不受影响)"
|
||
if !archived {
|
||
action = "取消归档"
|
||
msg = "套餐已恢复"
|
||
}
|
||
s.logOp(r, action, "package", id, action+" "+pkg.Name)
|
||
s.redirect(w, r, "/packages", msg)
|
||
}
|