216 lines
6.7 KiB
Go
216 lines
6.7 KiB
Go
package main
|
||
|
||
// festival.go 日历节日/节气自定义背景图。
|
||
// 管理员(云端账号 id=1)上传后写入本地 festival_images 表并随同步分发:
|
||
// 云端存放在 sync_settings 的 fest_img:<节日名> 行(统一挂在 id=1 名下),
|
||
// 管理员推送本地修改,所有账号拉取,实现"管理员配置、全员可见"。
|
||
//
|
||
// 行 value 为 JSON {"mode":"photo|art","image":"data:..."}:
|
||
// mode 决定日历格用照片还是动态插画(art 模式仍保留图片,切回无需重传);
|
||
// 兼容旧格式(value 直接是 dataURL,视为 photo 模式);空值为删除墓碑。
|
||
|
||
import (
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/wailsapp/wails/v3/pkg/application"
|
||
)
|
||
|
||
const (
|
||
festivalAdminID = 1
|
||
festivalImgEdge = 760 // 铺在日历格背景不需要原图精度,压小以控制同步体积
|
||
)
|
||
|
||
func parseFestivalValue(v string) (FestivalImage, bool) {
|
||
if v == "" {
|
||
return FestivalImage{}, false
|
||
}
|
||
if strings.HasPrefix(v, "data:") {
|
||
return FestivalImage{Mode: "photo", Image: v}, true // 旧格式:裸 dataURL
|
||
}
|
||
var f FestivalImage
|
||
if json.Unmarshal([]byte(v), &f) != nil || f.Image == "" {
|
||
return FestivalImage{}, false
|
||
}
|
||
if f.Mode != "art" {
|
||
f.Mode = "photo"
|
||
}
|
||
return f, true
|
||
}
|
||
|
||
// ListFestivalImages 返回节日名 → 背景配置的映射(本地缓存,含拉取到的云端配置)。
|
||
func (a *App) ListFestivalImages() (map[string]FestivalImage, error) {
|
||
if e := a.ready(); e != nil {
|
||
return nil, e
|
||
}
|
||
rows, e := a.store.db.Query(`SELECT fest_key,value FROM festival_images WHERE value!=''`)
|
||
if e != nil {
|
||
return nil, e
|
||
}
|
||
defer rows.Close()
|
||
m := map[string]FestivalImage{}
|
||
for rows.Next() {
|
||
var k, v string
|
||
if e := rows.Scan(&k, &v); e != nil {
|
||
return nil, e
|
||
}
|
||
if f, ok := parseFestivalValue(v); ok {
|
||
m[k] = f
|
||
}
|
||
}
|
||
return m, rows.Err()
|
||
}
|
||
|
||
func (a *App) festivalAdminGate() error {
|
||
if a.syncUserID() != festivalAdminID {
|
||
return errors.New("FESTIVAL_IMG_ADMIN_ONLY")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// PickFestivalImage 管理员为某个节日选择本地图片;处理保存后返回背景配置,取消返回空 image。
|
||
func (a *App) PickFestivalImage(key string) (FestivalImage, error) {
|
||
if e := a.ready(); e != nil {
|
||
return FestivalImage{}, e
|
||
}
|
||
if e := a.festivalAdminGate(); e != nil {
|
||
return FestivalImage{}, e
|
||
}
|
||
p, e := application.Get().Dialog.OpenFile().
|
||
SetTitle(a.localized("选择节日背景图", "Choose festival background")).
|
||
AddFilter(a.localized("图片文件", "Image files"), "*.png;*.jpg;*.jpeg;*.gif;*.webp").
|
||
PromptForSingleSelection()
|
||
if e != nil || strings.TrimSpace(p) == "" {
|
||
return FestivalImage{}, e
|
||
}
|
||
info, e := os.Stat(p)
|
||
if e != nil {
|
||
return FestivalImage{}, errors.New("FILE_NOT_FOUND")
|
||
}
|
||
if info.Size() > contentImgMaxBytes {
|
||
return FestivalImage{}, errors.New("IMAGE_FILE_TOO_LARGE")
|
||
}
|
||
raw, e := os.ReadFile(p)
|
||
if e != nil {
|
||
return FestivalImage{}, errors.New("FILE_READ_FAILED")
|
||
}
|
||
return a.saveFestivalImage(key, raw)
|
||
}
|
||
|
||
// SetFestivalImageData 管理员以 dataURL 形式设置节日图(自动化/粘贴场景)。
|
||
func (a *App) SetFestivalImageData(key, dataURL string) (FestivalImage, error) {
|
||
if e := a.ready(); e != nil {
|
||
return FestivalImage{}, e
|
||
}
|
||
if e := a.festivalAdminGate(); e != nil {
|
||
return FestivalImage{}, e
|
||
}
|
||
raw, e := dataURLBytes(dataURL)
|
||
if e != nil {
|
||
return FestivalImage{}, e
|
||
}
|
||
return a.saveFestivalImage(key, raw)
|
||
}
|
||
|
||
// SetFestivalImageMode 切换某节日的显示模式:photo=自定义照片,art=动态插画。
|
||
// 仅在已有图片的行上生效(无图时本就显示动态插画)。
|
||
func (a *App) SetFestivalImageMode(key, mode string) error {
|
||
if e := a.ready(); e != nil {
|
||
return e
|
||
}
|
||
if e := a.festivalAdminGate(); e != nil {
|
||
return e
|
||
}
|
||
key = strings.TrimSpace(key)
|
||
if key == "" || (mode != "photo" && mode != "art") {
|
||
return errors.New("FESTIVAL_IMG_BAD_KEY")
|
||
}
|
||
var v string
|
||
if e := a.store.db.QueryRow(`SELECT value FROM festival_images WHERE fest_key=?`, key).Scan(&v); e != nil {
|
||
return errors.New("FESTIVAL_IMG_NOT_FOUND")
|
||
}
|
||
f, ok := parseFestivalValue(v)
|
||
if !ok {
|
||
return errors.New("FESTIVAL_IMG_NOT_FOUND")
|
||
}
|
||
if f.Mode == mode {
|
||
return nil
|
||
}
|
||
f.Mode = mode
|
||
payload, e := json.Marshal(f)
|
||
if e != nil {
|
||
return e
|
||
}
|
||
if _, e := a.store.db.Exec(`UPDATE festival_images SET value=?,updated_at=?,dirty=1 WHERE fest_key=?`,
|
||
string(payload), a.festNextStamp(key), key); e != nil {
|
||
return e
|
||
}
|
||
a.store.Log("info", "日历", "节日背景模式已切换", key+" → "+mode)
|
||
go a.syncOnce(true)
|
||
return nil
|
||
}
|
||
|
||
// RemoveFestivalImage 移除自定义图(保留行并置空,作为删除标记同步给其他设备)。
|
||
func (a *App) RemoveFestivalImage(key string) error {
|
||
if e := a.ready(); e != nil {
|
||
return e
|
||
}
|
||
if e := a.festivalAdminGate(); e != nil {
|
||
return e
|
||
}
|
||
key = strings.TrimSpace(key)
|
||
if key == "" {
|
||
return errors.New("FESTIVAL_IMG_BAD_KEY")
|
||
}
|
||
if _, e := a.store.db.Exec(`INSERT INTO festival_images(fest_key,value,updated_at,dirty) VALUES(?,'',?,1)
|
||
ON CONFLICT(fest_key) DO UPDATE SET value='',updated_at=excluded.updated_at,dirty=1`, key, a.festNextStamp(key)); e != nil {
|
||
return e
|
||
}
|
||
a.store.Log("info", "日历", "节日图片已移除", key)
|
||
go a.syncOnce(true)
|
||
return nil
|
||
}
|
||
|
||
// festNextStamp 生成该节日行的下一个时间戳,保证严格大于现有值:
|
||
// nowRFC 精度只有秒,同一秒内先设图再移除会得到相同时间戳,
|
||
// 云端 LWW 的严格大于比较会拒绝第二次写入,本地却已清 dirty,造成永久分叉。
|
||
func (a *App) festNextStamp(key string) string {
|
||
now := nowRFC()
|
||
var prev string
|
||
_ = a.store.db.QueryRow(`SELECT updated_at FROM festival_images WHERE fest_key=?`, key).Scan(&prev)
|
||
if prev < now {
|
||
return now
|
||
}
|
||
if t, e := time.Parse(time.RFC3339, prev); e == nil {
|
||
return t.Add(time.Second).UTC().Format(time.RFC3339)
|
||
}
|
||
return now
|
||
}
|
||
|
||
func (a *App) saveFestivalImage(key string, raw []byte) (FestivalImage, error) {
|
||
key = strings.TrimSpace(key)
|
||
if key == "" || len(key) > 40 {
|
||
return FestivalImage{}, errors.New("FESTIVAL_IMG_BAD_KEY")
|
||
}
|
||
data, mime, e := encodeImageEdge(raw, festivalImgEdge)
|
||
if e != nil {
|
||
return FestivalImage{}, e
|
||
}
|
||
f := FestivalImage{Mode: "photo", Image: "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)}
|
||
payload, e := json.Marshal(f)
|
||
if e != nil {
|
||
return FestivalImage{}, e
|
||
}
|
||
if _, e := a.store.db.Exec(`INSERT INTO festival_images(fest_key,value,updated_at,dirty) VALUES(?,?,?,1)
|
||
ON CONFLICT(fest_key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at,dirty=1`, key, string(payload), a.festNextStamp(key)); e != nil {
|
||
return FestivalImage{}, e
|
||
}
|
||
a.store.Log("info", "日历", "节日图片已更新", key)
|
||
go a.syncOnce(true)
|
||
return f, nil
|
||
}
|