diff --git a/abineundo/console_windows.go b/abineundo/console_windows.go
index 0f79d3662c..42a401a72c 100644
--- a/abineundo/console_windows.go
+++ b/abineundo/console_windows.go
@@ -29,7 +29,7 @@ func setConsoleTitle(title string) (err error) {
if err != nil {
return
}
- r1, _, e1 := syscall.Syscall(procSetConsoleTitle.Addr(), 1, uintptr(unsafe.Pointer(p0)), 0, 0)
+ r1, _, e1 := syscall.SyscallN(procSetConsoleTitle.Addr(), uintptr(unsafe.Pointer(p0)))
if r1 == 0 {
err = errnoErr(e1)
}
@@ -46,9 +46,8 @@ func init() {
if debugMode {
logrus.Warnf("调试模式下忽略控制台模式获取失败: %v", err)
return // 调试模式下直接返回,跳过后续配置
- } else {
- panic(err) // 非调试模式下 panic
}
+ panic(err) // 非调试模式下 panic
}
mode &^= windows.ENABLE_QUICK_EDIT_MODE // 禁用快速编辑模式
diff --git a/main.go b/main.go
index 4ee2794a68..dda495ddd6 100644
--- a/main.go
+++ b/main.go
@@ -7,6 +7,7 @@ import (
"encoding/json"
"flag"
"fmt"
+ "io"
"math/rand"
"os"
"runtime"
@@ -43,7 +44,8 @@ import (
_ "github.com/FloatTech/ZeroBot-Plugin/plugin/atri" // ATRI词库
- _ "github.com/FloatTech/ZeroBot-Plugin/plugin/manager" // 群管
+ _ "github.com/FloatTech/ZeroBot-Plugin/plugin/manager" // 群管
+ _ "github.com/FloatTech/ZeroBot-Plugin/plugin/servicemenu" // 服务菜单(列表/用法/主题/重载)
_ "github.com/FloatTech/zbputils/job" // 定时指令触发器
@@ -236,9 +238,9 @@ func init() {
// 直接写死 AccessToken 时,请更改下面第二个参数
token := flag.String("t", "", "Set AccessToken of WSClient.")
// 直接写死 URL 时,请更改下面第二个参数
- url := flag.String("u", "ws://127.0.0.1:6700", "Set Url of WSClient.")
+ url := flag.String("u", "ws://192.168.1.244:6700", "Set Url of WSClient.")
// 默认昵称
- adana := flag.String("n", "椛椛", "Set default nickname.")
+ adana := flag.String("n", "亚托莉", "Set default nickname.")
prefix := flag.String("p", "/", "Set command prefix.")
runcfg := flag.String("c", "", "Run from config file.")
save := flag.String("s", "", "Save default config to file and exit.")
@@ -275,6 +277,22 @@ func init() {
// sus = append(sus, 12345678)
// sus = append(sus, 87654321)
+ // 从 data/superusers.txt 读取超级用户 QQ,每行一个,支持 # 注释
+ if f, err := os.Open("data/superusers.txt"); err == nil {
+ data, err := io.ReadAll(f)
+ f.Close()
+ if err == nil {
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(strings.TrimPrefix(line, "#"))
+ if i, err := strconv.ParseInt(line, 10, 64); err == nil {
+ sus = append(sus, i)
+ }
+ }
+ }
+ } else {
+ logrus.Infoln("[main] 未找到 data/superusers.txt, 请创建并写入你的QQ号以启用管理命令")
+ }
+
// 启用 webui
// go webctrl.RunGui(*g)
@@ -332,6 +350,45 @@ func main() {
rand.Seed(time.Now().UnixNano()) //nolint: staticcheck
}
message.SetForceBase64File(config.ForceBase64File)
+
+ // 跳过 github 原始资源站, 直接用镜像, 避免启动时每个文件等 1 分钟超时
+ file.SkipOriginal = true
+
+ // 修复 ZeroBot CommandRule 在 @机器人 + 命令 场景下的匹配 bug:
+ // CommandRule 只检查 Message[0].Type == "text",
+ // 但 @机器人 时 Message[0] 是 "at" 段,Message[1] 才是 text,
+ // 导致所有 /全局沉默 /启用 等命令永远匹配不上。
+ // 在 FirstPriority(0) 把 text 段提到最前面,让 CommandRule 正常工作。
+ zero.OnMessage(func(ctx *zero.Ctx) bool {
+ if len(ctx.Event.Message) < 2 {
+ return false
+ }
+ if ctx.Event.Message[0].Type == "text" {
+ return false
+ }
+ textIdx := -1
+ for i, seg := range ctx.Event.Message {
+ if seg.Type == "text" {
+ textIdx = i
+ break
+ }
+ }
+ if textIdx <= 0 {
+ return false
+ }
+ text := strings.TrimLeft(ctx.Event.Message[textIdx].Data["text"], " ")
+ if !strings.HasPrefix(text, zero.BotConfig.CommandPrefix) {
+ return false
+ }
+ // 把第一个 text 段移到最前面
+ segs := make(message.Message, 0, len(ctx.Event.Message))
+ segs = append(segs, ctx.Event.Message[textIdx])
+ segs = append(segs, ctx.Event.Message[:textIdx]...)
+ segs = append(segs, ctx.Event.Message[textIdx+1:]...)
+ ctx.Event.Message = segs
+ return false // 不拦截,让后续 matcher 继续匹配
+ }).FirstPriority().Handle(func(_ *zero.Ctx) {})
+
// 帮助
zero.OnFullMatchGroup([]string{"help", "/help", ".help", "菜单"}, zero.OnlyToMe).SetBlock(true).
Handle(func(ctx *zero.Ctx) {
diff --git a/plugin/aichat/main.go b/plugin/aichat/main.go
index 93d93a4aee..e4df914210 100644
--- a/plugin/aichat/main.go
+++ b/plugin/aichat/main.go
@@ -47,10 +47,11 @@ var (
)
func init() {
- en.OnMessage(chat.EnsureConfig, func(ctx *zero.Ctx) bool {
+ // 优先级设为 10,低于控制命令(SecondPriority=1),
+ // 避免 /全局禁用、/启用 等管理命令被 AI 聊天抢先拦截
+ aim := en.OnMessage(chat.EnsureConfig, func(ctx *zero.Ctx) bool {
stor, ok := ctx.State[zero.StateKeyPrefixKeep+"aichatcfg_stor__"].(chat.Storage)
if !ok {
- logrus.Warnln("ERROR: cannot get stor")
return false
}
mp := ctx.State[control.StateKeySyncxState].(*syncx.Map[string, any])
@@ -70,7 +71,9 @@ func init() {
ctx.Block()
}
return true
- }).SetBlock(false).Handle(func(ctx *zero.Ctx) {
+ }).SetBlock(false)
+ (*zero.Matcher)(aim).SetPriority(10)
+ aim.Handle(func(ctx *zero.Ctx) {
gid := ctx.Event.GroupID
if gid == 0 {
gid = -ctx.Event.UserID
diff --git a/plugin/aifalse/hardware_other.go b/plugin/aifalse/hardware_other.go
new file mode 100644
index 0000000000..d40febde1b
--- /dev/null
+++ b/plugin/aifalse/hardware_other.go
@@ -0,0 +1,76 @@
+//go:build !windows
+
+package aifalse
+
+import (
+ "context"
+ "os/exec"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/sirupsen/logrus"
+)
+
+func init() {
+ wmiGPUInfo = func() []gpuInfo {
+ // Linux/macOS: 用 lspci 列出 VGA/3D 设备
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ out, err := exec.CommandContext(ctx, "lspci").CombinedOutput()
+ if err != nil {
+ logrus.Debugf("[aifalse] lspci 失败: %v", err)
+ return nil
+ }
+
+ vgaRe := regexp.MustCompile(`VGA compatible controller:\s*(.*)`)
+ displayRe := regexp.MustCompile(`Display controller:\s*(.*)`)
+
+ var result []gpuInfo
+ for _, line := range strings.Split(string(out), "\n") {
+ var name string
+ if m := vgaRe.FindStringSubmatch(line); len(m) > 1 {
+ name = m[1]
+ } else if m := displayRe.FindStringSubmatch(line); len(m) > 1 {
+ name = m[1]
+ }
+ if name == "" {
+ continue
+ }
+ name = strings.TrimSpace(name)
+ result = append(result, gpuInfo{
+ Name: name,
+ Vendor: detectVendor(name),
+ })
+ }
+ if len(result) > 0 {
+ logrus.Debugf("[aifalse] lspci 识别到 %d 个 GPU", len(result))
+ }
+ return result
+ }
+}
+
+// queryHWiNFO 非 Windows 平台无 HWiNFO。
+func queryHWiNFO() (temps, fans, powers []*status) { return nil, nil, nil }
+
+// lhmSensor 非 Windows 平台不采集(LibreHardwareMonitor 仅支持 Windows),
+// 但字段需与 hardware_windows.go 保持一致,供跨平台代码(tempstate)编译通过。
+type lhmSensor struct {
+ Name string
+ SensorType string // Temperature / Fan / Power / Load / Clock / Voltage / Control
+ Parent string
+ Value float64
+}
+
+// queryLibreHardwareMonitor 非 Windows 平台永远返回 nil。
+func queryLibreHardwareMonitor() []lhmSensor { return nil }
+
+// queryLibreHardwareMonitorHTTP 非 Windows 平台永远返回 nil。
+func queryLibreHardwareMonitorHTTP() []lhmSensor { return nil }
+
+// mergeLHMSensors 非 Windows 平台占位。
+func mergeLHMSensors(_, _ []lhmSensor) []lhmSensor { return nil }
+
+// lhmSensorDisplayName 非 Windows 平台占位。
+func lhmSensorDisplayName(_ lhmSensor) string { return "" }
diff --git a/plugin/aifalse/hardware_windows.go b/plugin/aifalse/hardware_windows.go
new file mode 100644
index 0000000000..cd92e18718
--- /dev/null
+++ b/plugin/aifalse/hardware_windows.go
@@ -0,0 +1,441 @@
+//go:build windows
+
+package aifalse
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os/exec"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/sirupsen/logrus"
+)
+
+func init() {
+ wmiGPUInfo = func() []gpuInfo {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // PowerShell: Get-CimInstance Win32_VideoController 返回名称/显存/驱动
+ // 过滤掉虚拟显卡(远程桌面、虚拟机模拟器、虚拟显示适配器等)
+ filter := `'virtual|indirect|basic|spacedesk|mumu|mirabox|gameviewer|idd|remote|display adapter|Microsoft Basic'`
+ psCmd := `Get-CimInstance Win32_VideoController | ` +
+ fmt.Sprintf(`Where-Object { $_.Name -notmatch %s } | `, filter) +
+ `Select-Object Name, AdapterRAM, DriverVersion | ` +
+ `ConvertTo-Json -Compress`
+ cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", psCmd)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ logrus.Warnf("[aifalse] PowerShell Get-CimInstance 失败: %v, 输出: %s", err, string(out))
+ return pnpFallbackGPU()
+ }
+
+ return parsePowerShellGPU(out)
+ }
+}
+
+// parsePowerShellGPU 解析 PowerShell ConvertTo-Json 的输出。
+// PowerShell 在只有 1 个结果时返回单个 JSON 对象,多个时返回数组,需要统一处理。
+func parsePowerShellGPU(out []byte) []gpuInfo {
+ trimmed := strings.TrimSpace(string(out))
+ if trimmed == "" || trimmed == "null" {
+ return nil
+ }
+
+ var entries []struct {
+ Name string `json:"Name"`
+ AdapterRAM interface{} `json:"AdapterRAM"`
+ DriverVersion string `json:"DriverVersion"`
+ }
+
+ // 先尝试解析为数组
+ if err := json.Unmarshal(out, &entries); err != nil {
+ // 可能是单个对象,再试一次
+ var single struct {
+ Name string `json:"Name"`
+ AdapterRAM interface{} `json:"AdapterRAM"`
+ DriverVersion string `json:"DriverVersion"`
+ }
+ if err2 := json.Unmarshal(out, &single); err2 != nil {
+ logrus.Warnf("[aifalse] PowerShell GPU JSON 解析失败: %v / %v, raw=%s", err, err2, trimmed)
+ return nil
+ }
+ if single.Name == "" {
+ return nil
+ }
+ entries = []struct {
+ Name string `json:"Name"`
+ AdapterRAM interface{} `json:"AdapterRAM"`
+ DriverVersion string `json:"DriverVersion"`
+ }{single}
+ }
+
+ result := make([]gpuInfo, 0, len(entries))
+ for _, e := range entries {
+ if e.Name == "" {
+ continue
+ }
+ vendor := detectVendor(e.Name)
+ memMiB := parseRAM(e.AdapterRAM)
+ result = append(result, gpuInfo{
+ Name: e.Name,
+ Vendor: vendor,
+ MemTotal: memMiB,
+ })
+ }
+ return result
+}
+
+// pnpFallbackGPU 在 Get-CimInstance 也失败时,用 Get-PnpDevice -Class Display 兜底。
+// PnP 设备没有显存信息,但至少能列出显卡名称。
+func pnpFallbackGPU() []gpuInfo {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ filter := `'virtual|indirect|basic|spacedesk|mumu|mirabox|gameviewer|idd|remote|display adapter|Microsoft Basic'`
+ psCmd := `Get-PnpDevice -Class Display | ` +
+ fmt.Sprintf(`Where-Object { $_.FriendlyName -and $_.Status -eq 'OK' -and $_.FriendlyName -notmatch %s } | `, filter) +
+ `Select-Object FriendlyName | ` +
+ `ConvertTo-Json -Compress`
+ cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", psCmd)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ logrus.Warnf("[aifalse] PowerShell Get-PnpDevice 也失败: %v", err)
+ return nil
+ }
+
+ var items []struct {
+ Name string `json:"FriendlyName"`
+ }
+ trimmed := strings.TrimSpace(string(out))
+ if trimmed == "" || trimmed == "null" {
+ return nil
+ }
+ if err := json.Unmarshal(out, &items); err != nil {
+ var single struct {
+ Name string `json:"FriendlyName"`
+ }
+ if err2 := json.Unmarshal(out, &single); err2 != nil {
+ logrus.Warnf("[aifalse] PnP JSON 解析失败: %v / %v", err, err2)
+ return nil
+ }
+ if single.Name == "" {
+ return nil
+ }
+ items = []struct {
+ Name string `json:"FriendlyName"`
+ }{single}
+ }
+
+ result := make([]gpuInfo, 0, len(items))
+ for _, item := range items {
+ result = append(result, gpuInfo{
+ Name: item.Name,
+ Vendor: detectVendor(item.Name),
+ })
+ }
+ return result
+}
+
+// parseRAM 解析 PowerShell 返回的 AdapterRAM。
+// CimInstance 的 AdapterRAM 是 uint64,但 JSON 序列化后可能是 number 或 null。
+func parseRAM(v interface{}) float64 {
+ switch val := v.(type) {
+ case float64:
+ return val / (1024 * 1024)
+ case int64:
+ return float64(val) / (1024 * 1024)
+ case int:
+ return float64(val) / (1024 * 1024)
+ case nil:
+ return 0
+ }
+ return 0
+}
+
+// hwiSensor 对应 HWiNFO64 注册表 HKLM\SOFTWARE\HWiNFO64\VSB 里的传感器条目。
+// HWiNFO 在后台运行时(HWInfoSever 进程)会持续把传感器数据写入注册表,
+// 这是最简单最稳定的读取方式 —— 不需要额外装 LibreHardwareMonitor。
+type hwiSensor struct {
+ Name string `json:"Name"` // 如 "CPU Package"
+ Label string `json:"Label"` // 分组标签,如 "CPU [#0]: Intel Core i7-4170"
+ Value float64 `json:"Value"` // 裸数值
+ Unit string `json:"Unit"` // "°C" / "RPM" / "W"
+}
+
+// queryHWiNFO 从 HWiNFO64 注册表读取温度/风扇/功耗传感器数据。
+// 返回的三个切片按类型分好类:温度 / 风扇 / 功耗。
+// HWiNFO 没装或没在运行时返回 nil, nil, nil(正常情况,不报错)。
+func queryHWiNFO() (temps, fans, powers []*status) {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ // 尝试三个可能的注册表路径:
+ // 1. HKLM\SOFTWARE\HWiNFO64\VSB (64 位 HWiNFO 在 64 位系统)
+ // 2. HKLM\SOFTWARE\WOW6432Node\HWiNFO64\VSB (32 位 HWiNFO 在 64 位系统)
+ // 3. HKLM\SOFTWARE\HWiNFO32\VSB (32 位 HWiNFO 在 32 位系统)
+ psCmd := `$keys = @('HKLM:\SOFTWARE\HWiNFO64\VSB','HKLM:\SOFTWARE\WOW6432Node\HWiNFO64\VSB','HKLM:\SOFTWARE\HWiNFO32\VSB'); ` +
+ `foreach($k in $keys){ if(Test-Path $k){ $p = Get-ItemProperty $k; ` +
+ `$sensors = $p.PSObject.Properties.Name | Where-Object { $_ -match '^Sensor\d+$' }; ` +
+ `$result = @(); foreach($sn in $sensors){ $idx = $sn -replace '^Sensor',''; ` +
+ `$v = $p."Value$idx"; if($v -match '°C|RPM|W'){ ` +
+ `if($v -match '([\d.]+)\s*(°C|RPM|W)'){ $result += [PSCustomObject]@{ ` +
+ `Name=$p."Sensor$idx"; Label=$p."Label$idx"; Value=[double]$Matches[1]; Unit=$Matches[2] } } } }; ` +
+ `if($result.Count -gt 0){ $result | ConvertTo-Json -Compress; break } } }`
+
+ cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", psCmd)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return nil, nil, nil
+ }
+ trimmed := strings.TrimSpace(string(out))
+ if trimmed == "" || trimmed == "null" {
+ return nil, nil, nil
+ }
+
+ var sensors []hwiSensor
+ if err := json.Unmarshal(out, &sensors); err != nil {
+ var single hwiSensor
+ if err2 := json.Unmarshal(out, &single); err2 != nil {
+ logrus.Debugf("[aifalse] HWiNFO 注册表 JSON 解析失败: %v / %v", err, err2)
+ return nil, nil, nil
+ }
+ sensors = []hwiSensor{single}
+ }
+
+ for _, s := range sensors {
+ if s.Name == "" || s.Value <= 0 {
+ continue
+ }
+ name := s.Name
+ if s.Label != "" {
+ // Label 格式如 "CPU [#0]: Intel Core i7-4170",去掉 CPU 名称冗余
+ name = s.Label + " · " + s.Name
+ }
+ switch s.Unit {
+ case "°C":
+ temps = append(temps, &status{
+ name: name,
+ text: []string{fmt.Sprintf("%.1f°C", s.Value)},
+ precent: 0,
+ })
+ case "RPM":
+ fans = append(fans, &status{
+ name: name,
+ text: []string{fmt.Sprintf("%.0f RPM", s.Value)},
+ precent: 0,
+ })
+ case "W":
+ powers = append(powers, &status{
+ name: name + " 功耗",
+ text: []string{fmt.Sprintf("%.1f W", s.Value)},
+ precent: 0,
+ })
+ }
+ }
+ if len(temps)+len(fans)+len(powers) > 0 {
+ logrus.Infof("[aifalse] HWiNFO 注册表: 温度 %d, 风扇 %d, 功耗 %d",
+ len(temps), len(fans), len(powers))
+ }
+ return temps, fans, powers
+}
+
+// lhmSensor 对应 LibreHardwareMonitor WMI 中 root/LibreHardwareMonitor/Sensor 的字段。
+// LHM 是 HWiNFO 的开源免费替代方案,如果你没装 HWiNFO 也可以装 LHM。
+type lhmSensor struct {
+ Name string `json:"Name"`
+ SensorType string `json:"SensorType"` // Temperature / Fan / Power / Load / Clock / Voltage / Control
+ Parent string `json:"Parent"` // 所属硬件(如 "Supermicro X11" / "Intel Core i7-8700K")
+ Value float64 `json:"Value"`
+}
+
+// queryLibreHardwareMonitor 通过 WMI 查询 LibreHardwareMonitor 暴露的传感器数据。
+// 如果服务器上没装 LHM 或没在运行,返回空切片(不报错)。
+// LHM 需要后台运行(可以关闭 UI 但不能退出进程),WMI namespace 才会注册。
+func queryLibreHardwareMonitor() []lhmSensor {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ // Get-CimInstance -ClassName Sensor -Namespace root/LibreHardwareMonitor
+ // 只取我们关心的类型,减少数据量
+ psCmd := `Get-CimInstance -ClassName Sensor -Namespace root/LibreHardwareMonitor -ErrorAction SilentlyContinue | ` +
+ `Where-Object { $_.SensorType -in 'Temperature','Fan','Power' -and $_.Value -ne $null } | ` +
+ `Select-Object Name, SensorType, Parent, Value | ` +
+ `ConvertTo-Json -Compress`
+ cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", psCmd)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ // LHM 没装或没运行 —— 正常情况,不打警告
+ return nil
+ }
+ trimmed := strings.TrimSpace(string(out))
+ if trimmed == "" || trimmed == "null" {
+ return nil
+ }
+
+ var sensors []lhmSensor
+ if err := json.Unmarshal(out, &sensors); err != nil {
+ // 可能是单个对象
+ var single lhmSensor
+ if err2 := json.Unmarshal(out, &single); err2 != nil {
+ logrus.Debugf("[aifalse] LHM WMI JSON 解析失败: %v / %v", err, err2)
+ return nil
+ }
+ sensors = []lhmSensor{single}
+ }
+
+ // 过滤无效值
+ result := make([]lhmSensor, 0, len(sensors))
+ for _, s := range sensors {
+ if s.Name == "" || s.Value <= 0 {
+ continue
+ }
+ result = append(result, s)
+ }
+ return result
+}
+
+// lhmHTTPNode 是 LibreHardwareMonitor HTTP REST API /data.json 的节点结构。
+// 整个 JSON 是一棵递归树,传感器节点的 Value 字段是带单位的字符串,需要解析。
+// 例: {"Text":"CPU Package","Type":"Temperature","Value":"53.0 °C","Children":[]}
+type lhmHTTPNode struct {
+ ID int `json:"id"`
+ Text string `json:"Text"`
+ Type string `json:"Type"` // Temperature / Fan / Power / Load / Clock / Voltage / Data / Control / SmallData / Factor / Level / Throughput
+ SensorID string `json:"SensorId"` // 只有传感器节点才有
+ Value string `json:"Value"` // 字符串,带单位:"53.0 °C" / "2700 RPM" / "23.4 W"
+ RawValue string `json:"RawValue"` // 字符串,数值+单位
+ Children []lhmHTTPNode `json:"Children"`
+}
+
+// queryLibreHardwareMonitorHTTP 通过 LHM 的 HTTP REST API (默认 http://127.0.0.1:8085/data.json) 获取传感器数据。
+// 与 WMI 查询并行执行,谁有数据用谁。
+func queryLibreHardwareMonitorHTTP() []lhmSensor {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, "GET", "http://127.0.0.1:8085/data.json", nil)
+ if err != nil {
+ return nil
+ }
+
+ client := &http.Client{Timeout: 3 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil // HTTP server 没开 —— 正常情况
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return nil
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil
+ }
+
+ var root lhmHTTPNode
+ if err := json.Unmarshal(body, &root); err != nil {
+ logrus.Debugf("[aifalse] LHM HTTP JSON 解析失败: %v", err)
+ return nil
+ }
+
+ // 递归收集传感器
+ var result []lhmSensor
+ var walk func(nodes []lhmHTTPNode, parent string)
+ walk = func(nodes []lhmHTTPNode, parent string) {
+ for _, n := range nodes {
+ // 传感器节点的判断:有 SensorId 且有 Type
+ if n.SensorID != "" && n.Type != "" {
+ // 只收集我们关心的类型
+ if n.Type != "Temperature" && n.Type != "Fan" && n.Type != "Power" {
+ continue
+ }
+ // 解析 Value 字符串 "53.0 °C" / "2700 RPM" / "23.4 W" -> float64
+ val := parseLHMHTTPValue(n.Value)
+ if val <= 0 {
+ continue // 过滤无效值
+ }
+ result = append(result, lhmSensor{
+ Name: n.Text,
+ SensorType: n.Type,
+ Parent: parent,
+ Value: val,
+ })
+ } else if n.Text != "" {
+ // 硬件节点(分组节点),更新 parent 继续递归
+ walk(n.Children, n.Text)
+ continue
+ }
+ walk(n.Children, parent)
+ }
+ }
+ walk(root.Children, "")
+
+ if len(result) == 0 {
+ return nil
+ }
+ logrus.Infof("[aifalse] LHM HTTP REST 返回 %d 个传感器", len(result))
+ return result
+}
+
+// parseLHMHTTPValue 从 LHM HTTP /data.json 的 Value 字符串中提取数字。
+// 格式: "53.0 °C" / "2700 RPM" / "23.4 W" / "NaN %" 等
+func parseLHMHTTPValue(s string) float64 {
+ s = strings.TrimSpace(s)
+ if s == "" || strings.Contains(s, "NaN") || strings.Contains(s, "-") {
+ return 0
+ }
+ // 去掉单位部分:取空格前的数字
+ parts := strings.Fields(s)
+ if len(parts) == 0 {
+ return 0
+ }
+ val, err := strconv.ParseFloat(parts[0], 64)
+ if err != nil {
+ return 0
+ }
+ return val
+}
+
+// mergeLHMSensors 合并 WMI 和 HTTP REST 两个数据源的结果。
+// 用 Name+SensorType+Parent 做 key 去重,保留第一个非空值。
+func mergeLHMSensors(wmi, http []lhmSensor) []lhmSensor {
+ if len(wmi) == 0 && len(http) == 0 {
+ return nil
+ }
+ seen := make(map[string]struct{})
+ var merged []lhmSensor
+ add := func(s lhmSensor) {
+ k := s.Name + "|" + s.SensorType + "|" + s.Parent
+ if _, ok := seen[k]; ok {
+ return
+ }
+ seen[k] = struct{}{}
+ merged = append(merged, s)
+ }
+ for _, s := range wmi {
+ add(s)
+ }
+ for _, s := range http {
+ add(s)
+ }
+ return merged
+}
+
+// lhmSensorDisplayName 从 LHM 传感器对象生成对用户友好的显示名。
+// LHM 的 Name 类似 "CPU Package" / "CPU Core #1" / "Fan #1" / "CPU Package" (Power)
+// Parent 类似 "Intel(R) Core(TM) i7-8700K" 或主板名
+func lhmSensorDisplayName(s lhmSensor) string {
+ name := strings.TrimSpace(s.Name)
+ // 对于 Power 类型,标注清楚
+ if s.SensorType == "Power" {
+ return name + " 功耗"
+ }
+ return name
+}
diff --git a/plugin/aifalse/lhm_autosetup.go b/plugin/aifalse/lhm_autosetup.go
new file mode 100644
index 0000000000..90f179c950
--- /dev/null
+++ b/plugin/aifalse/lhm_autosetup.go
@@ -0,0 +1,378 @@
+//go:build windows
+
+package aifalse
+
+import (
+ "archive/zip"
+ "context"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/pkg/errors"
+ "github.com/sirupsen/logrus"
+)
+
+// lhmAutoSetup 负责在 Windows 上自动配置 LibreHardwareMonitor 后台进程。
+// 首次运行时自动下载 LHM portable zip、写 config、后台静默启动。
+// 后续启动检测进程是否存在,不存在则重启。
+//
+// 设计原则:
+// - 完全自动,用户零感知(不需要手动装任何东西)
+// - 网络不通或权限不足时优雅降级,bot 仍能跑(只是没温度/风扇/功耗数据)
+// - 下载失败 3 次后放弃,下次再试
+func lhmAutoSetup() {
+ lhmDir := filepath.Join("data", "aifalse", "librehardwaremonitor")
+ exePath := filepath.Join(lhmDir, "LibreHardwareMonitor.exe")
+ configPath := filepath.Join(lhmDir, "LibreHardwareMonitor.config")
+
+ // 1. 先检查 HTTP 服务器是否已经可达
+ if probeLHMHTTP() {
+ logrus.Debugln("[aifalse] LHM HTTP 已在运行,跳过自动配置")
+ return
+ }
+
+ // 2. 检查本地是否已有 LHM
+ if _, err := os.Stat(exePath); os.IsNotExist(err) {
+ logrus.Infoln("[aifalse] 未找到 LHM,开始自动下载配置...")
+ if err := downloadAndSetupLHM(lhmDir, configPath); err != nil {
+ logrus.Warnf("[aifalse] LHM 自动配置失败(不影响使用): %v", err)
+ return
+ }
+ }
+
+ // 3. 确保 config 开启了 HTTP server
+ if err := ensureLHMConfig(configPath); err != nil {
+ logrus.Warnf("[aifalse] LHM config 写入失败: %v", err)
+ }
+
+ // 4. 后台静默启动 LHM
+ if err := startLHMBackground(exePath); err != nil {
+ logrus.Warnf("[aifalse] LHM 启动失败(可能需要管理员权限): %v", err)
+ return
+ }
+
+ // 5. 等一下让 LHM 初始化 HTTP server
+ for i := 0; i < 20; i++ {
+ time.Sleep(500 * time.Millisecond)
+ if probeLHMHTTP() {
+ logrus.Infof("[aifalse] LHM 自动配置成功!HTTP server 已就绪")
+ return
+ }
+ }
+ logrus.Warnln("[aifalse] LHM 启动后 HTTP server 未就绪,可能需要管理员权限运行 bot")
+}
+
+// probeLHMHTTP 检测 http://127.0.0.1:8085/data.json 是否可达。
+func probeLHMHTTP() bool {
+ ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
+ defer cancel()
+ req, _ := http.NewRequestWithContext(ctx, "GET", "http://127.0.0.1:8085/data.json", nil)
+ client := &http.Client{Timeout: 1500 * time.Millisecond}
+ resp, err := client.Do(req)
+ if err != nil {
+ return false
+ }
+ resp.Body.Close()
+ return resp.StatusCode == 200
+}
+
+// downloadAndSetupLHM 从 GitHub release 下载 LHM portable zip 并解压到 lhmDir。
+func downloadAndSetupLHM(lhmDir, configPath string) error {
+ if err := os.MkdirAll(lhmDir, 0o755); err != nil {
+ return errors.Wrap(err, "创建目录失败")
+ }
+
+ // 先试 winget(如果有的话)
+ if tryWingetInstall() {
+ logrus.Infoln("[aifalse] winget 安装 LHM 成功")
+ return copyLHMFromWinget(lhmDir, configPath)
+ }
+
+ // winget 不行,直接下载 zip
+ urls := []string{
+ // .NET Framework 4.7.2 版本(兼容性好,Windows 10+ 默认有)
+ "https://github.com/LibreHardwareMonitor/LibreHardwareMonitor/releases/latest/download/LibreHardwareMonitor.zip",
+ }
+
+ var lastErr error
+ for _, url := range urls {
+ logrus.Infof("[aifalse] 下载 LHM: %s", url)
+ tmpZip := filepath.Join(lhmDir, "_lhm_download.zip")
+ if err := downloadFile(url, tmpZip); err != nil {
+ lastErr = err
+ logrus.Debugf("[aifalse] 下载失败: %v", err)
+ continue
+ }
+ if err := extractZip(tmpZip, lhmDir); err != nil {
+ lastErr = err
+ os.Remove(tmpZip)
+ continue
+ }
+ os.Remove(tmpZip)
+ // 解压后可能多了一层目录(librehardwaremonitor/xxx/LibreHardwareMonitor.exe)
+ if exe := findLHMExe(lhmDir); exe != "" {
+ // 如果 exe 在子目录,把文件移到 lhmDir 根目录
+ if exe != filepath.Join(lhmDir, "LibreHardwareMonitor.exe") {
+ if err := moveFiles(filepath.Dir(exe), lhmDir); err != nil {
+ logrus.Debugf("[aifalse] 移动 LHM 文件失败: %v", err)
+ }
+ }
+ return nil
+ }
+ lastErr = errors.Errorf("解压后没找到 LibreHardwareMonitor.exe")
+ }
+ return lastErr
+}
+
+// tryWingetInstall 尝试用 winget 安装 LHM(如果系统有 winget 的话)。
+func tryWingetInstall() bool {
+ _, err := exec.LookPath("winget")
+ if err != nil {
+ return false // 没装 winget
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ // 静默安装,接受协议
+ cmd := exec.CommandContext(ctx, "winget", "install", "--id=LibreHardwareMonitor.LibreHardwareMonitor",
+ "--silent", "--accept-source-agreements", "--accept-package-agreements")
+ cmd.Stdout = io.Discard
+ cmd.Stderr = io.Discard
+ if err := cmd.Run(); err != nil {
+ logrus.Debugf("[aifalse] winget 安装 LHM 失败: %v", err)
+ return false
+ }
+ return true
+}
+
+// copyLHMFromWinget winget 安装后,把 LHM 文件从安装目录复制到 lhmDir。
+func copyLHMFromWinget(lhmDir, _ string) error {
+ // winget 安装路径通常在 Program Files 或 Users\...\AppData\Local\Microsoft\WinGet\Packages
+ // 简化处理:搜索常见位置
+ locations := []string{
+ filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft", "WinGet", "Packages"),
+ filepath.Join(os.Getenv("ProgramFiles"), "LibreHardwareMonitor"),
+ filepath.Join(os.Getenv("ProgramFiles(x86)"), "LibreHardwareMonitor"),
+ }
+ for _, loc := range locations {
+ exe := findLHMExe(loc)
+ if exe != "" {
+ return copyFiles(filepath.Dir(exe), lhmDir)
+ }
+ }
+ return errors.Errorf("winget 安装了但找不到 exe")
+}
+
+// findLHMExe 在 rootDir 递归搜索 LibreHardwareMonitor.exe。
+func findLHMExe(rootDir string) string {
+ var found string
+ // 回调自身吞掉所有错误(读不到的条目直接跳过),Walk 的返回值无意义
+ _ = filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil || found != "" {
+ return nil
+ }
+ if !info.IsDir() && strings.EqualFold(info.Name(), "LibreHardwareMonitor.exe") {
+ found = path
+ }
+ return nil
+ })
+ return found
+}
+
+// downloadFile 下载 URL 到 dstPath。
+func downloadFile(url, dstPath string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return err
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ return errors.Errorf("HTTP %d", resp.StatusCode)
+ }
+ out, err := os.Create(dstPath)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+ _, err = io.Copy(out, resp.Body)
+ return err
+}
+
+// extractZip 解压 zip 文件到 destDir(扁平化结构)。
+func extractZip(zipPath, destDir string) error {
+ r, err := zip.OpenReader(zipPath)
+ if err != nil {
+ return err
+ }
+ defer r.Close()
+ for _, f := range r.File {
+ // 跳过目录
+ if f.FileInfo().IsDir() {
+ continue
+ }
+ // 安全:防止 zip slip(不允许解压到 destDir 外面)
+ target := filepath.Join(destDir, f.Name)
+ if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)) {
+ continue
+ }
+ // 创建父目录
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ continue
+ }
+ // 解压文件
+ src, err := f.Open()
+ if err != nil {
+ continue
+ }
+ dst, err := os.Create(target)
+ if err != nil {
+ src.Close()
+ continue
+ }
+ _, _ = io.Copy(dst, src) // 复制失败的条目直接跳过(best-effort 解压)
+ src.Close()
+ dst.Close()
+ }
+ return nil
+}
+
+// copyFiles 把 srcDir 下的所有文件复制到 dstDir。
+func copyFiles(srcDir, dstDir string) error {
+ return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil
+ }
+ if info.IsDir() {
+ return nil
+ }
+ rel, _ := filepath.Rel(srcDir, path)
+ target := filepath.Join(dstDir, rel)
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return nil
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil
+ }
+ return os.WriteFile(target, data, 0o644)
+ })
+}
+
+// moveFiles 把 srcDir 下的所有文件移到 dstDir。
+func moveFiles(srcDir, dstDir string) error {
+ return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil
+ }
+ if info.IsDir() {
+ return nil
+ }
+ rel, _ := filepath.Rel(srcDir, path)
+ target := filepath.Join(dstDir, rel)
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return nil
+ }
+ if err := os.Rename(path, target); err != nil {
+ logrus.Debugf("[aifalse] 移动文件失败 %s -> %s: %v", path, target, err)
+ }
+ return nil
+ })
+}
+
+// ensureLHMConfig 确保 config 文件里开启了 HTTP server。
+func ensureLHMConfig(configPath string) error {
+ if _, err := os.Stat(configPath); err != nil {
+ // config 不存在,创建一个最小可用的
+ config := `
+
+
+
+
+
+
+`
+ return os.WriteFile(configPath, []byte(config), 0o644)
+ }
+
+ data, err := os.ReadFile(configPath)
+ if err != nil {
+ return err
+ }
+ content := string(data)
+
+ // 已经有 runWebServerMenuItem=true 就不动
+ if strings.Contains(content, `key="runWebServerMenuItem" value="true"`) {
+ return nil
+ }
+
+ // 把已有的 runWebServerMenuItem 改成 true
+ if strings.Contains(content, `key="runWebServerMenuItem"`) {
+ content = strings.ReplaceAll(content,
+ `key="runWebServerMenuItem" value="false"`,
+ `key="runWebServerMenuItem" value="true"`)
+ } else {
+ // 在 前插入
+ insert := `
+
+
+`
+ content = strings.Replace(content, "", insert+" ", 1)
+ }
+
+ // 确保 listenerIp 是 127.0.0.1(避免 "?" 通配符问题)
+ content = strings.ReplaceAll(content, `key="listenerIp" value="?"`, `key="listenerIp" value="127.0.0.1"`)
+
+ return os.WriteFile(configPath, []byte(content), 0o644)
+}
+
+// startLHMBackground 后台静默启动 LHM。
+// 用 START /MIN 让窗口最小化到托盘(LHM 会自动缩到系统托盘)。
+func startLHMBackground(exePath string) error {
+ // 检查是否已经在运行
+ if isProcessRunning("LibreHardwareMonitor") {
+ logrus.Debugln("[aifalse] LHM 已在运行")
+ return nil
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // 用 cmd /c START /MIN 启动,隐藏主窗口
+ cmd := exec.CommandContext(ctx, "cmd", "/c", "start", "/min", filepath.Base(exePath))
+ cmd.Dir = filepath.Dir(exePath)
+ cmd.Stdout = io.Discard
+ cmd.Stderr = io.Discard
+
+ if err := cmd.Start(); err != nil {
+ return errors.Wrap(err, "启动失败")
+ }
+ // 不 Wait——让它自己跑
+ logrus.Infof("[aifalse] LHM 后台启动中: %s", exePath)
+ return nil
+}
+
+// isProcessRunning 检查指定进程是否在运行。
+func isProcessRunning(name string) bool {
+ _, err := exec.LookPath("tasklist")
+ if err != nil {
+ return false
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "tasklist", "/FI", "IMAGENAME eq "+name+".exe", "/FO", "CSV")
+ out, err := cmd.Output()
+ if err != nil {
+ return false
+ }
+ return strings.Contains(string(out), name+".exe")
+}
diff --git a/plugin/aifalse/lhm_autosetup_other.go b/plugin/aifalse/lhm_autosetup_other.go
new file mode 100644
index 0000000000..60b88c0ef2
--- /dev/null
+++ b/plugin/aifalse/lhm_autosetup_other.go
@@ -0,0 +1,6 @@
+//go:build !windows
+
+package aifalse
+
+// lhmAutoSetup Linux/macOS 不需要自动配置 LHM——gopsutil 直接读 sysfs 拿真实温度。
+func lhmAutoSetup() {}
diff --git a/plugin/aifalse/main.go b/plugin/aifalse/main.go
index e9037d9876..63f9ce558c 100644
--- a/plugin/aifalse/main.go
+++ b/plugin/aifalse/main.go
@@ -3,21 +3,24 @@ package aifalse
import (
"bytes"
- "errors"
+ "context"
+ "fmt"
"image"
"image/color"
+ "io"
"math"
+ "math/rand"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
"runtime"
"strconv"
"strings"
"sync"
- "sync/atomic"
"time"
- "unsafe"
- "github.com/FloatTech/AnimeAPI/bilibili"
"github.com/FloatTech/floatbox/file"
- "github.com/FloatTech/floatbox/web"
"github.com/FloatTech/gg"
"github.com/FloatTech/gg/factory"
ctrl "github.com/FloatTech/zbpctrl"
@@ -25,10 +28,12 @@ import (
"github.com/FloatTech/zbputils/ctxext"
"github.com/FloatTech/zbputils/img/text"
"github.com/disintegration/imaging"
+ "github.com/pkg/errors"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/mem"
+ "github.com/shirou/gopsutil/v4/sensors"
"github.com/sirupsen/logrus"
"golang.org/x/text/cases"
"golang.org/x/text/language"
@@ -39,27 +44,96 @@ import (
"github.com/wdvxdr1123/ZeroBot/message"
)
+// wmiGPUInfo 运行时初始化:
+// - Windows: 通过 PowerShell Get-CimInstance / Get-PnpDevice 查询所有品牌显卡名称和显存
+// - Linux/macOS: 通过 lspci 查询 VGA/3D 控制器
+// 变量名沿用旧名(原本打算用 WMI 但该库在 Windows Server 上不可用,改用 PowerShell)
+var (
+ wmiGPUInfo func() []gpuInfo
+)
+
+type gpuInfo struct {
+ Name string
+ Vendor string // "NVIDIA" / "Intel" / "AMD" / "Unknown"
+ MemTotal float64 // MiB, 0 = unknown
+}
+
+// detectVendor 从显卡名称字符串中识别厂商(全平台通用)。
+func detectVendor(name string) string {
+ lower := strings.ToLower(name)
+ switch {
+ case strings.Contains(lower, "nvidia"), strings.Contains(lower, "geforce"),
+ strings.Contains(lower, "quadro"), strings.Contains(lower, "tesla"),
+ strings.Contains(lower, "rtx"), strings.Contains(lower, "gtx"),
+ strings.Contains(lower, "titan"):
+ return "NVIDIA"
+ case strings.Contains(lower, "amd"), strings.Contains(lower, "radeon"),
+ strings.Contains(lower, "firepro"), strings.Contains(lower, "vega"),
+ strings.Contains(lower, "ryzen"):
+ return "AMD"
+ case strings.Contains(lower, "intel"), strings.Contains(lower, "iris"),
+ strings.Contains(lower, "uhd"), strings.Contains(lower, "arc"),
+ strings.Contains(lower, "hd graphics"):
+ return "Intel"
+ }
+ return "Unknown"
+}
+
const (
- backgroundURL = "https://pic.re/image"
- referer = "https://weibo.com/"
+ canvasW = 1280
+ cardw = canvasW - 70 - 70 // 两侧各 70 边距
+ topPad = 70
+ titleH = 250
+ basicH = 380
+ gap = 40
+ bottomPad = 70
)
var (
boottime = time.Now()
- bgdata *[]byte
- bgcount uintptr
isday bool
lightcolor = [3][4]uint8{{255, 70, 0, 255}, {255, 165, 0, 255}, {145, 240, 145, 255}}
darkcolor = [3][4]uint8{{215, 50, 0, 255}, {205, 135, 0, 255}, {115, 200, 115, 255}}
+
+ // 后台 CPU 采样器:避免每次自检都阻塞 1 秒等 cpu.Percent
+ cpuMu sync.RWMutex
+ cpuOverall float64
+ cpuModel string
+ cpuMhz float64
+ cpuCore int
+ cpuThread int
+
+ // 资源缓存:头像按 uid 缓存,字体全局加载一次,背景读取本地文件
+ avatarCache sync.Map // int64 -> []byte
+ fontMu sync.Mutex
+ fontBytes []byte
+ bgMu sync.Mutex
+ bgImage image.Image
+
+ // 带超时的 HTTP 客户端:floatbox/web.NewDefaultClient 和 http.DefaultClient 均无超时,
+ // 头像下载在网络异常时会挂死导致自检迟迟不出图,这里统一设置 10s 超时。
+ httpClient = &http.Client{
+ Timeout: 10 * time.Second,
+ Transport: &http.Transport{
+ Proxy: http.ProxyFromEnvironment,
+ },
+ }
+
+ // 数据目录(data/aifalse/),背景图等本地资源存放于此
+ dataFolder string
)
func init() { // 插件主体
+ startCPUSampler()
+
engine := control.AutoRegister(&ctrl.Options[*zero.Ctx]{
- DisableOnDefault: false,
- Brief: "自检, 全局限速",
+ DisableOnDefault: false,
+ Brief: "自检, 全局限速",
+ PrivateDataFolder: "aifalse",
Help: "- 查询计算机当前活跃度: [检查身体 | 自检 | 启动自检 | 系统状态]\n" +
"- 设置默认限速为每 m [分钟 | 秒] n 次触发",
})
+ dataFolder = engine.DataFolder()
c, ok := control.Lookup("aifalse")
if !ok {
panic("register aifalse error")
@@ -144,64 +218,132 @@ func init() { // 插件主体
})
}
+// startCPUSampler 启动一个常驻 goroutine 周期性采样 CPU 占用,
+// 这样自检时无需再阻塞 1 秒等待 cpu.Percent 返回,显著加快生成速度。
+func startCPUSampler() {
+ info, err := cpu.Info()
+ if err == nil && len(info) > 0 {
+ core, _ := cpu.Counts(false)
+ thread, _ := cpu.Counts(true)
+ cpuMu.Lock()
+ cpuModel = strings.TrimSpace(info[0].ModelName)
+ cpuMhz = info[0].Mhz
+ cpuCore = core
+ cpuThread = thread
+ cpuMu.Unlock()
+ }
+ go func() {
+ for {
+ p, err := cpu.Percent(time.Second, false)
+ if err == nil && len(p) > 0 {
+ cpuMu.Lock()
+ cpuOverall = p[0]
+ cpuMu.Unlock()
+ }
+ time.Sleep(time.Second)
+ }
+ }()
+}
+
func drawstatus(m *ctrl.Control[*zero.Ctx], uid int64, botname string, botrunstatus string) (sendimg image.Image, err error) {
- diskstate, err := diskstate()
+ // 并行采集较慢的硬件数据(磁盘、nvidia-smi、温度/风扇/功耗传感器),
+ // 同时主流程读取已缓存/快速的资源(字体、头像、背景、内存)。
+ gather := &sync.WaitGroup{}
+ gather.Add(4)
+ var (
+ disks []*status
+ moreinfos []*status
+ gpus []*status
+ temps []*status
+ fans []*status
+ powers []*status
+ eDisk error
+ eMore error
+ )
+ go func() { defer gather.Done(); disks, eDisk = diskstate() }()
+ go func() { defer gather.Done(); moreinfos, eMore = moreinfo(m) }()
+ go func() { defer gather.Done(); gpus = gpustate() }()
+ // tempstate 不返回 error(失败时返回 nil 切片),所以不需要 eTemp
+ go func() { defer gather.Done(); temps, fans, powers = tempstate() }()
+
+ basics, err := basicstate()
if err != nil {
return
}
- diskcardh := 40 + (20+50)*len(diskstate) + 40 - 20
-
- moreinfo, err := moreinfo(m)
+ back := loadBackground() // 自带兜底背景图,不会失败
+ avatarbuf, _ := loadAvatar(uid)
+ fontbyte, err := getFont()
if err != nil {
return
}
- moreinfocardh := 30 + (20+32*72/96)*len(moreinfo) + 30 - 20
- basicstate, err := basicstate()
- if err != nil {
+ gather.Wait()
+ if eDisk != nil {
+ err = eDisk
+ return
+ }
+ if eMore != nil {
+ err = eMore
return
}
+ // GPU / 温度为可选信息:取不到就跳过对应卡片
+ if len(disks) == 0 {
+ disks = []*status{{name: "/", text: []string{"无可用磁盘"}}}
+ }
- dldata := (*[]byte)(atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&bgdata))))
- if dldata == (*[]byte)(nil) || uintptr(time.Since(boottime).Hours()/24) >= atomic.LoadUintptr(&bgcount) {
- url, err1 := bilibili.GetRealURL(backgroundURL)
- if err1 != nil {
- return nil, err1
- }
- data, err1 := web.RequestDataWith(web.NewDefaultClient(), url, "", referer, "", nil)
- if err1 != nil {
- return nil, err1
+ var avatarf *factory.Factory
+ if len(avatarbuf) > 0 {
+ avatar, _, derr := image.Decode(bytes.NewReader(avatarbuf))
+ if derr == nil {
+ avatarf = factory.Size(avatar, 200, 200)
}
- atomic.AddUintptr(&bgcount, 1)
- atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(&bgdata)), unsafe.Pointer(&data))
- dldata = &data
}
- data := *dldata
- back, _, err := image.Decode(bytes.NewReader(data))
- if err != nil {
- return
+ // 计算各卡片高度与纵向位置
+ diskH := barCardH(len(disks))
+ moreH := infoCardH(len(moreinfos))
+ var gpuH, tempH, fanH, powerH int
+ if len(gpus) > 0 {
+ // GPU 统一用 info 卡渲染(左名称右详情),
+ // 比 bar 卡更适合展示 Intel/AMD 只有基础信息的场景,
+ // 也避免了无利用率数据时显示假的 0% 进度条
+ gpuH = infoCardH(len(gpus))
}
-
- data, err = web.GetData("https://q4.qlogo.cn/g?b=qq&nk=" + strconv.FormatInt(uid, 10) + "&s=640")
- if err != nil {
- return
+ if len(temps) > 0 {
+ tempH = infoCardH(len(temps))
}
- avatar, _, err := image.Decode(bytes.NewReader(data))
- if err != nil {
- return
+ if len(fans) > 0 {
+ fanH = infoCardH(len(fans))
}
- avatarf := factory.Size(avatar, 200, 200)
-
- fontbyte, err := file.GetLazyData(text.GlowSansFontFile, control.Md5File, true)
- if err != nil {
- return
+ if len(powers) > 0 {
+ powerH = infoCardH(len(powers))
}
- canvas := gg.NewContext(1280, 70+250+40+380+diskcardh+40+moreinfocardh+40+70)
-
- bh, bw, ch, cw := float64(back.Bounds().Dy()), float64(back.Bounds().Dx()), float64(canvas.H()), float64(canvas.W())
-
+ titleTop := topPad
+ basicTop := titleTop + titleH + gap
+ diskTop := basicTop + basicH + gap
+ y := diskTop + diskH
+ advance := func(h int) {
+ if h > 0 {
+ y += gap + h
+ }
+ }
+ advance(gpuH)
+ gpuTop := y - gpuH
+ advance(tempH)
+ tempTop := y - tempH
+ advance(fanH)
+ fanTop := y - fanH
+ advance(powerH)
+ powerTop := y - powerH
+ advance(moreH)
+ moreTop := y - moreH
+ footerTop := y + gap
+ totalH := footerTop + bottomPad
+
+ canvas := gg.NewContext(canvasW, totalH)
+ cw, ch := float64(canvas.W()), float64(canvas.H())
+ bh, bw := float64(back.Bounds().Dy()), float64(back.Bounds().Dx())
if bh/bw < ch/cw {
back = factory.Size(back, int(bw*ch/bh), int(bh*ch/bh)).Image()
canvas.DrawImageAnchored(back, canvas.W()/2, canvas.H()/2, 0.5, 0.5)
@@ -209,338 +351,331 @@ func drawstatus(m *ctrl.Control[*zero.Ctx], uid int64, botname string, botrunsta
back = factory.Size(back, int(bw*cw/bw), int(bh*cw/bw)).Image()
canvas.DrawImage(back, 0, 0)
}
- var blurback image.Image
- bwg := &sync.WaitGroup{}
- bwg.Add(1)
- go func() {
- defer bwg.Done()
- blurback = imaging.Blur(canvas.Image(), 8)
- }()
-
+ blurback := imaging.Blur(canvas.Image(), 8)
if !isday {
canvas.SetRGBA255(0, 0, 0, 50)
canvas.DrawRectangle(0, 0, cw, ch)
canvas.Fill()
}
- wg := &sync.WaitGroup{}
- wg.Add(5)
-
- cardw := canvas.W() - 70 - 70
-
- titlecardh := 250
- basiccardh := 380
-
- var titleimg, basicimg, diskimg, moreinfoimg, shadowimg image.Image
- go func() {
- defer wg.Done()
- titlecard := gg.NewContext(cardw, titlecardh)
- bwg.Wait()
-
- titlecard.DrawRoundedRectangle(1, 1, float64(titlecard.W()-1*2), float64(titlecardh-1*2), 16)
- titlecard.ClipPreserve()
- titlecard.DrawImage(blurback, -70, -70)
- titlecard.SetColor(colorswitch(140))
- titlecard.FillPreserve()
+ // 标题卡片
+ titleCard := newCard(titleTop, titleH, blurback)
+ if avatarf != nil {
+ titleCard.DrawImage(avatarf.Circle(0).Image(), (titleH-avatarf.H())/2, (titleH-avatarf.H())/2)
+ }
+ if err = titleCard.ParseFontFace(fontbyte, 72); err != nil {
+ return
+ }
+ fw, _ := titleCard.MeasureString(botname)
+ titleCard.SetColor(fontcolorswitch())
+ titleCard.DrawStringAnchored(botname, float64(titleH)+fw/2, float64(titleH)*0.5/2, 0.5, 0.5)
+ if err = titleCard.ParseFontFace(fontbyte, 24); err != nil {
+ return
+ }
+ titleCard.SetColor(fontcolorswitch())
+ titleCard.NewSubPath()
+ titleCard.MoveTo(float64(titleH), float64(titleH)/2)
+ titleCard.LineTo(float64(titleCard.W()-titleH), float64(titleH)/2)
+ titleCard.Stroke()
+ fw, _ = titleCard.MeasureString(botrunstatus)
+ titleCard.DrawStringAnchored(botrunstatus, float64(titleH)+fw/2, float64(titleH)*(0.5+0.25/2), 0.5, 0.5)
+ brt, err := botruntime()
+ if err != nil {
+ return
+ }
+ fw, _ = titleCard.MeasureString(brt)
+ titleCard.DrawStringAnchored(brt, float64(titleH)+fw/2, float64(titleH)*(0.5+0.5/2), 0.5, 0.5)
+ bs, err := botstatus()
+ if err != nil {
+ return
+ }
+ fw, _ = titleCard.MeasureString(bs)
+ titleCard.DrawStringAnchored(bs, float64(titleH)+fw/2, float64(titleH)*(0.5+0.75/2), 0.5, 0.5)
- titlecard.SetLineWidth(3)
- titlecard.SetColor(colorswitch(100))
- titlecard.ResetClip()
- titlecard.Stroke()
+ // 基础状态卡片(CPU / RAM / SWAP 圆环)
+ basicCard := newCard(basicTop, basicH, blurback)
+ if err = renderBasicCard(basicCard, basics[:], fontbyte); err != nil {
+ return
+ }
- titlecard.DrawImage(avatarf.Circle(0).Image(), (titlecardh-avatarf.H())/2, (titlecardh-avatarf.H())/2)
+ // 磁盘卡片
+ diskCard := newCard(diskTop, diskH, blurback)
+ if err = renderBarCard(diskCard, disks, fontbyte); err != nil {
+ return
+ }
- err = titlecard.ParseFontFace(fontbyte, 72)
- if err != nil {
+ // GPU 状态卡片(可选)—— info 卡样式(左名称右详情)
+ var gpuCard *gg.Context
+ if gpuH > 0 {
+ gpuCard = newCard(gpuTop, gpuH, blurback)
+ if err = renderInfoCard(gpuCard, gpus, fontbyte); err != nil {
return
}
- fw, _ := titlecard.MeasureString(botname)
-
- titlecard.SetColor(fontcolorswitch())
-
- titlecard.DrawStringAnchored(botname, float64(titlecardh)+fw/2, float64(titlecardh)*0.5/2, 0.5, 0.5)
+ }
- err = titlecard.ParseFontFace(fontbyte, 24)
- if err != nil {
+ // 硬件温度卡片(可选)
+ var tempCard *gg.Context
+ if tempH > 0 {
+ tempCard = newCard(tempTop, tempH, blurback)
+ if err = renderInfoCard(tempCard, temps, fontbyte); err != nil {
return
}
- titlecard.SetColor(fontcolorswitch())
-
- titlecard.NewSubPath()
- titlecard.MoveTo(float64(titlecardh), float64(titlecardh)/2)
- titlecard.LineTo(float64(titlecard.W()-titlecardh), float64(titlecardh)/2)
- titlecard.Stroke()
-
- fw, _ = titlecard.MeasureString(botrunstatus)
-
- titlecard.DrawStringAnchored(botrunstatus, float64(titlecardh)+fw/2, float64(titlecardh)*(0.5+0.25/2), 0.5, 0.5)
+ }
- brt, err := botruntime()
- if err != nil {
+ // 风扇转速卡片(可选,需要 LibreHardwareMonitor)
+ var fanCard *gg.Context
+ if fanH > 0 {
+ fanCard = newCard(fanTop, fanH, blurback)
+ if err = renderInfoCard(fanCard, fans, fontbyte); err != nil {
return
}
- fw, _ = titlecard.MeasureString(brt)
-
- titlecard.DrawStringAnchored(brt, float64(titlecardh)+fw/2, float64(titlecardh)*(0.5+0.5/2), 0.5, 0.5)
+ }
- bs, err := botstatus()
- if err != nil {
+ // 功耗卡片(可选,需要 LibreHardwareMonitor)
+ var powerCard *gg.Context
+ if powerH > 0 {
+ powerCard = newCard(powerTop, powerH, blurback)
+ if err = renderInfoCard(powerCard, powers, fontbyte); err != nil {
return
}
- fw, _ = titlecard.MeasureString(bs)
+ }
- titlecard.DrawStringAnchored(bs, float64(titlecardh)+fw/2, float64(titlecardh)*(0.5+0.75/2), 0.5, 0.5)
- titleimg = titlecard.Image()
- }()
- go func() {
- defer wg.Done()
- basiccard := gg.NewContext(cardw, basiccardh)
- bwg.Wait()
-
- basiccard.DrawRoundedRectangle(1, 1, float64(basiccard.W()-1*2), float64(basiccardh-1*2), 16)
- basiccard.ClipPreserve()
- basiccard.DrawImage(blurback, -70, -70-titlecardh-40)
- basiccard.SetColor(colorswitch(140))
- basiccard.FillPreserve()
-
- basiccard.SetLineWidth(3)
- basiccard.SetColor(colorswitch(100))
- basiccard.ResetClip()
- basiccard.Stroke()
-
- bslen := len(basicstate)
- for i, v := range basicstate {
- offset := float64(i) * ((float64(basiccard.W())-200*float64(bslen))/float64(bslen+1) + 200)
-
- basiccard.SetRGBA255(57, 57, 57, 255)
- if isday {
- basiccard.SetRGBA255(235, 235, 235, 255)
- }
- basiccard.DrawCircle((float64(basiccard.W())-200*float64(bslen))/float64(bslen+1)+200/2+offset, 20+200/2, 100)
- basiccard.Fill()
+ // 详细信息卡片
+ moreCard := newCard(moreTop, moreH, blurback)
+ if err = renderInfoCard(moreCard, moreinfos, fontbyte); err != nil {
+ return
+ }
- colors := darkcolor
- if isday {
- colors = lightcolor
- }
+ // 卡片阴影
+ shadow := gg.NewContext(canvas.W(), canvas.H())
+ shadow.SetRGBA255(0, 0, 0, 100)
+ shadow.SetLineWidth(12)
+ shadow.DrawRoundedRectangle(float64(70), float64(titleTop), float64(cardw), float64(titleH), 16)
+ shadow.Stroke()
+ shadow.DrawRoundedRectangle(float64(70), float64(basicTop), float64(cardw), float64(basicH), 16)
+ shadow.Stroke()
+ shadow.DrawRoundedRectangle(float64(70), float64(diskTop), float64(cardw), float64(diskH), 16)
+ shadow.Stroke()
+ if gpuH > 0 {
+ shadow.DrawRoundedRectangle(float64(70), float64(gpuTop), float64(cardw), float64(gpuH), 16)
+ shadow.Stroke()
+ }
+ if tempH > 0 {
+ shadow.DrawRoundedRectangle(float64(70), float64(tempTop), float64(cardw), float64(tempH), 16)
+ shadow.Stroke()
+ }
+ if fanH > 0 {
+ shadow.DrawRoundedRectangle(float64(70), float64(fanTop), float64(cardw), float64(fanH), 16)
+ shadow.Stroke()
+ }
+ if powerH > 0 {
+ shadow.DrawRoundedRectangle(float64(70), float64(powerTop), float64(cardw), float64(powerH), 16)
+ shadow.Stroke()
+ }
+ shadow.DrawRoundedRectangle(float64(70), float64(moreTop), float64(cardw), float64(moreH), 16)
+ shadow.Stroke()
+ canvas.DrawImage(imaging.Blur(shadow.Image(), 24), 0, 0)
+ canvas.DrawImage(titleCard.Image(), 70, titleTop)
+ canvas.DrawImage(basicCard.Image(), 70, basicTop)
+ canvas.DrawImage(diskCard.Image(), 70, diskTop)
+ if gpuH > 0 {
+ canvas.DrawImage(gpuCard.Image(), 70, gpuTop)
+ }
+ if tempH > 0 {
+ canvas.DrawImage(tempCard.Image(), 70, tempTop)
+ }
+ if fanH > 0 {
+ canvas.DrawImage(fanCard.Image(), 70, fanTop)
+ }
+ if powerH > 0 {
+ canvas.DrawImage(powerCard.Image(), 70, powerTop)
+ }
+ canvas.DrawImage(moreCard.Image(), 70, moreTop)
- switch {
- case v.precent > 90:
- basiccard.SetColor(slice2color(colors[0]))
- case v.precent > 70:
- basiccard.SetColor(slice2color(colors[1]))
- default:
- basiccard.SetColor(slice2color(colors[2]))
- }
+ if err = canvas.ParseFontFace(fontbyte, 28); err != nil {
+ return
+ }
+ canvas.SetRGBA255(0, 0, 0, 255)
+ canvas.DrawStringAnchored("Created By ZeroBot-Plugin "+banner.Version, float64(canvas.W())/2+3, float64(canvas.H())-float64(bottomPad)/2+3, 0.5, 0.5)
+ canvas.SetRGBA255(255, 255, 255, 255)
+ canvas.DrawStringAnchored("Created By ZeroBot-Plugin "+banner.Version, float64(canvas.W())/2, float64(canvas.H())-float64(bottomPad)/2, 0.5, 0.5)
- basiccard.NewSubPath()
- basiccard.MoveTo((float64(basiccard.W())-200*float64(bslen))/float64(bslen+1)+200/2+offset, 20+200/2)
- basiccard.DrawEllipticalArc((float64(basiccard.W())-200*float64(bslen))/float64(bslen+1)+200/2+offset, 20+200/2, 100, 100, -0.5*math.Pi, -0.5*math.Pi+2*v.precent*0.01*math.Pi)
- basiccard.Fill()
+ sendimg = canvas.Image()
+ return
+}
- basiccard.SetColor(colorswitch(255))
- basiccard.DrawCircle((float64(basiccard.W())-200*float64(bslen))/float64(bslen+1)+200/2+offset, 20+200/2, 80)
- basiccard.Fill()
+// newCard 创建一张带毛玻璃背景与描边的卡片画布。
+func newCard(topY, h int, blurback image.Image) *gg.Context {
+ c := gg.NewContext(cardw, h)
+ c.DrawRoundedRectangle(1, 1, float64(c.W()-2), float64(h-2), 16)
+ c.ClipPreserve()
+ c.DrawImage(blurback, -70, -topY)
+ c.SetColor(colorswitch(140))
+ c.FillPreserve()
+ c.SetLineWidth(3)
+ c.SetColor(colorswitch(100))
+ c.ResetClip()
+ c.Stroke()
+ return c
+}
- err = basiccard.ParseFontFace(fontbyte, 42)
- if err != nil {
- return
- }
+// renderBasicCard 渲染 CPU / RAM / SWAP 圆环卡片。
+func renderBasicCard(card *gg.Context, state []*status, fontbyte []byte) error {
+ bslen := len(state)
+ for i, v := range state {
+ offset := float64(i) * ((float64(card.W())-200*float64(bslen))/float64(bslen+1) + 200)
+ cx := (float64(card.W())-200*float64(bslen))/float64(bslen+1) + 200/2 + offset
+ cy := float64(20 + 200/2)
+
+ card.SetRGBA255(57, 57, 57, 255)
+ if isday {
+ card.SetRGBA255(235, 235, 235, 255)
+ }
+ card.DrawCircle(cx, cy, 100)
+ card.Fill()
- basiccard.SetRGBA255(213, 213, 213, 255)
- basiccard.DrawStringAnchored(strconv.FormatFloat(v.precent, 'f', 0, 64)+"%", (float64(basiccard.W())-200*float64(bslen))/float64(bslen+1)+200/2+offset, 20+200/2, 0.5, 0.5)
+ colors := darkcolor
+ if isday {
+ colors = lightcolor
+ }
+ switch {
+ case v.precent > 90:
+ card.SetColor(slice2color(colors[0]))
+ case v.precent > 70:
+ card.SetColor(slice2color(colors[1]))
+ default:
+ card.SetColor(slice2color(colors[2]))
+ }
+ card.NewSubPath()
+ card.MoveTo(cx, cy)
+ card.DrawEllipticalArc(cx, cy, 100, 100, -0.5*math.Pi, -0.5*math.Pi+2*v.precent*0.01*math.Pi)
+ card.Fill()
- basiccard.SetColor(fontcolorswitch())
+ card.SetColor(colorswitch(255))
+ card.DrawCircle(cx, cy, 80)
+ card.Fill()
- _, fw := basiccard.MeasureString(v.name)
- basiccard.DrawStringAnchored(v.name, (float64(basiccard.W())-200*float64(bslen))/float64(bslen+1)+200/2+offset, 20+200+15+basiccard.FontHeight()/2, 0.5, 0.5)
+ if err := card.ParseFontFace(fontbyte, 42); err != nil {
+ return err
+ }
+ card.SetRGBA255(213, 213, 213, 255)
+ card.DrawStringAnchored(strconv.FormatFloat(v.precent, 'f', 0, 64)+"%", cx, cy, 0.5, 0.5)
- err = basiccard.ParseFontFace(fontbyte, 20)
- if err != nil {
- return
- }
- basiccard.SetColor(fontcolorswitch())
+ card.SetColor(fontcolorswitch())
+ _, fw := card.MeasureString(v.name)
+ card.DrawStringAnchored(v.name, cx, 20+200+15+card.FontHeight()/2, 0.5, 0.5)
- textoffsety := basiccard.FontHeight() + 10
- for k, s := range v.text {
- basiccard.DrawStringAnchored(s, (float64(basiccard.W())-200*float64(bslen))/float64(bslen+1)+200/2+offset, 20+200+15+fw+15+basiccard.FontHeight()/2+float64(k)*textoffsety, 0.5, 0.5)
- }
+ if err := card.ParseFontFace(fontbyte, 20); err != nil {
+ return err
}
- basicimg = basiccard.Image()
- }()
- go func() {
- defer wg.Done()
- diskcard := gg.NewContext(cardw, diskcardh)
- bwg.Wait()
-
- diskcard.DrawRoundedRectangle(1, 1, float64(diskcard.W()-1*2), float64(diskcardh-1*2), 16)
- diskcard.ClipPreserve()
- diskcard.DrawImage(blurback, -70, -70-titlecardh-40-basiccardh-40)
- diskcard.SetColor(colorswitch(140))
- diskcard.FillPreserve()
-
- diskcard.SetLineWidth(3)
- diskcard.SetColor(colorswitch(100))
- diskcard.ResetClip()
- diskcard.Stroke()
-
- err = diskcard.ParseFontFace(fontbyte, 32)
- if err != nil {
- return
+ card.SetColor(fontcolorswitch())
+ textoffsety := card.FontHeight() + 10
+ for k, s := range v.text {
+ card.DrawStringAnchored(s, cx, 20+200+15+fw+15+card.FontHeight()/2+float64(k)*textoffsety, 0.5, 0.5)
+ }
+ }
+ return nil
+}
+
+// renderBarCard 渲染横向进度条卡片(磁盘 / GPU 复用)。
+func renderBarCard(card *gg.Context, state []*status, fontbyte []byte) error {
+ if err := card.ParseFontFace(fontbyte, 32); err != nil {
+ return err
+ }
+ dslen := len(state)
+ if dslen == 1 {
+ v := state[0]
+ card.SetRGBA255(57, 57, 57, 255)
+ if isday {
+ card.SetRGBA255(192, 192, 192, 255)
+ }
+ card.DrawRoundedRectangle(40, 40, float64(card.W())-40-100, 50, 12)
+ card.ClipPreserve()
+ card.Fill()
+
+ colors := darkcolor
+ if isday {
+ colors = lightcolor
}
+ switch {
+ case v.precent > 90:
+ card.SetColor(slice2color(colors[0]))
+ case v.precent > 70:
+ card.SetColor(slice2color(colors[1]))
+ default:
+ card.SetColor(slice2color(colors[2]))
+ }
+ card.DrawRoundedRectangle(40, 40, (float64(card.W())-40-100)*v.precent*0.01, 50, 12)
+ card.Fill()
+ card.ResetClip()
+
+ card.SetColor(fontcolorswitch())
+ fw, _ := card.MeasureString(v.name)
+ fw1, _ := card.MeasureString(v.text[0])
+ card.DrawStringAnchored(v.name, 40+10+fw/2, 40+50/2, 0.5, 0.5)
+ card.DrawStringAnchored(v.text[0], (float64(card.W())-100-10)-fw1/2, 40+50/2, 0.5, 0.5)
+ card.DrawStringAnchored(strconv.FormatFloat(v.precent, 'f', 0, 64)+"%", float64(card.W())-100/2, 40+50/2, 0.5, 0.5)
+ } else {
+ for i, v := range state {
+ offset := float64(i)*(50+20) - 20
+ barY := 40 + (float64(card.H()-40*2)-50*float64(dslen))/float64(dslen-1) + offset
- dslen := len(diskstate)
- if dslen == 1 {
- diskcard.SetRGBA255(57, 57, 57, 255)
+ card.SetRGBA255(57, 57, 57, 255)
if isday {
- diskcard.SetRGBA255(192, 192, 192, 255)
+ card.SetRGBA255(192, 192, 192, 255)
}
- diskcard.DrawRoundedRectangle(40, 40, float64(diskcard.W())-40-100, 50, 12)
- diskcard.ClipPreserve()
- diskcard.Fill()
+ card.DrawRoundedRectangle(40, barY, float64(card.W())-40-100, 50, 12)
+ card.ClipPreserve()
+ card.Fill()
colors := darkcolor
if isday {
colors = lightcolor
}
-
switch {
- case diskstate[0].precent > 90:
- diskcard.SetColor(slice2color(colors[0]))
- case diskstate[0].precent > 70:
- diskcard.SetColor(slice2color(colors[1]))
+ case v.precent > 90:
+ card.SetColor(slice2color(colors[0]))
+ case v.precent > 70:
+ card.SetColor(slice2color(colors[1]))
default:
- diskcard.SetColor(slice2color(colors[2]))
- }
-
- diskcard.DrawRoundedRectangle(40, 40, (float64(diskcard.W())-40-100)*diskstate[0].precent*0.01, 50, 12)
- diskcard.Fill()
- diskcard.ResetClip()
-
- diskcard.SetColor(fontcolorswitch())
-
- fw, _ := diskcard.MeasureString(diskstate[0].name)
- fw1, _ := diskcard.MeasureString(diskstate[0].text[0])
-
- diskcard.DrawStringAnchored(diskstate[0].name, 40+10+fw/2, 40+50/2, 0.5, 0.5)
- diskcard.DrawStringAnchored(diskstate[0].text[0], (float64(diskcard.W())-100-10)-fw1/2, 40+50/2, 0.5, 0.5)
- diskcard.DrawStringAnchored(strconv.FormatFloat(diskstate[0].precent, 'f', 0, 64)+"%", float64(diskcard.W())-100/2, 40+50/2, 0.5, 0.5)
- } else {
- for i, v := range diskstate {
- offset := float64(i)*(50+20) - 20
-
- diskcard.SetRGBA255(57, 57, 57, 255)
- if isday {
- diskcard.SetRGBA255(192, 192, 192, 255)
- }
-
- diskcard.DrawRoundedRectangle(40, 40+(float64(diskcardh-40*2)-50*float64(dslen))/float64(dslen-1)+offset, float64(diskcard.W())-40-100, 50, 12)
- diskcard.ClipPreserve()
- diskcard.Fill()
-
- colors := darkcolor
- if isday {
- colors = lightcolor
- }
-
- switch {
- case v.precent > 90:
- diskcard.SetColor(slice2color(colors[0]))
- case v.precent > 70:
- diskcard.SetColor(slice2color(colors[1]))
- default:
- diskcard.SetColor(slice2color(colors[2]))
- }
-
- diskcard.DrawRoundedRectangle(40, 40+(float64(diskcardh-40*2)-50*float64(dslen))/float64(dslen-1)+offset, (float64(diskcard.W())-40-100)*v.precent*0.01, 50, 12)
- diskcard.Fill()
- diskcard.ResetClip()
-
- diskcard.SetColor(fontcolorswitch())
-
- fw, _ := diskcard.MeasureString(v.name)
- fw1, _ := diskcard.MeasureString(v.text[0])
-
- diskcard.DrawStringAnchored(v.name, 40+10+fw/2, 40+(float64(diskcardh-40*2)-50*float64(dslen))/float64(dslen-1)+50/2+offset, 0.5, 0.5)
- diskcard.DrawStringAnchored(v.text[0], (float64(diskcard.W())-100-10)-fw1/2, 40+(float64(diskcardh-40*2)-50*float64(dslen))/float64(dslen-1)+50/2+offset, 0.5, 0.5)
- diskcard.DrawStringAnchored(strconv.FormatFloat(v.precent, 'f', 0, 64)+"%", float64(diskcard.W())-100/2, 40+(float64(diskcardh-40*2)-50*float64(dslen))/float64(dslen-1)+50/2+offset, 0.5, 0.5)
+ card.SetColor(slice2color(colors[2]))
}
+ card.DrawRoundedRectangle(40, barY, (float64(card.W())-40-100)*v.precent*0.01, 50, 12)
+ card.Fill()
+ card.ResetClip()
+
+ card.SetColor(fontcolorswitch())
+ fw, _ := card.MeasureString(v.name)
+ fw1, _ := card.MeasureString(v.text[0])
+ card.DrawStringAnchored(v.name, 40+10+fw/2, barY+50/2, 0.5, 0.5)
+ card.DrawStringAnchored(v.text[0], (float64(card.W())-100-10)-fw1/2, barY+50/2, 0.5, 0.5)
+ card.DrawStringAnchored(strconv.FormatFloat(v.precent, 'f', 0, 64)+"%", float64(card.W())-100/2, barY+50/2, 0.5, 0.5)
}
- diskimg = diskcard.Image()
- }()
- go func() {
- defer wg.Done()
- moreinfocard := gg.NewContext(cardw, moreinfocardh)
- bwg.Wait()
-
- moreinfocard.DrawRoundedRectangle(1, 1, float64(moreinfocard.W()-1*2), float64(moreinfocard.H()-1*2), 16)
- moreinfocard.ClipPreserve()
- moreinfocard.DrawImage(blurback, -70, -70-titlecardh-40-basiccardh-40-diskcardh-40)
- moreinfocard.SetColor(colorswitch(140))
- moreinfocard.FillPreserve()
-
- moreinfocard.SetLineWidth(3)
- moreinfocard.SetColor(colorswitch(100))
- moreinfocard.ResetClip()
- moreinfocard.Stroke()
-
- err = moreinfocard.ParseFontFace(fontbyte, 32)
- if err != nil {
- return
- }
-
- milen := len(moreinfo)
- for i, v := range moreinfo {
- offset := float64(i)*(20+moreinfocard.FontHeight()) - 20
-
- moreinfocard.SetColor(fontcolorswitch())
-
- fw, _ := moreinfocard.MeasureString(v.name)
- fw1, _ := moreinfocard.MeasureString(v.text[0])
-
- moreinfocard.DrawStringAnchored(v.name, 20+fw/2, 30+(float64(moreinfocardh-30*2)-moreinfocard.FontHeight()*float64(milen))/float64(milen-1)+moreinfocard.FontHeight()/2+offset, 0.5, 0.5)
- moreinfocard.DrawStringAnchored(v.text[0], float64(moreinfocard.W())-20-fw1/2, 30+(float64(moreinfocardh-30*2)-moreinfocard.FontHeight()*float64(milen))/float64(milen-1)+moreinfocard.FontHeight()/2+offset, 0.5, 0.5)
- }
- moreinfoimg = moreinfocard.Image()
- }()
- go func() {
- defer wg.Done()
- shadow := gg.NewContext(canvas.W(), canvas.H())
- shadow.SetRGBA255(0, 0, 0, 100)
- shadow.SetLineWidth(12)
- shadow.DrawRoundedRectangle(70, 70, float64(cardw), float64(titlecardh), 16)
- shadow.Stroke()
- shadow.DrawRoundedRectangle(70, float64(70+titlecardh+40), float64(cardw), float64(basiccardh), 16)
- shadow.Stroke()
- shadow.DrawRoundedRectangle(70, float64(70+titlecardh+40+basiccardh+40), float64(cardw), float64(diskcardh), 16)
- shadow.Stroke()
- shadow.DrawRoundedRectangle(70, float64(70+titlecardh+40+basiccardh+40+diskcardh+40), float64(cardw), float64(moreinfocardh), 16)
- shadow.Stroke()
- shadowimg = imaging.Blur(shadow.Image(), 24)
- }()
-
- wg.Wait()
- if shadowimg == nil || titleimg == nil || basicimg == nil || diskimg == nil || moreinfoimg == nil {
- err = errors.New("图片渲染失败")
- return
}
- canvas.DrawImage(shadowimg, 0, 0)
- canvas.DrawImage(titleimg, 70, 70)
- canvas.DrawImage(basicimg, 70, 70+titlecardh+40)
- canvas.DrawImage(diskimg, 70, 70+titlecardh+40+basiccardh+40)
- canvas.DrawImage(moreinfoimg, 70, 70+titlecardh+40+basiccardh+40+diskcardh+40)
+ return nil
+}
- err = canvas.ParseFontFace(fontbyte, 28)
- if err != nil {
- return
+// renderInfoCard 渲染左侧名称 / 右侧数值的信息卡片(详细信息 / 温度复用)。
+func renderInfoCard(card *gg.Context, state []*status, fontbyte []byte) error {
+ if err := card.ParseFontFace(fontbyte, 32); err != nil {
+ return err
}
- canvas.SetRGBA255(0, 0, 0, 255)
- canvas.DrawStringAnchored("Created By ZeroBot-Plugin "+banner.Version, float64(canvas.W())/2+3, float64(canvas.H())-70/2+3, 0.5, 0.5)
- canvas.SetRGBA255(255, 255, 255, 255)
- canvas.DrawStringAnchored("Created By ZeroBot-Plugin "+banner.Version, float64(canvas.W())/2, float64(canvas.H())-70/2, 0.5, 0.5)
-
- sendimg = canvas.Image()
- return
+ milen := len(state)
+ for i, v := range state {
+ offset := float64(i)*(20+card.FontHeight()) - 20
+ card.SetColor(fontcolorswitch())
+ fw, _ := card.MeasureString(v.name)
+ fw1, _ := card.MeasureString(v.text[0])
+ var y float64
+ if milen == 1 {
+ y = float64(card.H()) / 2
+ } else {
+ y = 30 + (float64(card.H()-30*2)-card.FontHeight()*float64(milen))/float64(milen-1) + card.FontHeight()/2 + offset
+ }
+ card.DrawStringAnchored(v.name, 20+fw/2, y, 0.5, 0.5)
+ card.DrawStringAnchored(v.text[0], float64(card.W())-20-fw1/2, y, 0.5, 0.5)
+ }
+ return nil
}
func botruntime() (string, error) {
@@ -583,27 +718,20 @@ type status struct {
}
func basicstate() (stateinfo [3]*status, err error) {
- percent, err := cpu.Percent(time.Second, true)
- if err != nil {
- return
- }
- cpuinfo, err := cpu.Info()
- if err != nil {
- return
- }
- cpucore, err := cpu.Counts(false)
- if err != nil {
- return
- }
- cputhread, err := cpu.Counts(true)
- if err != nil {
- return
+ cpuMu.RLock()
+ overall := cpuOverall
+ mhz := cpuMhz
+ core := cpuCore
+ thread := cpuThread
+ cpuMu.RUnlock()
+
+ cores := strconv.Itoa(core) + "C" + strconv.Itoa(thread) + "T"
+ times := "未知"
+ if mhz > 0 {
+ times = "最大 " + strconv.FormatFloat(mhz/1000, 'f', 1, 64) + "Ghz"
}
- cores := strconv.Itoa(cpucore) + "C" + strconv.Itoa(cputhread) + "T"
- times := "最大 " + strconv.FormatFloat(cpuinfo[0].Mhz/1000, 'f', 1, 64) + "Ghz"
-
stateinfo[0] = &status{
- precent: math.Round(percent[0]),
+ precent: math.Round(overall),
name: "CPU",
text: []string{cores, times},
}
@@ -612,28 +740,28 @@ func basicstate() (stateinfo [3]*status, err error) {
if err != nil {
return
}
- total := "总共 " + storagefmt(float64(raminfo.Total))
- used := "已用 " + storagefmt(float64(raminfo.Used))
- free := "剩余 " + storagefmt(float64(raminfo.Free))
-
stateinfo[1] = &status{
precent: math.Round(raminfo.UsedPercent),
name: "RAM",
- text: []string{total, used, free},
+ text: []string{
+ "总共 " + storagefmt(float64(raminfo.Total)),
+ "已用 " + storagefmt(float64(raminfo.Used)),
+ "剩余 " + storagefmt(float64(raminfo.Free)),
+ },
}
swapinfo, err := mem.SwapMemory()
if err != nil {
return
}
- total = "总共 " + storagefmt(float64(swapinfo.Total))
- used = "已用 " + storagefmt(float64(swapinfo.Used))
- free = "剩余 " + storagefmt(float64(swapinfo.Free))
-
stateinfo[2] = &status{
precent: math.Round(swapinfo.UsedPercent),
name: "SWAP",
- text: []string{total, used, free},
+ text: []string{
+ "总共 " + storagefmt(float64(swapinfo.Total)),
+ "已用 " + storagefmt(float64(swapinfo.Used)),
+ "剩余 " + storagefmt(float64(swapinfo.Free)),
+ },
}
return
}
@@ -678,6 +806,415 @@ func diskstate() (stateinfo []*status, err error) {
return stateinfo, nil
}
+// gpustate 采集 GPU 状态,合并 nvidia-smi(NVIDIA 详细数据)和全量显卡列表(所有品牌)。
+// 优先级:先拿全量列表(含 Intel/AMD/NVIDIA),再用 nvidia-smi 的详细数据覆盖匹配的 NVIDIA 卡。
+// 没有可用 GPU 时返回 nil,调用方跳过 GPU 卡片。
+func gpustate() []*status {
+ // 1. 先拿 nvidia-smi 的 NVIDIA 详细数据(利用率/温度/功耗)
+ nvidiaList, _ := nvidiaGPU()
+ logrus.Debugf("[aifalse] nvidia-smi 返回 %d 块", len(nvidiaList))
+
+ // 2. 再拿全量显卡列表(所有品牌,跨平台)
+ var allGPUs []gpuInfo
+ if wmiGPUInfo != nil {
+ allGPUs = wmiGPUInfo()
+ logrus.Debugf("[aifalse] 全量 GPU 列表 %d 个", len(allGPUs))
+ }
+ stateinfo := make([]*status, 0, len(nvidiaList)+len(allGPUs))
+
+ // 3. 构建 NVIDIA 名称 → status 的映射,用于合并
+ nvidiaByName := make(map[string]*status, len(nvidiaList))
+ for _, s := range nvidiaList {
+ nvidiaByName[s.name] = s
+ }
+
+ // 4. 按全量列表顺序输出,匹配到 NVIDIA 的用 nvidia-smi 详细数据覆盖
+ usedNvidia := make(map[string]bool, len(nvidiaList))
+ for _, g := range allGPUs {
+ vendor := g.Vendor
+ name := g.Name
+
+ if vendor == "NVIDIA" {
+ // 尝试用 nvidia-smi 匹配(名称精确匹配或模糊包含)
+ var matched *status
+ if s, ok := nvidiaByName[name]; ok {
+ matched = s
+ } else {
+ // 模糊匹配:全量列表可能带 "NVIDIA Corporation" 前缀,nvidia-smi 返回纯净名
+ for nName, nS := range nvidiaByName {
+ if strings.Contains(strings.ToLower(name), strings.ToLower(nName)) ||
+ strings.Contains(strings.ToLower(nName), strings.ToLower(name)) {
+ matched = nS
+ break
+ }
+ }
+ }
+ if matched != nil {
+ stateinfo = append(stateinfo, matched)
+ usedNvidia[matched.name] = true
+ continue
+ }
+ // NVIDIA 但 nvidia-smi 没数据 —— 驱动未装或 WSL 环境
+ stateinfo = append(stateinfo, basicGPUStatus(vendor, name, g.MemTotal, "NVIDIA 驱动未就绪,无详细监控数据"))
+ continue
+ }
+
+ // Intel / AMD / Unknown —— 展示基础信息
+ stateinfo = append(stateinfo, basicGPUStatus(vendor, name, g.MemTotal, ""))
+ }
+
+ // 5. 全量列表可能漏掉某些 NVIDIA 卡(PowerShell 过滤等),确保 nvidia-smi 找到的也加上
+ for nName, nS := range nvidiaByName {
+ if !usedNvidia[nName] {
+ stateinfo = append(stateinfo, nS)
+ }
+ }
+
+ logrus.Infof("[aifalse] 最终 GPU 列表 %d 个 (nvidia-smi=%d, 全量=%d)",
+ len(stateinfo), len(nvidiaList), len(allGPUs))
+ return stateinfo
+}
+
+// basicGPUStatus 构造一张基础 GPU 状态卡(无利用率/温度等详细指标时使用)。
+func basicGPUStatus(vendor, name string, memMiB float64, extraNote string) *status {
+ displayName := name
+ if vendor != "Unknown" {
+ displayName = vendor + " " + name
+ }
+ var detail string
+ switch {
+ case memMiB > 0 && extraNote != "":
+ detail = "显存 " + strconv.FormatFloat(memMiB, 'f', 0, 64) + " MiB · " + extraNote
+ case memMiB > 0:
+ detail = "显存 " + strconv.FormatFloat(memMiB, 'f', 0, 64) + " MiB"
+ case extraNote != "":
+ detail = extraNote
+ default:
+ detail = "共享显存 · 温度见下方传感器"
+ }
+ return &status{
+ precent: 0, // 无利用率数据
+ name: displayName,
+ text: []string{detail},
+ }
+}
+
+// nvidiaGPU 通过 nvidia-smi 采集 NVIDIA GPU 状态。
+func nvidiaGPU() ([]*status, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ out, err := exec.CommandContext(ctx, "nvidia-smi",
+ "--query-gpu=name,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw",
+ "--format=csv,noheader,nounits").Output()
+ if err != nil {
+ logrus.Warnln("[aifalse] nvidia-smi 执行失败:", err, "输出:", string(out))
+ return nil, err
+ }
+ lines := strings.Split(strings.TrimSpace(string(out)), "\n")
+ stateinfo := make([]*status, 0, len(lines))
+ for _, line := range lines {
+ if line == "" {
+ continue
+ }
+ fields := strings.Split(line, ",")
+ if len(fields) < 6 {
+ logrus.Warnf("[aifalse] nvidia-smi 字段数不足: %d, 行: %s", len(fields), line)
+ continue
+ }
+ for i := range fields {
+ fields[i] = strings.TrimSpace(fields[i])
+ }
+ util, _ := strconv.ParseFloat(fields[1], 64)
+ memUsed, _ := strconv.ParseFloat(fields[2], 64)
+ memTotal, _ := strconv.ParseFloat(fields[3], 64)
+ temp, _ := strconv.ParseFloat(fields[4], 64)
+ power, _ := strconv.ParseFloat(fields[5], 64)
+ // info 卡样式下 precent 不单独显示,把利用率放进详情文本
+ detail := fmt.Sprintf("%.0f%% · %s / %s MiB · %.0f°C · %.1f W",
+ util,
+ strconv.FormatFloat(memUsed, 'f', 0, 64),
+ strconv.FormatFloat(memTotal, 'f', 0, 64),
+ temp, power)
+ stateinfo = append(stateinfo, &status{
+ precent: util,
+ name: fields[0],
+ text: []string{detail},
+ })
+ }
+ logrus.Infof("[aifalse] nvidiaGPU 解析完成: %d 块 GPU", len(stateinfo))
+ return stateinfo, nil
+}
+
+// tempstate 采集各硬件温度、风扇转速、CPU功耗。
+// 并行尝试三种途径(都给 3s 超时):
+// 1. HWiNFO64 注册表 HKLM\SOFTWARE\HWiNFO64\VSB —— Windows 专属,最优先
+// 你服务器已经有 HWiNFO 后台在跑(HWInfoSever 进程),直接读注册表就行
+// 2. LibreHardwareMonitor WMI root/LibreHardwareMonitor/Sensor —— 开源免费备选
+// 3. gopsutil.SensorsTemperatures() —— 跨平台通用,Windows 上只能拿到 ACPI 热区
+//
+// 如果 HWiNFO 或 LHM 提供了真实的 CPU 温度/风扇/功耗数据,会自动过滤掉 gopsutil 返回的
+// ACPI 主板热区(那些 25-30°C 的值很容易误导)。
+func tempstate() (temps []*status, fans []*status, powers []*status) {
+ // Windows 上异步触发 LHM 自动配置(下载 + 启动),不阻塞当前采集
+ go lhmAutoSetup()
+
+ type gopsutilResult struct {
+ ts []sensors.TemperatureStat
+ }
+ gopsCh := make(chan gopsutilResult, 1)
+ go func() {
+ ts, _ := sensors.SensorsTemperatures()
+ gopsCh <- gopsutilResult{ts}
+ }()
+
+ lhmCh := make(chan []lhmSensor, 1)
+ go func() {
+ // WMI 和 HTTP REST API 并行跑,谁先返回数据就用谁,两个都有就合并
+ wmiCh := make(chan []lhmSensor, 1)
+ httpCh := make(chan []lhmSensor, 1)
+ go func() { wmiCh <- queryLibreHardwareMonitor() }()
+ go func() { httpCh <- queryLibreHardwareMonitorHTTP() }()
+ wmi := <-wmiCh
+ http := <-httpCh
+ lhmCh <- mergeLHMSensors(wmi, http)
+ }()
+
+ type hwiResult struct {
+ temps, fans, powers []*status
+ }
+ hwiCh := make(chan hwiResult, 1)
+ go func() {
+ t, f, p := queryHWiNFO()
+ hwiCh <- hwiResult{t, f, p}
+ }()
+
+ // 并行收集,给 3s 总超时
+ var gpTS []sensors.TemperatureStat
+ var lhmSensors []lhmSensor
+ var hwiTemps, hwiFans, hwiPowers []*status
+ timeout := time.After(3 * time.Second)
+ for i := 0; i < 3; i++ {
+ select {
+ case r := <-gopsCh:
+ gpTS = r.ts
+ case r := <-lhmCh:
+ lhmSensors = r
+ case r := <-hwiCh:
+ hwiTemps, hwiFans, hwiPowers = r.temps, r.fans, r.powers
+ case <-timeout:
+ logrus.Debugln("[aifalse] 温度采集超时")
+ }
+ }
+ // 兜底收剩余
+ select {
+ case r := <-gopsCh:
+ if gpTS == nil {
+ gpTS = r.ts
+ }
+ default:
+ }
+ select {
+ case r := <-lhmCh:
+ if lhmSensors == nil {
+ lhmSensors = r
+ }
+ default:
+ }
+ select {
+ case r := <-hwiCh:
+ if hwiTemps == nil && hwiFans == nil && hwiPowers == nil {
+ hwiTemps, hwiFans, hwiPowers = r.temps, r.fans, r.powers
+ }
+ default:
+ }
+
+ // === HWiNFO 优先 ===
+ realSensorSource := false
+ if len(hwiTemps)+len(hwiFans)+len(hwiPowers) > 0 {
+ temps = append(temps, hwiTemps...)
+ fans = append(fans, hwiFans...)
+ powers = append(powers, hwiPowers...)
+ realSensorSource = true
+ logrus.Infof("[aifalse] HWiNFO: 温度 %d, 风扇 %d, 功耗 %d",
+ len(hwiTemps), len(hwiFans), len(hwiPowers))
+ }
+
+ // === LibreHardwareMonitor 次优先 ===
+ if len(lhmSensors) > 0 {
+ logrus.Infof("[aifalse] LibreHardwareMonitor 返回 %d 个传感器", len(lhmSensors))
+ for _, s := range lhmSensors {
+ switch s.SensorType {
+ case "Temperature":
+ temps = append(temps, &status{
+ name: lhmSensorDisplayName(s),
+ text: []string{fmt.Sprintf("%.1f°C", s.Value)},
+ precent: 0,
+ })
+ realSensorSource = true
+ case "Fan":
+ fans = append(fans, &status{
+ name: lhmSensorDisplayName(s),
+ text: []string{fmt.Sprintf("%.0f RPM", s.Value)},
+ precent: 0,
+ })
+ realSensorSource = true
+ case "Power":
+ powers = append(powers, &status{
+ name: lhmSensorDisplayName(s),
+ text: []string{fmt.Sprintf("%.1f W", s.Value)},
+ precent: 0,
+ })
+ realSensorSource = true
+ }
+ }
+ }
+
+ // === gopsutil 最后 ===
+ if len(gpTS) > 0 {
+ if realSensorSource {
+ // 有真实传感器数据了,过滤掉 ACPI 主板热区(25-30°C 误导人)
+ gpTS = filterACPIThermalZones(gpTS)
+ }
+ temps = append(temps, cleanGopsutilTemps(gpTS)...)
+ }
+
+ // 简化温度卡片:只保留 CPU Core 相关温度,去掉 Package/Average/Max/主板热区等
+ temps = simplifyTemps(temps)
+
+ logrus.Infof("[aifalse] 最终: 温度 %d, 风扇 %d, 功耗 %d (realSensorSource=%v)",
+ len(temps), len(fans), len(powers), realSensorSource)
+ return temps, fans, powers
+}
+
+// simplifyTemps 只保留 CPU Core 相关温度,过滤掉:
+// - CPU Package / CPU 封装温度(通常和 Core Max 接近,冗余)
+// - Core Average / Core Max(Core #1/2/3/4 已经覆盖)
+// - 主板温度 / ACPI 热区(没有真实传感器时的兜底,已经被上面过滤掉了)
+// - GPU 温度(GPU 卡片自己已经显示了,冗余)
+//
+// 保留:CPU Core #1, CPU Core #2, CPU Core #3, CPU Core #4 ...(各核心温度)
+// 如果没有 Core 温度(比如老 CPU 或 Linux),则退而保留 Package 或其他 CPU 温度。
+func simplifyTemps(temps []*status) []*status {
+ if len(temps) <= 1 {
+ return temps // 只有一个,不管它是什么都留着
+ }
+
+ // 第一遍:优先挑 "Core" 相关
+ var coreOnly []*status
+ var cpuFallback []*status
+ for _, t := range temps {
+ name := strings.ToLower(strings.TrimSpace(t.name))
+ switch {
+ case strings.Contains(name, "core") && !strings.Contains(name, "average") && !strings.Contains(name, "max"):
+ coreOnly = append(coreOnly, t)
+ case strings.Contains(name, "package"):
+ cpuFallback = append(cpuFallback, t)
+ case strings.Contains(name, "cpu"):
+ cpuFallback = append(cpuFallback, t)
+ }
+ }
+
+ if len(coreOnly) > 0 {
+ return coreOnly
+ }
+ if len(cpuFallback) > 0 {
+ return cpuFallback
+ }
+ return temps // 什么都没匹配上,原样返回
+}
+
+// filterACPIThermalZones 过滤掉 gopsutil 返回的 ACPI ThermalZone 条目。
+// 当 HWiNFO / LHM 提供了真实的 CPU/GPU 温度时,ACPI 主板热区(通常 25-30°C)是多余的。
+func filterACPIThermalZones(ts []sensors.TemperatureStat) []sensors.TemperatureStat {
+ filtered := make([]sensors.TemperatureStat, 0, len(ts))
+ for _, t := range ts {
+ key := strings.ToLower(strings.TrimSpace(t.SensorKey))
+ if strings.Contains(key, "acpi") || strings.Contains(key, "thermalzone") {
+ continue // 跳过 ACPI 主板热区
+ }
+ filtered = append(filtered, t)
+ }
+ if len(filtered) < len(ts) {
+ logrus.Debugf("[aifalse] 过滤掉 %d 个 ACPI 热区", len(ts)-len(filtered))
+ }
+ return filtered
+}
+
+// cleanGopsutilTemps 清洗 gopsutil 温度数据,ACPI ThermalZone 条目重命名。
+// 清洗后若出现同名条目(如多个 ACPI 热区都叫"主板温度"),自动加编号后缀。
+func cleanGopsutilTemps(ts []sensors.TemperatureStat) []*status {
+ var result []*status
+ // 第一步:清洗名称
+ type rawEntry struct {
+ displayName string
+ temp float64
+ }
+ cleaned := make([]rawEntry, 0, len(ts))
+ seen := make(map[string]bool, len(ts))
+ for _, t := range ts {
+ if t.Temperature <= 0 {
+ continue
+ }
+ key := strings.TrimSpace(t.SensorKey)
+ if key == "" || seen[key] {
+ continue
+ }
+ seen[key] = true
+ cleaned = append(cleaned, rawEntry{
+ displayName: sanitizeSensorName(key, t.Temperature),
+ temp: t.Temperature,
+ })
+ }
+
+ // 第二步:处理同名冲突 → 加编号后缀
+ nameCount := make(map[string]int)
+ for _, e := range cleaned {
+ nameCount[e.displayName]++
+ }
+ nameIdx := make(map[string]int)
+ for _, e := range cleaned {
+ if nameCount[e.displayName] > 1 {
+ nameIdx[e.displayName]++
+ result = append(result, &status{
+ name: fmt.Sprintf("%s #%d", e.displayName, nameIdx[e.displayName]),
+ text: []string{fmt.Sprintf("%.1f°C", e.temp)},
+ precent: 0,
+ })
+ } else {
+ result = append(result, &status{
+ name: e.displayName,
+ text: []string{fmt.Sprintf("%.1f°C", e.temp)},
+ precent: 0,
+ })
+ }
+ }
+ return result
+}
+
+// sanitizeSensorName 把 gopsutil 的传感器 key(如 "ACPI\ThermalZone\TZ00_0")改成对用户友好的名字。
+func sanitizeSensorName(key string, temp float64) string {
+ // ACPI ThermalZone:主板环境温度,温度通常 25-40°C
+ if strings.Contains(key, "ACPI") || strings.Contains(key, "ThermalZone") ||
+ strings.Contains(key, "TZ00") || strings.Contains(key, "TZ01") ||
+ strings.Contains(key, "TZ02") {
+ if temp < 45 {
+ return "主板温度"
+ }
+ return "主板热区"
+ }
+ // hwmon 下的常见命名
+ if strings.Contains(key, "coretemp") {
+ return "CPU " + key
+ }
+ if strings.Contains(key, "k10temp") {
+ return "AMD " + key
+ }
+ if strings.Contains(key, "nouveau") || strings.Contains(key, "nvrm") {
+ return "GPU " + key
+ }
+ return key
+}
+
func moreinfo(m *ctrl.Control[*zero.Ctx]) (stateinfo []*status, err error) {
var mems runtime.MemStats
runtime.ReadMemStats(&mems)
@@ -687,15 +1224,17 @@ func moreinfo(m *ctrl.Control[*zero.Ctx]) (stateinfo []*status, err error) {
if err != nil {
return
}
- cpuinfo, err := cpu.Info()
- if err != nil {
- return
+ cpuMu.RLock()
+ modelName := cpuModel
+ cpuMu.RUnlock()
+ if modelName == "" {
+ modelName = "未知"
}
count := len(m.Manager.M)
stateinfo = []*status{
{name: "OS", text: []string{hostinfo.Platform}},
- {name: "CPU", text: []string{strings.TrimSpace(cpuinfo[0].ModelName)}},
+ {name: "CPU", text: []string{modelName}},
{name: "Version", text: []string{hostinfo.PlatformVersion}},
{name: "Plugin", text: []string{"共 " + strconv.Itoa(count) + " 个"}},
{name: "Memory", text: []string{"已用 " + fmtmem}},
@@ -703,6 +1242,135 @@ func moreinfo(m *ctrl.Control[*zero.Ctx]) (stateinfo []*status, err error) {
return
}
+// barCardH 返回横向进度条卡片的高度(磁盘 / GPU)。
+func barCardH(n int) int {
+ if n < 1 {
+ n = 1
+ }
+ return 60 + 70*n
+}
+
+// infoCardH 返回信息行卡片的高度(详细信息 / 温度)。
+func infoCardH(n int) int {
+ if n < 1 {
+ n = 1
+ }
+ return 40 + 44*n
+}
+
+// loadBackground 从本地 data/aifalse/ 目录随机挑选一张背景图。
+// 不再依赖网络下载,避免因图片服务不可用导致自检迟迟不出图。
+// 若目录下没有可用图片,返回兜底纯色背景。
+func loadBackground() image.Image {
+ bgMu.Lock()
+ defer bgMu.Unlock()
+
+ imgs := listBackgroundImages()
+ if len(imgs) == 0 {
+ logrus.Warnln("[aifalse] data/aifalse/ 下没有找到背景图,使用兜底背景")
+ if bgImage != nil {
+ return bgImage
+ }
+ return fallbackBackground()
+ }
+ // 随机选一张
+ path := imgs[rand.Intn(len(imgs))]
+ data, err := os.ReadFile(path)
+ if err != nil {
+ logrus.Warnln("[aifalse] 读取背景图失败:", err)
+ if bgImage != nil {
+ return bgImage
+ }
+ return fallbackBackground()
+ }
+ img, _, err := image.Decode(bytes.NewReader(data))
+ if err != nil {
+ logrus.Warnln("[aifalse] 解码背景图失败:", path, err)
+ if bgImage != nil {
+ return bgImage
+ }
+ return fallbackBackground()
+ }
+ bgImage = img
+ return img
+}
+
+// listBackgroundImages 列出 data/aifalse/ 下所有支持的图片文件。
+func listBackgroundImages() []string {
+ entries, err := os.ReadDir(dataFolder)
+ if err != nil {
+ return nil
+ }
+ exts := map[string]bool{
+ ".jpg": true,
+ ".jpeg": true,
+ ".png": true,
+ ".gif": true,
+ ".webp": true,
+ ".bmp": true,
+ }
+ var imgs []string
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ if exts[strings.ToLower(filepath.Ext(e.Name()))] {
+ imgs = append(imgs, filepath.Join(dataFolder, e.Name()))
+ }
+ }
+ return imgs
+}
+
+// fallbackBackground 生成一张兜底的深色背景图。
+func fallbackBackground() image.Image {
+ img := image.NewNRGBA(image.Rect(0, 0, 1280, 720))
+ for y := 0; y < 720; y++ {
+ for x := 0; x < 1280; x++ {
+ img.SetNRGBA(x, y, color.NRGBA{R: 25, G: 25, B: 35, A: 255})
+ }
+ }
+ return img
+}
+
+// loadAvatar 按 uid 缓存 QQ 头像。下载失败时返回空(调用方跳过头像绘制)。
+func loadAvatar(uid int64) ([]byte, error) {
+ if v, ok := avatarCache.Load(uid); ok {
+ if b, ok := v.([]byte); ok && len(b) > 0 {
+ return b, nil
+ }
+ }
+ url := "https://q4.qlogo.cn/g?b=qq&nk=" + strconv.FormatInt(uid, 10) + "&s=640"
+ resp, err := httpClient.Get(url)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, errors.Errorf("头像状态码: %d", resp.StatusCode)
+ }
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ avatarCache.Store(uid, data)
+ return data, nil
+}
+
+// getFont 全局只加载一次字体。
+func getFont() ([]byte, error) {
+ fontMu.Lock()
+ defer fontMu.Unlock()
+ if len(fontBytes) > 0 {
+ return fontBytes, nil
+ }
+ b, err := file.GetLazyData(text.GlowSansFontFile, control.Md5File, true)
+ if err != nil {
+ return nil, err
+ }
+ fontBytes = b
+ return b, nil
+}
+
func colorswitch(a uint8) color.Color {
if isday {
return color.NRGBA{255, 255, 255, a}
diff --git a/plugin/servicemenu/data/Font/FOT-Yuruka Std.ttf b/plugin/servicemenu/data/Font/FOT-Yuruka Std.ttf
new file mode 100644
index 0000000000..26cb936795
Binary files /dev/null and b/plugin/servicemenu/data/Font/FOT-Yuruka Std.ttf differ
diff --git a/plugin/servicemenu/data/Font/GlowSansSC-Normal-ExtraBold.ttf b/plugin/servicemenu/data/Font/GlowSansSC-Normal-ExtraBold.ttf
new file mode 100644
index 0000000000..daa2413bda
Binary files /dev/null and b/plugin/servicemenu/data/Font/GlowSansSC-Normal-ExtraBold.ttf differ
diff --git a/plugin/servicemenu/data/Font/Impact.ttf b/plugin/servicemenu/data/Font/Impact.ttf
new file mode 100644
index 0000000000..7b7956fd5b
Binary files /dev/null and b/plugin/servicemenu/data/Font/Impact.ttf differ
diff --git a/plugin/servicemenu/data/Font/Torus-Regular.otf b/plugin/servicemenu/data/Font/Torus-Regular.otf
new file mode 100644
index 0000000000..d5fc09e17c
Binary files /dev/null and b/plugin/servicemenu/data/Font/Torus-Regular.otf differ
diff --git a/plugin/servicemenu/data/control/kanban.png b/plugin/servicemenu/data/control/kanban.png
new file mode 100644
index 0000000000..f13e8dcba4
Binary files /dev/null and b/plugin/servicemenu/data/control/kanban.png differ
diff --git a/plugin/servicemenu/data/control/plugins.db b/plugin/servicemenu/data/control/plugins.db
new file mode 100644
index 0000000000..0b9fe3c551
Binary files /dev/null and b/plugin/servicemenu/data/control/plugins.db differ
diff --git a/plugin/servicemenu/data/control/stor.spb b/plugin/servicemenu/data/control/stor.spb
new file mode 100644
index 0000000000..15211e91b3
Binary files /dev/null and b/plugin/servicemenu/data/control/stor.spb differ
diff --git a/plugin/servicemenu/data/control/zbpuwu.png b/plugin/servicemenu/data/control/zbpuwu.png
new file mode 100644
index 0000000000..9b63b83258
Binary files /dev/null and b/plugin/servicemenu/data/control/zbpuwu.png differ
diff --git a/plugin/servicemenu/main.go b/plugin/servicemenu/main.go
new file mode 100644
index 0000000000..7ab61cc6a8
--- /dev/null
+++ b/plugin/servicemenu/main.go
@@ -0,0 +1,949 @@
+// Package servicemenu 服务菜单:自检图同款毛玻璃 UI + 主题切换 + 优雅重载
+package servicemenu
+
+import (
+ "bytes"
+ "image"
+ "image/color"
+ "image/draw"
+
+ // 注册jpg/gif解码器,背景图解码不依赖其他插件是否加载
+ _ "image/gif"
+ _ "image/jpeg"
+ "image/png"
+ "math"
+ "math/rand"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/FloatTech/gg"
+ zbpctrl "github.com/FloatTech/zbpctrl"
+ "github.com/FloatTech/zbputils/control"
+ "github.com/disintegration/imaging"
+ "github.com/sirupsen/logrus"
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/message"
+
+ "github.com/FloatTech/ZeroBot-Plugin/kanban/banner"
+)
+
+// theme 主题配色(iOS Liquid Glass:凸透镜放大 + 重磨砂 + 边缘折射 + 色散 + 奶白洗白)
+type theme struct {
+ Name string
+ DisplayName string
+ BackgroundFrom [3]uint8
+ BackgroundTo [3]uint8
+ OverlayColor color.RGBA // 整图覆盖色(暗主题黑压暗、亮主题白提亮)
+ OverlayAlpha uint8 // 整图覆盖层 alpha (0-255)
+ Blur float64 // 背景模糊半径(磨砂强度,目标风格 26-34)
+ Magnify float64 // 凸透镜放大倍率(1.2-1.4,核心特征)
+ Refraction float64 // 边缘折射强度(像素)
+ Dispersion float64 // 色散强度(0-1),RGB 通道分离
+ RimAlpha uint8 // 顶部轮廓光 alpha
+ BottomDark uint8 // 底部内阴影 alpha
+ Tint color.RGBA // 玻璃色调(奶白)
+ TintAlpha uint8 // 奶白洗白强度(55-76)
+ Saturate float64 // 饱和度提升(1.15-1.3)
+ TopLight uint8 // 顶部高光带 alpha
+ StrokeAlpha uint8 // 边缘细描边 alpha
+ Brightness float64 // 玻璃亮度
+ TextMain color.RGBA
+ TextSec color.RGBA
+}
+
+var themes = []theme{
+ // 1. 暗夜玻璃 - 冷紫调(苹果风格:中心通透 + 边缘折射)
+ {
+ Name: "glass-dark", DisplayName: "暗夜玻璃",
+ BackgroundFrom: [3]uint8{55, 48, 72},
+ BackgroundTo: [3]uint8{72, 60, 95},
+ OverlayColor: color.RGBA{R: 0, G: 0, B: 0, A: 255},
+ OverlayAlpha: 55,
+ Blur: 10, Magnify: 1.22, Refraction: 18, Dispersion: 0.12,
+ RimAlpha: 140, BottomDark: 65,
+ Tint: color.RGBA{R: 250, G: 248, B: 255, A: 255}, TintAlpha: 38,
+ Saturate: 1.3, TopLight: 55, StrokeAlpha: 60, Brightness: 1.1,
+ TextMain: color.RGBA{R: 255, G: 255, B: 255, A: 255},
+ TextSec: color.RGBA{R: 255, G: 255, B: 255, A: 215},
+ },
+ // 2. 柔光玻璃 - 纯白奶感(目标风格)
+ {
+ Name: "glass-light", DisplayName: "柔光玻璃",
+ BackgroundFrom: [3]uint8{232, 228, 242},
+ BackgroundTo: [3]uint8{218, 222, 238},
+ OverlayColor: color.RGBA{R: 255, G: 255, B: 255, A: 255},
+ OverlayAlpha: 25,
+ Blur: 10, Magnify: 1.22, Refraction: 18, Dispersion: 0.12,
+ RimAlpha: 140, BottomDark: 65,
+ Tint: color.RGBA{R: 255, G: 255, B: 255, A: 255}, TintAlpha: 46,
+ Saturate: 1.25, TopLight: 60, StrokeAlpha: 60, Brightness: 1.06,
+ TextMain: color.RGBA{R: 255, G: 255, B: 255, A: 255},
+ TextSec: color.RGBA{R: 255, G: 255, B: 255, A: 215},
+ },
+ // 3. 霓虹赛博 - 青调奶白
+ {
+ Name: "neon", DisplayName: "霓虹赛博",
+ BackgroundFrom: [3]uint8{20, 8, 35},
+ BackgroundTo: [3]uint8{42, 14, 58},
+ OverlayColor: color.RGBA{R: 0, G: 0, B: 0, A: 255},
+ OverlayAlpha: 75,
+ Blur: 12, Magnify: 1.26, Refraction: 22, Dispersion: 0.15,
+ RimAlpha: 145, BottomDark: 70,
+ Tint: color.RGBA{R: 244, G: 255, B: 252, A: 255}, TintAlpha: 44,
+ Saturate: 1.38, TopLight: 65, StrokeAlpha: 70, Brightness: 1.12,
+ TextMain: color.RGBA{R: 255, G: 255, B: 255, A: 255},
+ TextSec: color.RGBA{R: 240, G: 255, B: 250, A: 215},
+ },
+ // 4. 极简暖灰 - 暖米奶白
+ {
+ Name: "minimal", DisplayName: "极简暖灰",
+ BackgroundFrom: [3]uint8{238, 236, 232},
+ BackgroundTo: [3]uint8{228, 226, 220},
+ OverlayColor: color.RGBA{R: 255, G: 255, B: 255, A: 255},
+ OverlayAlpha: 35,
+ Blur: 9, Magnify: 1.2, Refraction: 16, Dispersion: 0.1,
+ RimAlpha: 135, BottomDark: 60,
+ Tint: color.RGBA{R: 255, G: 252, B: 246, A: 255}, TintAlpha: 42,
+ Saturate: 1.2, TopLight: 55, StrokeAlpha: 55, Brightness: 1.05,
+ TextMain: color.RGBA{R: 255, G: 255, B: 255, A: 255},
+ TextSec: color.RGBA{R: 255, G: 253, B: 248, A: 215},
+ },
+}
+
+var currentTheme = themes[0]
+
+// ===== 随机背景图(复用自检图 data/aifalse/ 目录)=====
+const bgDataDir = "data/aifalse"
+
+var (
+ bgListOnce sync.Once
+ bgFiles []string
+)
+
+func listBgFiles() []string {
+ bgListOnce.Do(func() {
+ entries, err := os.ReadDir(bgDataDir)
+ if err != nil {
+ return
+ }
+ exts := map[string]bool{
+ ".jpg": true, ".jpeg": true, ".png": true,
+ ".gif": true, ".webp": true, ".bmp": true,
+ }
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ if exts[strings.ToLower(filepath.Ext(e.Name()))] {
+ bgFiles = append(bgFiles, filepath.Join(bgDataDir, e.Name()))
+ }
+ }
+ })
+ return bgFiles
+}
+
+func loadRandomBg() image.Image {
+ files := listBgFiles()
+ if len(files) == 0 {
+ return nil
+ }
+ path := files[rand.Intn(len(files))]
+ f, err := os.Open(path)
+ if err != nil {
+ return nil
+ }
+ defer f.Close()
+ img, _, err := image.Decode(f)
+ if err != nil {
+ logrus.Warn("[servicemenu] 解码背景图失败:", path, err)
+ return nil
+ }
+ return img
+}
+
+// SetTheme 按名称切换当前主题(大小写不敏感),未找到返回 false。
+func SetTheme(name string) bool {
+ for _, t := range themes {
+ if strings.EqualFold(t.Name, name) {
+ currentTheme = t
+ return true
+ }
+ }
+ return false
+}
+
+func getPlugins() []*zbpctrl.Control[*zero.Ctx] {
+ var plugins []*zbpctrl.Control[*zero.Ctx]
+ control.ForEachByPrio(func(_ int, m *zbpctrl.Control[*zero.Ctx]) bool {
+ plugins = append(plugins, m)
+ return true
+ })
+ return plugins
+}
+
+func init() {
+ // ===== 命令注册 =====
+
+ zero.OnCommandGroup([]string{"服务列表", "service_list"}, zero.OnlyToMe).SetBlock(true).FirstPriority().
+ Handle(func(ctx *zero.Ctx) {
+ gid := ctx.Event.GroupID
+ if gid == 0 {
+ gid = -ctx.Event.UserID
+ }
+ page := 1
+ // 从命令参数里取页码
+ if raw := ctx.State["args"]; raw != nil {
+ if s, ok := raw.(string); ok && strings.TrimSpace(s) != "" {
+ page = atoi(strings.TrimSpace(s))
+ }
+ }
+ img, err := renderServiceList(gid, page)
+ if err != nil {
+ ctx.SendChain(message.Text("渲染失败: ", err))
+ return
+ }
+ ctx.SendChain(message.ImageBytes(img))
+ })
+
+ zero.OnRegex(`^服务列表\s*(\d*)\s*$`).SetBlock(true).FirstPriority().
+ Handle(func(ctx *zero.Ctx) {
+ gid := ctx.Event.GroupID
+ if gid == 0 {
+ gid = -ctx.Event.UserID
+ }
+ page := 1
+ if m := ctx.State["regex_matched"].([]string); len(m) > 1 && m[1] != "" {
+ page = atoi(m[1])
+ }
+ img, err := renderServiceList(gid, page)
+ if err != nil {
+ ctx.SendChain(message.Text("渲染失败: ", err))
+ return
+ }
+ ctx.SendChain(message.ImageBytes(img))
+ })
+
+ zero.OnRegex(`^(菜单用法|menuusage)\s+(\S+)$`).SetBlock(true).FirstPriority().
+ Handle(func(ctx *zero.Ctx) {
+ name := strings.ToLower(ctx.State["regex_matched"].([]string)[2])
+ m, ok := control.Lookup(name)
+ if !ok {
+ ctx.SendChain(message.Text("没有找到插件: ", name))
+ return
+ }
+ img, err := renderUsageCard(m)
+ if err != nil {
+ ctx.SendChain(message.Text("渲染失败: ", err))
+ return
+ }
+ ctx.SendChain(message.ImageBytes(img))
+ })
+
+ zero.OnRegex(`^主题\s+(\S+)$`).SetBlock(true).FirstPriority().
+ Handle(func(ctx *zero.Ctx) {
+ name := strings.ToLower(ctx.State["regex_matched"].([]string)[1])
+ if SetTheme(name) {
+ ctx.SendChain(message.Text("主题已切换为: ", currentTheme.DisplayName))
+ } else {
+ var names []string
+ for _, t := range themes {
+ names = append(names, t.Name+"("+t.DisplayName+")")
+ }
+ ctx.SendChain(message.Text("没有这个主题。可用: ", strings.Join(names, ", ")))
+ }
+ })
+
+ zero.OnFullMatchGroup([]string{"主题列表", "listtheme"}).SetBlock(true).FirstPriority().
+ Handle(func(ctx *zero.Ctx) {
+ var sb strings.Builder
+ sb.WriteString("可用主题:\n")
+ for i, t := range themes {
+ marker := " "
+ if t.Name == currentTheme.Name {
+ marker = "★"
+ }
+ sb.WriteString(marker + " " + itoa(i+1) + ". " + t.Name + " - " + t.DisplayName + "\n")
+ }
+ sb.WriteString("\n用法: menu <编号> 切换 | 主题 切换")
+ ctx.SendChain(message.Text(sb.String()))
+ })
+
+ zero.OnRegex(`^menu\s+(\d+)$`).SetBlock(true).FirstPriority().
+ Handle(func(ctx *zero.Ctx) {
+ n := atoi(ctx.State["regex_matched"].([]string)[1])
+ if n < 1 || n > len(themes) {
+ var sb strings.Builder
+ sb.WriteString("主题编号无效。可用:\n")
+ for i, t := range themes {
+ sb.WriteString(itoa(i+1) + ". " + t.DisplayName)
+ if t.Name == currentTheme.Name {
+ sb.WriteString(" ★")
+ }
+ sb.WriteString("\n")
+ }
+ ctx.SendChain(message.Text(sb.String()))
+ return
+ }
+ currentTheme = themes[n-1]
+ ctx.SendChain(message.Text("🎨 UI 已切换为 [", itoa(n), "] ", currentTheme.DisplayName))
+ })
+
+ zero.OnFullMatchGroup([]string{"重载全部", "reloadall", "重载bot"}, zero.SuperUserPermission).SetBlock(true).FirstPriority().
+ Handle(func(ctx *zero.Ctx) {
+ ctx.SendChain(message.Text("收到重载命令,正在重启进程..."))
+ go gracefulRestart()
+ })
+
+ zero.OnRegex(`^重载\s+(\S+)$`, zero.SuperUserPermission).SetBlock(true).FirstPriority().
+ Handle(func(ctx *zero.Ctx) {
+ name := strings.ToLower(ctx.State["regex_matched"].([]string)[1])
+ _, ok := control.Lookup(name)
+ if !ok {
+ ctx.SendChain(message.Text("没找到插件: ", name))
+ return
+ }
+ ctx.SendChain(message.Text("注意: Go 编译后的插件无法单独热重载,将重启整个进程..."))
+ go gracefulRestart()
+ })
+}
+
+func atoi(s string) int {
+ n := 0
+ for _, c := range s {
+ if c >= '0' && c <= '9' {
+ n = n*10 + int(c-'0')
+ }
+ }
+ return n
+}
+
+func itoa(n int) string {
+ if n == 0 {
+ return "0"
+ }
+ neg := false
+ if n < 0 {
+ neg = true
+ n = -n
+ }
+ var buf [20]byte
+ i := len(buf)
+ for n > 0 {
+ i--
+ buf[i] = byte('0' + n%10)
+ n /= 10
+ }
+ if neg {
+ i--
+ buf[i] = '-'
+ }
+ return string(buf[i:])
+}
+
+func gracefulRestart() {
+ logrus.Infoln("[servicemenu] 准备重启进程...")
+ time.Sleep(1 * time.Second)
+ exePath, err := os.Executable()
+ if err != nil {
+ logrus.Errorf("[servicemenu] 获取 exe 路径失败: %v", err)
+ os.Exit(0)
+ return
+ }
+ if runtime.GOOS == "windows" {
+ dir := exePath[:strings.LastIndex(exePath, `\`)]
+ cmd := exec.Command("cmd", "/c", "start", "/min", exePath)
+ cmd.Dir = dir
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Start(); err != nil {
+ logrus.Errorf("[servicemenu] 启动新进程失败: %v", err)
+ }
+ } else {
+ cmd := exec.Command(exePath, os.Args[1:]...)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ go func() {
+ if err := cmd.Run(); err != nil {
+ logrus.Errorf("[servicemenu] 重启进程失败: %v", err)
+ }
+ }()
+ }
+ os.Exit(0)
+}
+
+// ================ 图片渲染(和自检图同款模式)================
+
+const (
+ cardPadding = 70
+ cardMarginY = 20
+ colGap = 32
+ itemH = 120
+ headerH = 200
+ footerH = 60
+ cardRadius = 18
+ itemsPerRow = 2 // 两列:卡片更宽,字号可整体放大,无需放大图片即可看清
+ pageCount = 2 // 固定分成 2 页
+)
+
+// toRGBA 把任意 image.Image 转成 *image.RGBA(imaging.Blur 返回 NRGBA,SubImage 需要 RGBA)
+func toRGBA(src image.Image) *image.RGBA {
+ if rgba, ok := src.(*image.RGBA); ok {
+ return rgba
+ }
+ b := src.Bounds()
+ dst := image.NewRGBA(b)
+ draw.Draw(dst, b, src, b.Min, draw.Src)
+ return dst
+}
+
+// renderServiceList 渲染服务列表(固定分 2 页,page 从 1 开始)
+func renderServiceList(gid int64, page int) ([]byte, error) {
+ t := currentTheme
+ allPlugins := getPlugins()
+ total := len(allPlugins)
+ if total == 0 {
+ total = 1
+ allPlugins = append(allPlugins, nil) // 占位,防止除 0
+ }
+
+ perPage := (total + pageCount - 1) / pageCount // 向上取整
+ if page < 1 {
+ page = 1
+ }
+ if page > pageCount {
+ page = pageCount
+ }
+
+ start := (page - 1) * perPage
+ end := start + perPage
+ if end > total {
+ end = total
+ }
+ pagePlugins := allPlugins[start:end]
+
+ canvasW := 1200
+ rows := int(math.Ceil(float64(len(pagePlugins)) / float64(itemsPerRow)))
+ if rows < 1 {
+ rows = 1
+ }
+ canvasH := cardPadding + headerH + rows*(itemH+cardMarginY) + footerH + cardPadding + 40
+
+ c := gg.NewContext(canvasW, canvasH)
+ bg := buildBackground(canvasW, canvasH, t)
+ c.DrawImage(bg, 0, 0)
+
+ // 整图统一覆盖层(暗主题压暗、亮主题提亮)
+ if t.OverlayAlpha > 0 {
+ oc := t.OverlayColor
+ c.SetRGBA255(int(oc.R), int(oc.G), int(oc.B), int(t.OverlayAlpha))
+ c.DrawRectangle(0, 0, float64(canvasW), float64(canvasH))
+ c.Fill()
+ }
+
+ // blurback 必须对"已画完 Overlay 的完整 canvas"做 blur
+ // 这样卡片内外色调 100% 一致,毛玻璃才真实(和自检图 aifalse 同做法)
+ blurback := toRGBA(imaging.Blur(c.Image(), t.Blur))
+
+ // 标题卡
+ headerX, headerY := cardPadding, cardPadding
+ headerW := canvasW - cardPadding*2
+ drawNewCard(c, headerX, headerY, headerW, headerH, blurback, t)
+
+ c.SetColor(t.TextMain)
+ loadFont(c, "data/Font/GlowSansSC-Normal-ExtraBold.ttf", 48)
+ c.DrawString("ZeroBot-Plugin", float64(headerX+32), float64(headerY+60))
+
+ const secFont = "data/Font/regular-bold.ttf"
+ drawTextOutlined(c, "OneBot + ZeroBot + Golang", secFont, 20, float64(headerX+32), float64(headerY+92), t.TextSec)
+ drawTextOutlined(c, banner.Version+" · FloatTech", secFont, 18, float64(headerX+32), float64(headerY+120), t.TextSec)
+
+ c.SetColor(t.TextMain)
+ loadFont(c, "data/Font/GlowSansSC-Normal-ExtraBold.ttf", 40)
+ rightText := "服务列表"
+ fw, _ := c.MeasureString(rightText)
+ c.DrawString(rightText, float64(canvasW-cardPadding-32)-fw, float64(headerY+60))
+
+ subRight := "Server List"
+ loadFont(c, secFont, 24)
+ fw2, _ := c.MeasureString(subRight)
+ drawTextOutlined(c, subRight, secFont, 24, float64(canvasW-cardPadding-32)-fw2, float64(headerY+96), t.TextSec)
+
+ // 分隔线用矩形填充(gg 的 Stroke 会写入垃圾像素,已弃用)
+ c.SetRGBA255(128, 128, 128, 60)
+ c.DrawRectangle(float64(headerX+32), float64(headerY+headerH-41), float64(canvasW-cardPadding-32-(headerX+32)), 2)
+ c.Fill()
+
+ infoText := "总插件 " + itoa(total) + " · 第 " + itoa(page) + "/" + itoa(pageCount) + " 页 · 主题 " + t.DisplayName
+ drawTextOutlined(c, infoText, secFont, 16, float64(headerX+32), float64(headerY+headerH-18), t.TextSec)
+
+ // 插件卡片(当前页)
+ cardAreaTop := headerY + headerH + cardMarginY
+ for i, m := range pagePlugins {
+ if m == nil {
+ continue
+ }
+ col := i % itemsPerRow
+ row := i / itemsPerRow
+ cardW := (canvasW - cardPadding*2 - colGap*(itemsPerRow-1)) / itemsPerRow
+ x := float64(cardPadding) + float64(col)*float64(cardW+colGap)
+ cardTop := float64(cardAreaTop) + float64(row)*(itemH+cardMarginY)
+ enabled := m.IsEnabledIn(gid)
+ drawNewCard(c, int(x), int(cardTop), cardW, itemH, blurback, t)
+ drawPluginCardContent(c, int(x), int(cardTop), cardW, itemH, m.Service, m.Options.Brief, enabled, t)
+ }
+
+ // Footer
+ footerY := canvasH - cardPadding + 10
+ drawTextOutlined(c, "服务列表 [1/2] | menu <编号> | 主题 <名> | 重载全部 | 用法 <英文名>", secFont, 16, float64(cardPadding), float64(footerY), t.TextSec)
+
+ return encodePNG(c.Image())
+}
+
+// ============ liquid-glass 核心算法移植(from huangj17/liquid-glass-react & shuding/liquid-glass)========
+
+// smoothStep 平滑插值,对应 TS 版 smoothStep(a, b, t)
+// 注意:必须用 clamp 实现(原 TS 版支持 a > b 的反向区间,
+// 例如 smoothStep(1.5, -0.5, d) 表示 d<-0.5 时为 1、d>1.5 时为 0。
+// 若用 "t 1 {
+ x = 1
+ }
+ return x * x * (3 - 2*x)
+}
+
+// clampF 浮点 clamp
+func clampF(v, lo, hi float64) float64 {
+ if v < lo {
+ return lo
+ }
+ if v > hi {
+ return hi
+ }
+ return v
+}
+
+// sampleBilinear 双线性插值采样(消除位移采样的颗粒感)
+func sampleBilinear(bg *image.RGBA, rect image.Rectangle, sx, sy float64) color.RGBA {
+ gx := sx + float64(rect.Min.X)
+ gy := sy + float64(rect.Min.Y)
+ x0 := int(math.Floor(gx))
+ y0 := int(math.Floor(gy))
+ tx := gx - float64(x0)
+ ty := gy - float64(y0)
+
+ x1, y1 := x0+1, y0+1
+ W := bg.Bounds().Dx()
+ H := bg.Bounds().Dy()
+ clamp := func(v, hi int) int {
+ if v < 0 {
+ return 0
+ }
+ if v > hi {
+ return hi
+ }
+ return v
+ }
+ x0 = clamp(x0, W-1)
+ x1 = clamp(x1, W-1)
+ y0 = clamp(y0, H-1)
+ y1 = clamp(y1, H-1)
+
+ c00 := bg.At(x0, y0).(color.RGBA)
+ c10 := bg.At(x1, y0).(color.RGBA)
+ c01 := bg.At(x0, y1).(color.RGBA)
+ c11 := bg.At(x1, y1).(color.RGBA)
+
+ lerp := func(a, b, tt float64) float64 { return a + (b-a)*tt }
+ rr := lerp(lerp(float64(c00.R), float64(c10.R), tx), lerp(float64(c01.R), float64(c11.R), tx), ty)
+ g := lerp(lerp(float64(c00.G), float64(c10.G), tx), lerp(float64(c01.G), float64(c11.G), tx), ty)
+ bb := lerp(lerp(float64(c00.B), float64(c10.B), tx), lerp(float64(c01.B), float64(c11.B), tx), ty)
+ return color.RGBA{R: uint8(rr + 0.5), G: uint8(g + 0.5), B: uint8(bb + 0.5), A: 255}
+}
+
+// renderLiquidGlass 按 iOS Liquid Glass 原理渲染卡片区域(from forum.cocos.org/t/topic/171941):
+//
+// 凸透镜放大(核心) → 边缘折射 → RGB 色散 → 轮廓光 → 亮度/饱和 → 奶白洗白
+func renderLiquidGlass(blurback *image.RGBA, x, y, w, h int, t theme) *image.RGBA {
+ rect := image.Rect(x, y, x+w, y+h)
+ out := image.NewRGBA(image.Rect(0, 0, w, h))
+ bw := float64(w)
+ bh := float64(h)
+ cx := bw / 2
+ cy := bh / 2
+ rad := float64(cardRadius)
+ // 边缘折射带宽度:随卡片高度缩放(苹果液态玻璃的折射集中在外环 15%-30%)
+ edgeW := clampF(bh*0.30, 14, 36)
+
+ // 圆角矩形 SDF(内部为负、边缘为 0、外部为正)
+ sdf := func(fx, fy float64) float64 {
+ qx := math.Abs(fx-cx) - (bw/2 - rad)
+ qy := math.Abs(fy-cy) - (bh/2 - rad)
+ return math.Min(math.Max(qx, qy), 0) + math.Hypot(math.Max(qx, 0), math.Max(qy, 0)) - rad
+ }
+
+ for py := 0; py < h; py++ {
+ for px := 0; px < w; px++ {
+ fx := float64(px) + 0.5
+ fy := float64(py) + 0.5
+ dEdge := sdf(fx, fy)
+
+ // 圆角矩形 alpha:以名义边缘为中心的 1.6px 对称渐隐(经典 SDF AA)。
+ // 过窄(1px 偏外)会锯齿,过宽(3.5px)半透明边缘带压在阴影上呈暗环毛边
+ alphaF := smoothStep(0.8, -0.8, dEdge)
+ if alphaF <= 0 {
+ out.Set(px, py, color.RGBA{0, 0, 0, 0})
+ continue
+ }
+
+ // SDF 外法线(数值梯度):折射方向垂直于最近边缘,随形状/尺寸自适应
+ nx := sdf(fx+1, fy) - sdf(fx-1, fy)
+ ny := sdf(fx, fy+1) - sdf(fx, fy-1)
+ if nl := math.Hypot(nx, ny); nl > 1e-6 {
+ nx /= nl
+ ny /= nl
+ } else {
+ nx, ny = 0, 0
+ }
+
+ // === 1. 边缘折射:越靠近边缘越向外取样(外围背景被"压"进边缘环)
+ // 最外 5px 收敛到 0:边界处与卡外背景无错位,避免重影拖痕接缝 ===
+ mag := t.Refraction * smoothStep(-edgeW*1.5, -edgeW*0.2, dEdge) * smoothStep(2.0, -3.0, dEdge)
+
+ // === 2. 凸透镜放大(核心):采样区缩小 = 背景被放大 ===
+ sx := (fx+nx*mag-cx)/t.Magnify + cx
+ sy := (fy+ny*mag-cy)/t.Magnify + cy
+
+ // === 3. RGB 色散:R 前移,B 后移 ===
+ ab := t.Dispersion
+ r := float64(sampleBilinear(blurback, rect, sx+nx*mag*ab, sy+ny*mag*ab).R)
+ g := float64(sampleBilinear(blurback, rect, sx, sy).G)
+ b := float64(sampleBilinear(blurback, rect, sx-nx*mag*ab, sy-ny*mag*ab).B)
+
+ // === 4. 轮廓光:SDF 细边缘带(~5px),顶部最亮、侧边次之(环境光反射)===
+ band := smoothStep(-7, -1.5, dEdge)
+ wr := float64(t.RimAlpha) / 255.0
+ spec := band * (0.35 + 0.65*clampF(-ny, 0, 1)) * wr
+ r = r*(1-spec) + 255*spec
+ g = g*(1-spec) + 255*spec
+ b = b*(1-spec) + 255*spec
+
+ // === 5. 底部内阴影(玻璃厚度)===
+ bd := band * clampF(ny, 0, 1) * float64(t.BottomDark) / 255.0
+ r *= 1 - bd
+ g *= 1 - bd
+ b *= 1 - bd
+
+ // === 6. 亮度 + 饱和度 ===
+ r *= t.Brightness
+ g *= t.Brightness
+ b *= t.Brightness
+ gray := 0.2126*r + 0.7152*g + 0.0722*b
+ r = gray + (r-gray)*t.Saturate
+ g = gray + (g-gray)*t.Saturate
+ b = gray + (b-gray)*t.Saturate
+
+ // === 7. 奶白洗白(液态玻璃 milk 质感)===
+ ta := float64(t.TintAlpha) / 255.0
+ r = r*(1-ta) + float64(t.Tint.R)*ta
+ g = g*(1-ta) + float64(t.Tint.G)*ta
+ b = b*(1-ta) + float64(t.Tint.B)*ta
+
+ // === 8. 顶部高光带(SDF 实现,替代 gg Clip+LinearGradient——
+ // 该库的渐变填充在圆弧段会写入黑色垃圾像素)===
+ hh := bh * 0.4
+ if fy < hh {
+ ttH := fy / hh
+ ha := float64(t.TopLight)
+ if ttH < 0.5 {
+ ha += (ha/3 - ha) * (ttH * 2)
+ } else {
+ ha = ha / 3 * (1 - (ttH-0.5)*2)
+ }
+ inside := smoothStep(0, -2.5, dEdge) // 贴圆角轮廓,向内 2.5px 过渡
+ a := ha / 255.0 * inside
+ r = r*(1-a) + 255*a
+ g = g*(1-a) + 255*a
+ b = b*(1-a) + 255*a
+ }
+
+ // === 9. 边缘细描边(SDF 带,替代 gg Stroke——该库的矢量描边
+ // 在圆弧段会忽略样式写入黑色垃圾像素,即角部黑刺的元凶)===
+ sa := float64(t.StrokeAlpha) / 255.0 *
+ smoothStep(-1.8, -1.1, dEdge) * smoothStep(0.5, -0.3, dEdge)
+ if sa > 0 {
+ r = r*(1-sa) + 255*sa
+ g = g*(1-sa) + 255*sa
+ b = b*(1-sa) + 255*sa
+ }
+
+ // 输出必须预乘 alpha(image.RGBA 是预乘格式,否则边缘渐隐像素会被二次相乘变暗)
+ aF := 255 * alphaF
+ out.Set(px, py, color.RGBA{
+ R: uint8(clampF(r*alphaF, 0, 255) + 0.5),
+ G: uint8(clampF(g*alphaF, 0, 255) + 0.5),
+ B: uint8(clampF(b*alphaF, 0, 255) + 0.5),
+ A: uint8(aF + 0.5),
+ })
+ }
+ }
+ return out
+}
+
+// shadowCache 阴影图缓存(按尺寸),避免每张卡片重复模糊
+var (
+ shadowCacheMu sync.Mutex
+ shadowCache = map[image.Point]*image.RGBA{}
+)
+
+// shadowPad 阴影图四边留白宽度(px)。绘制阴影时按此边距反向偏移贴回卡片位置。
+const shadowPad = 28
+
+// getBlurredShadow 生成圆角矩形高斯模糊阴影图(四边各留 shadowPad 边距)
+// 阴影矩形相对卡片左右内缩 3px、整体只向下偏移:避免模糊 halo 贴着卡片
+// 左右/圆角形成深色环带(浅色背景上呈黑刺/磨损感)
+func getBlurredShadow(w, h, radius int) *image.RGBA {
+ const pad = shadowPad
+ key := image.Point{w, h}
+ shadowCacheMu.Lock()
+ cached, ok := shadowCache[key]
+ shadowCacheMu.Unlock()
+ if ok {
+ return cached
+ }
+
+ sc := gg.NewContext(w+pad*2, h+pad*2)
+ sc.SetColor(color.RGBA{R: 15, G: 15, B: 35, A: 50})
+ sc.DrawRoundedRectangle(float64(pad)+3, float64(pad), float64(w)-6, float64(h), float64(radius))
+ sc.Fill()
+ blurred := toRGBA(imaging.Blur(sc.Image(), 12))
+
+ shadowCacheMu.Lock()
+ shadowCache[key] = blurred
+ shadowCacheMu.Unlock()
+ return blurred
+}
+
+// drawNewCard 液态玻璃卡片(iOS Liquid Glass)
+// 分层(从下到上):
+// 1. 高斯模糊阴影(干净无角部残影)
+// 2. 液态玻璃底(凸透镜放大 + 边缘折射 + 色散 + 轮廓光 + 顶部高光 + 细描边 + 奶白洗白)
+//
+// 注意:不要用 gg 的 Stroke/Clip+Gradient 画描边和高光——FloatTech/gg 的矢量
+// 描边与渐变填充在圆角弧段会忽略样式写入黑色垃圾像素(角部黑刺/黑弧的元凶),
+// 两者均已改为 renderLiquidGlass 内部的 SDF 距离场实现。
+func drawNewCard(c *gg.Context, x, y, w, h int, blurback *image.RGBA, t theme) {
+ // === 1. 高斯模糊阴影(内缩+下移落影,无左右 halo)===
+ shadow := getBlurredShadow(w, h, cardRadius)
+ c.DrawImage(shadow, x-shadowPad, y-shadowPad+8)
+
+ // === 2. 液态玻璃底(含高光带与描边)===
+ glass := renderLiquidGlass(blurback, x, y, w, h, t)
+ c.DrawImage(glass, x, y)
+}
+
+// loadFont 加载字体到上下文,失败时告警(字体随插件打包,加载失败会退回默认
+// 字体导致排版错乱,打印日志便于排查部署环境的资源缺失)。
+func loadFont(c *gg.Context, path string, size float64) {
+ if err := c.LoadFontFace(path, size); err != nil {
+ logrus.Warnf("[servicemenu] 加载字体失败 %s: %v", path, err)
+ }
+}
+
+// ellipsizeByWidth 按像素宽度截断文本(rune 安全),超宽以 "..." 结尾。
+// 旧的按字节 len(brief)>24 截断有两个问题:中文每字 3 字节导致约 8 个汉字就
+// 被省略(远没占满卡片可用宽度),且 brief[:23] 可能切断 UTF-8 多字节字符
+// 产生乱码。现改为实测渲染宽度,二分找最长前缀。
+func ellipsizeByWidth(c *gg.Context, text, fontPath string, size, maxW float64) string {
+ loadFont(c, fontPath, size)
+ if tw, _ := c.MeasureString(text); tw <= maxW {
+ return text
+ }
+ const ell = "..."
+ runes := []rune(text)
+ lo, hi := 0, len(runes)
+ for lo < hi {
+ mid := (lo + hi + 1) / 2
+ if w, _ := c.MeasureString(string(runes[:mid]) + ell); w <= maxW {
+ lo = mid
+ } else {
+ hi = mid - 1
+ }
+ }
+ return string(runes[:lo]) + ell
+}
+
+// drawTextOutlined 带黑色描边绘制文字。FloatTech/gg 的 Stroke 在曲线
+// 字形上会写入垃圾像素(圆角黑弧同源 bug),因此用多向偏移暗色底绘
+// 模拟描边再绘主体:1px 实描边 + 1.8px 淡晕两层,保证白字在奶白玻璃上远看清晰。
+func drawTextOutlined(c *gg.Context, text, fontPath string, size float64, x, y float64, fill color.RGBA) {
+ loadFont(c, fontPath, size)
+ c.SetRGBA255(25, 28, 40, 170)
+ for _, d := range [...][2]float64{
+ {-1, 0}, {1, 0}, {0, -1}, {0, 1},
+ {-0.71, -0.71}, {0.71, -0.71}, {-0.71, 0.71}, {0.71, 0.71},
+ } {
+ c.DrawString(text, x+d[0], y+d[1])
+ }
+ c.SetRGBA255(25, 28, 40, 70)
+ for _, d := range [...][2]float64{
+ {-1.8, 0}, {1.8, 0}, {0, -1.8}, {0, 1.8},
+ {-1.3, -1.3}, {1.3, -1.3}, {-1.3, 1.3}, {1.3, 1.3},
+ } {
+ c.DrawString(text, x+d[0], y+d[1])
+ }
+ c.SetColor(fill)
+ c.DrawString(text, x, y)
+}
+
+func drawPluginCardContent(c *gg.Context, x, y, w, h int, name, brief string, enabled bool, t theme) {
+ // 状态色条
+ if enabled {
+ c.SetRGBA255(136, 178, 0, 255)
+ } else {
+ c.SetRGBA255(204, 51, 51, 255)
+ }
+ c.DrawRoundedRectangle(float64(x)+7, float64(y+24), float64(6), float64(h-48), 3)
+ c.Fill()
+
+ const (
+ nameFont = "data/Font/GlowSansSC-Normal-ExtraBold.ttf"
+ briefFont = "data/Font/regular-bold.ttf"
+ nameBaseline = 55 // 按 itemH=120 视觉居中:文本块上下各留约 30px
+ briefBaseline = 89
+ )
+
+ // 插件名(黑色描边,远看清晰)
+ drawTextOutlined(c, name, nameFont, 32, float64(x)+30, float64(y+nameBaseline), t.TextMain)
+
+ // Brief:按实际像素宽度省略,右侧留出状态徽章区(圆徽 28 + 边距 16 + 间隙 10)
+ availW := float64(w) - 30 - 54
+ brief = ellipsizeByWidth(c, brief, briefFont, 22, availW)
+ drawTextOutlined(c, brief, briefFont, 22, float64(x)+30, float64(y+briefBaseline), t.TextSec)
+
+ // 状态徽章:右侧圆点 + 矢量勾/叉(✓/✗ 在 GlowSansSC 无字形,DrawString
+ // 永远渲染不出来,改用直线段绘制图标,必然渲染且远看清晰)
+ const iconD = 28.0
+ cx := float64(x+w) - 16 - iconD/2
+ cy := float64(y) + float64(h)/2
+ if enabled {
+ c.SetRGBA255(104, 166, 0, 240)
+ } else {
+ c.SetRGBA255(204, 51, 51, 240)
+ }
+ c.DrawCircle(cx, cy, iconD/2)
+ c.Fill()
+
+ r := iconD / 2
+ c.SetStrokeStyle(gg.NewSolidPattern(color.RGBA{R: 255, G: 255, B: 255, A: 255}))
+ c.SetLineWidth(3)
+ c.SetLineCap(gg.LineCapRound)
+ if enabled {
+ // 勾:短臂下探到中低点,再长臂扬到右上
+ c.MoveTo(cx-0.42*r, cy+0.05*r)
+ c.LineTo(cx-0.10*r, cy+0.38*r)
+ c.LineTo(cx+0.45*r, cy-0.30*r)
+ c.Stroke()
+ } else {
+ // 叉:两条对角直线
+ c.MoveTo(cx-0.30*r, cy-0.30*r)
+ c.LineTo(cx+0.30*r, cy+0.30*r)
+ c.MoveTo(cx+0.30*r, cy-0.30*r)
+ c.LineTo(cx-0.30*r, cy+0.30*r)
+ c.Stroke()
+ }
+}
+
+// buildBackground 构建主题渐变 + 随机本地图(cover 模式)
+func buildBackground(w, h int, t theme) *image.RGBA {
+ bg := gg.NewContext(w, h)
+
+ fr, fg, fb := float64(t.BackgroundFrom[0]), float64(t.BackgroundFrom[1]), float64(t.BackgroundFrom[2])
+ tr, tg, tb := float64(t.BackgroundTo[0]), float64(t.BackgroundTo[1]), float64(t.BackgroundTo[2])
+ grad := gg.NewLinearGradient(0, 0, 0, float64(h))
+ grad.AddColorStop(0, color.RGBA{R: uint8(fr), G: uint8(fg), B: uint8(fb), A: 255})
+ grad.AddColorStop(1, color.RGBA{R: uint8(tr), G: uint8(tg), B: uint8(tb), A: 255})
+ bg.SetFillStyle(grad)
+ bg.DrawRectangle(0, 0, float64(w), float64(h))
+ bg.Fill()
+
+ randomImg := loadRandomBg()
+ if randomImg != nil {
+ srcW := randomImg.Bounds().Dx()
+ srcH := randomImg.Bounds().Dy()
+ scale := math.Max(float64(w)/float64(srcW), float64(h)/float64(srcH))
+ newW := int(float64(srcW) * scale)
+ newH := int(float64(srcH) * scale)
+ resized := imaging.Resize(randomImg, newW, newH, imaging.Box)
+ final := imaging.CropCenter(resized, w, h)
+ bg.DrawImage(final, 0, 0)
+ }
+
+ return bg.Image().(*image.RGBA)
+}
+
+func renderUsageCard(m *zbpctrl.Control[*zero.Ctx]) ([]byte, error) {
+ t := currentTheme
+ cardW := 900
+ headerH := 110
+
+ help := m.Options.Help
+ if help == "" {
+ help = "该插件无帮助文档。"
+ }
+ lines := strings.Count(help, "\n") + 1
+ lineH := 28
+ bodyH := lines*lineH + 60
+ cardH := cardPadding + headerH + bodyH + cardPadding
+
+ c := gg.NewContext(cardW, cardH)
+ bg := buildBackground(cardW, cardH, t)
+ c.DrawImage(bg, 0, 0)
+
+ if t.OverlayAlpha > 0 {
+ oc := t.OverlayColor
+ c.SetRGBA255(int(oc.R), int(oc.G), int(oc.B), int(t.OverlayAlpha))
+ c.DrawRectangle(0, 0, float64(cardW), float64(cardH))
+ c.Fill()
+ }
+
+ blurback := toRGBA(imaging.Blur(c.Image(), t.Blur))
+
+ drawNewCard(c, cardPadding, cardPadding, cardW-cardPadding*2, headerH, blurback, t)
+ c.SetColor(t.TextMain)
+ loadFont(c, "data/Font/GlowSansSC-Normal-ExtraBold.ttf", 48)
+ c.DrawString(m.Service, float64(cardPadding+32), float64(cardPadding+60))
+
+ c.SetColor(t.TextSec)
+ loadFont(c, "data/Font/regular-bold.ttf", 18)
+ c.DrawString(m.Options.Brief, float64(cardPadding+32), float64(cardPadding+90))
+
+ bodyY := cardPadding + headerH + cardMarginY
+ drawNewCard(c, cardPadding, bodyY, cardW-cardPadding*2, bodyH, blurback, t)
+ c.SetColor(t.TextMain)
+ loadFont(c, "data/Font/regular-bold.ttf", 16)
+ y := float64(bodyY) + 36
+ for _, line := range strings.Split(help, "\n") {
+ c.DrawString(line, float64(cardPadding+28), y)
+ y += float64(lineH)
+ }
+ return encodePNG(c.Image())
+}
+
+func encodePNG(img image.Image) ([]byte, error) {
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
diff --git a/plugin/servicemenu/render_test.go b/plugin/servicemenu/render_test.go
new file mode 100644
index 0000000000..d0f98aeeec
--- /dev/null
+++ b/plugin/servicemenu/render_test.go
@@ -0,0 +1,396 @@
+package servicemenu
+
+// 测试渲染:不走 bot,直接用真实渲染管线输出 PNG 供视觉验证
+// 运行: go test -run TestRenderListPNG ./plugin/servicemenu
+// 注意: chdir 到项目根目录,保证 data/Font、data/aifalse 相对路径可用
+
+import (
+ "fmt"
+ "image"
+ "image/color"
+ "math"
+ "os"
+ "testing"
+
+ "github.com/FloatTech/ZeroBot-Plugin/kanban/banner"
+ "github.com/FloatTech/gg"
+ "github.com/disintegration/imaging"
+)
+
+func TestMain(m *testing.M) {
+ _ = os.Chdir("../..") // 项目根目录
+ os.Exit(m.Run())
+}
+
+// newTestCanvas 构建带真实背景的画布 + 对应 blurback(与 renderServiceList 同流程)
+func newTestCanvas(w, h int, tt theme) (*gg.Context, *image.RGBA) {
+ c := gg.NewContext(w, h)
+ bg := buildBackground(w, h, tt)
+ c.DrawImage(bg, 0, 0)
+ if tt.OverlayAlpha > 0 {
+ oc := tt.OverlayColor
+ c.SetRGBA255(int(oc.R), int(oc.G), int(oc.B), int(tt.OverlayAlpha))
+ c.DrawRectangle(0, 0, float64(w), float64(h))
+ c.Fill()
+ }
+ blurback := toRGBA(imaging.Blur(c.Image(), tt.Blur))
+ return c, blurback
+}
+
+// writePNG 输出画布到项目根目录 PNG
+func writePNG(t *testing.T, c *gg.Context, out string) {
+ png, err := encodePNG(c.Image())
+ if err != nil {
+ t.Fatalf("编码失败: %v", err)
+ }
+ if err := os.WriteFile(out, png, 0o644); err != nil {
+ t.Fatalf("写出失败: %v", err)
+ }
+ t.Logf("已输出 %s", out)
+}
+
+type fakePlugin struct{ name, brief string }
+
+func renderTestList(t *testing.T, tt theme, out string) {
+ plugins := []fakePlugin{
+ {"job", "定时指令触发器"}, {"antiabuse", "违禁词检测"}, {"chat", "基础反应, 群空调"},
+ {"chatcount", "聊天时长统计"}, {"sleepmanage", "睡眠小助手"}, {"airecord", "群应用: AI声聊"},
+ {"atri", "atri人格文本回复"}, {"manager", "群管插件"}, {"aiwife", "ai随机生成老婆"},
+ // 长简介:验证按像素宽度省略(应占满可用宽度而非提前截断)
+ {"animetrace", "AnimeTrace 动画/Galgame 截图溯源,识别番剧出处与集数"},
+ {"danbooru", "二次元图片标签查询,支持 endless 名片与同名搜索重定向"},
+ {"event", "好友申请和群聊邀请审核事件处理,自动同意或拒绝入群请求"},
+ }
+ currentTheme = tt
+
+ // 与 renderServiceList 相同的布局常量
+ canvasW := 1200
+ rows := 6 // 12 个测试插件 / 两列
+ canvasH := cardPadding + headerH + rows*(itemH+cardMarginY) + footerH + cardPadding + 40
+ c, blurback := newTestCanvas(canvasW, canvasH, tt)
+
+ // header 卡
+ drawNewCard(c, cardPadding, cardPadding, canvasW-cardPadding*2, headerH, blurback, tt)
+ c.SetColor(tt.TextMain)
+ c.LoadFontFace("data/Font/GlowSansSC-Normal-ExtraBold.ttf", 48)
+ c.DrawString("ZeroBot-Plugin", float64(cardPadding+32), float64(cardPadding+60))
+ drawTextOutlined(c, "OneBot + ZeroBot + Golang", "data/Font/regular-bold.ttf", 20, float64(cardPadding+32), float64(cardPadding+92), tt.TextSec)
+ drawTextOutlined(c, banner.Version+" · FloatTech", "data/Font/regular-bold.ttf", 18, float64(cardPadding+32), float64(cardPadding+120), tt.TextSec)
+
+ // 插件卡片
+ cardAreaTop := cardPadding + headerH + cardMarginY
+ cardW := (canvasW - cardPadding*2 - colGap*(itemsPerRow-1)) / itemsPerRow
+ for j, p := range plugins {
+ col := j % itemsPerRow
+ row := j / itemsPerRow
+ x := float64(cardPadding) + float64(col)*float64(cardW+colGap)
+ cardTop := float64(cardAreaTop) + float64(row)*(itemH+cardMarginY)
+ drawNewCard(c, int(x), int(cardTop), cardW, itemH, blurback, tt)
+ drawPluginCardContent(c, int(x), int(cardTop), cardW, itemH, p.name, p.brief, j%4 != 3, tt)
+ }
+
+ writePNG(t, c, out)
+}
+
+func TestRenderListPNG(t *testing.T) {
+ for _, tt := range themes {
+ renderTestList(t, tt, fmt.Sprintf("glass_real_%s.png", tt.Name))
+ }
+}
+
+// TestRenderCloseupPNG 单卡放大特写:检查边缘折射 / 四角 / 颗粒感
+func TestRenderCloseupPNG(t *testing.T) {
+ tt := themes[0]
+ currentTheme = tt
+
+ c, blurback := newTestCanvas(760, 560, tt)
+
+ // 一大一小两块玻璃
+ drawNewCard(c, 60, 60, 640, 220, blurback, tt)
+ drawPluginCardContent(c, 60, 60, 640, 220, "liquid glass", "凸透镜放大 + 边缘折射 + 色散", true, tt)
+
+ drawNewCard(c, 60, 330, 300, 120, blurback, tt)
+ drawPluginCardContent(c, 60, 330, 300, 120, "job", "定时指令触发器", true, tt)
+
+ drawNewCard(c, 400, 330, 300, 120, blurback, tt)
+ drawPluginCardContent(c, 400, 330, 300, 120, "manager", "群管插件", false, tt)
+
+ writePNG(t, c, "glass_closeup.png")
+}
+
+// TestCropDebug 裁剪放大整表的卡片角部,定位黑刺瑕疵
+func TestCropDebug(t *testing.T) {
+ src, err := imaging.Open("glass_real_glass-dark.png")
+ if err != nil {
+ t.Skip("先跑 TestRenderListPNG")
+ }
+ // job 卡所在区域(含左右角 + 上下缘)
+ crop := imaging.Crop(src, image.Rect(50, 265, 470, 400))
+ zoom := imaging.Resize(crop, 420*3, 135*3, imaging.NearestNeighbor)
+ _ = imaging.Save(zoom, "glass_crop.png")
+ t.Log("已输出 glass_crop.png")
+
+ // 6x 特写:job 卡左上角 (70,290) 及左缘
+ crop2 := imaging.Crop(src, image.Rect(40, 262, 150, 372))
+ zoom2 := imaging.Resize(crop2, 110*6, 110*6, imaging.NearestNeighbor)
+ _ = imaging.Save(zoom2, "glass_corner6x.png")
+ t.Log("已输出 glass_corner6x.png")
+
+ // 像素扫描:横穿 job 卡左缘(y=320),定位暗线位置
+ for _, py := range []int{300, 320, 340} {
+ row := ""
+ for px := 60; px <= 84; px += 2 {
+ r, g, b, _ := src.At(px, py).RGBA()
+ row += fmt.Sprintf("x%d:%d,%d,%d ", px, r>>8, g>>8, b>>8)
+ }
+ t.Logf("y=%d %s", py, row)
+ }
+}
+
+// TestDarkPixelForensics 逐像素取证:step=1 扫描左缘全线 + 分层隔离(玻璃/阴影/描边)
+// 定位 1px 深色竖线与角部斑点来自哪一层
+func TestDarkPixelForensics(t *testing.T) {
+ tt := themes[0]
+ currentTheme = tt
+
+ // 复现 renderTestList 的 job 卡布局
+ canvasW := 1200
+ rows := 3
+ canvasH := cardPadding + headerH + rows*(itemH+cardMarginY) + footerH + cardPadding + 40
+ c, blurback := newTestCanvas(canvasW, canvasH, tt)
+ _ = c
+ cardAreaTop := cardPadding + headerH + cardMarginY
+ cardW := (canvasW - cardPadding*2 - colGap*(itemsPerRow-1)) / itemsPerRow
+ x, y, w, h := cardPadding, cardAreaTop, cardW, itemH
+ t.Logf("job 卡: x=%d y=%d w=%d h=%d cardRadius=%d edgeW=%.1f", x, y, w, h, cardRadius, clampF(float64(h)*0.30, 14, 36))
+
+ // === 分层渲染 ===
+ // L1: 仅玻璃
+ glass := renderLiquidGlass(blurback, x, y, w, h, tt)
+ // L2: 仅阴影
+ shadow := getBlurredShadow(w, h, cardRadius)
+ // L3: 完整卡片(画在副本 canvas 上)
+ c2, blurback2 := newTestCanvas(canvasW, canvasH, tt)
+ drawNewCard(c2, x, y, w, h, blurback2, tt)
+
+ scanDark := func(label string, img *image.RGBA, ox, oy int) {
+ found := 0
+ for py := y; py < y+60 && py-oy < img.Bounds().Dy(); py++ {
+ for px := x - 6; px <= x+8; px++ {
+ lx, ly := px-ox, py-oy
+ if lx < 0 || ly < 0 || lx >= img.Bounds().Dx() || ly >= img.Bounds().Dy() {
+ continue
+ }
+ r, g, b, a := img.At(lx, ly).RGBA()
+ r8, g8, b8, a8 := int(r>>8), int(g>>8), int(b>>8), int(a>>8)
+ if a8 > 100 && r8 < 90 && g8 < 90 {
+ t.Logf("[%s] 暗点 (%d,%d) RGB=%d,%d,%d A=%d", label, px, py, r8, g8, b8, a8)
+ found++
+ if found > 12 {
+ return
+ }
+ }
+ }
+ }
+ if found == 0 {
+ t.Logf("[%s] 左缘 y+0..60 无暗点", label)
+ }
+ }
+
+ // blurback 本身(对照组:证明背景没有暗点)
+ scanDark("blurback", blurback, 0, 0)
+ // 仅玻璃层
+ scanDark("glass-only", glass, x, y)
+ // 阴影层(在其自身坐标系里扫)
+ scanDark("shadow-only", shadow, x-shadowPad, y-shadowPad)
+ // 完整卡片
+ scanDark("full-card", toRGBA(c2.Image()), 0, 0)
+
+ // === step=1 全线扫描:完整卡片左缘 x-6..x+8, y..y+60 全部输出 ===
+ full := toRGBA(c2.Image())
+ line := ""
+ for py := y; py < y+40; py++ {
+ r, g, b, _ := full.At(x+2, py).RGBA()
+ v := (int(r>>8) + int(g>>8) + int(b>>8)) / 3
+ if v < 120 {
+ line += fmt.Sprintf(" (%d,%d:%d)", x+2, py, v)
+ }
+ }
+ t.Logf("x=%d 列上 40 行内暗像素(<120):%s", x+2, line)
+
+ // === 玻璃层内部取证:对暗点反算采样坐标 ===
+ cx, cy := float64(w)/2, float64(h)/2
+ rad := float64(cardRadius)
+ edgeW := clampF(float64(h)*0.30, 14, 36)
+ sdf := func(fx, fy float64) float64 {
+ qx := math.Abs(fx-cx) - (float64(w)/2 - rad)
+ qy := math.Abs(fy-cy) - (float64(h)/2 - rad)
+ return math.Min(math.Max(qx, qy), 0) + math.Hypot(math.Max(qx, 0), math.Max(qy, 0)) - rad
+ }
+ for py := 0; py < 60; py++ {
+ for px := 0; px < 12; px++ {
+ r, g, b, a := glass.At(px, py).RGBA()
+ if a>>8 <= 100 || int(r>>8) >= 90 {
+ continue
+ }
+ fx, fy := float64(px)+0.5, float64(py)+0.5
+ dE := sdf(fx, fy)
+ nx := sdf(fx+1, fy) - sdf(fx-1, fy)
+ ny := sdf(fx, fy+1) - sdf(fx, fy-1)
+ if nl := math.Hypot(nx, ny); nl > 1e-6 {
+ nx, ny = nx/nl, ny/nl
+ }
+ mag := tt.Refraction * smoothStep(-edgeW*1.5, -edgeW*0.2, dE) * smoothStep(2.0, -3.0, dE)
+ sx := (fx+nx*mag-cx)/tt.Magnify + cx
+ sy := (fy+ny*mag-cy)/tt.Magnify + cy
+ sr, sg, sb, _ := blurback.At(int(sx)+x, int(sy)+y).RGBA()
+ t.Logf("glass 暗点 local(%d,%d) dEdge=%.2f mag=%.1f n=(%.2f,%.2f) sample=(%.1f,%.1f)->canvas(%d,%d) 采样色=%d,%d,%d 输出=%d,%d,%d",
+ px, py, dE, mag, nx, ny, sx, sy, int(sx)+x, int(sy)+y, sr>>8, sg>>8, sb>>8, r>>8, g>>8, b>>8)
+ }
+ }
+
+ // 输出放大图目视
+ crop := imaging.Crop(full, image.Rect(x-12, y-12, x+60, y+72))
+ z6 := imaging.Resize(crop, 72*6, 84*6, imaging.NearestNeighbor)
+ _ = imaging.Save(z6, "glass_forensic6x.png")
+ t.Log("已输出 glass_forensic6x.png")
+}
+
+// TestGGBugProbe 最小复现:分别测试 gg 的描边(带 alpha 的 SolidPattern)与
+// 圆角矩形 Clip+渐变填充,是否会在路径附近写入垃圾像素(黑点/蓝点)
+func TestGGBugProbe(t *testing.T) {
+ probe := func(label string, fn func(c *gg.Context)) {
+ c := gg.NewContext(240, 240)
+ c.SetRGBA255(200, 200, 200, 255)
+ c.DrawRectangle(0, 0, 240, 240)
+ c.Fill()
+ fn(c)
+ bad := 0
+ for py := 0; py < 240; py++ {
+ for px := 0; px < 240; px++ {
+ r, g, b, a := c.Image().At(px, py).RGBA()
+ r8, g8, b8, a8 := int(r>>8), int(g>>8), int(b>>8), int(a>>8)
+ // 灰底 200 与白色系之外的颜色都算异常
+ if a8 < 250 || (r8 < 150 && g8 < 150) || b8 > 240 && r8 < 100 {
+ if bad < 8 {
+ t.Logf("[%s] 异常像素 (%d,%d) RGB=%d,%d,%d A=%d", label, px, py, r8, g8, b8, a8)
+ }
+ bad++
+ }
+ }
+ }
+ t.Logf("[%s] 异常像素总数: %d", label, bad)
+ }
+
+ // A: 描边 - SetStrokeStyle(白色 A=60) + Stroke
+ probe("stroke-alpha60", func(c *gg.Context) {
+ c.SetLineWidth(1.2)
+ c.SetStrokeStyle(gg.NewSolidPattern(color.RGBA{R: 255, G: 255, B: 255, A: 60}))
+ c.DrawRoundedRectangle(20.5, 20.5, 199, 199, 16)
+ c.Stroke()
+ })
+
+ // B: 描边 - 不设样式(默认黑色描边,预期全黑一圈作为对照)
+ probe("stroke-default", func(c *gg.Context) {
+ c.SetLineWidth(1.2)
+ c.DrawRoundedRectangle(20.5, 20.5, 199, 199, 16)
+ c.Stroke()
+ })
+
+ // C: Clip 圆角矩形 + 线性渐变填充(复刻高光带,radius 16 vs 高度 96)
+ probe("clip-gradient", func(c *gg.Context) {
+ c.DrawRoundedRectangle(22, 21, 196, 96, 16)
+ c.Clip()
+ hl := gg.NewLinearGradient(0, 20, 0, 116)
+ hl.AddColorStop(0, color.RGBA{R: 255, G: 255, B: 255, A: 55})
+ hl.AddColorStop(0.5, color.RGBA{R: 255, G: 255, B: 255, A: 18})
+ hl.AddColorStop(1, color.RGBA{R: 255, G: 255, B: 255, A: 0})
+ c.SetFillStyle(hl)
+ c.DrawRectangle(20, 20, 200, 96)
+ c.Fill()
+ c.ResetClip()
+ })
+
+ // D: Clip 圆角矩形 + 纯色填充(隔离渐变因素)
+ probe("clip-solid", func(c *gg.Context) {
+ c.DrawRoundedRectangle(22, 21, 196, 96, 16)
+ c.Clip()
+ c.SetRGBA255(255, 255, 255, 40)
+ c.DrawRectangle(20, 20, 200, 96)
+ c.Fill()
+ c.ResetClip()
+ })
+}
+
+// TestLayerDebug 分层隔离实验:定位黑刺来自哪一层(阴影/玻璃/描边高光)
+func TestLayerDebug(t *testing.T) {
+ tt := themes[0]
+ currentTheme = tt
+
+ const panelW, panelH = 480, 150
+ c := gg.NewContext(panelW, panelH*4)
+ // 浅色平底背景(模拟浅色二次元图)
+ c.SetRGBA255(235, 220, 215, 255)
+ c.DrawRectangle(0, 0, float64(panelW), float64(panelH*4))
+ c.Fill()
+
+ // 先构建干净的 blurback(平底 + overlay + blur),避免把面板内容采进去
+ flat := gg.NewContext(panelW, panelH*4)
+ flat.SetRGBA255(235, 220, 215, 255)
+ flat.DrawRectangle(0, 0, float64(panelW), float64(panelH*4))
+ flat.Fill()
+ if tt.OverlayAlpha > 0 {
+ oc := tt.OverlayColor
+ flat.SetRGBA255(int(oc.R), int(oc.G), int(oc.B), int(tt.OverlayAlpha))
+ flat.DrawRectangle(0, 0, float64(panelW), float64(panelH*4))
+ flat.Fill()
+ }
+ blurback := toRGBA(imaging.Blur(flat.Image(), tt.Blur))
+
+ x, y, w, h := 70, 35, 337, 80
+
+ // 面板1: 完整 drawNewCard
+ drawNewCard(c, x, y, w, h, blurback, tt)
+
+ // 面板2: 仅阴影
+ shadow := getBlurredShadow(w, h, cardRadius)
+ c.DrawImage(shadow, x-shadowPad, panelH+y-shadowPad+8)
+
+ // 面板3: 仅玻璃(无阴影无描边)
+ glass := renderLiquidGlass(blurback, x, y, w, h, tt)
+ c.DrawImage(glass, x, panelH*2+y)
+
+ // 面板4: 仅描边 + 顶部高光(无阴影无玻璃)
+ r := float64(cardRadius)
+ py3 := float64(panelH*3 + y)
+ c.DrawRoundedRectangle(float64(x)+2, py3+1, float64(w)-4, float64(h)*0.4, r)
+ c.Clip()
+ hl := gg.NewLinearGradient(0, py3, 0, py3+float64(h)*0.4)
+ hl.AddColorStop(0, color.RGBA{R: 255, G: 255, B: 255, A: tt.TopLight})
+ hl.AddColorStop(0.5, color.RGBA{R: 255, G: 255, B: 255, A: uint8(int(tt.TopLight) / 3)})
+ hl.AddColorStop(1, color.RGBA{R: 255, G: 255, B: 255, A: 0})
+ c.SetFillStyle(hl)
+ c.DrawRectangle(float64(x), py3, float64(w), float64(h)*0.4)
+ c.Fill()
+ c.ResetClip()
+ c.SetLineWidth(1.2)
+ c.SetRGBA255(255, 255, 255, int(tt.StrokeAlpha))
+ c.DrawRoundedRectangle(float64(x)+0.5, py3+0.5, float64(w)-1, float64(h)-1, r)
+ c.Stroke()
+
+ // 放大 2.5x 便于查看
+ zoom := imaging.Resize(c.Image(), panelW*5/2, panelH*10, imaging.NearestNeighbor)
+ _ = imaging.Save(zoom, "glass_layers.png")
+ t.Log("已输出 glass_layers.png")
+
+ // 像素取证:p4 顶部左角外围 (卡片左上角在 x=70, y=panelH*3+35=485)
+ img := c.Image()
+ for _, py := range []int{474, 478, 482, 486, 490} {
+ row := ""
+ for px := 56; px <= 88; px += 4 {
+ r, g, b, a := img.At(px, py).RGBA()
+ row += fmt.Sprintf("[%3d,%3d,%3d,%3d] ", r>>8, g>>8, b>>8, a>>8)
+ }
+ t.Logf("y=%d x=56..88: %s", py, row)
+ }
+}
diff --git a/plugin/thesaurus/chat.go b/plugin/thesaurus/chat.go
index fafdd5826d..3e62c90794 100644
--- a/plugin/thesaurus/chat.go
+++ b/plugin/thesaurus/chat.go
@@ -16,8 +16,11 @@ func init() {
Brief: "词典匹配回复, 仅@触发",
PublicDataFolder: "Chat",
})
- engine.OnMessage(zero.OnlyToMe, canmatch()).
- SetBlock(false).Handle(func(ctx *zero.Ctx) {
+ // 优先级设为 10,低于控制命令(SecondPriority=1),
+ // 避免 /全局禁用、/启用 等管理命令被词库回复抢先拦截
+ chatm := engine.OnMessage(zero.OnlyToMe, canmatch()).SetBlock(false)
+ (*zero.Matcher)(chatm).SetPriority(10)
+ chatm.Handle(func(ctx *zero.Ctx) {
msg := ctx.ExtractPlainText()
r, err := kimoi.Chat(msg)
if err == nil {