优化: 知识卡片交互+deepseek模型+鼠标钩子

This commit is contained in:
2026-06-05 21:45:45 +08:00
parent 004dba7db6
commit 0302546267
10 changed files with 667 additions and 77 deletions

View File

@@ -0,0 +1,175 @@
# Overlay 鼠标交互实践
> 2026-06-05 | 知识卡片刷新按钮点击 + 悬停效果
---
## 背景
桌面壁纸 overlay 通过 WebView2 嵌入 WorkerW 实现位于桌面图标层SysListView32之下。这意味着
- **WM_NCHITTEST 永远到不了 overlay**——`WindowFromPoint` 返回的是 SysListView32
- **CSS hover/click 无效**——鼠标事件被上层桌面图标窗口拦截
- 需要 WH_MOUSE_LL 全局低级钩子Hook Type 14从 Go 层直接拦截
---
## 踩坑记录
### 1. GWLP_WNDPROC 常量值错误
```go
// ❌ 错误0xFFFFFFF4 = -12 = GWLP_ID不是 GWLP_WNDPROC
const gwlpWndProc = uintptr(0xFFFFFFF4)
// ✅ 正确:-4 = GWLP_WNDPROC
const gwlpWndProc = ^uintptr(3) // 按位取反(3) = -4
```
**教训**Windows 常量用负数表示时,不要手动换算十六进制,用 Go 的 `^uintptr(N)` 表达。
### 2. GC 回收 NewCallback 导致全局鼠标异常
```go
// ❌ 错误cb 是局部变量,函数返回后可被 GC 回收
func installMouseHook() {
cb := windows.NewCallback(func(...) uintptr { ... })
procSetWindowsHookExW.Call(14, cb, 0, 0)
}
// ✅ 正确:存储到包级变量
var llMouseHookCb uintptr
func installMouseHook() {
llMouseHookCb = windows.NewCallback(func(...) uintptr { ... })
procSetWindowsHookExW.Call(14, llMouseHookCb, 0, 0)
}
```
**现象**GC 回收回调后,钩子指向无效内存,导致**所有鼠标点击被拦截**——桌面、其他软件全部点不了。
**教训**`windows.NewCallback` 返回的引用必须保存在 Go 可达的变量中Go GC 不知道 Windows 持有回调引用。
### 3. WindowFromPoint 传参方式
```go
// ❌ 错误:传指针
hwndAt, _, _ := procWindowFromPoint.Call(uintptr(unsafe.Pointer(&screenPt)))
// ✅ 正确POINT 按值传递,打包到单个 uintptr
ptPacked := uintptr(s.X) | (uintptr(uint32(s.Y)) << 32)
hwndAt, _, _ := procWindowFromPoint.Call(ptPacked)
```
**教训**Win32 API 中 `WindowFromPoint(POINT)` 的 POINT 是按值传递的,不是指针。在 64 位下两个 int32 打包为一个 uintptr。
### 4. SetCursor 在 WH_MOUSE_LL 中无效
```go
// ❌ 无效:被后续窗口的 WM_SETCURSOR 覆盖
procSetCursor.Call(handCursor) // 闪一下就被桌面窗口重设为箭头
```
**原因**:钩子返回后 WM_MOUSEMOVE 继续传递给桌面窗口SysListView32其在处理 WM_SETCURSOR 时将自己的光标设回去。
**结论**:在 overlay 这种底层窗口架构下,无法可靠地改变鼠标光标样式。可行的替代方案:
- 按钮视觉高亮(旋转 + 放大 + 背景色)✅
- 全局 `SetSystemCursor`(影响整个系统,副作用大)❌ 不推荐
### 5. go-webview2 Bind 参数类型
```go
// ❌ 错误JSON number 解码为 float64int32 静默失败
wv.Bind("setInteractiveRect", func(left, top, right, bottom int32) string { ... })
// ✅ 正确:用 float64
wv.Bind("setInteractiveRect", func(left, top, right, bottom float64) string { ... })
```
### 6. DPR 缩放坐标转换
JS `getBoundingClientRect()` 返回 CSS 像素,`ScreenToClient` 返回物理像素。必须乘 DPR
```javascript
const dpr = window.devicePixelRatio || 1
setInteractiveRect(
Math.round(r.left * dpr), Math.round(r.top * dpr),
Math.round(r.right * dpr), Math.round(r.bottom * dpr)
)
```
### 7. HTTP 429 重试时 body 被消耗
```go
// ❌ 错误:第一次请求消耗了 body重试发送空 body
resp, err := httpClient.Do(req)
if resp.StatusCode == 429 {
time.Sleep(3 * time.Second)
resp, err = httpClient.Do(req) // body 已经 EOF
}
// ✅ 正确:创建新 Request
req2, _ := http.NewRequest("POST", cpaURL, bytes.NewReader(jsonData))
resp2, _ := httpClient.Do(req2)
```
### 8. WindowFromPoint + Progman 判断桌面遮挡
```go
func isDesktopAtPoint(screenX, screenY int32) bool {
ptPacked := uintptr(screenX) | (uintptr(uint32(screenY)) << 32)
hwndAt, _, _ := procWindowFromPoint.Call(ptPacked)
for h := hwndAt; h != 0; h, _, _ = procGetParent.Call(h) {
if h == progmanHwnd {
return true
}
}
return false
}
```
**用途**:判断点击位置是否有其他窗口遮挡。只有桌面背景可见时才消费点击事件,避免拦截其他软件的点击。
---
## 最终架构
```
WH_MOUSE_LL 全局钩子
├── WM_MOUSEMOVE (0x0200)
│ └── 检查鼠标是否在按钮 hit rect 内
│ ├── 进入 → 按钮添加 hover-active 样式(旋转+放大+高亮)
│ └── 离开 → 移除样式
├── WM_LBUTTONDOWN (0x0201)
│ ├── WindowFromPoint 检查是否桌面背景可见
│ ├── ScreenToClient 转换到 wvHwnd 坐标
│ └── 命中按钮 rect → pushKnowledgeLoading + goroutine 刷新
└── 其他事件 → CallNextHookEx 传递
```
**hit rect 来源**JS 端 `getBoundingClientRect() * DPR`,仅覆盖刷新按钮(+8px 扩展)。
**冷却机制**30s 内重复点击回退显示缓存内容,不停留在"加载中"。
---
## 关键文件
| 文件 | 改动 |
|------|------|
| `win32.go` | 鼠标钩子、WindowFromPoint 桌面判断、Progman 句柄 |
| `knowledge.go` | deepseek-v4-pro 模型、质量检查、冷却回退 |
| `KnowledgeCard.vue` | 按钮 hit rect、hover-active 样式 |
| `systray.go` | setInteractiveRect float64 参数 |
---
## 速查Win32 API 要点
| API | 陷阱 | 正确做法 |
|-----|------|----------|
| `SetWindowLongPtrW` | 负数索引常量 | `^uintptr(3)` 表示 -4 |
| `windows.NewCallback` | GC 不跟踪外部引用 | 存包级变量 |
| `WindowFromPoint` | POINT 按值非指针 | 打包为 uintptr |
| `SetCursor` (WH_MOUSE_LL) | 被后续 WM_SETCURSOR 覆盖 | 不可靠,放弃 |
| `ScreenToClient` | 物理像素 | JS 侧乘 DPR |
| `go-webview2 Bind` | JSON number = float64 | 参数用 float64 |

View File

@@ -9,16 +9,19 @@ import (
"math/rand"
"net/http"
"path/filepath"
"regexp"
"sync/atomic"
"strings"
"time"
"unicode"
"unicode/utf8"
_ "modernc.org/sqlite"
)
const cpaURL = "https://cpa.1216.top/v1/chat/completions"
const cpaModel = "glm-5.1"
const minKnowledgeRunes = 80
const cpaModel = "deepseek-v4-pro"
const minKnowledgeRunes = 120
type knowledgeData struct {
Content string `json:"content"`
@@ -96,31 +99,34 @@ func getKnowledgeCardCount(keyword string) int {
return count
}
// systemPromptCodeMark 用于在 Go raw string 中拼接反引号
const systemPromptCodeMark = "`"
const knowledgeSystemPrompt = `你是一位在一线写了 15 年底层系统代码的工程师,只在踩过真实生产级坑之后才写知识卡片。
内容铁律:
- 必须是大多数人不知道的冷知识、反直觉行为、或版本变更导致的隐蔽 break
- 必须包含至少一个精确数值(阈值/延迟/版本号/缓冲区大小/默认值等)
- 必须讲清因果链:触发条件 → 内部机制 → 实际后果 → 正确做法
- 禁止泛泛而谈,禁止复述文档,禁止入门级内容
- 每次从不同角度切入,不要重复之前的知识点
格式:
- Markdown**加粗**重点、` + systemPromptCodeMark + `行内代码` + systemPromptCodeMark + `标记术语、简短列表
- 120-200字紧凑无废话
- 不要 emoji`
func fetchKnowledgeFromLLM(keyword string, cfg *Config) string {
basePrompt := buildKnowledgePrompt(keyword, cfg.KnowledgePrompt)
messages := []map[string]string{
{
"role": "system",
"content": `你是一位资深技术专家,面向有多年经验的开发者撰写知识卡片。
内容标准:
- 必须包含可直接用于生产的硬核知识:底层原理、性能陷阱、边界条件、源码级细节
- 优先讲"为什么"而非"是什么"——机制、权衡、踩坑经验
- 用具体数值、代码片段(仅关键词级别)、或真实场景佐证
- 读者看完能立刻在项目中应用或避免踩坑
禁止:
- 入门级科普、"是什么"式定义、百科搬运
- 空泛鸡汤("很重要"、"提升效率"、"广泛应用"
- 标题、序号、Markdown标记、emoji`,
},
{"role": "system", "content": knowledgeSystemPrompt},
{"role": "user", "content": basePrompt},
}
body := map[string]interface{}{
"model": cpaModel,
"max_completion_tokens": 2048,
"temperature": 0.55,
"temperature": 0.8,
"messages": messages,
}
content := requestKnowledgeCompletion(body)
@@ -131,9 +137,13 @@ func fetchKnowledgeFromLLM(keyword string, cfg *Config) string {
log.Printf("知识卡片质量不足,重试: %q", content)
messages = append(messages, map[string]string{
"role": "assistant",
"content": content,
}, map[string]string{
"role": "user",
"content": fmt.Sprintf(
"上一条太短或信息密度不足。请重写一条「%s」知识卡片120-180个中文字符必须包含一个明确机制/原理、一个具体例子或应用场景、一个结论。只输出正文。",
"上面这条「%s」知识卡片质量不行要么太短、要么没有具体数值、要么信息密度不够。"+
"请换一个完全不同的角度重写,必须包含:一个精确数值 + 一个底层机制 + 一个生产踩坑场景。",
keyword,
),
})
@@ -147,14 +157,38 @@ func fetchKnowledgeFromLLM(keyword string, cfg *Config) string {
return ""
}
const promptCodeMark = "`"
// 知识卡片随机切入角度,确保每次生成不同内容
var knowledgeAngles = []string{
"性能陷阱与调优",
"并发/竞态/死锁",
"内存泄漏与 GC",
"网络协议底层行为",
"安全漏洞与防御",
"错误处理的隐蔽坑",
"版本升级导致的 break change",
"配置默认值的陷阱",
"日志/监控/可观测性",
"序列化/反序列化边界",
"连接池/资源耗尽",
"编译器/运行时的反直觉行为",
"缓存一致性与失效",
"时区/时间处理陷阱",
"文件系统/IO 边界条件",
}
func buildKnowledgePrompt(keyword, customPrompt string) string {
basePrompt := fmt.Sprintf(`围绕「%s」写一条面向资深开发者的技术知识卡片。
angle := knowledgeAngles[rand.Intn(len(knowledgeAngles))]
basePrompt := fmt.Sprintf(`围绕「%s」从「%s」角度写一条面向资深开发者的技术知识卡片。
要求:
- 100-160字2-3句连贯正文
- 必须触及底层机制、性能细节或生产踩坑经验
- 必须包含一个具体场景或数值佐证(如延迟数据、内存阈值、版本差异等)
- 直接输出正文不要标题、编号、Markdown`, keyword)
- 120-180字信息密度极高
- 必须是反常识、冷门陷阱、或版本差异等非常规知识点
- 必须包含至少一个精确数值(如:默认超时 30s、缓冲区 4KB、Go 1.21 修复等)
- 必须讲清因果链:什么场景触发 → 底层发生了什么 → 有什么后果 → 正确做法
- 用 Markdown 格式:**加粗重点**、`+promptCodeMark+`行内代码`+promptCodeMark+`标记术语
- 不要写常见知识点,不要复述官方文档`, keyword, angle)
if customPrompt != "" {
basePrompt += "\n附加风格要求不能覆盖上面的字数和质量要求" + customPrompt
}
@@ -186,7 +220,29 @@ func requestKnowledgeCompletion(body map[string]interface{}) string {
if resp.StatusCode != 200 {
log.Printf("知识API返回状态码: %d", resp.StatusCode)
return ""
if resp.StatusCode == 429 {
// 限流:等待后用新 body 重试
time.Sleep(3 * time.Second)
req2, err2 := http.NewRequest("POST", cpaURL, bytes.NewReader(jsonData))
if err2 != nil {
return ""
}
req2.Header.Set("Authorization", "Bearer "+key)
req2.Header.Set("Content-Type", "application/json")
resp2, err2 := httpClient.Do(req2)
if err2 != nil {
log.Println("知识API重试失败:", err2)
return ""
}
defer resp2.Body.Close()
if resp2.StatusCode != 200 {
log.Printf("知识API重试状态码: %d", resp2.StatusCode)
return ""
}
resp = resp2
} else {
return ""
}
}
var result struct {
@@ -201,6 +257,10 @@ func requestKnowledgeCompletion(body map[string]interface{}) string {
log.Println("知识API响应解析失败")
return ""
}
if len(result.Choices) == 0 {
log.Println("知识API: choices 为空")
return ""
}
if len(result.Choices) > 0 {
c := result.Choices[0].Message.Content
if c == "" {
@@ -211,29 +271,58 @@ func requestKnowledgeCompletion(body map[string]interface{}) string {
return ""
}
// normalizeKnowledgeContent 清洗 LLM 输出的格式残留Markdown标记、标题前缀、多余空白)
// normalizeKnowledgeContent 清洗 LLM 输出的格式残留,保留 Markdown 标记
func normalizeKnowledgeContent(content string) string {
content = strings.TrimSpace(content)
content = strings.Trim(content, "` \t\r\n")
// 去除整段代码块包裹
if strings.HasPrefix(content, "```") && strings.HasSuffix(content, "```") {
content = strings.TrimPrefix(content, "```")
// 去除语言标识行
if idx := strings.Index(content, "\n"); idx >= 0 && !strings.Contains(content[:idx], " ") {
content = content[idx+1:]
}
content = strings.TrimSuffix(content, "```")
content = strings.TrimSpace(content)
}
// 去除常见前缀
content = strings.TrimPrefix(content, "知识卡片:")
content = strings.TrimPrefix(content, "知识卡片:")
content = strings.TrimPrefix(content, "正文:")
content = strings.TrimPrefix(content, "正文:")
// 统一换行
content = strings.ReplaceAll(content, "\r\n", "\n")
lines := strings.FieldsFunc(content, func(r rune) bool {
return r == '\n' || r == '\r' || r == '\t'
})
content = strings.Join(lines, " ")
content = strings.Join(strings.Fields(content), " ")
// 连续空行压缩为两个换行Markdown 段落分隔)
for strings.Contains(content, "\n\n\n") {
content = strings.ReplaceAll(content, "\n\n\n", "\n\n")
}
// 行尾空白清理
lines := strings.Split(content, "\n")
for i, line := range lines {
lines[i] = strings.TrimRight(line, " \t")
}
content = strings.Join(lines, "\n")
return strings.TrimSpace(content)
}
// isQualityKnowledgeCard 检查字数 ≥80 且空泛表述 <3 条
var numberRe = regexp.MustCompile(`\d+`)
// weakPhrases 空泛表述列表
var weakPhrases = []string{
"很重要", "非常重要", "很有用", "提升效率", "值得关注",
"可以帮助", "广泛应用", "具有重要作用", "不可忽视", "具有重要意义",
}
// isQualityKnowledgeCard 检查质量字数≥120、有空泛表述上限、必须含数字、信息密度
func isQualityKnowledgeCard(content string) bool {
if utf8.RuneCountInString(content) < minKnowledgeRunes {
runes := utf8.RuneCountInString(content)
if runes < minKnowledgeRunes {
return false
}
weakPhrases := []string{"很重要", "非常重要", "很有用", "提升效率", "值得关注", "可以帮助", "广泛应用"}
// 必须包含数字(确保有具体数值)
if !numberRe.MatchString(content) {
return false
}
// 空泛表述检查
weakHits := 0
for _, phrase := range weakPhrases {
if strings.Contains(content, phrase) {
@@ -243,7 +332,21 @@ func isQualityKnowledgeCard(content string) bool {
if weakHits >= 3 {
return false
}
return strings.ContainsAny(content, "。;;:,")
// 必须有标点(非空内容)
if !strings.ContainsAny(content, "。;;:,") {
return false
}
// 信息密度非中文字符代码、数字、标点、Markdown占比 ≥ 15%
nonCJK := 0
for _, r := range content {
if !unicode.Is(unicode.Han, r) && !unicode.IsSpace(r) {
nonCJK++
}
}
if float64(nonCJK)/float64(runes) < 0.10 {
return false
}
return true
}
func pushKnowledgeJSON(content, keyword string) {
@@ -251,7 +354,21 @@ func pushKnowledgeJSON(content, keyword string) {
evalJS(fmt.Sprintf(`if(window.updateKnowledgeFromGo) window.updateKnowledgeFromGo(%s)`, string(data)))
}
var lastFetchTime int64 // unix timestamp, 限流冷却
func fetchAndPushKnowledge() {
// 30s 冷却,防止频繁点击触发限流
now := time.Now().Unix()
if now-atomic.LoadInt64(&lastFetchTime) < 30 {
// 冷却中,回退显示缓存
cfg := loadConfig()
if cached := getRandomKnowledgeCard(cfg.KnowledgeKeyword); cached != "" {
pushKnowledgeJSON(cached, cfg.KnowledgeKeyword)
}
return
}
atomic.StoreInt64(&lastFetchTime, now)
cfg := loadConfig()
keyword := cfg.KnowledgeKeyword
if keyword == "" {

View File

@@ -12,6 +12,7 @@ import (
"unsafe"
"github.com/jchv/go-webview2"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
@@ -19,9 +20,12 @@ import (
var settingsHTML string
var (
settingsMu sync.Mutex
settingsOpen bool
settingsHwnd uintptr
settingsMu sync.Mutex
settingsWv webview2.WebView // 持久实例nil 直到首次打开
settingsHwnd uintptr
settingsOldWndProc uintptr // 子类化前的原始 wndproc
settingsWndProcCb uintptr // NewCallback 指针,防止 GC
settingsCreated bool // 首次创建完成后 true
)
const runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run`
@@ -71,6 +75,16 @@ func isSystemLightTheme() bool {
return v == 1
}
// settingsWndProc 拦截 WM_CLOSE隐藏窗口而非销毁
func settingsWndProc(hwnd, msg, wp, lp uintptr) uintptr {
if msg == 0x0010 { // WM_CLOSE
procShowWindow.Call(hwnd, 0) // SW_HIDE
return 0
}
r, _, _ := procCallWindowProcW.Call(settingsOldWndProc, hwnd, msg, wp, lp)
return r
}
var themeNames = []struct {
Name ThemeName
Label string
@@ -105,26 +119,20 @@ func refreshVisibleCards(cfg, oldCfg *Config) {
func openSettingsWindow() {
settingsMu.Lock()
if settingsOpen {
if settingsCreated {
settingsMu.Unlock()
if settingsHwnd != 0 {
procShowWindow.Call(settingsHwnd, 9)
procGetForegroundWindow.Call(settingsHwnd)
}
procShowWindow.Call(settingsHwnd, 9) // SW_RESTORE
procSetForegroundWindow.Call(settingsHwnd)
settingsWv.Dispatch(func() {
settingsWv.Eval("if(window.__refreshSettings) window.__refreshSettings()")
})
return
}
settingsOpen = true
settingsMu.Unlock()
go func() {
runtime.LockOSThread()
defer func() {
settingsMu.Lock()
settingsOpen = false
settingsHwnd = 0
settingsMu.Unlock()
runtime.UnlockOSThread()
}()
defer runtime.UnlockOSThread()
dataDir := filepath.Join(configDir(), "settings-data")
os.MkdirAll(dataDir, 0755)
@@ -135,7 +143,7 @@ func openSettingsWindow() {
WindowOptions: webview2.WindowOptions{
Title: "桌面设置",
Width: 760,
Height: 1350,
Height: 800,
},
})
if w == nil {
@@ -143,6 +151,17 @@ func openSettingsWindow() {
return
}
settingsWv = w
hwnd := uintptr(w.Window())
// 子类化:拦截 WM_CLOSE → 隐藏而非销毁
settingsWndProcCb = windows.NewCallback(settingsWndProc)
oldProc, _, _ := procSetWindowLongPtrW.Call(hwnd, gwlpWndProc, settingsWndProcCb)
settingsOldWndProc = oldProc
// 初始隐藏resizeToFit 完成后再显示
procShowWindow.Call(hwnd, 0) // SW_HIDE
w.Bind("loadAllSettings", func() string {
cfg := loadConfig()
@@ -536,13 +555,12 @@ func openSettingsWindow() {
w.SetHtml(settingsHTML)
hwnd := uintptr(w.Window())
// disable resize + hide from taskbar
style, _, _ := procGetWindowLongPtrW.Call(hwnd, gwlStyle)
procSetWindowLongPtrW.Call(hwnd, gwlStyle, style & ^uintptr(wsSizebox|wsMaxbox))
procSetWindowLongPtrW.Call(hwnd, gwlExStyle, wsExToolwindow)
style, _, _ := procGetWindowLongPtrW.Call(hwnd, gwlStyle)
procSetWindowLongPtrW.Call(hwnd, gwlStyle, style & ^uintptr(wsSizebox|wsMaxbox))
procSetWindowLongPtrW.Call(hwnd, gwlExStyle, wsExToolwindow)
// resizeToFit: JS measures content, Go adjusts window frame
// resizeToFit: JS measures content, Go adjusts window frame, then shows window
w.Bind("resizeToFit", func(contentW, contentH int) string {
type rect struct{ Left, Top, Right, Bottom int32 }
var wr, cr rect
@@ -562,15 +580,19 @@ func openSettingsWindow() {
x := (int(screenW) - winW) / 2
y := (int(screenH) - winH) / 2
procMoveWindow.Call(hwnd, uintptr(x), uintptr(y), uintptr(winW), uintptr(winH), 1)
// 首次 resize 完成后显示窗口
procShowWindow.Call(hwnd, 9) // SW_RESTORE
procSetForegroundWindow.Call(hwnd)
return ""
})
settingsMu.Lock()
settingsHwnd = hwnd
settingsCreated = true
settingsMu.Unlock()
log.Println("设置窗口已打开")
w.Run()
log.Println("设置窗口已关闭")
log.Println("设置窗口消息循环退出")
}()
}

View File

@@ -124,7 +124,7 @@ func startWebView() {
wvHwnd = uintptr(wv.Window())
procShowWindow.Call(wvHwnd, 0) // SW_HIDE
procSetWindowLongPtrW.Call(wvHwnd, gwlStyle, wsPopup|wsVisible|wsChild)
procSetWindowLongPtrW.Call(wvHwnd, gwlExStyle, wsExTransparent|wsExLayered)
procSetWindowLongPtrW.Call(wvHwnd, gwlExStyle, wsExLayered)
// 立即嵌入 WorkerW
embedWorkerW(workerw, screenW, screenH)
@@ -136,6 +136,20 @@ func startWebView() {
return saveConfig(cfg)
})
wv.Bind("refreshKnowledge", func() string {
go fetchAndPushKnowledge()
return ""
})
wv.Bind("setInteractiveRect", func(left, top, right, bottom float64) string {
overlayHitRect.Left = int32(left)
overlayHitRect.Top = int32(top)
overlayHitRect.Right = int32(right)
overlayHitRect.Bottom = int32(bottom)
overlayHasHitRect = true
return ""
})
log.Println("设置 HTML...")
cfg := loadConfig()
wv.SetHtml(buildWallpaperHTML(cfg))
@@ -145,6 +159,11 @@ func startWebView() {
procShowWindow.Call(wvHwnd, 5)
log.Println("壁纸窗口已显示")
// 子类化WM_NCHITTEST 动态穿透WebView2 初始化完成后)
subclassOverlayWindows()
// 全局鼠标钩子:桌面图标层拦截了 WM_NCHITTEST用钩子直接捕获点击
installMouseHook()
go func() {
time.Sleep(500 * time.Millisecond)
reloadAllCards()

View File

@@ -3,18 +3,73 @@
<div class="knowledge-header">
<span class="knowledge-label">知识卡片</span>
<span class="knowledge-keyword-tag" v-if="state.knowledge.keyword">#{{ state.knowledge.keyword }}</span>
<button class="knowledge-refresh-btn" @click="refresh" title="换一条"></button>
</div>
<div class="knowledge-divider"></div>
<div class="knowledge-body" v-if="state.knowledge.content">
<span class="knowledge-quote">"</span>
<span class="knowledge-text">{{ state.knowledge.content }}</span>
<div class="knowledge-text" v-html="renderedContent"></div>
</div>
<div class="knowledge-placeholder" v-else>请在设置中配置知识关键字</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import { state } from '../composables/useOverlayState'
function refresh() {
state.knowledge = { ...state.knowledge, content: '加载中...' }
;(window as any).refreshKnowledge?.()
}
function updateHitRect() {
const btn = document.querySelector('.knowledge-refresh-btn') as HTMLElement
if (!btn) return
const r = btn.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
// 扩大点击区域 8px 方便点击
const pad = 8 * dpr
;(window as any).setInteractiveRect?.(
Math.round(r.left * dpr - pad), Math.round(r.top * dpr - pad),
Math.round(r.right * dpr + pad), Math.round(r.bottom * dpr + pad)
)
}
onMounted(() => {
updateHitRect()
window.addEventListener('resize', updateHitRect)
})
onUnmounted(() => {
window.removeEventListener('resize', updateHitRect)
})
function renderMarkdown(text: string): string {
let html = text
// 代码块 (```...```) → <pre><code>
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
// 行内代码 (`...`) → <code>
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
// 加粗 (**...**) → <strong>
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// 斜体 (*...*) → <em>
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>')
// ## 标题 → <h3>
html = html.replace(/^##\s+(.+)$/gm, '<h3>$1</h3>')
// 无序列表 → <li>
html = html.replace(/^[-*]\s+(.+)$/gm, '<li>$1</li>')
// 连续 <li> 包裹 <ul>
html = html.replace(/((?:<li>[\s\S]*?<\/li>\s*)+)/g, '<ul>$1</ul>')
// 段落(双换行分隔)
html = html.replace(/\n\n+/g, '</p><p>')
// 单换行 → <br>(非 <li>、<h3>、<pre> 内部)
html = html.replace(/(?<!<\/li>|<\/h3>|<\/pre>|<\/code>)\n(?!<li>|<h3>|<pre>|<\/ul>)/g, '<br>')
// 包裹在 <p> 中
if (!html.startsWith('<')) html = '<p>' + html + '</p>'
return html
}
const renderedContent = computed(() => renderMarkdown(state.knowledge.content))
</script>
<style scoped>
@@ -38,6 +93,25 @@ import { state } from '../composables/useOverlayState'
font-size: 10px; font-weight: 600;
border: 1px solid rgba(255,216,107,0.15);
}
.knowledge-refresh-btn {
margin-left: auto;
background: none; border: none; cursor: pointer;
color: var(--accent-warm); font-size: 16px; line-height: 1;
padding: 2px 4px; border-radius: 4px;
transition: all 0.3s;
opacity: 0.7;
animation: pulse-hint 3s ease-in-out infinite;
}
.knowledge-refresh-btn:hover,
.knowledge-refresh-btn.hover-active {
opacity: 1;
transform: rotate(180deg) scale(1.2);
background: rgba(255,216,107,0.15);
}
@keyframes pulse-hint {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.8; }
}
.knowledge-divider {
height: 1px;
background: linear-gradient(90deg, var(--accent-warm), rgba(255,216,107,0.08), transparent);
@@ -56,9 +130,41 @@ import { state } from '../composables/useOverlayState'
.knowledge-text {
font-size: 15px; color: var(--text-main);
line-height: 1.7; text-shadow: 0 1px 4px rgba(0,0,0,0.5);
display: -webkit-box; -webkit-line-clamp: 8;
display: -webkit-box; -webkit-line-clamp: 10;
-webkit-box-orient: vertical; overflow: hidden;
}
.knowledge-text :deep(h3) {
font-size: 14px; font-weight: 700;
color: var(--accent-warm); margin: 10px 0 6px;
}
.knowledge-text :deep(strong) {
color: var(--text-strong); font-weight: 600;
}
.knowledge-text :deep(code) {
background: rgba(255,255,255,0.08);
padding: 1px 5px; border-radius: 3px;
font-size: 13px; font-family: 'Cascadia Code', 'Fira Code', monospace;
color: var(--accent-warm);
}
.knowledge-text :deep(pre) {
background: rgba(0,0,0,0.3);
padding: 8px 10px; border-radius: 6px;
margin: 8px 0; overflow-x: auto;
}
.knowledge-text :deep(pre code) {
background: none; padding: 0;
font-size: 13px;
}
.knowledge-text :deep(ul) {
padding-left: 16px; margin: 6px 0;
}
.knowledge-text :deep(li) {
font-size: 14px; line-height: 1.6;
list-style: disc;
}
.knowledge-text :deep(em) {
color: var(--text-faint); font-style: italic;
}
.knowledge-placeholder {
font-size: 13px; color: var(--text-faint);
font-style: italic; padding-top: 8px;

View File

@@ -48,6 +48,7 @@ import PhotoCard from './PhotoCard.vue'
pointer-events: none;
}
#layout-multi > .card { pointer-events: none; }
#card-knowledge { pointer-events: auto; }
#card-time {
grid-area: time; text-align: left;
width: 100%; height: 100%; padding: 22px 30px;

View File

@@ -38,6 +38,16 @@ const s = reactive<SettingsData>({
audioVizStyle: 'bars', audioSensitivity: 1.5, audioSmoothing: 0.7, audioColorScheme: 'neon',
})
async function refreshFromGo() {
const raw = await go.loadAllSettings()
const data = JSON.parse(raw) as SettingsData
if (data.lightTheme) document.documentElement.className = 'light'
Object.assign(s, { ...data, citiesByProv: data.citiesByProv || {}, provinces: data.provinces || [], themes: data.themes || [], savedColors: data.savedColors || [] })
if (data.color1) { currentColor.c1 = data.color1; currentColor.c2 = data.color2 || ''; currentColor.gradient = data.colorGradient || false }
}
;(window as any).__refreshSettings = refreshFromGo
// Shared state for color section
const currentColor = reactive({ c1: '', c2: '', gradient: false })
@@ -76,11 +86,7 @@ async function onRemoveSavedColor(idx: number) {
}
onMounted(async () => {
const raw = await go.loadAllSettings()
const data = JSON.parse(raw) as SettingsData
if (data.lightTheme) document.documentElement.className = 'light'
Object.assign(s, { ...data, citiesByProv: data.citiesByProv || {}, provinces: data.provinces || [], themes: data.themes || [], savedColors: data.savedColors || [] })
if (data.color1) { currentColor.c1 = data.color1; currentColor.c2 = data.color2 || ''; currentColor.gradient = data.colorGradient || false }
await refreshFromGo()
setTimeout(() => {
const el = document.documentElement
if (go.resizeToFit) go.resizeToFit(el.scrollWidth, el.scrollHeight + 8)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

144
win32.go
View File

@@ -30,6 +30,14 @@ var (
procTranslateMessage = user32.NewProc("TranslateMessage")
procDispatchMessageW = user32.NewProc("DispatchMessageW")
procGetParent = user32.NewProc("GetParent")
procCallWindowProcW = user32.NewProc("CallWindowProcW")
procSetForegroundWindow = user32.NewProc("SetForegroundWindow")
procScreenToClient = user32.NewProc("ScreenToClient")
procEnumChildWindows = user32.NewProc("EnumChildWindows")
procGetWindow = user32.NewProc("GetWindow")
procSetWindowsHookExW = user32.NewProc("SetWindowsHookExW")
procCallNextHookEx = user32.NewProc("CallNextHookEx")
procWindowFromPoint = user32.NewProc("WindowFromPoint")
)
var wv webview2.WebView
@@ -52,14 +60,149 @@ const (
wsMaxbox = uintptr(0x00010000)
)
const gwlpWndProc = ^uintptr(3) // GWLP_WNDPROC = -4
const (
wsExTransparent = uintptr(0x00000020)
wsExLayered = uintptr(0x00080000)
wsExToolwindow = uintptr(0x00000080)
)
const (
gwChild = 5 // GW_CHILD
gwHwndNext = 2 // GW_HWNDNEXT
)
var htmlQueue = make(chan string, 1)
// overlay 窗口子类化:动态 hit test
var (
overlayOldWndProc uintptr
overlayWndProcCb uintptr
overlayHitRect struct{ Left, Top, Right, Bottom int32 }
overlayHasHitRect bool
childOldWndProcs = make(map[uintptr]uintptr) // 子窗口 hwnd -> 旧 wndproc
)
// overlayWndProc 拦截 WM_NCHITTEST交互区域返回 HTCLIENT其余 HTTRANSPARENT穿透
// 注意:由于 overlay 在桌面图标层之下,此 wndproc 主要作为备用
func overlayWndProc(hwnd, msg, wp, lp uintptr) uintptr {
if msg == 0x0084 && overlayHasHitRect { // WM_NCHITTEST
x := int32(int16(lp & 0xFFFF))
y := int32(int16(lp >> 16))
pt := struct{ X, Y int32 }{x, y}
procScreenToClient.Call(wvHwnd, uintptr(unsafe.Pointer(&pt)))
if pt.X >= overlayHitRect.Left && pt.X <= overlayHitRect.Right &&
pt.Y >= overlayHitRect.Top && pt.Y <= overlayHitRect.Bottom {
return uintptr(1) // HTCLIENT
}
return 0xFFFFFFFF // HTTRANSPARENT
}
// 查找该窗口的旧 wndproc
oldProc := overlayOldWndProc
if v, ok := childOldWndProcs[hwnd]; ok {
oldProc = v
}
r, _, _ := procCallWindowProcW.Call(oldProc, hwnd, msg, wp, lp)
return r
}
// installMouseHook 安装 WH_MOUSE_LL 全局钩子拦截刷新按钮点击
// overlay 在 WorkerW 中位于桌面图标层之下WM_NCHITTEST 无法到达
// 因此用低级鼠标钩子直接在 Go 层处理点击
var llMouseHook uintptr
var llMouseHookCb uintptr // 防止 GC 回收回调函数
var progmanHwnd uintptr // Progman 句柄,用于判断点击是否在桌面
var btnHoverActive bool // 按钮悬停状态,避免重复推送 JS
// isMouseInBtnRect 检查鼠标是否在按钮 hit rect 内(屏幕坐标转 wvHwnd 客户端坐标)
func isMouseInBtnRect(screenX, screenY int32) bool {
if !overlayHasHitRect {
return false
}
pt := struct{ X, Y int32 }{screenX, screenY}
procScreenToClient.Call(wvHwnd, uintptr(unsafe.Pointer(&pt)))
return pt.X >= overlayHitRect.Left && pt.X <= overlayHitRect.Right &&
pt.Y >= overlayHitRect.Top && pt.Y <= overlayHitRect.Bottom
}
// isDesktopAtPoint 检查屏幕坐标处是否为桌面背景(无其他窗口遮挡)
func isDesktopAtPoint(screenX, screenY int32) bool {
ptPacked := uintptr(screenX) | (uintptr(uint32(screenY)) << 32)
hwndAt, _, _ := procWindowFromPoint.Call(ptPacked)
for h := hwndAt; h != 0; h, _, _ = procGetParent.Call(h) {
if h == progmanHwnd {
return true
}
}
return false
}
func installMouseHook() {
llMouseHookCb = windows.NewCallback(func(nCode, wp, lp uintptr) uintptr {
if int32(nCode) >= 0 {
type msll struct {
X, Y int32
MouseData uint32
Flags uint32
Time uint32
DwExtraInfo uintptr
}
s := (*msll)(unsafe.Pointer(lp))
switch wp {
case 0x0200: // WM_MOUSEMOVE — 悬停检测
inBtn := isMouseInBtnRect(s.X, s.Y)
if inBtn && !btnHoverActive {
btnHoverActive = true
evalJS("document.querySelector('.knowledge-refresh-btn')?.classList.add('hover-active')")
} else if !inBtn && btnHoverActive {
btnHoverActive = false
evalJS("document.querySelector('.knowledge-refresh-btn')?.classList.remove('hover-active')")
}
case 0x0201: // WM_LBUTTONDOWN — 点击
if !isDesktopAtPoint(s.X, s.Y) {
break
}
if isMouseInBtnRect(s.X, s.Y) {
pushKnowledgeLoading("")
go fetchAndPushKnowledge()
return 1
}
}
}
r, _, _ := procCallNextHookEx.Call(llMouseHook, nCode, wp, lp)
return r
})
var llMouseHookVal uintptr
llMouseHookVal, _, _ = procSetWindowsHookExW.Call(
14, // WH_MOUSE_LL
llMouseHookCb, 0, 0,
)
llMouseHook = llMouseHookVal
log.Printf("mouse hook installed, progman=0x%x, hook=0x%x", progmanHwnd, llMouseHookVal)
}
// subclassOverlayWindows 子类化 WebView2 父窗口及其所有子窗口
func subclassOverlayWindows() {
overlayWndProcCb = windows.NewCallback(overlayWndProc)
oldProc, _, _ := procSetWindowLongPtrW.Call(wvHwnd, gwlpWndProc, overlayWndProcCb)
overlayOldWndProc = oldProc
subclassChildTree(wvHwnd)
}
// subclassChildTree 递归子类化 parent 下的所有子窗口
func subclassChildTree(parent uintptr) {
child, _, _ := procGetWindow.Call(parent, uintptr(gwChild))
for child != 0 {
old, _, _ := procSetWindowLongPtrW.Call(child, gwlpWndProc, overlayWndProcCb)
childOldWndProcs[child] = old
subclassChildTree(child)
child, _, _ = procGetWindow.Call(child, uintptr(gwHwndNext))
}
}
var (
classProgman = uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("Progman")))
classShellDefView = uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("SHELLDLL_DefView")))
@@ -85,6 +228,7 @@ func findWorkerW() uintptr {
log.Println("findWorkerW: Progman not found")
return 0
}
progmanHwnd = progman // 保存 Progman 句柄供鼠标钩子判断
// 发送 0x052C 触发 Progman 创建 WorkerW
var result uintptr
procSendMessageTimeoutW.Call(progman, 0x052C, 0, 0, 0x0000, 1000, uintptr(unsafe.Pointer(&result)))