Skip to content

Commit 33dbbb0

Browse files
committed
feat(servicemenu): 用法卡横竖屏双样式与随机背景, 移除重载命令
1 parent bd6c520 commit 33dbbb0

5 files changed

Lines changed: 1016 additions & 120 deletions

File tree

plugin/servicemenu/badge.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// badge.go 服务列表红点徽章:插件首次出现时自动打标(类手机 app 角标),
2+
// 持久化到 data/servicemenu/badges.json,可通过「清除红点」命令手动清除。
3+
//
4+
// 自动打标规则:
5+
// - 存储文件不存在(首次运行):当前全部插件视为"已见"不打标,
6+
// 仅对 launchBadges 里本版本新增的功能播种红点;
7+
// - 之后每次进程启动对比当前插件集合与 seen 记录,新出现的插件自动打标;
8+
// - 已消失的插件从 seen/badges 中清理,红点不残留。
9+
package servicemenu
10+
11+
import (
12+
"encoding/json"
13+
"os"
14+
"path/filepath"
15+
"strings"
16+
"sync"
17+
"time"
18+
19+
"github.com/sirupsen/logrus"
20+
zero "github.com/wdvxdr1123/ZeroBot"
21+
"github.com/wdvxdr1123/ZeroBot/message"
22+
)
23+
24+
// launchBadges 本版本新增功能的红点首发标记。仅在存储文件首次初始化时生效,
25+
// 之后的版本无需维护:新插件出现时会相对 seen 记录自动打标。
26+
var launchBadges = []string{"hyperv", "splayer"}
27+
28+
// badgePath 徽章持久化文件(相对机器人工作目录)。测试中可替换到临时目录。
29+
var badgePath = filepath.Join("data", "servicemenu", "badges.json")
30+
31+
// badgeStore 徽章存储结构:
32+
// - seen:出现过的插件名 → 最近一次出现时间(unix 秒),防止重复打标
33+
// - badges:当前带红点的插件名 → 打标时间(unix 秒)
34+
type badgeStore struct {
35+
Seen map[string]int64 `json:"seen"`
36+
Badges map[string]int64 `json:"badges"`
37+
}
38+
39+
var badgeMu sync.Mutex
40+
41+
// badgeDetectOnce 每个进程只做一次新插件检测(插件集合在编译期固定)
42+
var badgeDetectOnce sync.Once
43+
44+
// readStore 读取存储文件。返回 (存储, 是否首次运行)。
45+
// 文件不存在或解析失败均视为首次运行(返回空存储),不视为错误。
46+
func readStore() (*badgeStore, bool) {
47+
b, err := os.ReadFile(badgePath)
48+
if err != nil {
49+
return &badgeStore{Seen: map[string]int64{}, Badges: map[string]int64{}}, true
50+
}
51+
var s badgeStore
52+
if err := json.Unmarshal(b, &s); err != nil {
53+
return &badgeStore{Seen: map[string]int64{}, Badges: map[string]int64{}}, true
54+
}
55+
if s.Seen == nil {
56+
s.Seen = map[string]int64{}
57+
}
58+
if s.Badges == nil {
59+
s.Badges = map[string]int64{}
60+
}
61+
return &s, false
62+
}
63+
64+
// saveStore 落盘(调用方须持有 badgeMu)
65+
func saveStore(s *badgeStore) error {
66+
if err := os.MkdirAll(filepath.Dir(badgePath), 0o755); err != nil {
67+
return err
68+
}
69+
b, err := json.MarshalIndent(s, "", " ")
70+
if err != nil {
71+
return err
72+
}
73+
return os.WriteFile(badgePath, b, 0o644)
74+
}
75+
76+
// refreshBadges 对比当前插件集合与存储:新出现的插件自动打红点,
77+
// 已消失的插件清理出 seen/badges,最后落盘。names 为全部插件英文名(小写)。
78+
func refreshBadges(names []string) {
79+
badgeMu.Lock()
80+
defer badgeMu.Unlock()
81+
82+
s, firstRun := readStore()
83+
now := time.Now().Unix()
84+
85+
if firstRun {
86+
// 首次运行:当前插件全部记入 seen(不打标),
87+
// 仅对本版本新增功能播种红点
88+
s.Seen = map[string]int64{}
89+
s.Badges = map[string]int64{}
90+
for _, n := range names {
91+
s.Seen[n] = now
92+
}
93+
for _, n := range launchBadges {
94+
s.Badges[n] = now
95+
}
96+
} else {
97+
for _, n := range names {
98+
if _, seen := s.Seen[n]; !seen {
99+
// 首次出现的新插件 → 自动打标
100+
s.Badges[n] = now
101+
}
102+
s.Seen[n] = now
103+
}
104+
// 清理已消失的插件
105+
current := make(map[string]bool, len(names))
106+
for _, n := range names {
107+
current[n] = true
108+
}
109+
for n := range s.Seen {
110+
if !current[n] {
111+
delete(s.Seen, n)
112+
}
113+
}
114+
for n := range s.Badges {
115+
if !current[n] {
116+
delete(s.Badges, n)
117+
}
118+
}
119+
}
120+
121+
if err := saveStore(s); err != nil {
122+
// 落盘失败只影响下次启动的"已见"记录,不阻断渲染
123+
logrus.Warnf("[servicemenu] 保存红点徽章失败: %v", err)
124+
}
125+
}
126+
127+
// badgedSet 返回当前带红点的插件名集合(渲染用,副本)
128+
func badgedSet() map[string]bool {
129+
badgeMu.Lock()
130+
defer badgeMu.Unlock()
131+
s, _ := readStore()
132+
out := make(map[string]bool, len(s.Badges))
133+
for n := range s.Badges {
134+
out[strings.ToLower(n)] = true
135+
}
136+
return out
137+
}
138+
139+
// clearBadges 清除红点:name 为空清除全部,否则只清指定插件(大小写不敏感)。
140+
// 返回实际清除的数量。
141+
func clearBadges(name string) int {
142+
badgeMu.Lock()
143+
defer badgeMu.Unlock()
144+
145+
s, firstRun := readStore()
146+
if firstRun {
147+
return 0
148+
}
149+
cleared := 0
150+
if name == "" {
151+
cleared = len(s.Badges)
152+
s.Badges = map[string]int64{}
153+
} else {
154+
n := strings.ToLower(name)
155+
if _, ok := s.Badges[n]; ok {
156+
delete(s.Badges, n)
157+
cleared = 1
158+
}
159+
}
160+
if cleared > 0 {
161+
if err := saveStore(s); err != nil {
162+
logrus.Warnf("[servicemenu] 保存红点徽章失败: %v", err)
163+
return 0
164+
}
165+
}
166+
return cleared
167+
}
168+
169+
func init() {
170+
// 清除红点:「清除红点」清全部,「清除红点 <插件名>」清单个
171+
// (\s 不含全角空格,故显式加入 )
172+
zero.OnRegex(`^(?:清除红点|clearbadge)(?:[\s ]+(\S+))?$`).SetBlock(true).FirstPriority().
173+
Handle(func(ctx *zero.Ctx) {
174+
var name string
175+
if m := ctx.State["regex_matched"].([]string); len(m) > 1 {
176+
name = strings.TrimSpace(m[1])
177+
}
178+
n := clearBadges(name)
179+
switch {
180+
case name != "" && n == 1:
181+
ctx.SendChain(message.Text("已清除 ", name, " 的红点"))
182+
case name != "":
183+
ctx.SendChain(message.Text(name, " 没有红点"))
184+
case n == 0:
185+
ctx.SendChain(message.Text("当前没有红点"))
186+
default:
187+
ctx.SendChain(message.Text("已清除 ", itoa(n), " 个红点"))
188+
}
189+
})
190+
}

plugin/servicemenu/badge_test.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package servicemenu
2+
3+
// 单元测试:红点徽章生命周期 / 「/用法」命令解析 / 红点渲染像素验证
4+
// 运行: go test ./plugin/servicemenu -run 'TestBadge|TestUsage'
5+
6+
import (
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/FloatTech/gg"
12+
)
13+
14+
// withTempBadgePath 把徽章存储指到临时目录,测试互不污染
15+
func withTempBadgePath(t *testing.T) {
16+
t.Helper()
17+
old := badgePath
18+
badgePath = filepath.Join(t.TempDir(), "badges.json")
19+
t.Cleanup(func() { badgePath = old })
20+
}
21+
22+
// TestBadgeLifecycle 首次运行播种 → 新插件自动打标 → 消失清理 → 手动清除 → 不复活
23+
func TestBadgeLifecycle(t *testing.T) {
24+
withTempBadgePath(t)
25+
26+
// 1. 首次运行:launchBadges(hyperv/splayer)打标,存量插件不打标
27+
refreshBadges([]string{"job", "chat", "hyperv", "splayer"})
28+
s := badgedSet()
29+
if !s["hyperv"] || !s["splayer"] {
30+
t.Fatalf("首发新插件应有红点: %v", s)
31+
}
32+
if s["job"] || s["chat"] {
33+
t.Fatalf("存量插件不应打标: %v", s)
34+
}
35+
36+
// 2. 之后出现新插件 → 自动打标
37+
refreshBadges([]string{"job", "chat", "hyperv", "splayer", "newplug"})
38+
s = badgedSet()
39+
if !s["newplug"] {
40+
t.Fatalf("新出现的插件 newplug 应自动打标: %v", s)
41+
}
42+
43+
// 3. 插件消失 → 红点与 seen 一并清理
44+
refreshBadges([]string{"job", "hyperv", "splayer"})
45+
s = badgedSet()
46+
if s["newplug"] {
47+
t.Fatalf("已消失插件的红点应被清理: %v", s)
48+
}
49+
50+
// 4. 手动清除指定
51+
if n := clearBadges("HYPERV"); n != 1 { // 大小写不敏感
52+
t.Fatalf("清除 hyperv 应返回 1, got %d", n)
53+
}
54+
if badgedSet()["hyperv"] {
55+
t.Fatal("清除后 hyperv 不应再有红点")
56+
}
57+
58+
// 5. 清除不存在的红点
59+
if n := clearBadges("hyperv"); n != 0 {
60+
t.Fatalf("重复清除应返回 0, got %d", n)
61+
}
62+
63+
// 6. 清除全部
64+
if n := clearBadges(""); n != 1 { // 只剩 splayer
65+
t.Fatalf("清除全部应返回 1, got %d", n)
66+
}
67+
if len(badgedSet()) != 0 {
68+
t.Fatalf("清除全部后不应有红点: %v", badgedSet())
69+
}
70+
71+
// 7. 持久化验证:清除后重新检测,已清除的红点不复活
72+
refreshBadges([]string{"job", "hyperv", "splayer"})
73+
s = badgedSet()
74+
if s["hyperv"] || s["splayer"] {
75+
t.Fatalf("已清除的红点不应复活: %v", s)
76+
}
77+
}
78+
79+
// TestBadgeCorruptStore 存储损坏时视为首次运行,按 launchBadges 重建
80+
func TestBadgeCorruptStore(t *testing.T) {
81+
withTempBadgePath(t)
82+
refreshBadges([]string{"a", "hyperv"}) // 生成正常文件
83+
// 写入坏内容
84+
if err := os.WriteFile(badgePath, []byte("{not json"), 0o644); err != nil {
85+
t.Fatal(err)
86+
}
87+
clearBadges("") // 应安全返回
88+
refreshBadges([]string{"a", "hyperv", "splayer"})
89+
s := badgedSet()
90+
if !s["hyperv"] || !s["splayer"] {
91+
t.Fatalf("损坏存储应按首次运行重建: %v", s)
92+
}
93+
}
94+
95+
// TestUsageDetailsForNewPlugins 新插件必须有四分区详细用法
96+
func TestUsageDetailsForNewPlugins(t *testing.T) {
97+
for _, name := range []string{"hyperv", "splayer"} {
98+
ss, ok := usageDetails[name]
99+
if !ok {
100+
t.Fatalf("usageDetails 缺少 %s", name)
101+
}
102+
if len(ss) != 4 {
103+
t.Fatalf("%s 详细用法应有 4 个分区, got %d", name, len(ss))
104+
}
105+
for i, want := range []string{"功能介绍", "操作步骤", "参数说明", "使用示例"} {
106+
if ss[i].Title != want {
107+
t.Fatalf("%s 分区 %d 标题应为 %s, got %s", name, i, want, ss[i].Title)
108+
}
109+
if len([]rune(ss[i].Body)) < 8 {
110+
t.Fatalf("%s 分区 %s 正文过短", name, want)
111+
}
112+
}
113+
}
114+
}
115+
116+
// TestUsageCommandRegex 「/用法」命令解析:中英文空格 / 斜杠 / 无空格 / 旧命令名
117+
func TestUsageCommandRegex(t *testing.T) {
118+
cases := []struct{ in, want string }{
119+
{"/用法 splayer", "splayer"},
120+
{"用法 splayer", "splayer"},
121+
{"/用法hyperv", "hyperv"}, // 无空格直连
122+
{"用法 hyperv", "hyperv"}, // 全角空格
123+
{"用法 splayer", "splayer"}, // 多个半角空格
124+
{"!用法 SPlayer", "SPlayer"}, // 全角感叹号 + 大写(处理时统一 ToLower)
125+
{"!用法 splayer", "splayer"},
126+
{"菜单用法 aiimage", "aiimage"},
127+
{"menuusage job", "job"},
128+
{"menuusage chat", "chat"},
129+
}
130+
for _, tc := range cases {
131+
m := usageNameRe.FindStringSubmatch(tc.in)
132+
if m == nil {
133+
t.Fatalf("%q 应匹配", tc.in)
134+
}
135+
if m[1] != tc.want {
136+
t.Fatalf("%q 捕获应为 %q, got %q", tc.in, tc.want, m[1])
137+
}
138+
}
139+
for _, in := range []string{"用法", "菜单用法", "服务列表", "主题 glass-dark", "清除红点"} {
140+
if usageNameRe.MatchString(in) {
141+
t.Fatalf("%q 不应匹配", in)
142+
}
143+
}
144+
}
145+
146+
// TestBadgeDotRendering 红点像素验证:hasBadge=true 时卡片右上角出现 iOS 红,
147+
// false 时同区域无红色
148+
func TestBadgeDotRendering(t *testing.T) {
149+
tt := themes[0]
150+
currentTheme = tt
151+
const w, h = 514, 120
152+
153+
renderCard := func(hasBadge bool) int {
154+
c := gg.NewContext(w+60, h+60)
155+
c.SetRGBA255(90, 90, 110, 255)
156+
c.DrawRectangle(0, 0, float64(w+60), float64(h+60))
157+
c.Fill()
158+
drawPluginCardContent(c, 30, 30, w, h, "splayer", "SPlayer-Next 播放器控制", true, hasBadge, tt)
159+
red := 0
160+
// 扫描卡片右上角区域(红点中心在 x+w-20, y+40 局部坐标)
161+
for py := 30; py < 30+34; py++ {
162+
for px := 30 + w - 44; px < 30+w+6; px++ {
163+
r, g, b, a := c.Image().At(px, py).RGBA()
164+
if a>>8 > 200 && r>>8 > 200 && g>>8 < 110 && b>>8 < 110 {
165+
red++
166+
}
167+
}
168+
}
169+
return red
170+
}
171+
172+
if red := renderCard(true); red < 50 {
173+
t.Fatalf("红点未渲染: 红像素 %d", red)
174+
}
175+
if red := renderCard(false); red != 0 {
176+
t.Fatalf("无徽章时不应有红像素: %d", red)
177+
}
178+
}

0 commit comments

Comments
 (0)