Compare commits
3
Commits
a2542e1135
...
bb923aab4d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb923aab4d | ||
|
|
bcce6bb830 | ||
|
|
a3fd817edc |
@@ -154,6 +154,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 4.3 包赛季与按次有效期
|
||||||
|
|
||||||
|
- **包赛季**:套餐计费方式新增第三种「包赛季」,套餐上直接填赛季开始日/结束日。客户开通后,日历上用一条横向色带标出整段赛季;赛季内任意一天点「完成」即整段完成,完成后不再出现在日历与待打,客户详情显示「赛季已完成 + 日期」。
|
||||||
|
- **赛季过期不可选**:今天已过赛季结束日的套餐,在客户开通下拉里会置灰并标「赛季已结束」,服务端也会拒绝开通。
|
||||||
|
- **按次默认有效期**:按次套餐可设「默认有效期(天)」,开单时自动算出到期日(开始日 + 天数 − 1),仍可手动改。
|
||||||
|
- **删除订阅**:已结束的订阅卡片上,在「重新启用」旁新增「删除」,二次确认后彻底删除该订阅及其打卡/追加记录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 4.2 套餐标签与批量开通
|
## 4.2 套餐标签与批量开通
|
||||||
|
|
||||||
- **套餐标签**:套餐编辑页可以填一个标签(如「包月」)。日历/大屏顶部页签会**按标签合并**:同标签的套餐合成一个页签;勾了「在日历中独立显示」的套餐**额外**再开一个自己的页签。
|
- **套餐标签**:套餐编辑页可以填一个标签(如「包月」)。日历/大屏顶部页签会**按标签合并**:同标签的套餐合成一个页签;勾了「在日历中独立显示」的套餐**额外**再开一个自己的页签。
|
||||||
@@ -175,7 +184,7 @@
|
|||||||
|
|
||||||
- **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。
|
- **迁移**:关闭程序 → 把 `daili.db` 复制到新电脑的新目录 → 放入 `daili.exe` 双击即可,账号、套餐、客户、订阅、追加记录、打卡、日志全部原样保留。
|
||||||
- **重置**:关闭程序 → 删除 `daili.db` → 重新打开,会回到「创建第一个管理员」的全新状态。
|
- **重置**:关闭程序 → 删除 `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/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。
|
- `daili.ini`(监听/备份/日志配置)与 `backup/`、`logs/` 都是**独立于数据**的,迁移时可以不拷,程序会按默认值重新生成。
|
||||||
|
|
||||||
- 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库;
|
- 手动备份:`设置 → 立即备份`(同样遵守保留份数),备份文件本身也是可直接打开的单文件库;
|
||||||
|
|||||||
@@ -139,6 +139,31 @@ func (db *DB) Migrate() error {
|
|||||||
"ALTER TABLE packages ADD COLUMN tag TEXT NOT NULL DEFAULT ''"); err != nil {
|
"ALTER TABLE packages ADD COLUMN tag TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +227,10 @@ var schemaStatements = []string{
|
|||||||
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 '',
|
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,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
)`,
|
)`,
|
||||||
@@ -231,6 +260,8 @@ var schemaStatements = []string{
|
|||||||
end_date TEXT,
|
end_date TEXT,
|
||||||
total_count INTEGER NOT NULL DEFAULT 0,
|
total_count INTEGER NOT NULL DEFAULT 0,
|
||||||
used_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 '',
|
note TEXT NOT NULL DEFAULT '',
|
||||||
created_by INTEGER REFERENCES users(id),
|
created_by INTEGER REFERENCES users(id),
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
|
|||||||
+33
-11
@@ -24,6 +24,13 @@ type Package struct {
|
|||||||
CalendarSeparate bool
|
CalendarSeparate bool
|
||||||
// Tag 套餐标签:相同标签的套餐在日历里合并显示
|
// Tag 套餐标签:相同标签的套餐在日历里合并显示
|
||||||
Tag string
|
Tag string
|
||||||
|
// Season 包赛季模式:开始~结束之间任意一天可整段完成
|
||||||
|
Season bool
|
||||||
|
// DefaultValidityDays 按次套餐默认有效期(天),0 不限
|
||||||
|
DefaultValidityDays int
|
||||||
|
// 包赛季的固定区间(套餐级)
|
||||||
|
SeasonStart string
|
||||||
|
SeasonEnd string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
SubCount int
|
SubCount int
|
||||||
@@ -32,6 +39,9 @@ type Package struct {
|
|||||||
|
|
||||||
// UnitLabel 返回类型中文名。
|
// UnitLabel 返回类型中文名。
|
||||||
func (p *Package) UnitLabel() string {
|
func (p *Package) UnitLabel() string {
|
||||||
|
if p.Season {
|
||||||
|
return "包赛季"
|
||||||
|
}
|
||||||
if p.Unit == domain.UnitCount {
|
if p.Unit == domain.UnitCount {
|
||||||
return "按次"
|
return "按次"
|
||||||
}
|
}
|
||||||
@@ -41,9 +51,9 @@ func (p *Package) UnitLabel() string {
|
|||||||
func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
|
func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
|
||||||
var p Package
|
var p Package
|
||||||
var price sql.NullInt64
|
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,
|
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 err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -52,6 +62,8 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
|
|||||||
}
|
}
|
||||||
p.Archived = archived == 1
|
p.Archived = archived == 1
|
||||||
p.CalendarSeparate = separate == 1
|
p.CalendarSeparate = separate == 1
|
||||||
|
p.Season = season == 1
|
||||||
|
p.DefaultValidityDays = validity
|
||||||
if price.Valid {
|
if price.Valid {
|
||||||
p.PriceCents = int(price.Int64)
|
p.PriceCents = int(price.Int64)
|
||||||
p.HasPrice = true
|
p.HasPrice = true
|
||||||
@@ -61,7 +73,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.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),
|
||||||
(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,13 +90,15 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p Package
|
var p Package
|
||||||
var price sql.NullInt64
|
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,
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
p.Archived = archived == 1
|
p.Archived = archived == 1
|
||||||
p.CalendarSeparate = separate == 1
|
p.CalendarSeparate = separate == 1
|
||||||
|
p.Season = season == 1
|
||||||
|
p.DefaultValidityDays = validity
|
||||||
if price.Valid {
|
if price.Valid {
|
||||||
p.PriceCents = int(price.Int64)
|
p.PriceCents = int(price.Int64)
|
||||||
p.HasPrice = true
|
p.HasPrice = true
|
||||||
@@ -96,7 +110,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,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))
|
FROM packages WHERE id=?`, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,9 +128,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,tag,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,season,default_validity_days,season_start,season_end,created_at,updated_at)
|
||||||
VALUES(?,?,?,?,?,?,0,?,?,?,?,?)`,
|
VALUES(?,?,?,?,?,?,0,?,?,?,?,?,?,?,?,?)`,
|
||||||
p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), now, now)
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -136,8 +150,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=?,tag=?,updated_at=?
|
_, 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), 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), b2i(p.Season), p.DefaultValidityDays, p.SeasonStart, p.SeasonEnd, nowStamp(), p.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,3 +208,11 @@ func (db *DB) PackageTags() ([]string, error) {
|
|||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SeasonExpired 判断包赛季套餐在 today 是否已结束。
|
||||||
|
func (p Package) SeasonExpired(today string) bool {
|
||||||
|
if !p.Season || p.SeasonEnd == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return today > p.SeasonEnd
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ type Subscription struct {
|
|||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
ClosedAt string
|
ClosedAt string
|
||||||
|
// Season 包赛季;SeasonDone 标记整段完成的日期(空=未完成)
|
||||||
|
Season bool
|
||||||
|
SeasonDone string
|
||||||
|
|
||||||
// 联表
|
// 联表
|
||||||
PackageName 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,''),
|
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,''),
|
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,''),
|
p.name,p.color,c.tail,c.name,IFNULL(c.contact,''),
|
||||||
IFNULL(c.assignee_id,0),
|
IFNULL(c.assignee_id,0),
|
||||||
IFNULL((SELECT u.display_name FROM users u WHERE u.id=c.assignee_id),''),
|
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) {
|
func scanSub(row interface{ Scan(dest ...any) error }) (*Subscription, error) {
|
||||||
var s Subscription
|
var s Subscription
|
||||||
|
var seasonInt int
|
||||||
err := row.Scan(&s.ID, &s.CustomerID, &s.PackageID, &s.Unit, &s.Status, &s.StartDate, &s.EndDate,
|
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,
|
&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.PackageName, &s.PackageColor, &s.CustomerTail, &s.CustomerName, &s.CustomerContact,
|
||||||
&s.AssigneeID, &s.AssigneeName, &s.AssigneeColor)
|
&s.AssigneeID, &s.AssigneeName, &s.AssigneeColor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -118,6 +124,7 @@ func scanSub(row interface{ Scan(dest ...any) error }) (*Subscription, error) {
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
s.Season = seasonInt == 1
|
||||||
return &s, nil
|
return &s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +270,9 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
|
|||||||
if pkg.Archived {
|
if pkg.Archived {
|
||||||
return nil, errors.New("该套餐已归档,不能新建订阅")
|
return nil, errors.New("该套餐已归档,不能新建订阅")
|
||||||
}
|
}
|
||||||
|
if pkg.Season && strings.TrimSpace(in.StartDate) == "" {
|
||||||
|
in.StartDate = pkg.SeasonStart
|
||||||
|
}
|
||||||
start, err := domain.ParseDate(in.StartDate)
|
start, err := domain.ParseDate(in.StartDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("开始日期格式不正确")
|
return nil, errors.New("开始日期格式不正确")
|
||||||
@@ -309,8 +319,28 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
|
|||||||
return nil, errors.New("有效期不能早于开始日期")
|
return nil, errors.New("有效期不能早于开始日期")
|
||||||
}
|
}
|
||||||
endDate = domain.Date(end)
|
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()
|
now := nowStamp()
|
||||||
var id int64
|
var id int64
|
||||||
@@ -324,9 +354,9 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
|
|||||||
if !errors.Is(err, sql.ErrNoRows) {
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
return err
|
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)
|
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,?,?,?,?)`,
|
VALUES(?,?,?,'active',?,?,?,0,?,?,?,?,?)`,
|
||||||
in.CustomerID, in.PackageID, unit, domain.Date(start), nullIfEmpty(endDate), total, in.Note, nullIfZero(in.OperatorID), now, now)
|
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 err != nil {
|
||||||
if isUnique(err) {
|
if isUnique(err) {
|
||||||
return errors.New("该客户已订阅此套餐,请改用「追加时长」")
|
return errors.New("该客户已订阅此套餐,请改用「追加时长」")
|
||||||
@@ -605,3 +635,29 @@ func (db *DB) CountActiveSubs(today string) (int, error) {
|
|||||||
func (s *Subscription) SubTitle() string {
|
func (s *Subscription) SubTitle() string {
|
||||||
return fmt.Sprintf("%s %s · %s", s.CustomerTail, s.CustomerName, s.PackageName)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ type DayCell struct {
|
|||||||
DoneCount int
|
DoneCount int
|
||||||
UrgentCnt int
|
UrgentCnt int
|
||||||
Heat int
|
Heat int
|
||||||
|
// 赛季色带
|
||||||
|
HasSeason bool
|
||||||
|
SeasonColor string
|
||||||
|
SeasonStart bool
|
||||||
|
SeasonEnd bool
|
||||||
// PendingCount 当日还没完成的单数(= TaskCount - DoneCount)
|
// PendingCount 当日还没完成的单数(= TaskCount - DoneCount)
|
||||||
PendingCount int
|
PendingCount int
|
||||||
Tickets []Ticket
|
Tickets []Ticket
|
||||||
@@ -136,6 +141,12 @@ func (c calSub) covers(date string) bool {
|
|||||||
if c.sub.Status == domain.StatusClosed || !c.hasEnd {
|
if c.sub.Status == domain.StatusClosed || !c.hasEnd {
|
||||||
return false
|
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 {
|
if date < c.sub.StartDate || date > c.sub.EndDate {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -363,6 +374,16 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
|||||||
cell.DoneCount++
|
cell.DoneCount++
|
||||||
}
|
}
|
||||||
tickets = append(tickets, t)
|
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 {
|
sort.SliceStable(tickets, func(i, j int) bool {
|
||||||
if tickets[i].Urgent != tickets[j].Urgent {
|
if tickets[i].Urgent != tickets[j].Urgent {
|
||||||
|
|||||||
@@ -323,12 +323,32 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
pauses, _ := s.db.PausesForSubs(ids)
|
pauses, _ := s.db.PausesForSubs(ids)
|
||||||
|
|
||||||
users, _ := s.db.ActiveUsers()
|
users, _ := s.db.ActiveUsers()
|
||||||
|
// 已开通的套餐不再出现在「开通订阅」下拉里,延期走追加
|
||||||
|
owned := map[int64]bool{}
|
||||||
|
for _, sub := range subs {
|
||||||
|
owned[sub.PackageID] = true
|
||||||
|
}
|
||||||
|
available := make([]store.Package, 0, len(pkgs))
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
if !owned[pkg.ID] {
|
||||||
|
available = append(available, pkg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sorted := append([]store.Subscription{}, subs...)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
ai := sorted[i].Status == domain.StatusClosed
|
||||||
|
aj := sorted[j].Status == domain.StatusClosed
|
||||||
|
if ai != aj {
|
||||||
|
return !ai
|
||||||
|
}
|
||||||
|
return sorted[i].ID < sorted[j].ID
|
||||||
|
})
|
||||||
data := &customerDetailData{
|
data := &customerDetailData{
|
||||||
Users: users,
|
Users: users,
|
||||||
CustCal: s.buildCustomerCal(c, r.URL.Query().Get("cm"), today),
|
CustCal: s.buildCustomerCal(c, r.URL.Query().Get("cm"), today),
|
||||||
Customer: c,
|
Customer: c,
|
||||||
Subs: subViews(subs, today, warn),
|
Subs: subViews(sorted, today, warn),
|
||||||
Packages: pkgs,
|
Packages: available,
|
||||||
Timeline: buildTimeline(subs, pauses, today),
|
Timeline: buildTimeline(subs, pauses, today),
|
||||||
AdjSubName: map[int64]string{},
|
AdjSubName: map[int64]string{},
|
||||||
AdjSubColor: map[int64]string{},
|
AdjSubColor: map[int64]string{},
|
||||||
|
|||||||
@@ -68,10 +68,27 @@ func readPackageForm(r *http.Request) *store.Package {
|
|||||||
}
|
}
|
||||||
p.CalendarSeparate = r.PostFormValue("calendar_separate") != ""
|
p.CalendarSeparate = r.PostFormValue("calendar_separate") != ""
|
||||||
p.Tag = strings.TrimSpace(r.PostFormValue("tag"))
|
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
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func validatePackage(p *store.Package) string {
|
func validatePackage(p *store.Package) string {
|
||||||
|
if p.Season {
|
||||||
|
if p.SeasonStart == "" || p.SeasonEnd == "" {
|
||||||
|
return "包赛季套餐必须填写赛季开始日和结束日"
|
||||||
|
}
|
||||||
|
if p.SeasonEnd < p.SeasonStart {
|
||||||
|
return "赛季结束日不能早于开始日"
|
||||||
|
}
|
||||||
|
}
|
||||||
if p.Name == "" {
|
if p.Name == "" {
|
||||||
return "套餐名称不能为空"
|
return "套餐名称不能为空"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,19 @@ func (s *Server) handleCheckinSave(w http.ResponseWriter, r *http.Request) {
|
|||||||
if u != nil {
|
if u != nil {
|
||||||
opID = u.ID
|
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 {
|
if err := s.db.SaveCheckin(id, date, status, note, opID); err != nil {
|
||||||
s.redirect(w, r, back, "打卡失败:"+err.Error())
|
s.redirect(w, r, back, "打卡失败:"+err.Error())
|
||||||
return
|
return
|
||||||
@@ -183,3 +196,43 @@ func (s *Server) handleCheckinDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.logOp(r, "撤销打卡", "subscription", subID, "撤销打卡记录 #"+itoa(id))
|
s.logOp(r, "撤销打卡", "subscription", subID, "撤销打卡记录 #"+itoa(id))
|
||||||
s.redirect(w, r, back, "已撤销打卡")
|
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}/resume", s.auth(s.handleSubAction("resume")))
|
||||||
mux.HandleFunc("POST /subscriptions/{id}/close", s.auth(s.handleSubAction("close")))
|
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}/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 /subscriptions/{id}/checkin", s.auth(s.handleCheckinSave))
|
||||||
mux.HandleFunc("POST /checkins/{id}/delete", s.auth(s.handleCheckinDelete))
|
mux.HandleFunc("POST /checkins/{id}/delete", s.auth(s.handleCheckinDelete))
|
||||||
mux.HandleFunc("POST /checkins/batch", s.auth(s.handleCheckinBatch))
|
mux.HandleFunc("POST /checkins/batch", s.auth(s.handleCheckinBatch))
|
||||||
|
|||||||
@@ -1093,3 +1093,29 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
|
|||||||
.keypad-toggle.on {
|
.keypad-toggle.on {
|
||||||
background: var(--accent); border-color: var(--accent); color: var(--paper);
|
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; }
|
||||||
|
|
||||||
|
|
||||||
|
/* ---------- v1.8.1:已结束订阅置灰 ---------- */
|
||||||
|
|
||||||
|
.subcard.is-closed {
|
||||||
|
opacity: 0.55;
|
||||||
|
filter: saturate(0.5);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.subcard.is-closed:hover { opacity: 0.8; }
|
||||||
|
|||||||
@@ -69,16 +69,38 @@
|
|||||||
var daysWrap = form.querySelector('[data-days-wrap]');
|
var daysWrap = form.querySelector('[data-days-wrap]');
|
||||||
var countWrap = form.querySelector('[data-count-wrap]');
|
var countWrap = form.querySelector('[data-count-wrap]');
|
||||||
var endWrap = form.querySelector('[data-end-wrap]');
|
var endWrap = form.querySelector('[data-end-wrap]');
|
||||||
|
var startWrap = form.querySelector('[data-start-wrap]');
|
||||||
|
var startInput = form.querySelector('input[name="start_date"]');
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
|
|
||||||
function sync() {
|
function sync() {
|
||||||
var opt = sel.options[sel.selectedIndex];
|
var opt = sel.options[sel.selectedIndex];
|
||||||
var unit = opt && opt.getAttribute('data-unit');
|
var unit = opt && opt.getAttribute('data-unit');
|
||||||
var amount = opt && opt.getAttribute('data-amount');
|
var amount = opt && opt.getAttribute('data-amount');
|
||||||
|
var validity = opt && parseInt(opt.getAttribute('data-validity') || '0', 10);
|
||||||
|
var endInput = endWrap && endWrap.querySelector('input');
|
||||||
|
|
||||||
|
if (unit === 'season') {
|
||||||
|
if (daysWrap) daysWrap.hidden = true;
|
||||||
|
if (countWrap) countWrap.hidden = true;
|
||||||
|
if (endWrap) endWrap.hidden = true;
|
||||||
|
if (startWrap) startWrap.hidden = true;
|
||||||
|
if (startInput) startInput.required = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startWrap) startWrap.hidden = false;
|
||||||
|
if (startInput) startInput.required = true;
|
||||||
|
|
||||||
if (unit === 'count') {
|
if (unit === 'count') {
|
||||||
if (daysWrap) daysWrap.hidden = true;
|
if (daysWrap) daysWrap.hidden = true;
|
||||||
if (countWrap) { countWrap.hidden = false; if (amount) countWrap.querySelector('input').value = amount; }
|
if (countWrap) { countWrap.hidden = false; if (amount) countWrap.querySelector('input').value = amount; }
|
||||||
if (endWrap) endWrap.hidden = true;
|
if (endWrap) endWrap.hidden = false;
|
||||||
|
if (validity > 0 && endInput && !endInput.value && startInput && startInput.value) {
|
||||||
|
var d = new Date(startInput.value + 'T00:00:00');
|
||||||
|
d.setDate(d.getDate() + validity - 1);
|
||||||
|
endInput.value = d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
} else if (unit === 'day') {
|
} else if (unit === 'day') {
|
||||||
if (daysWrap) { daysWrap.hidden = false; if (amount) daysWrap.querySelector('input').value = amount; }
|
if (daysWrap) { daysWrap.hidden = false; if (amount) daysWrap.querySelector('input').value = amount; }
|
||||||
if (countWrap) countWrap.hidden = true;
|
if (countWrap) countWrap.hidden = true;
|
||||||
@@ -101,6 +123,13 @@
|
|||||||
var v = 'day';
|
var v = 'day';
|
||||||
radios.forEach(function (r) { if (r.checked) v = r.value; });
|
radios.forEach(function (r) { if (r.checked) v = r.value; });
|
||||||
if (label) label.textContent = v === 'count' ? '次' : '天';
|
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); });
|
radios.forEach(function (r) { r.addEventListener('change', sync); });
|
||||||
sync();
|
sync();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "sub-card"}}
|
{{define "sub-card"}}
|
||||||
<article class="subcard" id="sub-{{.ID}}" style="--tag:{{.PackageColor}}">
|
<article class="subcard{{if eq .Status "closed"}} is-closed{{end}}" id="sub-{{.ID}}" style="--tag:{{.PackageColor}}">
|
||||||
<div class="subcard-head">
|
<div class="subcard-head">
|
||||||
<span class="tag"></span>
|
<span class="tag"></span>
|
||||||
<h3>{{.PackageName}}</h3>
|
<h3>{{.PackageName}}</h3>
|
||||||
@@ -41,7 +41,18 @@
|
|||||||
<button class="btn btn-sm">今日打卡</button>
|
<button class="btn btn-sm">今日打卡</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{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>
|
<a class="btn btn-primary btn-sm" href="?extend={{.ID}}#extend">{{if eq .Unit "day"}}追加时长{{else}}追加次数{{end}}</a>
|
||||||
|
{{end}}
|
||||||
{{if eq .Status "active"}}
|
{{if eq .Status "active"}}
|
||||||
<form method="post" action="/subscriptions/{{.ID}}/pause" data-confirm="暂停后剩余时长冻结,确认暂停?">
|
<form method="post" action="/subscriptions/{{.ID}}/pause" data-confirm="暂停后剩余时长冻结,确认暂停?">
|
||||||
<button class="btn btn-sm">暂停</button>
|
<button class="btn btn-sm">暂停</button>
|
||||||
@@ -58,6 +69,10 @@
|
|||||||
<form method="post" action="/subscriptions/{{.ID}}/reopen">
|
<form method="post" action="/subscriptions/{{.ID}}/reopen">
|
||||||
<button class="btn btn-sm">重新启用</button>
|
<button class="btn btn-sm">重新启用</button>
|
||||||
</form>
|
</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}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
{{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}}&{{$.D.FilterQuery}}" role="gridcell">
|
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}}
|
{{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>
|
||||||
|
|||||||
@@ -89,12 +89,12 @@
|
|||||||
<select name="package_id" required data-pkg-select>
|
<select name="package_id" required data-pkg-select>
|
||||||
<option value="">选择套餐…</option>
|
<option value="">选择套餐…</option>
|
||||||
{{range .D.Packages}}
|
{{range .D.Packages}}
|
||||||
<option value="{{.ID}}" data-unit="{{.Unit}}" data-amount="{{.DefaultAmount}}"{{if .Archived}} disabled{{end}}>
|
<option value="{{.ID}}" data-unit="{{if .Season}}season{{else}}{{.Unit}}{{end}}" data-amount="{{.DefaultAmount}}" data-validity="{{.DefaultValidityDays}}"{{if .Archived}} disabled{{end}}{{if .SeasonExpired $.TodayStr}} disabled{{end}}>
|
||||||
{{.Name}} · {{.UnitLabel}} {{.DefaultAmount}}{{if eq .Unit "day"}}天{{else}}次{{end}}{{if .Archived}}(已归档){{end}}
|
{{.Name}} · {{.UnitLabel}} {{if .Season}}{{.SeasonStart}}~{{.SeasonEnd}}{{else}}{{.DefaultAmount}}{{if eq .Unit "day"}}天{{else}}次{{end}}{{end}}{{if .SeasonExpired $.TodayStr}}(赛季已结束){{end}}{{if .Archived}}(已归档){{end}}
|
||||||
</option>
|
</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
<label class="inline-field">开始日
|
<label class="inline-field" data-start-wrap>开始日
|
||||||
<input type="date" name="start_date" value="{{.TodayStr}}" required>
|
<input type="date" name="start_date" value="{{.TodayStr}}" required>
|
||||||
</label>
|
</label>
|
||||||
<label class="inline-field" data-days-wrap>{{/* 默认显示 */}}
|
<label class="inline-field" data-days-wrap>{{/* 默认显示 */}}
|
||||||
|
|||||||
@@ -16,13 +16,25 @@
|
|||||||
<fieldset class="field">
|
<fieldset class="field">
|
||||||
<span>计费方式 <i>*</i></span>
|
<span>计费方式 <i>*</i></span>
|
||||||
<div class="radios">
|
<div class="radios">
|
||||||
<label><input type="radio" name="unit" value="day"{{if eq .D.Package.Unit "day"}} checked{{end}}>
|
<label><input type="radio" name="unit" value="day"{{if and (eq .D.Package.Unit "day") (not .D.Package.Season)}} checked{{end}}>
|
||||||
<b>按日</b><small>有开始日与结束日,每天一条待打任务,可打卡</small></label>
|
<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>
|
<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>
|
</div>
|
||||||
</fieldset>
|
</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">
|
<div class="grid-2">
|
||||||
<label class="field"><span>默认量 <i>*</i></span>
|
<label class="field"><span>默认量 <i>*</i></span>
|
||||||
<div class="input-suffix">
|
<div class="input-suffix">
|
||||||
|
|||||||
+17
-2
@@ -78,6 +78,20 @@ func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
|
|||||||
StateClass: class,
|
StateClass: class,
|
||||||
Today: domain.Date(today),
|
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 {
|
if s.Unit == domain.UnitDay {
|
||||||
v.UnitLabel = "按日"
|
v.UnitLabel = "按日"
|
||||||
v.RemainText = fmt.Sprintf("剩 %d 天", e.RemainingDays)
|
v.RemainText = fmt.Sprintf("剩 %d 天", e.RemainingDays)
|
||||||
@@ -89,8 +103,8 @@ func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
v.UnitLabel = "按次"
|
v.UnitLabel = "按次"
|
||||||
v.RemainText = fmt.Sprintf("剩 %d / %d 次", e.RemainingCount, s.TotalCount)
|
v.RemainText = fmt.Sprintf("已完成 %d / %d 次", s.UsedCount, s.TotalCount)
|
||||||
v.RemainShort = fmt.Sprintf("%d 次", e.RemainingCount)
|
v.RemainShort = fmt.Sprintf("%d/%d 次", s.UsedCount, s.TotalCount)
|
||||||
v.PeriodText = s.StartDate + " 起"
|
v.PeriodText = s.StartDate + " 起"
|
||||||
if s.EndDate != "" {
|
if s.EndDate != "" {
|
||||||
v.PeriodText += " · 有效期至 " + s.EndDate
|
v.PeriodText += " · 有效期至 " + s.EndDate
|
||||||
@@ -98,6 +112,7 @@ func newSubView(s store.Subscription, today time.Time, warnDays int) SubView {
|
|||||||
v.PeriodText += " · 无有效期"
|
v.PeriodText += " · 无有效期"
|
||||||
}
|
}
|
||||||
if s.TotalCount > 0 {
|
if s.TotalCount > 0 {
|
||||||
|
v.Progress = clampPct(s.UsedCount * 100 / s.TotalCount)
|
||||||
v.Progress = clampPct(e.RemainingCount * 100 / s.TotalCount)
|
v.Progress = clampPct(e.RemainingCount * 100 / s.TotalCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import (
|
|||||||
"daili/internal/web"
|
"daili/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "1.7.0"
|
const version = "1.8.2"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")")
|
cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")")
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ $r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '2';
|
|||||||
Check 'U-02' '开通按次订阅(10 次 / 有效期 T+10)' ((Has $r.Content '已开通')) "flash=$(Flash $r.Content)"
|
Check 'U-02' '开通按次订阅(10 次 / 有效期 T+10)' ((Has $r.Content '已开通')) "flash=$(Flash $r.Content)"
|
||||||
$r = Send $admin 'GET' "/customers/$custID"
|
$r = Send $admin 'GET' "/customers/$custID"
|
||||||
$p2ID = (SubIDs $r.Content)[1]
|
$p2ID = (SubIDs $r.Content)[1]
|
||||||
Check 'U-02b' '按次剩余 10/10 次' ((Has $r.Content '剩 10 / 10 次')) ""
|
Check 'U-02b' '按次已完成 0/10 次' ((Has $r.Content '已完成 0 / 10 次')) ""
|
||||||
|
|
||||||
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '1'; start_date = $today; days = '30' }
|
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '1'; start_date = $today; days = '30' }
|
||||||
Check 'U-03' '重复订阅被拦截' ((Has $r.Content '已订阅此套餐')) "flash=$(Flash $r.Content)"
|
Check 'U-03' '重复订阅被拦截' ((Has $r.Content '已订阅此套餐')) "flash=$(Flash $r.Content)"
|
||||||
@@ -239,7 +239,7 @@ $before = (Send $admin 'GET' "/customers/$custID").Content
|
|||||||
$r = Send $admin 'POST' '/packages' @{ name = '副本五次'; unit = 'count'; default_amount = '5'; price = '120'; color = '#41556B'; note = '无有效期'; sort_order = '3' }
|
$r = Send $admin 'POST' '/packages' @{ name = '副本五次'; unit = 'count'; default_amount = '5'; price = '120'; color = '#41556B'; note = '无有效期'; sort_order = '3' }
|
||||||
Check 'P-05' '新增套餐 D 成功' ((Has $r.Content '套餐已创建')) ""
|
Check 'P-05' '新增套餐 D 成功' ((Has $r.Content '套餐已创建')) ""
|
||||||
$after = (Send $admin 'GET' "/customers/$custID").Content
|
$after = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
Check 'P-05b' '新增套餐后既有订阅剩余量不变' ((Has $after '剩 30 天') -and (Has $after '剩 10 / 10 次')) ""
|
Check 'P-05b' '新增套餐后既有订阅进度不变' ((Has $after '剩 30 天') -and (Has $after '已完成 0 / 10 次')) ""
|
||||||
|
|
||||||
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '3'; start_date = $today; total_count = '5' }
|
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '3'; start_date = $today; total_count = '5' }
|
||||||
Check 'U-04' '开通第三个套餐(无有效期按次)' ((Has $r.Content '已开通')) "flash=$(Flash $r.Content)"
|
Check 'U-04' '开通第三个套餐(无有效期按次)' ((Has $r.Content '已开通')) "flash=$(Flash $r.Content)"
|
||||||
@@ -313,7 +313,7 @@ Check 'E-07' '已结束订阅不可追加' ((Has $r.Content '请先「重新启
|
|||||||
|
|
||||||
$r = Send $admin 'POST' "/subscriptions/$p2ID/extend" @{ add_count = '5'; note = '加购 5 次'; back = "/customers/$custID" }
|
$r = Send $admin 'POST' "/subscriptions/$p2ID/extend" @{ add_count = '5'; note = '加购 5 次'; back = "/customers/$custID" }
|
||||||
$d4 = (Send $admin 'GET' "/customers/$custID").Content
|
$d4 = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
Check 'E-08' '按次追加次数' ((Has $r.Content '已追加 5 次') -and (Has $d4 '剩 15 / 15 次')) "flash=$(Flash $r.Content)"
|
Check 'E-08' '按次追加次数' ((Has $r.Content '已追加 5 次') -and (Has $d4 '已完成 0 / 15 次')) "flash=$(Flash $r.Content)"
|
||||||
|
|
||||||
$r = Send $admin 'POST' "/subscriptions/$p2ID/extend" @{ add_count = '5'; new_end_opt = (D 30); note = '加次并延期'; back = "/customers/$custID" }
|
$r = Send $admin 'POST' "/subscriptions/$p2ID/extend" @{ add_count = '5'; new_end_opt = (D 30); note = '加次并延期'; back = "/customers/$custID" }
|
||||||
$d5 = (Send $admin 'GET' "/customers/$custID").Content
|
$d5 = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
@@ -365,22 +365,22 @@ Check 'K-02' '按日打卡不扣天数' ((Has $d '剩 30 天')) ""
|
|||||||
|
|
||||||
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
|
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
|
||||||
$d = (Send $admin 'GET' "/customers/$custID").Content
|
$d = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
Check 'K-03' '按次打卡扣 1 次' ((Has $d '剩 19 / 20 次')) ""
|
Check 'K-03' '按次打卡扣 1 次' ((Has $d '已完成 1 / 20 次')) ""
|
||||||
|
|
||||||
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
|
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
|
||||||
$d = (Send $admin 'GET' "/customers/$custID").Content
|
$d = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
Check 'K-04' '同日重复打卡不重复扣次' ((Has $d '剩 19 / 20 次')) ""
|
Check 'K-04' '同日重复打卡不重复扣次' ((Has $d '已完成 1 / 20 次')) ""
|
||||||
|
|
||||||
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'skipped'; back = "/customers/$custID" }
|
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'skipped'; back = "/customers/$custID" }
|
||||||
$d = (Send $admin 'GET' "/customers/$custID").Content
|
$d = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
Check 'K-05' '改为跳过返还 1 次' ((Has $d '剩 20 / 20 次')) ""
|
Check 'K-05' '改为跳过返还 1 次' ((Has $d '已完成 0 / 20 次')) ""
|
||||||
|
|
||||||
Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" } | Out-Null
|
Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" } | Out-Null
|
||||||
$d = (Send $admin 'GET' "/customers/$custID").Content
|
$d = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
$ciID = (Dbtool @('scalar', $Db, "SELECT id FROM checkins WHERE sub_id=$p2ID AND service_date='$today'")).Trim()
|
$ciID = (Dbtool @('scalar', $Db, "SELECT id FROM checkins WHERE sub_id=$p2ID AND service_date='$today'")).Trim()
|
||||||
$r = Send $admin 'POST' "/checkins/$ciID/delete" @{ back = "/customers/$custID" }
|
$r = Send $admin 'POST' "/checkins/$ciID/delete" @{ back = "/customers/$custID" }
|
||||||
$d = (Send $admin 'GET' "/customers/$custID").Content
|
$d = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
Check 'K-06' '撤销打卡返还次数' ((Has $r.Content '已撤销') -and (Has $d '剩 20 / 20 次')) "flash=$(Flash $r.Content)"
|
Check 'K-06' '撤销打卡返还次数' ((Has $r.Content '已撤销') -and (Has $d '已完成 0 / 20 次')) "flash=$(Flash $r.Content)"
|
||||||
|
|
||||||
Dbtool @('exec', $Db, "UPDATE subscriptions SET used_count=total_count WHERE id=$p2ID") | Out-Null
|
Dbtool @('exec', $Db, "UPDATE subscriptions SET used_count=total_count WHERE id=$p2ID") | Out-Null
|
||||||
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
|
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
|
||||||
@@ -395,7 +395,7 @@ Check 'K-11' '无有效期订阅卡片有「今日打卡」按钮' ((Has $r.Cont
|
|||||||
|
|
||||||
$r = Send $admin 'POST' "/subscriptions/$p3ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
|
$r = Send $admin 'POST' "/subscriptions/$p3ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
|
||||||
$d = (Send $admin 'GET' "/customers/$custID").Content
|
$d = (Send $admin 'GET' "/customers/$custID").Content
|
||||||
Check 'K-09' '无有效期按次订阅可打卡扣次' ((Has $d '剩 4 / 5 次')) "flash=$(Flash $r.Content)"
|
Check 'K-09' '无有效期按次订阅可打卡扣次' ((Has $d '已完成 1 / 5 次')) "flash=$(Flash $r.Content)"
|
||||||
Check 'K-10' '打卡历史含日期/备注/操作人' ((Has $d '今日已打') -or (Has $d '完成')) ""
|
Check 'K-10' '打卡历史含日期/备注/操作人' ((Has $d '今日已打') -or (Has $d '完成')) ""
|
||||||
|
|
||||||
Write-Host "`n=== 11. 日历(L) ===" -ForegroundColor Cyan
|
Write-Host "`n=== 11. 日历(L) ===" -ForegroundColor Cyan
|
||||||
|
|||||||
Reference in New Issue
Block a user