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 }