Compare commits
10 Commits
bb1574641f
...
aee7997195
| Author | SHA1 | Date | |
|---|---|---|---|
| aee7997195 | |||
| f3148bf72f | |||
| 0cd9cd40b4 | |||
| 92bca936d8 | |||
| 79851781aa | |||
| 8e7ec8424d | |||
| eed461e325 | |||
| 9fd3acede3 | |||
| 2287e12e0d | |||
| d4bce23d19 |
4
.gitignore
vendored
@@ -22,3 +22,7 @@ vendor/
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
u-desktop
|
u-desktop
|
||||||
|
|
||||||
|
# Web UI
|
||||||
|
web-ui/node_modules/
|
||||||
|
web-ui/dist/
|
||||||
|
|||||||
121
README.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# u-desktop
|
||||||
|
|
||||||
|
Windows 桌面壁纸增强工具 — 将 WebView2 嵌入桌面壁纸层,在壁纸上叠加时间、天气、星座运势、AI 资讯等信息卡片。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
| 模块 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 壁纸 | 6 种主题动画(极光/星空/渐变/粒子/极光流体/文字)、本地图片、Bing 每日壁纸、纯色/渐变 |
|
||||||
|
| 时间 | 大字时钟,支持秒显示开关,整点光晕动画,节日倒计时 |
|
||||||
|
| 天气 | 和风天气 API,当前天气 + 24h/7d 预报,IP 自动定位 + 手动选城 |
|
||||||
|
| 星座运势 | 天聚数行 API,5 维指数进度条 + 幸运标签 + 今日概述 |
|
||||||
|
| AI 资讯 | 天聚数行 API,图文布局 5 条展示 |
|
||||||
|
| 知识卡片 | AI 生成,自定义关键字 + 提示词 |
|
||||||
|
| 桌面设置 | 独立 WebView2 窗口,所有配置可视化操作 |
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
u-desktop/
|
||||||
|
├── main.go # 入口: 单实例互斥锁 + 配置目录 + 托盘启动
|
||||||
|
├── win32.go # Win32 API 声明 (user32/kernel32/ole32)
|
||||||
|
├── systray.go # 系统托盘 + WebView2 壁纸嵌入 + 消息循环
|
||||||
|
├── wallpaper.go # 壁纸 HTML 构建 + 主题注入 + 重载
|
||||||
|
├── config.go # 配置结构体 + JSON 持久化
|
||||||
|
├── settings.go # 设置窗口 (独立 WebView2)
|
||||||
|
├── weather.go # 天气 API + IP 定位 + 城市列表
|
||||||
|
├── horoscope.go # 星座运势 API + 文件缓存
|
||||||
|
├── ainews.go # AI 资讯 API + 文件缓存
|
||||||
|
├── knowledge.go # 知识卡片 AI 生成
|
||||||
|
├── bing.go # Bing 壁纸下载 + 历史导航 + 收藏
|
||||||
|
├── dialog.go # Win32 对话框 (文件选择/颜色选择)
|
||||||
|
├── web/
|
||||||
|
│ ├── overlay.html # 桌面覆盖层 (时间/天气/星座/资讯/知识)
|
||||||
|
│ ├── settings.html # 设置窗口 UI
|
||||||
|
│ └── themes/ # 壁纸主题 HTML
|
||||||
|
│ ├── aurora.html
|
||||||
|
│ ├── starfield.html
|
||||||
|
│ ├── gradient.html
|
||||||
|
│ ├── particles.html
|
||||||
|
│ ├── fractal.html
|
||||||
|
│ └── text.html
|
||||||
|
├── config/ # 运行时配置 (settings.json + 缓存)
|
||||||
|
└── docs/
|
||||||
|
└── wallpaper-embedding.md # 壁纸嵌入技术笔记
|
||||||
|
```
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ ┌─────────────────┐
|
||||||
|
│ systray.go │────▶│ WebView2 主窗口 │ SetParent → WorkerW (壁纸层)
|
||||||
|
│ (托盘+消息循环) │ │ overlay.html │
|
||||||
|
└──────┬───────┘ └─────────────────┘
|
||||||
|
│ evalJS / SetHtml
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────┐ ┌─────────────────┐
|
||||||
|
│ settings.go │────▶│ WebView2 设置窗口 │ 独立窗口, 760x1350
|
||||||
|
│ │ │ settings.html │
|
||||||
|
└──────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心通信机制**:
|
||||||
|
- `evalJS(cmd)` — 通过 channel + PostMessage 向壁纸 WebView 注入 JS
|
||||||
|
- `htmlQueue` — 通过 SetHtml 替换整个壁纸 HTML(触发完整重载)
|
||||||
|
- WebView2 `Bind` — 设置窗口通过 Go 绑定函数读写配置
|
||||||
|
|
||||||
|
**数据刷新策略**:
|
||||||
|
- 启动时推送文件缓存(即时显示),后台拉取新数据后替换
|
||||||
|
- 星座: 24h / AI 资讯: 2h / 知识卡片: 30min / 天气: 10min / Bing: 4h
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
配置文件: `config/settings.json`(与 exe 同级目录)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| layout | string | "single" | 布局: single(合并卡片) / multi(独立卡片) |
|
||||||
|
| wallpaperType | string | "theme" | 壁纸类型: theme/image/bing/color |
|
||||||
|
| theme | string | "aurora" | 主题动画 |
|
||||||
|
| zodiac | string | "射手座" | 星座 |
|
||||||
|
| city | string | "" | 天气城市 ID |
|
||||||
|
| showSeconds | bool | false | 显示秒 |
|
||||||
|
| hideWallpaper | bool | false | 隐藏壁纸 |
|
||||||
|
| hideTime | bool | false | 隐藏时间 |
|
||||||
|
| hideWeather | bool | false | 隐藏天气 |
|
||||||
|
| hideZodiac | bool | false | 隐藏星座 |
|
||||||
|
| hideAINews | bool | false | 隐藏 AI 资讯 |
|
||||||
|
| hideKnowledge | bool | false | 隐藏知识卡片 |
|
||||||
|
| bingAutoRefresh | bool | false | Bing 每小时自动切换 |
|
||||||
|
| knowledgeKeyword | string | "" | 知识卡片关键字 |
|
||||||
|
| knowledgePrompt | string | "" | 知识卡片提示词 |
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o u-desktop.exe .
|
||||||
|
```
|
||||||
|
|
||||||
|
需要 Windows 10+ 和 WebView2 Runtime(Win11 已内置)。
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
|
||||||
|
```
|
||||||
|
github.com/jchv/go-webview2 # WebView2 绑定
|
||||||
|
github.com/getlantern/systray # 系统托盘
|
||||||
|
golang.org/x/sys/windows # Win32 API
|
||||||
|
github.com/anthropics/anthropic-sdk-go # Claude API (知识卡片)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开机自启
|
||||||
|
|
||||||
|
注册表 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\UDesktopWallpaper`
|
||||||
|
|
||||||
|
## 技术要点
|
||||||
|
|
||||||
|
- WebView2 嵌入桌面壁纸层通过 `SetParent` 到 WorkerW 实现(详见 [docs/wallpaper-embedding.md](docs/wallpaper-embedding.md))
|
||||||
|
- 自定义 Win32 消息循环替代 `wv.Run()`(SetParent 后 Run() 消息路由异常)
|
||||||
|
- 全屏应用检测:定时检查前台窗口是否全屏,自动暂停/恢复壁纸渲染
|
||||||
|
- 配置变更即时生效:设置窗口通过 evalJS 直接操作壁纸层 DOM,无需重启
|
||||||
178
ainews.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tianapiAIURL = "https://apis.tianapi.com/ai/index"
|
||||||
|
|
||||||
|
type aiNewsResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Result struct {
|
||||||
|
Newslist []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CTime string `json:"ctime"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
PicUrl string `json:"picUrl"`
|
||||||
|
} `json:"newslist"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type aiNewsItem struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
CTime string `json:"ctime"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
PicURL string `json:"picUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
aiNewsMu sync.Mutex
|
||||||
|
aiNewsCache []aiNewsItem
|
||||||
|
aiNewsCacheAt time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
func aiNewsCachePath() string {
|
||||||
|
return filepath.Join(configDir(), "ainews_cache.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAINews() []aiNewsItem {
|
||||||
|
aiNewsMu.Lock()
|
||||||
|
if aiNewsCache != nil && time.Since(aiNewsCacheAt) < 2*time.Hour {
|
||||||
|
cached := aiNewsCache
|
||||||
|
aiNewsMu.Unlock()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
aiNewsMu.Unlock()
|
||||||
|
|
||||||
|
key := loadConfig().tianapiKey()
|
||||||
|
if key == "" {
|
||||||
|
log.Println("未配置天聚数行 API Key")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("%s?key=%s", tianapiAIURL, key)
|
||||||
|
data, err := httpGet(url)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("AI资讯请求失败:", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp aiNewsResp
|
||||||
|
if json.Unmarshal(data, &resp) != nil || resp.Code != 200 {
|
||||||
|
log.Println("AI资讯解析失败:", string(data[:min(len(data), 100)]))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []aiNewsItem
|
||||||
|
for _, n := range resp.Result.Newslist {
|
||||||
|
items = append(items, aiNewsItem{
|
||||||
|
Title: n.Title,
|
||||||
|
Description: n.Description,
|
||||||
|
Source: n.Source,
|
||||||
|
CTime: n.CTime,
|
||||||
|
URL: n.URL,
|
||||||
|
PicURL: n.PicUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
aiNewsMu.Lock()
|
||||||
|
aiNewsCache = items
|
||||||
|
aiNewsCacheAt = time.Now()
|
||||||
|
aiNewsMu.Unlock()
|
||||||
|
|
||||||
|
// 缓存到文件
|
||||||
|
cacheData, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"items": items,
|
||||||
|
"at": time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
if err := os.WriteFile(aiNewsCachePath(), cacheData, 0644); err != nil {
|
||||||
|
log.Println("AI资讯缓存写入失败:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("AI资讯已获取: %d条", len(items))
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAINewsCache() []aiNewsItem {
|
||||||
|
data, err := os.ReadFile(aiNewsCachePath())
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var cached struct {
|
||||||
|
Items []aiNewsItem `json:"items"`
|
||||||
|
At string `json:"at"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &cached) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cached.Items
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushAINews(items []aiNewsItem) {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonData, _ := json.Marshal(items)
|
||||||
|
js := fmt.Sprintf(`if(window.updateAINewsFromGo) window.updateAINewsFromGo(%s)`, string(jsonData))
|
||||||
|
evalJS(js)
|
||||||
|
}
|
||||||
|
|
||||||
|
func aiNewsLoop() {
|
||||||
|
cfg := loadConfig()
|
||||||
|
if !cfg.HideAINews {
|
||||||
|
// 先推送缓存
|
||||||
|
if cached := loadAINewsCache(); cached != nil {
|
||||||
|
pushAINews(cached)
|
||||||
|
}
|
||||||
|
} else if cached := loadAINewsCache(); cached != nil {
|
||||||
|
aiNewsMu.Lock()
|
||||||
|
aiNewsCache = cached
|
||||||
|
aiNewsCacheAt = time.Now()
|
||||||
|
aiNewsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(8 * time.Second)
|
||||||
|
|
||||||
|
cfg = loadConfig()
|
||||||
|
if !cfg.HideAINews {
|
||||||
|
var items []aiNewsItem
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
items = fetchAINews()
|
||||||
|
if items != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
}
|
||||||
|
if items != nil {
|
||||||
|
pushAINews(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(2 * time.Hour)
|
||||||
|
for range ticker.C {
|
||||||
|
cfg := loadConfig()
|
||||||
|
if !cfg.HideAINews {
|
||||||
|
if items := fetchAINews(); items != nil {
|
||||||
|
pushAINews(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerAINewsRefresh() {
|
||||||
|
go func() {
|
||||||
|
if items := fetchAINews(); items != nil {
|
||||||
|
pushAINews(items)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
BIN
assets/icons/tray-icon-128.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
assets/icons/tray-icon-16.png
Normal file
|
After Width: | Height: | Size: 772 B |
BIN
assets/icons/tray-icon-20.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
assets/icons/tray-icon-24.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/icons/tray-icon-256.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
assets/icons/tray-icon-32.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
assets/icons/tray-icon-40.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
assets/icons/tray-icon-48.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/icons/tray-icon-64.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
assets/icons/tray-icon.png
Normal file
|
After Width: | Height: | Size: 649 KiB |
BIN
assets/icons/tray-source.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/icons/tray.ico
Normal file
|
After Width: | Height: | Size: 113 KiB |
323
bing.go
@@ -2,81 +2,326 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const bingAPI = "https://www.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=zh-CN"
|
const bingAPIBase = "https://www.bing.com/HPImageArchive.aspx?format=js&n=8&mkt=zh-CN"
|
||||||
|
|
||||||
type bingResponse struct {
|
type bingResponse struct {
|
||||||
Images []struct {
|
Images []struct {
|
||||||
|
StartDate string `json:"startdate"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
URLBase string `json:"urlbase"`
|
URLBase string `json:"urlbase"`
|
||||||
Copyright string `json:"copyright"`
|
Copyright string `json:"copyright"`
|
||||||
} `json:"images"`
|
} `json:"images"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchBingWallpaper() {
|
type BingRecord struct {
|
||||||
resp, err := httpClient.Get(bingAPI)
|
Date string `json:"date"`
|
||||||
|
URLBase string `json:"urlbase"`
|
||||||
|
Copyright string `json:"copyright"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Favorited bool `json:"favorited"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BingHistory struct {
|
||||||
|
Records []BingRecord `json:"records"`
|
||||||
|
CurrentIdx int `json:"currentIdx"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var bingMu sync.Mutex
|
||||||
|
|
||||||
|
func bingDir() string {
|
||||||
|
return filepath.Join(configDir(), "bing")
|
||||||
|
}
|
||||||
|
|
||||||
|
func bingHistoryPath() string {
|
||||||
|
return filepath.Join(configDir(), "bing_history.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadBingHistory() *BingHistory {
|
||||||
|
data, err := os.ReadFile(bingHistoryPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Bing API 请求失败:", err)
|
return &BingHistory{}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
var h BingHistory
|
||||||
data, err := io.ReadAll(resp.Body)
|
if json.Unmarshal(data, &h) != nil {
|
||||||
if err != nil {
|
return &BingHistory{}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
var br bingResponse
|
return &h
|
||||||
if json.Unmarshal(data, &br) != nil || len(br.Images) == 0 {
|
}
|
||||||
log.Println("Bing API 解析失败")
|
|
||||||
return
|
func saveBingHistory(h *BingHistory) error {
|
||||||
|
data, _ := json.MarshalIndent(h, "", " ")
|
||||||
|
return os.WriteFile(bingHistoryPath(), data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchBingHistory() {
|
||||||
|
bingMu.Lock()
|
||||||
|
defer bingMu.Unlock()
|
||||||
|
|
||||||
|
os.MkdirAll(bingDir(), 0755)
|
||||||
|
existing := loadBingHistory()
|
||||||
|
existingMap := make(map[string]BingRecord)
|
||||||
|
for _, r := range existing.Records {
|
||||||
|
existingMap[r.Date] = r
|
||||||
}
|
}
|
||||||
|
|
||||||
imgURL := br.Images[0].URL
|
// 分页下载: idx=0,8,16... 直到命中已有记录或无新图片
|
||||||
if !strings.HasPrefix(imgURL, "http") {
|
for idx := 0; idx < 80; idx += 8 {
|
||||||
imgURL = "https://www.bing.com" + imgURL
|
url := fmt.Sprintf("%s&idx=%d", bingAPIBase, idx)
|
||||||
|
resp, err := httpClient.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Bing API 请求失败 (idx=%d): %v", idx, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
data, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
var br bingResponse
|
||||||
|
if json.Unmarshal(data, &br) != nil || len(br.Images) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
newCount := 0
|
||||||
|
for _, img := range br.Images {
|
||||||
|
date := img.StartDate
|
||||||
|
if date == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := existingMap[date]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
imgURL := img.URL
|
||||||
|
if !strings.HasPrefix(imgURL, "http") {
|
||||||
|
imgURL = "https://www.bing.com" + imgURL
|
||||||
|
}
|
||||||
|
|
||||||
|
imgResp, err := httpClient.Get(imgURL)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
imgData, _ := io.ReadAll(imgResp.Body)
|
||||||
|
imgResp.Body.Close()
|
||||||
|
if len(imgData) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := date + ".jpg"
|
||||||
|
localPath := filepath.Join(bingDir(), filename)
|
||||||
|
os.WriteFile(localPath, imgData, 0644)
|
||||||
|
log.Printf("Bing 壁纸已下载: %s (%d bytes)", filename, len(imgData))
|
||||||
|
|
||||||
|
existingMap[date] = BingRecord{
|
||||||
|
Date: date,
|
||||||
|
URLBase: img.URLBase,
|
||||||
|
Copyright: img.Copyright,
|
||||||
|
Filename: filename,
|
||||||
|
}
|
||||||
|
newCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if newCount == 0 && idx > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
imgResp, err := httpClient.Get(imgURL)
|
// 按 API 返回顺序重建 records (newest first)
|
||||||
if err != nil {
|
// 用 existingMap 里所有记录按 date 降序排列
|
||||||
log.Println("Bing 图片下载失败:", err)
|
var records []BingRecord
|
||||||
return
|
for _, r := range existingMap {
|
||||||
}
|
records = append(records, r)
|
||||||
defer imgResp.Body.Close()
|
|
||||||
imgData, err := io.ReadAll(imgResp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
sort.Slice(records, func(i, j int) bool {
|
||||||
|
return records[i].Date > records[j].Date
|
||||||
|
})
|
||||||
|
|
||||||
bingPath := filepath.Join(configDir(), "bing_wallpaper.jpg")
|
history := &BingHistory{
|
||||||
if err := os.WriteFile(bingPath, imgData, 0644); err != nil {
|
Records: records,
|
||||||
log.Println("Bing 壁纸缓存失败:", err)
|
CurrentIdx: 0,
|
||||||
return
|
|
||||||
}
|
}
|
||||||
log.Printf("Bing 壁纸已缓存: %s (%d bytes)", bingPath, len(imgData))
|
if existing.CurrentIdx < len(records) {
|
||||||
|
history.CurrentIdx = existing.CurrentIdx
|
||||||
|
}
|
||||||
|
saveBingHistory(history)
|
||||||
|
|
||||||
|
log.Printf("Bing 壁纸: 共 %d 张", len(records))
|
||||||
reloadWallpaper()
|
reloadWallpaper()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getCurrentBingPath() string {
|
||||||
|
h := loadBingHistory()
|
||||||
|
if h.CurrentIdx < 0 || h.CurrentIdx >= len(h.Records) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(bingDir(), h.Records[h.CurrentIdx].Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCurrentBingRecord() *BingRecord {
|
||||||
|
h := loadBingHistory()
|
||||||
|
if h.CurrentIdx < 0 || h.CurrentIdx >= len(h.Records) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &h.Records[h.CurrentIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
func bingPrev() {
|
||||||
|
bingMu.Lock()
|
||||||
|
defer bingMu.Unlock()
|
||||||
|
|
||||||
|
h := loadBingHistory()
|
||||||
|
if len(h.Records) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.CurrentIdx = (h.CurrentIdx + 1) % len(h.Records)
|
||||||
|
saveBingHistory(h)
|
||||||
|
log.Printf("Bing 壁纸: 上一个 (idx=%d, date=%s)", h.CurrentIdx, h.Records[h.CurrentIdx].Date)
|
||||||
|
bingReloadImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
func bingNext() {
|
||||||
|
bingMu.Lock()
|
||||||
|
defer bingMu.Unlock()
|
||||||
|
|
||||||
|
h := loadBingHistory()
|
||||||
|
if len(h.Records) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.CurrentIdx = (h.CurrentIdx - 1 + len(h.Records)) % len(h.Records)
|
||||||
|
saveBingHistory(h)
|
||||||
|
log.Printf("Bing 壁纸: 下一个 (idx=%d, date=%s)", h.CurrentIdx, h.Records[h.CurrentIdx].Date)
|
||||||
|
bingReloadImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
func bingToggleFavorite() string {
|
||||||
|
bingMu.Lock()
|
||||||
|
defer bingMu.Unlock()
|
||||||
|
|
||||||
|
h := loadBingHistory()
|
||||||
|
if h.CurrentIdx < 0 || h.CurrentIdx >= len(h.Records) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
r := &h.Records[h.CurrentIdx]
|
||||||
|
r.Favorited = !r.Favorited
|
||||||
|
saveBingHistory(h)
|
||||||
|
if r.Favorited {
|
||||||
|
log.Printf("Bing 壁纸: 已收藏 (date=%s)", r.Date)
|
||||||
|
return "☆ 取消收藏"
|
||||||
|
}
|
||||||
|
log.Printf("Bing 壁纸: 已取消收藏 (date=%s)", r.Date)
|
||||||
|
return "★ 收藏当前壁纸"
|
||||||
|
}
|
||||||
|
|
||||||
|
func bingCurrentState() string {
|
||||||
|
h := loadBingHistory()
|
||||||
|
if h.CurrentIdx < 0 || h.CurrentIdx >= len(h.Records) {
|
||||||
|
return `{"fav":false,"label":"☆","copyright":"","idx":0,"total":0}`
|
||||||
|
}
|
||||||
|
r := h.Records[h.CurrentIdx]
|
||||||
|
label := "☆"
|
||||||
|
if r.Favorited {
|
||||||
|
label = "⭐"
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"fav": r.Favorited,
|
||||||
|
"label": label,
|
||||||
|
"copyright": r.Copyright,
|
||||||
|
"date": r.Date,
|
||||||
|
"filename": r.Filename,
|
||||||
|
"idx": h.CurrentIdx,
|
||||||
|
"total": len(h.Records),
|
||||||
|
})
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bingFavoritesJSON() string {
|
||||||
|
h := loadBingHistory()
|
||||||
|
type favItem struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Copyright string `json:"copyright"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Idx int `json:"idx"`
|
||||||
|
}
|
||||||
|
var favs []favItem
|
||||||
|
for i, r := range h.Records {
|
||||||
|
if r.Favorited {
|
||||||
|
favs = append(favs, favItem{r.Date, r.Copyright, r.Filename, i})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if favs == nil {
|
||||||
|
favs = []favItem{}
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(favs)
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bingSetByIdx(idx int) string {
|
||||||
|
bingMu.Lock()
|
||||||
|
defer bingMu.Unlock()
|
||||||
|
h := loadBingHistory()
|
||||||
|
if idx < 0 || idx >= len(h.Records) {
|
||||||
|
return bingCurrentState()
|
||||||
|
}
|
||||||
|
h.CurrentIdx = idx
|
||||||
|
saveBingHistory(h)
|
||||||
|
bingReloadImage()
|
||||||
|
return bingCurrentState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func bingThumbDataURI(filename string) string {
|
||||||
|
p := filepath.Join(bingDir(), filename)
|
||||||
|
return imageToDataURI(p)
|
||||||
|
}
|
||||||
|
|
||||||
func bingWallpaperLoop() {
|
func bingWallpaperLoop() {
|
||||||
cfg := loadConfig()
|
cfg := loadConfig()
|
||||||
if cfg.WallpaperType == WPBing {
|
if cfg.WallpaperType == WPBing {
|
||||||
bingPath := filepath.Join(configDir(), "bing_wallpaper.jpg")
|
h := loadBingHistory()
|
||||||
if _, err := os.Stat(bingPath); err != nil {
|
if len(h.Records) == 0 {
|
||||||
fetchBingWallpaper()
|
fetchBingHistory()
|
||||||
|
} else {
|
||||||
|
reloadWallpaper()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ticker := time.NewTicker(4 * time.Hour)
|
// 定时切换壁纸 (1 小时间隔)
|
||||||
for range ticker.C {
|
go func() {
|
||||||
cfg := loadConfig()
|
ticker := time.NewTicker(1 * time.Hour)
|
||||||
if cfg.WallpaperType == WPBing {
|
for range ticker.C {
|
||||||
fetchBingWallpaper()
|
cfg := loadConfig()
|
||||||
|
if cfg.WallpaperType != WPBing || !cfg.BingAutoRefresh {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bingMu.Lock()
|
||||||
|
h := loadBingHistory()
|
||||||
|
if len(h.Records) <= 1 {
|
||||||
|
bingMu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.CurrentIdx = (h.CurrentIdx + 1) % len(h.Records)
|
||||||
|
saveBingHistory(h)
|
||||||
|
bingMu.Unlock()
|
||||||
|
bingReloadImage()
|
||||||
|
log.Printf("Bing 自动切换: idx=%d/%d", h.CurrentIdx, len(h.Records))
|
||||||
}
|
}
|
||||||
}
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
fetchTicker := time.NewTicker(4 * time.Hour)
|
||||||
|
for range fetchTicker.C {
|
||||||
|
cfg := loadConfig()
|
||||||
|
if cfg.WallpaperType == WPBing {
|
||||||
|
go fetchBingHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
81
build.ps1
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
u-desktop 构建脚本
|
||||||
|
.PARAMETER Pack
|
||||||
|
打包为 zip(含 WebView2 bootstrapper)
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[switch]$Pack
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$project = "u-desktop"
|
||||||
|
$buildDir = "dist"
|
||||||
|
|
||||||
|
# 构建 Web UI
|
||||||
|
Write-Host "=== 构建 Web UI ===" -ForegroundColor Cyan
|
||||||
|
Push-Location web-ui
|
||||||
|
npm ci --prefer-offline
|
||||||
|
npm run build
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "Web UI 构建失败" -ForegroundColor Red; exit 1 }
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
|
# 清理
|
||||||
|
if (Test-Path $buildDir) { Remove-Item $buildDir -Recurse -Force }
|
||||||
|
New-Item -ItemType Directory -Force -Path $buildDir | Out-Null
|
||||||
|
|
||||||
|
# 版本号
|
||||||
|
$gitHash = git rev-parse --short HEAD
|
||||||
|
$version = "$(Get-Date -Format 'yyyyMMdd').$gitHash"
|
||||||
|
Write-Host "=== 构建 $project v$version ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 构建(隐藏控制台窗口)
|
||||||
|
$env:GOOS = "windows"
|
||||||
|
$env:GOARCH = "amd64"
|
||||||
|
go build -ldflags="-s -w -H windowsgui -X main.version=$version" -o "$buildDir/$project.exe" .
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "构建失败" -ForegroundColor Red; exit 1 }
|
||||||
|
|
||||||
|
$sizeBefore = [math]::Round((Get-Item "$buildDir/$project.exe").Length / 1MB, 1)
|
||||||
|
Write-Host "构建完成: $buildDir/$project.exe ($sizeBefore MB)" -ForegroundColor Green
|
||||||
|
|
||||||
|
# UPX 压缩
|
||||||
|
if (Get-Command upx -ErrorAction SilentlyContinue) {
|
||||||
|
Write-Host "UPX 压缩中..." -ForegroundColor Yellow
|
||||||
|
upx --best "$buildDir/$project.exe"
|
||||||
|
$sizeAfter = [math]::Round((Get-Item "$buildDir/$project.exe").Length / 1MB, 1)
|
||||||
|
Write-Host "UPX 完成: $sizeBefore MB -> $sizeAfter MB" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "UPX 未安装,跳过压缩" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Pack) { exit 0 }
|
||||||
|
|
||||||
|
# --- 打包 ---
|
||||||
|
$packDir = "$buildDir\$project-win10-amd64"
|
||||||
|
if (Test-Path $packDir) { Remove-Item $packDir -Recurse -Force }
|
||||||
|
New-Item -ItemType Directory -Force -Path $packDir | Out-Null
|
||||||
|
|
||||||
|
# 复制 exe
|
||||||
|
Copy-Item "$buildDir\$project.exe" $packDir
|
||||||
|
|
||||||
|
# 下载 WebView2 bootstrapper
|
||||||
|
$bootstrapper = "$packDir\MicrosoftEdgeWebview2Setup.exe"
|
||||||
|
Write-Host "下载 WebView2 bootstrapper..." -ForegroundColor Yellow
|
||||||
|
Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/p/?LinkId=2124703" -OutFile $bootstrapper -UseBasicParsing
|
||||||
|
|
||||||
|
# 写入安装说明
|
||||||
|
@"
|
||||||
|
u-desktop v$version
|
||||||
|
|
||||||
|
== 首次运行(Windows 10)==
|
||||||
|
如果程序无法启动,请先运行 MicrosoftEdgeWebview2Setup.exe 安装 WebView2 运行时。
|
||||||
|
Windows 11 无需安装,直接运行 $project.exe 即可。
|
||||||
|
"@ | Out-File "$packDir\README.txt" -Encoding UTF8
|
||||||
|
|
||||||
|
# 打包 zip
|
||||||
|
$zipName = "$project-win10-amd64-v$version.zip"
|
||||||
|
Compress-Archive -Path $packDir -DestinationPath "$buildDir\$zipName" -Force
|
||||||
|
Write-Host "打包完成: $buildDir\$zipName" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 清理临时目录
|
||||||
|
Remove-Item $packDir -Recurse -Force
|
||||||
108
config.go
@@ -1,12 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
_ "embed"
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"image"
|
"log"
|
||||||
"image/color"
|
|
||||||
"image/png"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
@@ -20,6 +17,13 @@ const (
|
|||||||
WPColor WallpaperType = "color"
|
WPColor WallpaperType = "color"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Layout string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LayoutSingle Layout = "single"
|
||||||
|
LayoutMulti Layout = "multi"
|
||||||
|
)
|
||||||
|
|
||||||
type ThemeName string
|
type ThemeName string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -27,23 +31,55 @@ const (
|
|||||||
ThemeStar ThemeName = "starfield"
|
ThemeStar ThemeName = "starfield"
|
||||||
ThemeGradient ThemeName = "gradient"
|
ThemeGradient ThemeName = "gradient"
|
||||||
ThemeParticle ThemeName = "particles"
|
ThemeParticle ThemeName = "particles"
|
||||||
|
ThemeFractal ThemeName = "fractal"
|
||||||
|
ThemeText ThemeName = "text"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type SavedColor struct {
|
||||||
|
Color1 string `json:"color1"`
|
||||||
|
Color2 string `json:"color2"`
|
||||||
|
Gradient bool `json:"gradient"`
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Zodiac string `json:"zodiac"`
|
Zodiac string `json:"zodiac"`
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
WallpaperType WallpaperType `json:"wallpaperType"`
|
WallpaperType WallpaperType `json:"wallpaperType"`
|
||||||
Theme ThemeName `json:"theme"`
|
Theme ThemeName `json:"theme"`
|
||||||
ImagePath string `json:"imagePath"`
|
ImagePath string `json:"imagePath"`
|
||||||
Color1 string `json:"color1"`
|
Color1 string `json:"color1"`
|
||||||
Color2 string `json:"color2"`
|
Color2 string `json:"color2"`
|
||||||
ColorGradient bool `json:"colorGradient"`
|
ColorGradient bool `json:"colorGradient"`
|
||||||
|
WallpaperText string `json:"wallpaperText"`
|
||||||
|
Layout Layout `json:"layout"`
|
||||||
|
HideWallpaper bool `json:"hideWallpaper"`
|
||||||
|
HideTime bool `json:"hideTime"`
|
||||||
|
HideWeather bool `json:"hideWeather"`
|
||||||
|
HideZodiac bool `json:"hideZodiac"`
|
||||||
|
HideAINews bool `json:"hideAINews"`
|
||||||
|
ShowSeconds bool `json:"showSeconds"`
|
||||||
|
PhotoDir string `json:"photoDir"`
|
||||||
|
PhotoInterval int `json:"photoInterval"`
|
||||||
|
HidePhoto bool `json:"hidePhoto"`
|
||||||
|
PhotoFrameMode bool `json:"photoFrameMode"`
|
||||||
|
KnowledgeKeyword string `json:"knowledgeKeyword"`
|
||||||
|
KnowledgePrompt string `json:"knowledgePrompt"`
|
||||||
|
HideKnowledge bool `json:"hideKnowledge"`
|
||||||
|
SavedColors []SavedColor `json:"savedColors"`
|
||||||
|
BingAutoRefresh bool `json:"bingAutoRefresh"`
|
||||||
|
AutoStart bool `json:"autoStart"`
|
||||||
|
QWeatherKey string `json:"qweatherKey,omitempty"`
|
||||||
|
TianapiKey string `json:"tianapiKey,omitempty"`
|
||||||
|
CPAKey string `json:"cpaKey,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultZodiac = "射手座"
|
const defaultZodiac = "射手座"
|
||||||
|
|
||||||
var configPath string
|
var configPath string
|
||||||
|
|
||||||
|
//go:embed assets/icons/tray.ico
|
||||||
|
var trayIcon []byte
|
||||||
|
|
||||||
func configDir() string {
|
func configDir() string {
|
||||||
return filepath.Dir(configPath)
|
return filepath.Dir(configPath)
|
||||||
}
|
}
|
||||||
@@ -69,6 +105,9 @@ func loadConfig() *Config {
|
|||||||
if cfg.Color1 == "" {
|
if cfg.Color1 == "" {
|
||||||
cfg.Color1 = "#1a1a2e"
|
cfg.Color1 = "#1a1a2e"
|
||||||
}
|
}
|
||||||
|
if cfg.Layout == "" {
|
||||||
|
cfg.Layout = LayoutSingle
|
||||||
|
}
|
||||||
return &cfg
|
return &cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,30 +122,29 @@ func defaultConfig() *Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func saveConfig(cfg *Config) error {
|
func saveConfig(cfg *Config) error {
|
||||||
data, _ := json.MarshalIndent(cfg, "", " ")
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
log.Println("配置序列化失败:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
return os.WriteFile(configPath, data, 0644)
|
return os.WriteFile(configPath, data, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateIcon() []byte {
|
func getSecret(envName, cfgValue string) string {
|
||||||
img := image.NewRGBA(image.Rect(0, 0, 16, 16))
|
if v := os.Getenv(envName); v != "" {
|
||||||
c := color.RGBA{R: 88, G: 101, B: 242, A: 255}
|
return v
|
||||||
for y := 0; y < 16; y++ {
|
|
||||||
for x := 0; x < 16; x++ {
|
|
||||||
img.Set(x, y, c)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
return cfgValue
|
||||||
png.Encode(&buf, img)
|
}
|
||||||
pngData := buf.Bytes()
|
|
||||||
ico := make([]byte, 22+len(pngData))
|
func (c *Config) qweatherKey() string {
|
||||||
binary.LittleEndian.PutUint16(ico[0:], 0)
|
return getSecret("U_DESKTOP_QWEATHER_KEY", c.QWeatherKey)
|
||||||
binary.LittleEndian.PutUint16(ico[2:], 1)
|
}
|
||||||
binary.LittleEndian.PutUint16(ico[4:], 1)
|
|
||||||
ico[6], ico[7], ico[8], ico[9] = 16, 16, 0, 0
|
func (c *Config) tianapiKey() string {
|
||||||
binary.LittleEndian.PutUint16(ico[10:], 1)
|
return getSecret("U_DESKTOP_TIANAPI_KEY", c.TianapiKey)
|
||||||
binary.LittleEndian.PutUint16(ico[12:], 32)
|
}
|
||||||
binary.LittleEndian.PutUint32(ico[14:], uint32(len(pngData)))
|
|
||||||
binary.LittleEndian.PutUint32(ico[18:], 22)
|
func (c *Config) cpaKey() string {
|
||||||
copy(ico[22:], pngData)
|
return getSecret("U_DESKTOP_CPA_KEY", c.CPAKey)
|
||||||
return ico
|
|
||||||
}
|
}
|
||||||
|
|||||||
56
config_icon_test.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"image/png"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTrayIconIsMultiSizeICO(t *testing.T) {
|
||||||
|
ico := trayIcon
|
||||||
|
if len(ico) < 6 {
|
||||||
|
t.Fatalf("icon is too short: %d bytes", len(ico))
|
||||||
|
}
|
||||||
|
if got := binary.LittleEndian.Uint16(ico[0:2]); got != 0 {
|
||||||
|
t.Fatalf("reserved field = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if got := binary.LittleEndian.Uint16(ico[2:4]); got != 1 {
|
||||||
|
t.Fatalf("icon type = %d, want 1", got)
|
||||||
|
}
|
||||||
|
count := int(binary.LittleEndian.Uint16(ico[4:6]))
|
||||||
|
if count < 3 {
|
||||||
|
t.Fatalf("entry count = %d, want at least 3", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[int]bool{}
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
entry := 6 + i*16
|
||||||
|
size := int(ico[entry])
|
||||||
|
if size == 0 {
|
||||||
|
size = 256
|
||||||
|
}
|
||||||
|
seen[size] = true
|
||||||
|
dataSize := int(binary.LittleEndian.Uint32(ico[entry+8 : entry+12]))
|
||||||
|
dataOffset := int(binary.LittleEndian.Uint32(ico[entry+12 : entry+16]))
|
||||||
|
if dataOffset+dataSize > len(ico) {
|
||||||
|
t.Fatalf("entry %d data range %d..%d exceeds icon length %d", i, dataOffset, dataOffset+dataSize, len(ico))
|
||||||
|
}
|
||||||
|
img, err := png.Decode(bytes.NewReader(ico[dataOffset : dataOffset+dataSize]))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("entry %d PNG decode failed: %v", i, err)
|
||||||
|
}
|
||||||
|
if got := img.Bounds().Dx(); got != int(size) {
|
||||||
|
t.Fatalf("entry %d decoded width = %d, want %d", i, got, size)
|
||||||
|
}
|
||||||
|
if got := img.Bounds().Dy(); got != int(size) {
|
||||||
|
t.Fatalf("entry %d decoded height = %d, want %d", i, got, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, size := range []int{16, 32, 48, 256} {
|
||||||
|
if !seen[size] {
|
||||||
|
t.Fatalf("icon is missing %dx%d entry", size, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
dialog.go
@@ -10,17 +10,28 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
comdlg32 = windows.NewLazySystemDLL("comdlg32.dll")
|
comdlg32 = windows.NewLazySystemDLL("comdlg32.dll")
|
||||||
procGetOpenFileNameW = comdlg32.NewProc("GetOpenFileNameW")
|
procGetOpenFileNameW = comdlg32.NewProc("GetOpenFileNameW")
|
||||||
procChooseColorW = comdlg32.NewProc("ChooseColorW")
|
procChooseColorW = comdlg32.NewProc("ChooseColorW")
|
||||||
|
|
||||||
|
shell32 = windows.NewLazySystemDLL("shell32.dll")
|
||||||
|
procSHBrowseForFolderW = shell32.NewProc("SHBrowseForFolderW")
|
||||||
|
procSHGetPathFromIDListW = shell32.NewProc("SHGetPathFromIDListW")
|
||||||
|
|
||||||
|
ole32dll = windows.NewLazySystemDLL("ole32.dll")
|
||||||
|
procCoTaskMemFree = ole32dll.NewProc("CoTaskMemFree")
|
||||||
)
|
)
|
||||||
|
|
||||||
func slicePtr(s interface{}) uintptr {
|
func slicePtr(s interface{}) uintptr {
|
||||||
switch v := s.(type) {
|
switch v := s.(type) {
|
||||||
case []uint16:
|
case []uint16:
|
||||||
if len(v) == 0 { return 0 }
|
if len(v) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
return uintptr(unsafe.Pointer(&v[0]))
|
return uintptr(unsafe.Pointer(&v[0]))
|
||||||
case []uint32:
|
case []uint32:
|
||||||
if len(v) == 0 { return 0 }
|
if len(v) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
return uintptr(unsafe.Pointer(&v[0]))
|
return uintptr(unsafe.Pointer(&v[0]))
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
@@ -117,3 +128,34 @@ func colorPickerDialog(owner uintptr, initialColor string) string {
|
|||||||
b := (cc.rgbResult >> 16) & 0xFF
|
b := (cc.rgbResult >> 16) & 0xFF
|
||||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func browseForFolderDialog(owner uintptr) string {
|
||||||
|
title, _ := windows.UTF16PtrFromString("选择图片目录")
|
||||||
|
var displayName [260]uint16
|
||||||
|
|
||||||
|
bi := struct {
|
||||||
|
HwndOwner uintptr
|
||||||
|
PidlRoot uintptr
|
||||||
|
PszDisplayName uintptr
|
||||||
|
LpszTitle uintptr
|
||||||
|
UlFlags uint32
|
||||||
|
LpFn uintptr
|
||||||
|
LParam uintptr
|
||||||
|
IImage int32
|
||||||
|
}{
|
||||||
|
HwndOwner: owner,
|
||||||
|
PszDisplayName: uintptr(unsafe.Pointer(&displayName[0])),
|
||||||
|
LpszTitle: uintptr(unsafe.Pointer(title)),
|
||||||
|
UlFlags: 0x00000001 | 0x00000040,
|
||||||
|
}
|
||||||
|
|
||||||
|
pidl, _, _ := procSHBrowseForFolderW.Call(uintptr(unsafe.Pointer(&bi)))
|
||||||
|
if pidl == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer procCoTaskMemFree.Call(pidl)
|
||||||
|
|
||||||
|
var path [260]uint16
|
||||||
|
procSHGetPathFromIDListW.Call(pidl, uintptr(unsafe.Pointer(&path[0])))
|
||||||
|
return windows.UTF16ToString(path[:])
|
||||||
|
}
|
||||||
|
|||||||
@@ -143,10 +143,23 @@ wallpaper.html 里把 `RENDER_SCALE` 从 1.0 降到 0.5,canvas 渲染分辨率
|
|||||||
|
|
||||||
```
|
```
|
||||||
u-desktop/
|
u-desktop/
|
||||||
├── main.go # Go 层: WebView2 创建 + WorkerW 嵌入 + 系统托盘
|
├── main.go # 入口: 单实例互斥锁 + 配置目录 + 托盘启动
|
||||||
├── wallpaper.html # 渲染层: WebGL 极光 + 天气组件 + 星座运势
|
├── win32.go # Win32 API 声明
|
||||||
├── backup-opengl/ # OpenGL 方案备份(已确证不可行)
|
├── systray.go # 系统托盘 + WebView2 壁纸嵌入 + 消息循环
|
||||||
├── go.mod / go.sum
|
├── wallpaper.go # 壁纸 HTML 构建 + 主题注入
|
||||||
|
├── config.go # 配置结构体 + JSON 持久化
|
||||||
|
├── settings.go # 设置窗口 (独立 WebView2)
|
||||||
|
├── weather.go # 天气 API + IP 定位 + 城市列表
|
||||||
|
├── horoscope.go # 星座运势 API + 文件缓存
|
||||||
|
├── ainews.go # AI 资讯 API + 文件缓存
|
||||||
|
├── knowledge.go # 知识卡片 AI 生成
|
||||||
|
├── bing.go # Bing 壁纸下载 + 历史导航 + 收藏
|
||||||
|
├── dialog.go # Win32 对话框 (文件/颜色选择)
|
||||||
|
├── web/
|
||||||
|
│ ├── overlay.html # 桌面覆盖层 (时间/天气/星座/资讯/知识)
|
||||||
|
│ ├── settings.html # 设置窗口 UI
|
||||||
|
│ └── themes/ # 壁纸主题 HTML
|
||||||
|
├── config/ # 运行时配置 (settings.json + 缓存)
|
||||||
└── docs/
|
└── docs/
|
||||||
└── wallpaper-embedding.md # 本文档
|
└── wallpaper-embedding.md # 本文档
|
||||||
```
|
```
|
||||||
|
|||||||
11
go.mod
@@ -3,19 +3,28 @@ module u-desktop
|
|||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/getlantern/systray v1.2.2
|
||||||
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808
|
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808
|
||||||
golang.org/x/sys v0.45.0
|
golang.org/x/sys v0.45.0
|
||||||
|
modernc.org/sqlite v1.50.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
|
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
|
||||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
|
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
|
||||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
|
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
|
||||||
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
|
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
|
||||||
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
|
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
|
||||||
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
|
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
|
||||||
github.com/getlantern/systray v1.2.2 // indirect
|
|
||||||
github.com/go-stack/stack v1.8.0 // indirect
|
github.com/go-stack/stack v1.8.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
|
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
modernc.org/libc v1.72.3 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
52
go.sum
@@ -1,4 +1,7 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
|
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
|
||||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
|
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
|
||||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
|
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
|
||||||
@@ -15,22 +18,71 @@ github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sTho
|
|||||||
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
|
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
|
||||||
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 h1:ftnsTqIUH57XQEF+PnXX9++nlHCzdkuB5zbWyMMruZo=
|
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 h1:ftnsTqIUH57XQEF+PnXX9++nlHCzdkuB5zbWyMMruZo=
|
||||||
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808/go.mod h1:rWifBlzkgrvd7zUqlfq91sWt3473OikgnglnIILx/Jo=
|
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808/go.mod h1:rWifBlzkgrvd7zUqlfq91sWt3473OikgnglnIILx/Jo=
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
|
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
|
||||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
|
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
|
||||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
|
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||||
|
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||||
|
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||||
|
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||||
|
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||||
|
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
172
horoscope.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tianapiStarURL = "https://apis.tianapi.com/star/index"
|
||||||
|
|
||||||
|
var zodiacToSign = map[string]string{
|
||||||
|
"白羊座": "aries", "金牛座": "taurus", "双子座": "gemini", "巨蟹座": "cancer",
|
||||||
|
"狮子座": "leo", "处女座": "virgo", "天秤座": "libra", "天蝎座": "scorpio",
|
||||||
|
"射手座": "sagittarius", "摩羯座": "capricorn", "水瓶座": "aquarius", "双鱼座": "pisces",
|
||||||
|
}
|
||||||
|
|
||||||
|
type tianapiStarResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Result struct {
|
||||||
|
List []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"list"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type horoscopeInfo struct {
|
||||||
|
Zodiac string `json:"zodiac"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
All string `json:"all"`
|
||||||
|
Love string `json:"love"`
|
||||||
|
Work string `json:"work"`
|
||||||
|
Money string `json:"money"`
|
||||||
|
Health string `json:"health"`
|
||||||
|
LuckyColor string `json:"luckyColor"`
|
||||||
|
LuckyNum string `json:"luckyNum"`
|
||||||
|
Noble string `json:"noble"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func horoscopeCachePath() string {
|
||||||
|
return filepath.Join(configDir(), "horoscope_cache.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveHoroscopeCache(info *horoscopeInfo) {
|
||||||
|
info.Date = time.Now().Format("2006-01-02")
|
||||||
|
data, _ := json.Marshal(info)
|
||||||
|
os.WriteFile(horoscopeCachePath(), data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadHoroscopeCache() *horoscopeInfo {
|
||||||
|
data, err := os.ReadFile(horoscopeCachePath())
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var info horoscopeInfo
|
||||||
|
if json.Unmarshal(data, &info) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &info
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchHoroscope(zodiac string) *horoscopeInfo {
|
||||||
|
sign := zodiacToSign[zodiac]
|
||||||
|
if sign == "" {
|
||||||
|
sign = "sagittarius"
|
||||||
|
}
|
||||||
|
|
||||||
|
key := loadConfig().tianapiKey()
|
||||||
|
if key == "" {
|
||||||
|
log.Println("未配置天聚数行 API Key")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("%s?key=%s&astro=%s", tianapiStarURL, key, sign)
|
||||||
|
data, err := httpGet(url)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("星座运势请求失败:", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp tianapiStarResp
|
||||||
|
if json.Unmarshal(data, &resp) != nil || resp.Code != 200 {
|
||||||
|
log.Println("星座运势解析失败:", string(data))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &horoscopeInfo{Zodiac: zodiac}
|
||||||
|
for _, item := range resp.Result.List {
|
||||||
|
switch item.Type {
|
||||||
|
case "综合指数":
|
||||||
|
info.All = strings.TrimSuffix(item.Content, "%")
|
||||||
|
case "爱情指数":
|
||||||
|
info.Love = strings.TrimSuffix(item.Content, "%")
|
||||||
|
case "工作指数":
|
||||||
|
info.Work = strings.TrimSuffix(item.Content, "%")
|
||||||
|
case "财运指数":
|
||||||
|
info.Money = strings.TrimSuffix(item.Content, "%")
|
||||||
|
case "健康指数":
|
||||||
|
info.Health = strings.TrimSuffix(item.Content, "%")
|
||||||
|
case "幸运颜色":
|
||||||
|
info.LuckyColor = item.Content
|
||||||
|
case "幸运数字":
|
||||||
|
info.LuckyNum = item.Content
|
||||||
|
case "贵人星座":
|
||||||
|
info.Noble = item.Content
|
||||||
|
case "今日概述":
|
||||||
|
info.Summary = strings.TrimSpace(item.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("星座运势已获取: %s", zodiac)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushHoroscopeInfo(info *horoscopeInfo) {
|
||||||
|
jsonData, _ := json.Marshal(info)
|
||||||
|
log.Printf("星座运势JS: %s", string(jsonData[:min(len(jsonData), 120)]))
|
||||||
|
js := fmt.Sprintf(`if(window.updateHoroscopeFromGo) window.updateHoroscopeFromGo(%s)`, string(jsonData))
|
||||||
|
evalJS(js)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushHoroscope(zodiac string) {
|
||||||
|
info := fetchHoroscope(zodiac)
|
||||||
|
if info == nil {
|
||||||
|
log.Println("星座运势: fetchHoroscope返回nil")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveHoroscopeCache(info)
|
||||||
|
pushHoroscopeInfo(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func horoscopeLoop() {
|
||||||
|
cfg := loadConfig()
|
||||||
|
|
||||||
|
cached := loadHoroscopeCache()
|
||||||
|
if cached != nil && !cfg.HideZodiac {
|
||||||
|
pushHoroscopeInfo(cached)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
|
||||||
|
cfg = loadConfig()
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
if !cfg.HideZodiac && !(cached != nil && cached.Date == today && cached.Zodiac == cfg.Zodiac) {
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
pushHoroscope(cfg.Zodiac)
|
||||||
|
cached := loadHoroscopeCache()
|
||||||
|
if cached != nil && cached.Date == time.Now().Format("2006-01-02") && cached.Zodiac == cfg.Zodiac {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(24 * time.Hour)
|
||||||
|
for range ticker.C {
|
||||||
|
cfg := loadConfig()
|
||||||
|
if !cfg.HideZodiac {
|
||||||
|
pushHoroscope(cfg.Zodiac)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerHoroscopeRefresh(zodiac string) {
|
||||||
|
go func() {
|
||||||
|
pushHoroscope(zodiac)
|
||||||
|
}()
|
||||||
|
}
|
||||||
310
knowledge.go
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cpaURL = "https://cpa.1216.top/v1/chat/completions"
|
||||||
|
const cpaModel = "glm-4.5-air"
|
||||||
|
const minKnowledgeRunes = 80
|
||||||
|
|
||||||
|
type knowledgeData struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Keyword string `json:"keyword"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var knowledgeDB *sql.DB
|
||||||
|
|
||||||
|
func initKnowledgeDB() {
|
||||||
|
dbPath := filepath.Join(configDir(), "knowledge.db")
|
||||||
|
var err error
|
||||||
|
knowledgeDB, err = sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("知识库打开失败:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
knowledgeDB.SetMaxOpenConns(1)
|
||||||
|
_, err = knowledgeDB.Exec(`CREATE TABLE IF NOT EXISTS knowledge_cards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
keyword TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("知识库建表失败:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveKnowledgeCard(keyword, content string) {
|
||||||
|
if knowledgeDB == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := knowledgeDB.Exec("INSERT INTO knowledge_cards (keyword, content) VALUES (?, ?)", keyword, content)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("知识保存失败:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRandomKnowledgeCard(keyword string) string {
|
||||||
|
if knowledgeDB == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rows, err := knowledgeDB.Query(
|
||||||
|
"SELECT content FROM knowledge_cards WHERE keyword = ? ORDER BY RANDOM() LIMIT 20",
|
||||||
|
keyword,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var content string
|
||||||
|
if rows.Scan(&content) == nil {
|
||||||
|
content = normalizeKnowledgeContent(content)
|
||||||
|
if isQualityKnowledgeCard(content) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getKnowledgeCardCount(keyword string) int {
|
||||||
|
if knowledgeDB == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
err := knowledgeDB.QueryRow(
|
||||||
|
"SELECT COUNT(*) FROM knowledge_cards WHERE keyword = ?",
|
||||||
|
keyword,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchKnowledgeFromLLM(keyword string, cfg *Config) string {
|
||||||
|
basePrompt := buildKnowledgePrompt(keyword, cfg.KnowledgePrompt)
|
||||||
|
messages := []map[string]string{
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "你是严谨的中文知识卡片作者,输出必须具体、准确、有信息密度。不要写空泛鸡汤,不要只给一句定义。",
|
||||||
|
},
|
||||||
|
{"role": "user", "content": basePrompt},
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"model": cpaModel,
|
||||||
|
"max_tokens": 512,
|
||||||
|
"temperature": 0.55,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
content := requestKnowledgeCompletion(body)
|
||||||
|
content = normalizeKnowledgeContent(content)
|
||||||
|
if isQualityKnowledgeCard(content) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("知识卡片质量不足,重试: %q", content)
|
||||||
|
messages = append(messages, map[string]string{
|
||||||
|
"role": "user",
|
||||||
|
"content": fmt.Sprintf(
|
||||||
|
"上一条太短或信息密度不足。请重写一条「%s」知识卡片:120-180个中文字符,必须包含一个明确机制/原理、一个具体例子或应用场景、一个结论。只输出正文。",
|
||||||
|
keyword,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
body["messages"] = messages
|
||||||
|
content = requestKnowledgeCompletion(body)
|
||||||
|
content = normalizeKnowledgeContent(content)
|
||||||
|
if isQualityKnowledgeCard(content) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildKnowledgePrompt(keyword, customPrompt string) string {
|
||||||
|
basePrompt := fmt.Sprintf(`围绕关键词「%s」生成一条桌面知识卡片。
|
||||||
|
硬性要求:
|
||||||
|
1. 120-180个中文字符,分成2-3句;
|
||||||
|
2. 必须讲清一个具体机制、原理、权衡或实践经验;
|
||||||
|
3. 必须包含一个具体例子、场景或判断标准;
|
||||||
|
4. 避免“很重要、非常有用、提升效率”这类空泛表述;
|
||||||
|
5. 不要标题、序号、Markdown、表情,直接输出正文。`, keyword)
|
||||||
|
if customPrompt != "" {
|
||||||
|
basePrompt += "\n附加风格要求(不能覆盖上面的字数和质量要求):" + customPrompt
|
||||||
|
}
|
||||||
|
return basePrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestKnowledgeCompletion(body map[string]interface{}) string {
|
||||||
|
jsonData, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", cpaURL, bytes.NewReader(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
log.Println("知识API请求创建失败:", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
key := loadConfig().cpaKey()
|
||||||
|
if key == "" {
|
||||||
|
log.Println("未配置知识卡片 API Key")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+key)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("知识API请求失败:", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
if json.NewDecoder(resp.Body).Decode(&result) != nil {
|
||||||
|
log.Println("知识API响应解析失败")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(result.Choices) > 0 {
|
||||||
|
return result.Choices[0].Message.Content
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeKnowledgeContent 清洗 LLM 输出的格式残留(Markdown标记、标题前缀、多余空白)
|
||||||
|
func normalizeKnowledgeContent(content string) string {
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
content = strings.Trim(content, "` \t\r\n")
|
||||||
|
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), " ")
|
||||||
|
return strings.TrimSpace(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isQualityKnowledgeCard 检查字数 ≥80 且空泛表述 <3 条
|
||||||
|
func isQualityKnowledgeCard(content string) bool {
|
||||||
|
if utf8.RuneCountInString(content) < minKnowledgeRunes {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
weakPhrases := []string{"很重要", "非常重要", "很有用", "提升效率", "值得关注", "可以帮助", "广泛应用"}
|
||||||
|
weakHits := 0
|
||||||
|
for _, phrase := range weakPhrases {
|
||||||
|
if strings.Contains(content, phrase) {
|
||||||
|
weakHits++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if weakHits >= 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.ContainsAny(content, "。;;::,,")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushKnowledgeJSON(content, keyword string) {
|
||||||
|
data, _ := json.Marshal(knowledgeData{Content: content, Keyword: keyword})
|
||||||
|
evalJS(fmt.Sprintf(`if(window.updateKnowledgeFromGo) window.updateKnowledgeFromGo(%s)`, string(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAndPushKnowledge() {
|
||||||
|
cfg := loadConfig()
|
||||||
|
keyword := cfg.KnowledgeKeyword
|
||||||
|
if keyword == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var content string
|
||||||
|
|
||||||
|
count := getKnowledgeCardCount(keyword)
|
||||||
|
if count > 0 && rand.Intn(10) < 3 {
|
||||||
|
content = getRandomKnowledgeCard(keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
if content == "" {
|
||||||
|
content = fetchKnowledgeFromLLM(keyword, cfg)
|
||||||
|
if content != "" {
|
||||||
|
saveKnowledgeCard(keyword, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if content == "" && count > 0 {
|
||||||
|
content = getRandomKnowledgeCard(keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
if content == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pushKnowledgeJSON(content, keyword)
|
||||||
|
preview := content
|
||||||
|
if len(preview) > 30 {
|
||||||
|
preview = preview[:30] + "..."
|
||||||
|
}
|
||||||
|
log.Println("知识卡片已推送:", preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushKnowledgeLoading(keyword string) {
|
||||||
|
pushKnowledgeJSON("加载中...", keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushKnowledgePlaceholder() {
|
||||||
|
pushKnowledgeJSON("请设置知识关键字", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func knowledgeLoop() {
|
||||||
|
initKnowledgeDB()
|
||||||
|
|
||||||
|
cfg := loadConfig()
|
||||||
|
if cfg.KnowledgeKeyword != "" && !cfg.HideKnowledge {
|
||||||
|
if cached := getRandomKnowledgeCard(cfg.KnowledgeKeyword); cached != "" {
|
||||||
|
pushKnowledgeJSON(cached, cfg.KnowledgeKeyword)
|
||||||
|
} else {
|
||||||
|
pushKnowledgeLoading(cfg.KnowledgeKeyword)
|
||||||
|
}
|
||||||
|
} else if cfg.KnowledgeKeyword == "" {
|
||||||
|
pushKnowledgePlaceholder()
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
|
||||||
|
cfg = loadConfig()
|
||||||
|
if cfg.KnowledgeKeyword != "" && !cfg.HideKnowledge {
|
||||||
|
fetchAndPushKnowledge()
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(30 * time.Minute)
|
||||||
|
for range ticker.C {
|
||||||
|
cfg := loadConfig()
|
||||||
|
if cfg.KnowledgeKeyword != "" && !cfg.HideKnowledge {
|
||||||
|
fetchAndPushKnowledge()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerKnowledgeRefresh() {
|
||||||
|
go fetchAndPushKnowledge()
|
||||||
|
}
|
||||||
41
main.go
@@ -1,24 +1,57 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
"github.com/getlantern/systray"
|
"github.com/getlantern/systray"
|
||||||
|
"github.com/jchv/go-webview2/webviewloader"
|
||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func showError(msg string) {
|
||||||
|
title, _ := windows.UTF16PtrFromString("u-desktop")
|
||||||
|
text, _ := windows.UTF16PtrFromString(msg)
|
||||||
|
procMessageBoxW.Call(0, uintptr(unsafe.Pointer(text)), uintptr(unsafe.Pointer(title)), 0x10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupLog() {
|
||||||
|
exePath, _ := os.Executable()
|
||||||
|
logFile, err := os.Create(filepath.Join(filepath.Dir(exePath), "u-desktop.log"))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.SetOutput(logFile)
|
||||||
|
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.SetFlags(log.Ltime | log.Lmicroseconds)
|
setupLog()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Println("PANIC:", r)
|
||||||
|
showError(fmt.Sprintf("程序崩溃: %v\n详情见 u-desktop.log", r))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ver, err := webviewloader.GetInstalledVersion()
|
||||||
|
if err != nil || ver == "" {
|
||||||
|
showError("未检测到 WebView2 运行时,请先安装 Microsoft Edge WebView2 Runtime。\n\n下载地址: https://developer.microsoft.com/microsoft-edge/webview2/")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
log.Println("WebView2:", ver)
|
||||||
|
|
||||||
mutexName, _ := windows.UTF16PtrFromString("Global\\u-desktop-single-instance")
|
mutexName, _ := windows.UTF16PtrFromString("Global\\u-desktop-single-instance")
|
||||||
mutex, err := windows.CreateMutex(nil, false, mutexName)
|
mutex, err := windows.CreateMutex(nil, false, mutexName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("创建互斥锁失败:", err)
|
showError("创建互斥锁失败: " + err.Error())
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if windows.GetLastError() == windows.ERROR_ALREADY_EXISTS {
|
if windows.GetLastError() == windows.ERROR_ALREADY_EXISTS {
|
||||||
log.Println("已有实例运行,退出")
|
|
||||||
windows.CloseHandle(mutex)
|
windows.CloseHandle(mutex)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -29,5 +62,7 @@ func main() {
|
|||||||
os.MkdirAll(cfgDir, 0755)
|
os.MkdirAll(cfgDir, 0755)
|
||||||
configPath = filepath.Join(cfgDir, "settings.json")
|
configPath = filepath.Join(cfgDir, "settings.json")
|
||||||
procSetProcessDPIAware.Call()
|
procSetProcessDPIAware.Call()
|
||||||
|
|
||||||
|
log.Println("启动 systray...")
|
||||||
systray.Run(onSystrayReady, nil)
|
systray.Run(onSystrayReady, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
188
photo.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
photoMu sync.Mutex
|
||||||
|
photoFiles []string
|
||||||
|
photoIdx int
|
||||||
|
photoDir string
|
||||||
|
photoStop chan struct{}
|
||||||
|
photoDone chan struct{}
|
||||||
|
photoCacheMu sync.Mutex
|
||||||
|
photoCacheMap map[string]string
|
||||||
|
photoCacheDir string
|
||||||
|
)
|
||||||
|
|
||||||
|
func scanPhotoDir(dir string) []string {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var files []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg", ".png", ".bmp", ".webp", ".gif":
|
||||||
|
files = append(files, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
func preCachePhotos(dir string, files []string) {
|
||||||
|
cache := make(map[string]string, len(files))
|
||||||
|
for _, name := range files {
|
||||||
|
uri := imageToDataURI(filepath.Join(dir, name))
|
||||||
|
if uri != "" {
|
||||||
|
cache[name] = uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
photoCacheMu.Lock()
|
||||||
|
photoCacheMap = cache
|
||||||
|
photoCacheDir = dir
|
||||||
|
photoCacheMu.Unlock()
|
||||||
|
log.Printf("相册: 预缓存 %d/%d 张", len(cache), len(files))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCachedPhotoURI(dir, name string) string {
|
||||||
|
photoCacheMu.Lock()
|
||||||
|
if photoCacheMap != nil && photoCacheDir == dir {
|
||||||
|
if uri, ok := photoCacheMap[name]; ok {
|
||||||
|
photoCacheMu.Unlock()
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
photoCacheMu.Unlock()
|
||||||
|
return imageToDataURI(filepath.Join(dir, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushCurrentPhoto(interval int) {
|
||||||
|
photoMu.Lock()
|
||||||
|
files := photoFiles
|
||||||
|
idx := photoIdx
|
||||||
|
dir := photoDir
|
||||||
|
photoMu.Unlock()
|
||||||
|
|
||||||
|
if len(files) == 0 || dir == "" {
|
||||||
|
evalJS(`if(window.updatePhotoFromGo) updatePhotoFromGo(null)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if idx >= len(files) {
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
src := getCachedPhotoURI(dir, files[idx])
|
||||||
|
if src == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"src": src,
|
||||||
|
"counter": fmt.Sprintf("%d / %d", idx+1, len(files)),
|
||||||
|
"interval": interval,
|
||||||
|
})
|
||||||
|
evalJS(fmt.Sprintf(`if(window.updatePhotoFromGo) updatePhotoFromGo(%s)`, string(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func startPhotoLoop() {
|
||||||
|
cfg := loadConfig()
|
||||||
|
if cfg.PhotoDir == "" || cfg.HidePhoto {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
interval := cfg.PhotoInterval
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 15
|
||||||
|
}
|
||||||
|
|
||||||
|
files := scanPhotoDir(cfg.PhotoDir)
|
||||||
|
if len(files) == 0 {
|
||||||
|
log.Println("相册: 目录为空或无图片")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
photoMu.Lock()
|
||||||
|
photoDir = cfg.PhotoDir
|
||||||
|
photoFiles = files
|
||||||
|
photoIdx = 0
|
||||||
|
stop := make(chan struct{})
|
||||||
|
done := make(chan struct{})
|
||||||
|
photoStop = stop
|
||||||
|
photoDone = done
|
||||||
|
photoMu.Unlock()
|
||||||
|
|
||||||
|
photoCacheMu.Lock()
|
||||||
|
photoCacheMap = nil
|
||||||
|
photoCacheDir = cfg.PhotoDir
|
||||||
|
photoCacheMu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("相册: 共 %d 张, 间隔 %ds", len(files), interval)
|
||||||
|
go preCachePhotos(cfg.PhotoDir, files)
|
||||||
|
pushCurrentPhoto(interval)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(time.Duration(interval) * time.Second)
|
||||||
|
defer func() {
|
||||||
|
ticker.Stop()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
photoMu.Lock()
|
||||||
|
if len(photoFiles) > 0 {
|
||||||
|
photoIdx = (photoIdx + 1) % len(photoFiles)
|
||||||
|
}
|
||||||
|
photoMu.Unlock()
|
||||||
|
pushCurrentPhoto(interval)
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopPhotoLoop() {
|
||||||
|
photoMu.Lock()
|
||||||
|
stop := photoStop
|
||||||
|
done := photoDone
|
||||||
|
photoStop = nil
|
||||||
|
photoDone = nil
|
||||||
|
photoFiles = nil
|
||||||
|
photoIdx = 0
|
||||||
|
photoDir = ""
|
||||||
|
photoMu.Unlock()
|
||||||
|
|
||||||
|
photoCacheMu.Lock()
|
||||||
|
photoCacheMap = nil
|
||||||
|
photoCacheDir = ""
|
||||||
|
photoCacheMu.Unlock()
|
||||||
|
|
||||||
|
if stop != nil {
|
||||||
|
close(stop)
|
||||||
|
}
|
||||||
|
if done != nil {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartPhotoLoop() {
|
||||||
|
stopPhotoLoop()
|
||||||
|
evalJS(`if(window.updatePhotoFromGo) updatePhotoFromGo(null)`)
|
||||||
|
startPhotoLoop()
|
||||||
|
}
|
||||||
98
scripts/build_tray_icon.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageChops
|
||||||
|
|
||||||
|
|
||||||
|
ICON_SIZES = (16, 20, 24, 32, 40, 48, 64, 128, 256)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_green_background(image: Image.Image) -> Image.Image:
|
||||||
|
rgba = image.convert("RGBA")
|
||||||
|
pixels = rgba.load()
|
||||||
|
width, height = rgba.size
|
||||||
|
|
||||||
|
for y in range(height):
|
||||||
|
for x in range(width):
|
||||||
|
r, g, b, a = pixels[x, y]
|
||||||
|
green_score = g - max(r, b)
|
||||||
|
is_key = g > 90 and green_score > 35
|
||||||
|
is_fringe = g > 32 and g > r * 1.35 and g > b * 1.20
|
||||||
|
if is_key or is_fringe:
|
||||||
|
edge = max(0, min(255, (green_score - 18) * 5))
|
||||||
|
alpha = max(0, 255 - edge)
|
||||||
|
if alpha < 56:
|
||||||
|
pixels[x, y] = (0, 0, 0, 0)
|
||||||
|
else:
|
||||||
|
despilled_g = min(g, max(r, b))
|
||||||
|
pixels[x, y] = (r, despilled_g, b, min(a, alpha))
|
||||||
|
|
||||||
|
return rgba
|
||||||
|
|
||||||
|
|
||||||
|
def crop_to_alpha(image: Image.Image, padding_ratio: float) -> Image.Image:
|
||||||
|
alpha = image.getchannel("A")
|
||||||
|
bbox = alpha.getbbox()
|
||||||
|
if not bbox:
|
||||||
|
raise ValueError("source image has no visible pixels after background removal")
|
||||||
|
|
||||||
|
cropped = image.crop(bbox)
|
||||||
|
side = max(cropped.size)
|
||||||
|
padding = round(side * padding_ratio)
|
||||||
|
canvas_side = side + padding * 2
|
||||||
|
canvas = Image.new("RGBA", (canvas_side, canvas_side), (0, 0, 0, 0))
|
||||||
|
canvas.alpha_composite(cropped, ((canvas_side - cropped.width) // 2, (canvas_side - cropped.height) // 2))
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
def trim_transparent_padding(image: Image.Image) -> Image.Image:
|
||||||
|
empty = Image.new("RGBA", image.size, (0, 0, 0, 0))
|
||||||
|
bbox = ImageChops.difference(image, empty).getbbox()
|
||||||
|
if bbox is None:
|
||||||
|
return image
|
||||||
|
return image.crop(bbox)
|
||||||
|
|
||||||
|
|
||||||
|
def build_icons(source: Path, out_dir: Path, padding_ratio: float) -> None:
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
source_image = Image.open(source)
|
||||||
|
transparent = remove_green_background(source_image)
|
||||||
|
master = crop_to_alpha(transparent, padding_ratio)
|
||||||
|
master = trim_transparent_padding(master)
|
||||||
|
|
||||||
|
master_path = out_dir / "tray-icon.png"
|
||||||
|
master.save(master_path)
|
||||||
|
|
||||||
|
resized_images: list[Image.Image] = []
|
||||||
|
for size in ICON_SIZES:
|
||||||
|
resized = master.resize((size, size), Image.Resampling.LANCZOS)
|
||||||
|
resized.save(out_dir / f"tray-icon-{size}.png")
|
||||||
|
resized_images.append(resized)
|
||||||
|
|
||||||
|
ico_path = out_dir / "tray.ico"
|
||||||
|
resized_images[-1].save(
|
||||||
|
ico_path,
|
||||||
|
format="ICO",
|
||||||
|
sizes=[(size, size) for size in ICON_SIZES],
|
||||||
|
append_images=resized_images[:-1],
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"wrote {master_path}")
|
||||||
|
print(f"wrote {ico_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Build u-desktop tray icon assets from a generated source image.")
|
||||||
|
parser.add_argument("--source", type=Path, required=True, help="Generated source PNG on a green chroma-key background.")
|
||||||
|
parser.add_argument("--out-dir", type=Path, default=Path("assets/icons"), help="Directory for PNG sizes and tray.ico.")
|
||||||
|
parser.add_argument("--padding", type=float, default=0.04, help="Transparent padding ratio around the cropped icon.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
build_icons(args.source, args.out_dir, args.padding)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
8
scripts/check-resources.ps1
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
$p = Get-Process -Name 'u-desktop' -ErrorAction SilentlyContinue
|
||||||
|
if (-not $p) { Write-Host "u-desktop not running"; exit 0 }
|
||||||
|
Write-Host "PID: $($p.Id)"
|
||||||
|
Write-Host "CPU time: $([math]::Round($p.CPU, 2))s"
|
||||||
|
Write-Host "WorkingSet: $([math]::Round($p.WorkingSet64/1MB, 1)) MB"
|
||||||
|
Write-Host "PrivateMem: $([math]::Round($p.PrivateMemorySize64/1MB, 1)) MB"
|
||||||
|
Write-Host "Threads: $($p.Threads.Count)"
|
||||||
|
Write-Host "Handles: $($p.HandleCount)"
|
||||||
532
settings.go
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/jchv/go-webview2"
|
||||||
|
"golang.org/x/sys/windows/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed web/settings.html
|
||||||
|
var settingsHTML string
|
||||||
|
|
||||||
|
var (
|
||||||
|
settingsMu sync.Mutex
|
||||||
|
settingsOpen bool
|
||||||
|
settingsHwnd uintptr
|
||||||
|
)
|
||||||
|
|
||||||
|
const runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run`
|
||||||
|
|
||||||
|
func isAutoStartEnabled() bool {
|
||||||
|
k, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.QUERY_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
_, _, err = k.GetStringValue("u-desktop")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAutoStart(enabled bool) {
|
||||||
|
exePath, _ := os.Executable()
|
||||||
|
if enabled {
|
||||||
|
k, _, err := registry.CreateKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("注册表写入失败:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
k.SetStringValue("u-desktop", exePath)
|
||||||
|
log.Println("已开启开机启动")
|
||||||
|
} else {
|
||||||
|
k, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
k.DeleteValue("u-desktop")
|
||||||
|
log.Println("已关闭开机启动")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSystemLightTheme() bool {
|
||||||
|
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Themes\Personalize`, registry.QUERY_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
v, _, err := k.GetIntegerValue("AppsUseLightTheme")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return v == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var themeNames = []struct {
|
||||||
|
Name ThemeName
|
||||||
|
Label string
|
||||||
|
}{
|
||||||
|
{ThemeAurora, "极光"},
|
||||||
|
{ThemeStar, "星空"},
|
||||||
|
{ThemeGradient, "渐变"},
|
||||||
|
{ThemeParticle, "粒子"},
|
||||||
|
{ThemeFractal, "极光流体"},
|
||||||
|
{ThemeText, "文字"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshVisibleCards(cfg, oldCfg *Config) {
|
||||||
|
if oldCfg.HideWeather && !cfg.HideWeather {
|
||||||
|
go fetchAndPushWeather(getCurrentCity())
|
||||||
|
}
|
||||||
|
if oldCfg.HideZodiac && !cfg.HideZodiac {
|
||||||
|
triggerHoroscopeRefresh(cfg.Zodiac)
|
||||||
|
}
|
||||||
|
if oldCfg.HideKnowledge && !cfg.HideKnowledge {
|
||||||
|
if cfg.KnowledgeKeyword != "" {
|
||||||
|
triggerKnowledgeRefresh()
|
||||||
|
} else {
|
||||||
|
pushKnowledgePlaceholder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldCfg.HideAINews && !cfg.HideAINews {
|
||||||
|
triggerAINewsRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openSettingsWindow() {
|
||||||
|
settingsMu.Lock()
|
||||||
|
if settingsOpen && settingsHwnd != 0 {
|
||||||
|
settingsMu.Unlock()
|
||||||
|
procShowWindow.Call(settingsHwnd, 9)
|
||||||
|
procGetForegroundWindow.Call(settingsHwnd)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settingsOpen = true
|
||||||
|
settingsMu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer func() {
|
||||||
|
settingsMu.Lock()
|
||||||
|
settingsOpen = false
|
||||||
|
settingsHwnd = 0
|
||||||
|
settingsMu.Unlock()
|
||||||
|
runtime.UnlockOSThread()
|
||||||
|
}()
|
||||||
|
|
||||||
|
dataDir := filepath.Join(os.TempDir(), "u-desktop-settings")
|
||||||
|
os.MkdirAll(dataDir, 0755)
|
||||||
|
|
||||||
|
w := webview2.NewWithOptions(webview2.WebViewOptions{
|
||||||
|
AutoFocus: true,
|
||||||
|
DataPath: dataDir,
|
||||||
|
WindowOptions: webview2.WindowOptions{
|
||||||
|
Title: "桌面设置",
|
||||||
|
Width: 760,
|
||||||
|
Height: 1350,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if w == nil {
|
||||||
|
log.Println("设置窗口: 创建失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Bind("loadAllSettings", func() string {
|
||||||
|
cfg := loadConfig()
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var provinces []string
|
||||||
|
citiesByProv := map[string][]map[string]string{}
|
||||||
|
for _, c := range cities {
|
||||||
|
if !seen[c.Adm1] {
|
||||||
|
seen[c.Adm1] = true
|
||||||
|
provinces = append(provinces, c.Adm1)
|
||||||
|
}
|
||||||
|
citiesByProv[c.Adm1] = append(citiesByProv[c.Adm1], map[string]string{
|
||||||
|
"id": c.ID, "name": c.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type themeJSON struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
var tl []themeJSON
|
||||||
|
for _, t := range themeNames {
|
||||||
|
tl = append(tl, themeJSON{Value: string(t.Name), Label: t.Label})
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"lightTheme": isSystemLightTheme(),
|
||||||
|
"wallpaper": !cfg.HideWallpaper,
|
||||||
|
"time": !cfg.HideTime,
|
||||||
|
"weather": !cfg.HideWeather,
|
||||||
|
"zodiacCard": !cfg.HideZodiac,
|
||||||
|
"knowledgeCard": !cfg.HideKnowledge,
|
||||||
|
"layout": string(cfg.Layout),
|
||||||
|
"zodiac": cfg.Zodiac,
|
||||||
|
"city": cfg.City,
|
||||||
|
"provinces": provinces,
|
||||||
|
"citiesByProv": citiesByProv,
|
||||||
|
"wallpaperType": string(cfg.WallpaperType),
|
||||||
|
"theme": string(cfg.Theme),
|
||||||
|
"themes": tl,
|
||||||
|
"color1": cfg.Color1,
|
||||||
|
"color2": cfg.Color2,
|
||||||
|
"colorGradient": cfg.ColorGradient,
|
||||||
|
"savedColors": cfg.SavedColors,
|
||||||
|
"bingAutoRefresh": cfg.BingAutoRefresh,
|
||||||
|
"knowledgeKeyword": cfg.KnowledgeKeyword,
|
||||||
|
"knowledgePrompt": cfg.KnowledgePrompt,
|
||||||
|
"wallpaperText": cfg.WallpaperText,
|
||||||
|
"imagePath": cfg.ImagePath,
|
||||||
|
"showSeconds": cfg.ShowSeconds,
|
||||||
|
"ainewsCard": !cfg.HideAINews,
|
||||||
|
"photoDir": cfg.PhotoDir,
|
||||||
|
"photoInterval": cfg.PhotoInterval,
|
||||||
|
"photoCard": !cfg.HidePhoto,
|
||||||
|
"photoFrameMode": cfg.PhotoFrameMode,
|
||||||
|
"autoStart": isAutoStartEnabled(),
|
||||||
|
})
|
||||||
|
return string(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("saveToggles", func(jsonStr string) string {
|
||||||
|
var data map[string]bool
|
||||||
|
if json.Unmarshal([]byte(jsonStr), &data) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cfg := loadConfig()
|
||||||
|
oldCfg := *cfg
|
||||||
|
if v, ok := data["wallpaper"]; ok {
|
||||||
|
cfg.HideWallpaper = !v
|
||||||
|
evalJS(fmt.Sprintf("if(window.setWallpaperVisible) setWallpaperVisible(%v)", v))
|
||||||
|
}
|
||||||
|
if v, ok := data["time"]; ok {
|
||||||
|
cfg.HideTime = !v
|
||||||
|
evalJS(fmt.Sprintf("if(window.setCardVisible) setCardVisible('time',%v)", v))
|
||||||
|
}
|
||||||
|
if v, ok := data["weather"]; ok {
|
||||||
|
cfg.HideWeather = !v
|
||||||
|
evalJS(fmt.Sprintf("if(window.setCardVisible) setCardVisible('weather',%v)", v))
|
||||||
|
}
|
||||||
|
if v, ok := data["zodiacCard"]; ok {
|
||||||
|
cfg.HideZodiac = !v
|
||||||
|
evalJS(fmt.Sprintf("if(window.setCardVisible) setCardVisible('zodiac',%v)", v))
|
||||||
|
}
|
||||||
|
if v, ok := data["knowledgeCard"]; ok {
|
||||||
|
cfg.HideKnowledge = !v
|
||||||
|
evalJS(fmt.Sprintf("if(window.setCardVisible) setCardVisible('knowledge',%v)", v))
|
||||||
|
}
|
||||||
|
if v, ok := data["ainewsCard"]; ok {
|
||||||
|
cfg.HideAINews = !v
|
||||||
|
evalJS(fmt.Sprintf("if(window.setCardVisible) setCardVisible('ainews',%v)", v))
|
||||||
|
}
|
||||||
|
if v, ok := data["showSeconds"]; ok {
|
||||||
|
cfg.ShowSeconds = v
|
||||||
|
evalJS(fmt.Sprintf("if(window.setShowSeconds) setShowSeconds(%v)", v))
|
||||||
|
}
|
||||||
|
if v, ok := data["autoStart"]; ok {
|
||||||
|
setAutoStart(v)
|
||||||
|
}
|
||||||
|
if v, ok := data["photoFrameMode"]; ok {
|
||||||
|
cfg.PhotoFrameMode = v
|
||||||
|
evalJS(fmt.Sprintf("if(window.setPhotoFrameMode) setPhotoFrameMode(%v)", v))
|
||||||
|
if v {
|
||||||
|
cfg.HideWallpaper = true
|
||||||
|
cfg.HideTime = true
|
||||||
|
cfg.HideWeather = true
|
||||||
|
cfg.HideZodiac = true
|
||||||
|
cfg.HideAINews = true
|
||||||
|
cfg.HideKnowledge = true
|
||||||
|
evalJS(`if(window.setWallpaperVisible) setWallpaperVisible(false)`)
|
||||||
|
for _, card := range []string{"time", "weather", "zodiac", "ainews", "knowledge"} {
|
||||||
|
evalJS(fmt.Sprintf("if(window.setCardVisible) setCardVisible('%s',false)", card))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := data["photoCard"]; ok {
|
||||||
|
cfg.HidePhoto = !v
|
||||||
|
saveConfig(cfg)
|
||||||
|
evalJS(fmt.Sprintf("if(window.setCardVisible) setCardVisible('photo',%v)", v))
|
||||||
|
refreshVisibleCards(cfg, &oldCfg)
|
||||||
|
if cfg.PhotoDir != "" {
|
||||||
|
if v {
|
||||||
|
restartPhotoLoop()
|
||||||
|
} else {
|
||||||
|
stopPhotoLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
saveConfig(cfg)
|
||||||
|
refreshVisibleCards(cfg, &oldCfg)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("saveLayout", func(layout string) string {
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.Layout = Layout(layout)
|
||||||
|
saveConfig(cfg)
|
||||||
|
reloadWallpaper()
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("saveZodiac", func(zodiac string) string {
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.Zodiac = zodiac
|
||||||
|
saveConfig(cfg)
|
||||||
|
evalJS(fmt.Sprintf(`window.userZodiac = %q; if(window.updateTime) updateTime();`, zodiac))
|
||||||
|
triggerHoroscopeRefresh(zodiac)
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("saveCity", func(cityID string) string {
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.City = cityID
|
||||||
|
saveConfig(cfg)
|
||||||
|
for _, c := range cities {
|
||||||
|
if c.ID == cityID {
|
||||||
|
go fetchAndPushWeather(c)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("saveWallpaperType", func(wpType, theme string) string {
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.WallpaperType = WallpaperType(wpType)
|
||||||
|
cfg.Theme = ThemeName(theme)
|
||||||
|
saveConfig(cfg)
|
||||||
|
reloadWallpaper()
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("pickLocalImage", func() string {
|
||||||
|
hwnd := uintptr(w.Window())
|
||||||
|
path := openFileDialog(hwnd)
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.WallpaperType = WPImage
|
||||||
|
cfg.ImagePath = path
|
||||||
|
saveConfig(cfg)
|
||||||
|
reloadWallpaper()
|
||||||
|
return path
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("enableBing", func() string {
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.WallpaperType = WPBing
|
||||||
|
saveConfig(cfg)
|
||||||
|
reloadWallpaper()
|
||||||
|
go fetchBingHistory()
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("bingPrev", func() string {
|
||||||
|
bingPrev()
|
||||||
|
return bingCurrentState()
|
||||||
|
})
|
||||||
|
w.Bind("bingNext", func() string {
|
||||||
|
bingNext()
|
||||||
|
return bingCurrentState()
|
||||||
|
})
|
||||||
|
w.Bind("bingToggleFavorite", func() string {
|
||||||
|
bingToggleFavorite()
|
||||||
|
return bingCurrentState()
|
||||||
|
})
|
||||||
|
w.Bind("getBingInfo", func() string {
|
||||||
|
return bingCurrentState()
|
||||||
|
})
|
||||||
|
w.Bind("saveBingAutoRefresh", func(val bool) string {
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.BingAutoRefresh = val
|
||||||
|
saveConfig(cfg)
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
w.Bind("getBingFavorites", func() string {
|
||||||
|
return bingFavoritesJSON()
|
||||||
|
})
|
||||||
|
w.Bind("bingSetByIdx", func(idx int) string {
|
||||||
|
return bingSetByIdx(idx)
|
||||||
|
})
|
||||||
|
w.Bind("bingThumbDataURI", func(filename string) string {
|
||||||
|
return bingThumbDataURI(filename)
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("pickSolidColor", func() string {
|
||||||
|
hwnd := uintptr(w.Window())
|
||||||
|
color := colorPickerDialog(hwnd, "")
|
||||||
|
if color == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.WallpaperType = WPColor
|
||||||
|
cfg.Color1 = color
|
||||||
|
cfg.ColorGradient = false
|
||||||
|
saveConfig(cfg)
|
||||||
|
reloadWallpaper()
|
||||||
|
return color
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("pickGradientColor", func() string {
|
||||||
|
hwnd := uintptr(w.Window())
|
||||||
|
c1 := colorPickerDialog(hwnd, "")
|
||||||
|
if c1 == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
c2 := colorPickerDialog(hwnd, "")
|
||||||
|
if c2 == "" {
|
||||||
|
c2 = "#16213e"
|
||||||
|
}
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.WallpaperType = WPColor
|
||||||
|
cfg.Color1 = c1
|
||||||
|
cfg.Color2 = c2
|
||||||
|
cfg.ColorGradient = true
|
||||||
|
saveConfig(cfg)
|
||||||
|
reloadWallpaper()
|
||||||
|
return c1 + "," + c2
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("addSavedColor", func(c1, c2 string, gradient bool) string {
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.SavedColors = append(cfg.SavedColors, SavedColor{Color1: c1, Color2: c2, Gradient: gradient})
|
||||||
|
saveConfig(cfg)
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("removeSavedColor", func(idx int) string {
|
||||||
|
cfg := loadConfig()
|
||||||
|
if idx >= 0 && idx < len(cfg.SavedColors) {
|
||||||
|
cfg.SavedColors = append(cfg.SavedColors[:idx], cfg.SavedColors[idx+1:]...)
|
||||||
|
saveConfig(cfg)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("applySavedColor", func(idx int) string {
|
||||||
|
cfg := loadConfig()
|
||||||
|
if idx >= 0 && idx < len(cfg.SavedColors) {
|
||||||
|
sc := cfg.SavedColors[idx]
|
||||||
|
cfg.WallpaperType = WPColor
|
||||||
|
cfg.Color1 = sc.Color1
|
||||||
|
cfg.Color2 = sc.Color2
|
||||||
|
cfg.ColorGradient = sc.Gradient
|
||||||
|
saveConfig(cfg)
|
||||||
|
reloadWallpaper()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("saveWallpaperText", func(text string) string {
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.WallpaperText = text
|
||||||
|
saveConfig(cfg)
|
||||||
|
reloadWallpaper()
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("saveKnowledgeKeyword", func(keyword string) string {
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.KnowledgeKeyword = keyword
|
||||||
|
saveConfig(cfg)
|
||||||
|
if keyword != "" {
|
||||||
|
triggerKnowledgeRefresh()
|
||||||
|
} else {
|
||||||
|
pushKnowledgePlaceholder()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
w.Bind("saveKnowledgePrompt", func(prompt string) string {
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.KnowledgePrompt = prompt
|
||||||
|
saveConfig(cfg)
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("pickPhotoDir", func() string {
|
||||||
|
hwnd := uintptr(w.Window())
|
||||||
|
dir := browseForFolderDialog(hwnd)
|
||||||
|
if dir == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.PhotoDir = dir
|
||||||
|
if cfg.PhotoInterval <= 0 {
|
||||||
|
cfg.PhotoInterval = 15
|
||||||
|
}
|
||||||
|
saveConfig(cfg)
|
||||||
|
restartPhotoLoop()
|
||||||
|
return dir
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("clearPhotoDir", func() string {
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.PhotoDir = ""
|
||||||
|
saveConfig(cfg)
|
||||||
|
restartPhotoLoop()
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("savePhotoInterval", func(val int) string {
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.PhotoInterval = val
|
||||||
|
saveConfig(cfg)
|
||||||
|
if cfg.PhotoDir != "" {
|
||||||
|
restartPhotoLoop()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
w.SetHtml(settingsHTML)
|
||||||
|
|
||||||
|
hwnd := uintptr(w.Window())
|
||||||
|
// disable resize
|
||||||
|
style, _, _ := procGetWindowLongPtrW.Call(hwnd, gwlStyle)
|
||||||
|
procSetWindowLongPtrW.Call(hwnd, gwlStyle, style & ^uintptr(wsSizebox|wsMaxbox))
|
||||||
|
|
||||||
|
// resizeToFit: JS measures content, Go adjusts window frame
|
||||||
|
w.Bind("resizeToFit", func(contentW, contentH int) string {
|
||||||
|
type rect struct{ Left, Top, Right, Bottom int32 }
|
||||||
|
var wr, cr rect
|
||||||
|
procGetWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&wr)))
|
||||||
|
procGetClientRect.Call(hwnd, uintptr(unsafe.Pointer(&cr)))
|
||||||
|
frameW := int(wr.Right-wr.Left) - int(cr.Right-cr.Left)
|
||||||
|
frameH := int(wr.Bottom-wr.Top) - int(cr.Bottom-cr.Top)
|
||||||
|
winW := contentW + frameW
|
||||||
|
winH := contentH + frameH
|
||||||
|
screenW, screenH := getScreenSize()
|
||||||
|
if winH > int(screenH)-60 {
|
||||||
|
winH = int(screenH) - 60
|
||||||
|
}
|
||||||
|
if winW > int(screenW)-60 {
|
||||||
|
winW = int(screenW) - 60
|
||||||
|
}
|
||||||
|
x := (int(screenW) - winW) / 2
|
||||||
|
y := (int(screenH) - winH) / 2
|
||||||
|
procMoveWindow.Call(hwnd, uintptr(x), uintptr(y), uintptr(winW), uintptr(winH), 1)
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
settingsMu.Lock()
|
||||||
|
settingsHwnd = hwnd
|
||||||
|
settingsMu.Unlock()
|
||||||
|
|
||||||
|
log.Println("设置窗口已打开")
|
||||||
|
w.Run()
|
||||||
|
log.Println("设置窗口已关闭")
|
||||||
|
}()
|
||||||
|
}
|
||||||
297
systray.go
@@ -1,9 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -14,226 +14,19 @@ import (
|
|||||||
"github.com/jchv/go-webview2"
|
"github.com/jchv/go-webview2"
|
||||||
)
|
)
|
||||||
|
|
||||||
var zodiacItems []*systray.MenuItem
|
|
||||||
var cityItems []*systray.MenuItem
|
|
||||||
var themeItems []*systray.MenuItem
|
|
||||||
|
|
||||||
var themeNames = []struct {
|
|
||||||
Name ThemeName
|
|
||||||
Label string
|
|
||||||
}{
|
|
||||||
{ThemeAurora, "极光"},
|
|
||||||
{ThemeStar, "星空"},
|
|
||||||
{ThemeGradient, "渐变"},
|
|
||||||
{ThemeParticle, "粒子"},
|
|
||||||
}
|
|
||||||
|
|
||||||
func onSystrayReady() {
|
func onSystrayReady() {
|
||||||
systray.SetIcon(generateIcon())
|
systray.SetIcon(trayIcon)
|
||||||
systray.SetTooltip("动态壁纸引擎")
|
systray.SetTooltip("动态壁纸引擎")
|
||||||
|
|
||||||
cfg := loadConfig()
|
mSettings := systray.AddMenuItem("桌面设置", "打开设置窗口")
|
||||||
|
mRestart := systray.AddMenuItem("重启", "重启程序")
|
||||||
mPause := systray.AddMenuItem("暂停", "暂停/继续")
|
|
||||||
systray.AddSeparator()
|
|
||||||
|
|
||||||
// 壁纸主题
|
|
||||||
mTheme := systray.AddMenuItem("壁纸主题", "")
|
|
||||||
for _, t := range themeNames {
|
|
||||||
item := mTheme.AddSubMenuItem(t.Label, t.Label)
|
|
||||||
if cfg.WallpaperType == WPTheme && cfg.Theme == t.Name {
|
|
||||||
item.Check()
|
|
||||||
}
|
|
||||||
themeItems = append(themeItems, item)
|
|
||||||
}
|
|
||||||
mLocalImage := systray.AddMenuItem("本地图片", "选择本地图片作为壁纸")
|
|
||||||
mBingDaily := systray.AddMenuItem("Bing 每日壁纸", "使用 Bing 每日壁纸")
|
|
||||||
mSolidColor := systray.AddMenuItem("纯色壁纸", "选择纯色壁纸")
|
|
||||||
mGradientColor := systray.AddMenuItem("渐变壁纸", "选择渐变壁纸")
|
|
||||||
|
|
||||||
systray.AddSeparator()
|
|
||||||
|
|
||||||
// 星座
|
|
||||||
mZodiac := systray.AddMenuItem("星座设置", "")
|
|
||||||
zodiacs := []string{
|
|
||||||
"白羊座", "金牛座", "双子座",
|
|
||||||
"巨蟹座", "狮子座", "处女座",
|
|
||||||
"天秤座", "天蝎座", "射手座",
|
|
||||||
"摩羯座", "水瓶座", "双鱼座",
|
|
||||||
}
|
|
||||||
for _, z := range zodiacs {
|
|
||||||
item := mZodiac.AddSubMenuItem(z, z)
|
|
||||||
if z == cfg.Zodiac {
|
|
||||||
item.Check()
|
|
||||||
}
|
|
||||||
zodiacItems = append(zodiacItems, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
systray.AddSeparator()
|
|
||||||
|
|
||||||
// 城市
|
|
||||||
mCity := systray.AddMenuItem("城市设置", "")
|
|
||||||
for _, c := range cities {
|
|
||||||
item := mCity.AddSubMenuItem(c.Name, c.Adm1+" "+c.Name)
|
|
||||||
if cfg.City == c.ID {
|
|
||||||
item.Check()
|
|
||||||
}
|
|
||||||
cityItems = append(cityItems, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
systray.AddSeparator()
|
|
||||||
mQuit := systray.AddMenuItem("退出", "退出程序")
|
mQuit := systray.AddMenuItem("退出", "退出程序")
|
||||||
|
|
||||||
// 主题切换监听
|
// 设置窗口
|
||||||
for i, item := range themeItems {
|
|
||||||
go func(idx int, mi *systray.MenuItem) {
|
|
||||||
for {
|
|
||||||
<-mi.ClickedCh
|
|
||||||
cfg := loadConfig()
|
|
||||||
cfg.WallpaperType = WPTheme
|
|
||||||
cfg.Theme = themeNames[idx].Name
|
|
||||||
saveConfig(cfg)
|
|
||||||
for _, it := range themeItems {
|
|
||||||
it.Uncheck()
|
|
||||||
}
|
|
||||||
mi.Check()
|
|
||||||
log.Printf("主题切换: %s", themeNames[idx].Label)
|
|
||||||
reloadWallpaper()
|
|
||||||
}
|
|
||||||
}(i, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 本地图片
|
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
<-mLocalImage.ClickedCh
|
<-mSettings.ClickedCh
|
||||||
path := openFileDialog(wvHwnd)
|
openSettingsWindow()
|
||||||
if path == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cfg := loadConfig()
|
|
||||||
cfg.WallpaperType = WPImage
|
|
||||||
cfg.ImagePath = path
|
|
||||||
saveConfig(cfg)
|
|
||||||
for _, it := range themeItems {
|
|
||||||
it.Uncheck()
|
|
||||||
}
|
|
||||||
log.Printf("本地图片: %s", path)
|
|
||||||
reloadWallpaper()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Bing 每日
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
<-mBingDaily.ClickedCh
|
|
||||||
cfg := loadConfig()
|
|
||||||
cfg.WallpaperType = WPBing
|
|
||||||
saveConfig(cfg)
|
|
||||||
for _, it := range themeItems {
|
|
||||||
it.Uncheck()
|
|
||||||
}
|
|
||||||
log.Println("切换 Bing 壁纸")
|
|
||||||
go fetchBingWallpaper()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 纯色壁纸
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
<-mSolidColor.ClickedCh
|
|
||||||
color := colorPickerDialog(wvHwnd, "")
|
|
||||||
if color == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cfg := loadConfig()
|
|
||||||
cfg.WallpaperType = WPColor
|
|
||||||
cfg.Color1 = color
|
|
||||||
cfg.ColorGradient = false
|
|
||||||
saveConfig(cfg)
|
|
||||||
for _, it := range themeItems {
|
|
||||||
it.Uncheck()
|
|
||||||
}
|
|
||||||
log.Printf("纯色壁纸: %s", color)
|
|
||||||
reloadWallpaper()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 渐变壁纸
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
<-mGradientColor.ClickedCh
|
|
||||||
c1 := colorPickerDialog(wvHwnd, "")
|
|
||||||
if c1 == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
c2 := colorPickerDialog(wvHwnd, "")
|
|
||||||
if c2 == "" {
|
|
||||||
c2 = "#16213e"
|
|
||||||
}
|
|
||||||
cfg := loadConfig()
|
|
||||||
cfg.WallpaperType = WPColor
|
|
||||||
cfg.Color1 = c1
|
|
||||||
cfg.Color2 = c2
|
|
||||||
cfg.ColorGradient = true
|
|
||||||
saveConfig(cfg)
|
|
||||||
for _, it := range themeItems {
|
|
||||||
it.Uncheck()
|
|
||||||
}
|
|
||||||
log.Printf("渐变壁纸: %s -> %s", c1, c2)
|
|
||||||
reloadWallpaper()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 星座选择监听
|
|
||||||
for i, item := range zodiacItems {
|
|
||||||
go func(idx int, mi *systray.MenuItem) {
|
|
||||||
name := zodiacs[idx]
|
|
||||||
for {
|
|
||||||
<-mi.ClickedCh
|
|
||||||
cfg := loadConfig()
|
|
||||||
cfg.Zodiac = name
|
|
||||||
saveConfig(cfg)
|
|
||||||
for _, it := range zodiacItems {
|
|
||||||
it.Uncheck()
|
|
||||||
}
|
|
||||||
mi.Check()
|
|
||||||
evalJS(fmt.Sprintf(`window.userZodiac = %q; if(window.updateTime) updateTime();`, name))
|
|
||||||
}
|
|
||||||
}(i, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 城市选择监听
|
|
||||||
for i, item := range cityItems {
|
|
||||||
go func(idx int, mi *systray.MenuItem) {
|
|
||||||
for {
|
|
||||||
<-mi.ClickedCh
|
|
||||||
city := cities[idx]
|
|
||||||
cfg := loadConfig()
|
|
||||||
cfg.City = city.ID
|
|
||||||
saveConfig(cfg)
|
|
||||||
for _, it := range cityItems {
|
|
||||||
it.Uncheck()
|
|
||||||
}
|
|
||||||
mi.Check()
|
|
||||||
go fetchAndPushWeather(city)
|
|
||||||
}
|
|
||||||
}(i, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 暂停
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
<-mPause.ClickedCh
|
|
||||||
newVal := 1 - atomic.LoadInt32(&paused)
|
|
||||||
atomic.StoreInt32(&paused, newVal)
|
|
||||||
isPaused := newVal == 1
|
|
||||||
if isPaused {
|
|
||||||
mPause.SetTitle("继续")
|
|
||||||
} else {
|
|
||||||
mPause.SetTitle("暂停")
|
|
||||||
}
|
|
||||||
evalJS("if(window.setPaused) setPaused(" + strconv.FormatBool(isPaused) + ")")
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -243,22 +36,41 @@ func onSystrayReady() {
|
|||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// 重启
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
<-mRestart.ClickedCh
|
||||||
|
exe, _ := os.Executable()
|
||||||
|
exec.Command(exe).Start()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
go startWebView()
|
go startWebView()
|
||||||
go weatherLoop()
|
go weatherLoop()
|
||||||
|
go horoscopeLoop()
|
||||||
|
go aiNewsLoop()
|
||||||
go bingWallpaperLoop()
|
go bingWallpaperLoop()
|
||||||
|
go knowledgeLoop()
|
||||||
|
go startPhotoLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func startWebView() {
|
func startWebView() {
|
||||||
runtime.LockOSThread()
|
runtime.LockOSThread()
|
||||||
|
log.Println("startWebView: 开始")
|
||||||
|
|
||||||
workerw := findWorkerW()
|
workerw := findWorkerW()
|
||||||
if workerw == 0 {
|
if workerw == 0 {
|
||||||
log.Fatal("WorkerW not found")
|
log.Println("ERROR: WorkerW not found")
|
||||||
|
showError("无法找到桌面窗口(WorkerW),程序无法启动。")
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
log.Printf("WorkerW: 0x%x", workerw)
|
||||||
|
|
||||||
screenW, screenH := getScreenSize()
|
screenW, screenH := getScreenSize()
|
||||||
log.Printf("Screen: %dx%d", screenW, screenH)
|
log.Printf("Screen: %dx%d", screenW, screenH)
|
||||||
|
|
||||||
|
log.Println("创建 WebView2...")
|
||||||
wv = webview2.NewWithOptions(webview2.WebViewOptions{
|
wv = webview2.NewWithOptions(webview2.WebViewOptions{
|
||||||
AutoFocus: false,
|
AutoFocus: false,
|
||||||
WindowOptions: webview2.WindowOptions{
|
WindowOptions: webview2.WindowOptions{
|
||||||
@@ -268,8 +80,20 @@ func startWebView() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
if wv == nil {
|
if wv == nil {
|
||||||
log.Fatal("WebView2 create failed")
|
showError("WebView2 创建失败,请确保已安装 WebView2 Runtime。")
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
log.Println("WebView2 创建成功")
|
||||||
|
|
||||||
|
// 立即获取 HWND 并隐藏窗口,避免闪烁
|
||||||
|
wvHwnd = uintptr(wv.Window())
|
||||||
|
procShowWindow.Call(wvHwnd, 0) // SW_HIDE
|
||||||
|
procSetWindowLongPtrW.Call(wvHwnd, gwlStyle, wsPopup|wsVisible|wsChild)
|
||||||
|
|
||||||
|
// 立即嵌入 WorkerW
|
||||||
|
procSetParent.Call(wvHwnd, workerw)
|
||||||
|
procMoveWindow.Call(wvHwnd, uintptr(^uint(0)), uintptr(^uint(0)), uintptr(screenW+2), uintptr(screenH+2), 1)
|
||||||
|
log.Printf("已嵌入 WorkerW: HWND=0x%x, %dx%d", wvHwnd, screenW, screenH)
|
||||||
|
|
||||||
wv.Bind("setZodiacFromGo", func(zodiac string) error {
|
wv.Bind("setZodiacFromGo", func(zodiac string) error {
|
||||||
cfg := loadConfig()
|
cfg := loadConfig()
|
||||||
@@ -277,33 +101,22 @@ func startWebView() {
|
|||||||
return saveConfig(cfg)
|
return saveConfig(cfg)
|
||||||
})
|
})
|
||||||
|
|
||||||
wv.SetHtml(buildWallpaperHTML(loadConfig()))
|
log.Println("设置 HTML...")
|
||||||
time.Sleep(1 * time.Second)
|
cfg := loadConfig()
|
||||||
|
wv.SetHtml(buildWallpaperHTML(cfg))
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
wvHwnd = uintptr(wv.Window())
|
// 嵌入完成后再显示
|
||||||
procSetWindowLongPtrW.Call(wvHwnd, uintptr(0xFFFFFFF0), uintptr(0x80000000|0x10000000|0x02000000))
|
|
||||||
procShowWindow.Call(wvHwnd, 5)
|
procShowWindow.Call(wvHwnd, 5)
|
||||||
procMoveWindow.Call(wvHwnd, uintptr(^uint(0)), uintptr(^uint(0)), uintptr(screenW+2), uintptr(screenH+2), 1)
|
log.Println("壁纸窗口已显示")
|
||||||
log.Printf("壁纸已嵌入: HWND=0x%x, %dx%d", wvHwnd, screenW, screenH)
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
cfg := loadConfig()
|
reloadAllCards()
|
||||||
evalJS(fmt.Sprintf(`window.userZodiac = %q;`, cfg.Zodiac))
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
go fullscreenMonitor()
|
go fullscreenMonitor()
|
||||||
|
|
||||||
go func() {
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
workerw := findWorkerW()
|
|
||||||
if workerw != 0 {
|
|
||||||
oldParent, _, _ := procSetParent.Call(wvHwnd, workerw)
|
|
||||||
log.Printf("SetParent: 0x%x -> 0x%x (old=0x%x)", wvHwnd, workerw, oldParent)
|
|
||||||
procMoveWindow.Call(wvHwnd, uintptr(^uint(0)), uintptr(^uint(0)), uintptr(screenW+2), uintptr(screenH+2), 1)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
type msg struct {
|
type msg struct {
|
||||||
hwnd uintptr
|
hwnd uintptr
|
||||||
message uint32
|
message uint32
|
||||||
@@ -331,15 +144,15 @@ func startWebView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.message == wmSetHtml {
|
if m.message == wmSetHtml {
|
||||||
select {
|
select {
|
||||||
case html := <-htmlQueue:
|
case html := <-htmlQueue:
|
||||||
wv.SetHtml(html)
|
wv.SetHtml(html)
|
||||||
default:
|
default:
|
||||||
|
}
|
||||||
|
goto nextMsg
|
||||||
}
|
}
|
||||||
goto nextMsg
|
nextMsg:
|
||||||
}
|
|
||||||
nextMsg:
|
|
||||||
procTranslateMessage.Call(uintptr(unsafe.Pointer(&m)))
|
procTranslateMessage.Call(uintptr(unsafe.Pointer(&m)))
|
||||||
procDispatchMessageW.Call(uintptr(unsafe.Pointer(&m)))
|
procDispatchMessageW.Call(uintptr(unsafe.Pointer(&m)))
|
||||||
}
|
}
|
||||||
|
|||||||
174
wallpaper.go
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -26,50 +27,68 @@ var themeGradient string
|
|||||||
//go:embed web/themes/particles.html
|
//go:embed web/themes/particles.html
|
||||||
var themeParticles string
|
var themeParticles string
|
||||||
|
|
||||||
|
//go:embed web/themes/fractal.html
|
||||||
|
var themeFractal string
|
||||||
|
|
||||||
|
//go:embed web/themes/text.html
|
||||||
|
var themeText string
|
||||||
|
|
||||||
var themeMap = map[ThemeName]string{
|
var themeMap = map[ThemeName]string{
|
||||||
ThemeAurora: themeAurora,
|
ThemeAurora: themeAurora,
|
||||||
ThemeStar: themeStarfield,
|
ThemeStar: themeStarfield,
|
||||||
ThemeGradient: themeGradient,
|
ThemeGradient: themeGradient,
|
||||||
ThemeParticle: themeParticles,
|
ThemeParticle: themeParticles,
|
||||||
|
ThemeFractal: themeFractal,
|
||||||
|
ThemeText: themeText,
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildWallpaperHTML(cfg *Config) string {
|
func buildWallpaperHTML(cfg *Config) string {
|
||||||
var bg string
|
var bg string
|
||||||
|
|
||||||
switch cfg.WallpaperType {
|
if cfg.WallpaperType == WPTheme {
|
||||||
case WPTheme:
|
|
||||||
if t, ok := themeMap[cfg.Theme]; ok {
|
if t, ok := themeMap[cfg.Theme]; ok {
|
||||||
bg = t
|
bg = t
|
||||||
} else {
|
} else {
|
||||||
bg = themeAurora
|
bg = themeAurora
|
||||||
}
|
}
|
||||||
case WPImage:
|
} else {
|
||||||
if cfg.ImagePath != "" {
|
bg = buildBgHTML(cfg)
|
||||||
src := imageToDataURI(cfg.ImagePath)
|
|
||||||
if src != "" {
|
|
||||||
bg = fmt.Sprintf(`<img src="%s" style="position:fixed;top:0;left:0;width:100%%;height:100%%;object-fit:cover;z-index:1;">`, src)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case WPBing:
|
|
||||||
bingPath := filepath.Join(configDir(), "bing_wallpaper.jpg")
|
|
||||||
if _, err := os.Stat(bingPath); err == nil {
|
|
||||||
src := imageToDataURI(bingPath)
|
|
||||||
if src != "" {
|
|
||||||
bg = fmt.Sprintf(`<img src="%s" style="position:fixed;top:0;left:0;width:100%%;height:100%%;object-fit:cover;z-index:1;">`, src)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case WPColor:
|
|
||||||
if cfg.ColorGradient && cfg.Color2 != "" {
|
|
||||||
bg = fmt.Sprintf(`<div style="position:fixed;top:0;left:0;width:100%%;height:100%%;z-index:1;background:linear-gradient(135deg,%s,%s);"></div>`, cfg.Color1, cfg.Color2)
|
|
||||||
} else {
|
|
||||||
bg = fmt.Sprintf(`<div style="position:fixed;top:0;left:0;width:100%%;height:100%%;z-index:1;background:%s;"></div>`, cfg.Color1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if bg == "" {
|
if bg == "" {
|
||||||
bg = themeAurora
|
bg = themeAurora
|
||||||
}
|
}
|
||||||
return strings.Replace(overlayHTML, "{{BACKGROUND}}", bg, 1)
|
bgWrapped := fmt.Sprintf(`<div id="bg-layer">%s</div>`, bg)
|
||||||
|
if cfg.HideWallpaper {
|
||||||
|
bgWrapped = `<div id="bg-layer" style="display:none"></div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
initialData := map[string]interface{}{
|
||||||
|
"backgroundHtml": bgWrapped,
|
||||||
|
"layout": string(cfg.Layout),
|
||||||
|
"showSeconds": cfg.ShowSeconds,
|
||||||
|
"userZodiac": cfg.Zodiac,
|
||||||
|
"wallpaperVisible": !cfg.HideWallpaper,
|
||||||
|
"photoFrameMode": cfg.PhotoFrameMode && cfg.PhotoDir != "",
|
||||||
|
"cardVisibility": map[string]bool{
|
||||||
|
"time": !cfg.HideTime,
|
||||||
|
"weather": !cfg.HideWeather,
|
||||||
|
"zodiac": !cfg.HideZodiac,
|
||||||
|
"knowledge": !cfg.HideKnowledge,
|
||||||
|
"ainews": !cfg.HideAINews,
|
||||||
|
"photo": !cfg.HidePhoto,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dataJSON, _ := json.Marshal(initialData)
|
||||||
|
inject := fmt.Sprintf(`<script>window.__INITIAL_DATA__=%s;</script>`, string(dataJSON))
|
||||||
|
|
||||||
|
// 注入自定义文字
|
||||||
|
if cfg.WallpaperType == WPTheme && cfg.Theme == ThemeText && cfg.WallpaperText != "" {
|
||||||
|
escaped, _ := json.Marshal(cfg.WallpaperText)
|
||||||
|
inject += fmt.Sprintf(`<script>window.wallpaperText=%s;</script>`, string(escaped))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Replace(overlayHTML, "</head>", inject+"</head>", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func imageToDataURI(path string) string {
|
func imageToDataURI(path string) string {
|
||||||
@@ -98,16 +117,111 @@ func reloadWallpaper() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cfg := loadConfig()
|
cfg := loadConfig()
|
||||||
|
|
||||||
|
// 非主题壁纸切换:仅替换 #bg-layer,不销毁卡片状态
|
||||||
|
if cfg.WallpaperType != WPTheme {
|
||||||
|
updateBackground(cfg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主题切换需要 SetHtml(canvas+script),之后恢复卡片数据
|
||||||
html := buildWallpaperHTML(cfg)
|
html := buildWallpaperHTML(cfg)
|
||||||
select {
|
select {
|
||||||
case htmlQueue <- html:
|
case htmlQueue <- html:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
procPostMessageW.Call(wvHwnd, wmSetHtml, 0, 0)
|
procPostMessageW.Call(wvHwnd, wmSetHtml, 0, 0)
|
||||||
go func() {
|
go reloadAllCards()
|
||||||
time.Sleep(1 * time.Second)
|
}
|
||||||
evalJS(fmt.Sprintf(`window.userZodiac = %q;`, cfg.Zodiac))
|
|
||||||
city := getCurrentCity()
|
func buildBgHTML(cfg *Config) string {
|
||||||
go fetchAndPushWeather(city)
|
switch cfg.WallpaperType {
|
||||||
}()
|
case WPImage:
|
||||||
|
if cfg.ImagePath != "" {
|
||||||
|
src := imageToDataURI(cfg.ImagePath)
|
||||||
|
if src != "" {
|
||||||
|
return buildCoverImgHTML(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case WPBing:
|
||||||
|
if p := getCurrentBingPath(); p != "" {
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
src := imageToDataURI(p)
|
||||||
|
if src != "" {
|
||||||
|
return buildCoverImgHTML(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case WPColor:
|
||||||
|
if cfg.ColorGradient && cfg.Color2 != "" {
|
||||||
|
return fmt.Sprintf(`<div style="position:fixed;top:0;left:0;width:100%%;height:100%%;z-index:1;background:linear-gradient(135deg,%s,%s);"></div>`, cfg.Color1, cfg.Color2)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`<div style="position:fixed;top:0;left:0;width:100%%;height:100%%;z-index:1;background:%s;"></div>`, cfg.Color1)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverImgTpl = `<img src="%s" style="position:fixed;top:0;left:0;width:100%%;height:100%%;object-fit:cover;z-index:1;">`
|
||||||
|
|
||||||
|
func buildCoverImgHTML(src string) string {
|
||||||
|
return fmt.Sprintf(coverImgTpl, src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateBackground(cfg *Config) {
|
||||||
|
bg := buildBgHTML(cfg)
|
||||||
|
if bg == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
display := ""
|
||||||
|
if cfg.HideWallpaper {
|
||||||
|
display = ` style="display:none"`
|
||||||
|
}
|
||||||
|
html := fmt.Sprintf(`<div id="bg-layer"%s>%s</div>`, display, bg)
|
||||||
|
// Update Vue reactive state via bridge
|
||||||
|
evalJS(fmt.Sprintf(`if(window.__updateBackgroundHtml) window.__updateBackgroundHtml(%q);`, html))
|
||||||
|
// Fallback: direct DOM update for non-Vue contexts
|
||||||
|
evalJS(fmt.Sprintf(`var el=document.getElementById('bg-layer'); if(el && !window.__updateBackgroundHtml){el.outerHTML=%q;}`, html))
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadAllCards() {
|
||||||
|
time.Sleep(800 * time.Millisecond)
|
||||||
|
|
||||||
|
evalJS(fmt.Sprintf(`window.userZodiac = %q;`, loadConfig().Zodiac))
|
||||||
|
|
||||||
|
if cached := loadHoroscopeCache(); cached != nil {
|
||||||
|
pushHoroscopeInfo(cached)
|
||||||
|
}
|
||||||
|
if cached := loadAINewsCache(); cached != nil {
|
||||||
|
pushAINews(cached)
|
||||||
|
}
|
||||||
|
cfg := loadConfig()
|
||||||
|
if cfg.KnowledgeKeyword != "" && !cfg.HideKnowledge {
|
||||||
|
if cached := getRandomKnowledgeCard(cfg.KnowledgeKeyword); cached != "" {
|
||||||
|
pushKnowledgeJSON(cached, cfg.KnowledgeKeyword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if city := getCurrentCity(); city.ID != "" {
|
||||||
|
go fetchAndPushWeather(city)
|
||||||
|
}
|
||||||
|
if cfg.PhotoDir != "" && !cfg.HidePhoto {
|
||||||
|
go func() {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
pushCurrentPhoto(cfg.PhotoInterval)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bingReloadImage() {
|
||||||
|
if wv == nil || wvHwnd == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := getCurrentBingPath()
|
||||||
|
if p == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
src := imageToDataURI(p)
|
||||||
|
if src == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
evalJS(fmt.Sprintf(`var bg=document.querySelector('#bg-layer img'); if(bg) bg.src=%q;`, src))
|
||||||
}
|
}
|
||||||
|
|||||||
75
weather.go
@@ -8,10 +8,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const qweatherKey = "3b67b65a53c04170b602d2d1a7e6096f"
|
|
||||||
const qweatherHost = "https://pb4nmv4qnu.re.qweatherapi.com"
|
const qweatherHost = "https://pb4nmv4qnu.re.qweatherapi.com"
|
||||||
|
|
||||||
type City struct {
|
type City struct {
|
||||||
@@ -34,6 +34,12 @@ var cities = []City{
|
|||||||
{"101050101", "哈尔滨", "哈尔滨", "黑龙江"},
|
{"101050101", "哈尔滨", "哈尔滨", "黑龙江"},
|
||||||
{"101250101", "长沙", "长沙", "湖南"},
|
{"101250101", "长沙", "长沙", "湖南"},
|
||||||
{"101270101", "成都", "成都", "四川"},
|
{"101270101", "成都", "成都", "四川"},
|
||||||
|
{"101090101", "石家庄", "石家庄", "河北"},
|
||||||
|
{"101090206", "任丘", "任丘", "河北"},
|
||||||
|
{"101090301", "邯郸", "邯郸", "河北"},
|
||||||
|
{"101290106", "宣威", "宣威", "云南"},
|
||||||
|
{"101290101", "昆明", "昆明", "云南"},
|
||||||
|
{"101260101", "贵阳", "贵阳", "贵州"},
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultCity = City{"101200101", "武汉", "武汉", "湖北"}
|
var defaultCity = City{"101200101", "武汉", "武汉", "湖北"}
|
||||||
@@ -77,14 +83,18 @@ func getLocation() City {
|
|||||||
return defaultCity
|
return defaultCity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
reIPIP = regexp.MustCompile(`来自于[::]\s*(.+?)\s*$`)
|
||||||
|
reIPAddr = regexp.MustCompile(`(\d+\.\d+\.\d+\.\d+)`)
|
||||||
|
)
|
||||||
|
|
||||||
func locateByIPIP() *City {
|
func locateByIPIP() *City {
|
||||||
data, err := httpGet("https://myip.ipip.net")
|
data, err := httpGet("https://myip.ipip.net")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
text := string(data)
|
text := string(data)
|
||||||
re := regexp.MustCompile(`来自于[::]\s*(.+?)\s*$`)
|
m := reIPIP.FindStringSubmatch(text)
|
||||||
m := re.FindStringSubmatch(text)
|
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -107,8 +117,7 @@ func locateByIPIP() *City {
|
|||||||
func locateByGeoAPI() *City {
|
func locateByGeoAPI() *City {
|
||||||
data, err := httpGet("https://myip.ipip.net")
|
data, err := httpGet("https://myip.ipip.net")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
re := regexp.MustCompile(`(\d+\.\d+\.\d+\.\d+)`)
|
if m := reIPAddr.FindStringSubmatch(string(data)); m != nil {
|
||||||
if m := re.FindStringSubmatch(string(data)); m != nil {
|
|
||||||
return geoLookup(m[1])
|
return geoLookup(m[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +125,12 @@ func locateByGeoAPI() *City {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func geoLookup(ip string) *City {
|
func geoLookup(ip string) *City {
|
||||||
url := fmt.Sprintf(qweatherHost+"/v2/city/lookup?location=%s&key=%s", ip, qweatherKey)
|
key := loadConfig().qweatherKey()
|
||||||
|
if key == "" {
|
||||||
|
log.Println("未配置和风天气 API Key")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf(qweatherHost+"/v2/city/lookup?location=%s&key=%s", ip, key)
|
||||||
data, err := httpGet(url)
|
data, err := httpGet(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -160,6 +174,7 @@ type hourlyItem struct {
|
|||||||
Time string `json:"time"`
|
Time string `json:"time"`
|
||||||
Temp string `json:"temp"`
|
Temp string `json:"temp"`
|
||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
|
Pop string `json:"pop,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type dailyItem struct {
|
type dailyItem struct {
|
||||||
@@ -174,7 +189,29 @@ type currentWeather struct {
|
|||||||
Temp string `json:"temp"`
|
Temp string `json:"temp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
weatherCacheMu sync.Mutex
|
||||||
|
weatherCache map[string]time.Time
|
||||||
|
weatherCacheData map[string]string
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
weatherCache = make(map[string]time.Time)
|
||||||
|
weatherCacheData = make(map[string]string)
|
||||||
|
}
|
||||||
|
|
||||||
func fetchAndPushWeather(city City) {
|
func fetchAndPushWeather(city City) {
|
||||||
|
// 5 分钟内同一城市不重复请求
|
||||||
|
weatherCacheMu.Lock()
|
||||||
|
if last, ok := weatherCache[city.ID]; ok && time.Since(last) < 5*time.Minute {
|
||||||
|
if cached, ok := weatherCacheData[city.ID]; ok {
|
||||||
|
weatherCacheMu.Unlock()
|
||||||
|
evalJS(fmt.Sprintf(`if(window.updateWeatherFromGo) window.updateWeatherFromGo(%s)`, cached))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
weatherCacheMu.Unlock()
|
||||||
|
|
||||||
type weatherData struct {
|
type weatherData struct {
|
||||||
Current string `json:"current"`
|
Current string `json:"current"`
|
||||||
Hourly []hourlyItem `json:"hourly"`
|
Hourly []hourlyItem `json:"hourly"`
|
||||||
@@ -200,12 +237,21 @@ func fetchAndPushWeather(city City) {
|
|||||||
wd.Daily = fetchDailyForecast(city.ID)
|
wd.Daily = fetchDailyForecast(city.ID)
|
||||||
|
|
||||||
jsonData, _ := json.Marshal(wd)
|
jsonData, _ := json.Marshal(wd)
|
||||||
|
weatherCacheMu.Lock()
|
||||||
|
weatherCache[city.ID] = time.Now()
|
||||||
|
weatherCacheData[city.ID] = string(jsonData)
|
||||||
|
weatherCacheMu.Unlock()
|
||||||
evalJS(fmt.Sprintf(`if(window.updateWeatherFromGo) window.updateWeatherFromGo(%s)`, string(jsonData)))
|
evalJS(fmt.Sprintf(`if(window.updateWeatherFromGo) window.updateWeatherFromGo(%s)`, string(jsonData)))
|
||||||
log.Println("天气已推送:", wd.Current)
|
log.Println("天气已推送:", wd.Current)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchCurrentWeather(cityID string) *currentWeather {
|
func fetchCurrentWeather(cityID string) *currentWeather {
|
||||||
url := fmt.Sprintf(qweatherHost+"/v7/weather/now?location=%s&key=%s", cityID, qweatherKey)
|
key := loadConfig().qweatherKey()
|
||||||
|
if key == "" {
|
||||||
|
log.Println("未配置和风天气 API Key")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf(qweatherHost+"/v7/weather/now?location=%s&key=%s", cityID, key)
|
||||||
data, err := httpGet(url)
|
data, err := httpGet(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -224,7 +270,11 @@ func fetchCurrentWeather(cityID string) *currentWeather {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fetchHourlyForecast(cityID string) []hourlyItem {
|
func fetchHourlyForecast(cityID string) []hourlyItem {
|
||||||
url := fmt.Sprintf(qweatherHost+"/v7/weather/24h?location=%s&key=%s", cityID, qweatherKey)
|
key := loadConfig().qweatherKey()
|
||||||
|
if key == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf(qweatherHost+"/v7/weather/24h?location=%s&key=%s", cityID, key)
|
||||||
data, err := httpGet(url)
|
data, err := httpGet(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -234,6 +284,7 @@ func fetchHourlyForecast(cityID string) []hourlyItem {
|
|||||||
Hourly []struct {
|
Hourly []struct {
|
||||||
FxTime string `json:"fxTime"`
|
FxTime string `json:"fxTime"`
|
||||||
Temp string `json:"temp"`
|
Temp string `json:"temp"`
|
||||||
|
Pop string `json:"pop"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
} `json:"hourly"`
|
} `json:"hourly"`
|
||||||
}
|
}
|
||||||
@@ -252,13 +303,17 @@ func fetchHourlyForecast(cityID string) []hourlyItem {
|
|||||||
t = t[:5]
|
t = t[:5]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result = append(result, hourlyItem{t, h.Temp + "°", getWeatherIcon(h.Text)})
|
result = append(result, hourlyItem{t, h.Temp + "°", getWeatherIcon(h.Text), h.Pop})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchDailyForecast(cityID string) []dailyItem {
|
func fetchDailyForecast(cityID string) []dailyItem {
|
||||||
url := fmt.Sprintf(qweatherHost+"/v7/weather/7d?location=%s&key=%s", cityID, qweatherKey)
|
key := loadConfig().qweatherKey()
|
||||||
|
if key == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf(qweatherHost+"/v7/weather/7d?location=%s&key=%s", cityID, key)
|
||||||
data, err := httpGet(url)
|
data, err := httpGet(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
11
web-ui/overlay.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/overlay/index.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2451
web-ui/package-lock.json
generated
Normal file
23
web-ui/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "u-desktop-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build --mode overlay && vite build --mode settings",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-singlefile": "^2.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
web-ui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
11
web-ui/settings.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/settings/index.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7
web-ui/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
59
web-ui/src/overlay/App.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div id="bg-mount" ref="bgMount"></div>
|
||||||
|
|
||||||
|
<template v-if="!state.photoFrameMode">
|
||||||
|
<SingleLayout v-if="state.layout === 'single'" />
|
||||||
|
<MultiLayout v-else />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<PhotoFrame />
|
||||||
|
|
||||||
|
<div id="author">绝尘</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { state, loadInitialData } from './composables/useOverlayState'
|
||||||
|
import SingleLayout from './components/SingleLayout.vue'
|
||||||
|
import MultiLayout from './components/MultiLayout.vue'
|
||||||
|
import PhotoFrame from './components/PhotoFrame.vue'
|
||||||
|
import './overlay.css'
|
||||||
|
|
||||||
|
const bgMount = ref<HTMLElement>()
|
||||||
|
|
||||||
|
function mountBackground(html: string) {
|
||||||
|
const el = bgMount.value
|
||||||
|
if (!el) return
|
||||||
|
el.innerHTML = html
|
||||||
|
|
||||||
|
// v-html skips <script>; manually execute them
|
||||||
|
el.querySelectorAll('script').forEach(old => {
|
||||||
|
const ns = document.createElement('script')
|
||||||
|
if (old.src) ns.src = old.src
|
||||||
|
else ns.textContent = old.textContent
|
||||||
|
// copy attributes
|
||||||
|
for (const attr of old.attributes) {
|
||||||
|
ns.setAttribute(attr.name, attr.value)
|
||||||
|
}
|
||||||
|
old.parentNode?.replaceChild(ns, old)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadInitialData()
|
||||||
|
if (state.backgroundHtml && state.wallpaperVisible) {
|
||||||
|
mountBackground(state.backgroundHtml)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => state.backgroundHtml, (html) => {
|
||||||
|
if (state.wallpaperVisible) mountBackground(html)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => state.wallpaperVisible, (visible) => {
|
||||||
|
const el = bgMount.value
|
||||||
|
if (!el) return
|
||||||
|
if (visible && state.backgroundHtml) mountBackground(state.backgroundHtml)
|
||||||
|
else el.innerHTML = ''
|
||||||
|
})
|
||||||
|
</script>
|
||||||
57
web-ui/src/overlay/bridge.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { state } from './composables/useOverlayState'
|
||||||
|
|
||||||
|
function parseArg(data: any) {
|
||||||
|
return typeof data === 'string' ? JSON.parse(data) : data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerGoBridge() {
|
||||||
|
const w = window as any
|
||||||
|
|
||||||
|
w.updateHoroscopeFromGo = (data: any) => {
|
||||||
|
state.horoscope = parseArg(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.updateAINewsFromGo = (items: any) => {
|
||||||
|
const parsed = parseArg(items)
|
||||||
|
if (parsed?.length) state.aiNews = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
w.updateKnowledgeFromGo = (data: any) => {
|
||||||
|
state.knowledge = parseArg(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.updateWeatherFromGo = (data: any) => {
|
||||||
|
state.weather = parseArg(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.updatePhotoFromGo = (data: any) => {
|
||||||
|
state.photo = parseArg(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.setCardVisible = (card: string, visible: boolean) => {
|
||||||
|
if (card in state.cardVisibility) {
|
||||||
|
(state.cardVisibility as any)[card] = visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.setWallpaperVisible = (visible: boolean) => {
|
||||||
|
state.wallpaperVisible = visible
|
||||||
|
}
|
||||||
|
|
||||||
|
w.setPhotoFrameMode = (enabled: boolean) => {
|
||||||
|
state.photoFrameMode = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
w.setShowSeconds = (v: boolean) => {
|
||||||
|
state.showSeconds = v
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(w, 'userZodiac', {
|
||||||
|
set(v: string) { state.userZodiac = v },
|
||||||
|
get() { return state.userZodiac },
|
||||||
|
})
|
||||||
|
|
||||||
|
w.__updateBackgroundHtml = (html: string) => {
|
||||||
|
state.backgroundHtml = html
|
||||||
|
}
|
||||||
|
}
|
||||||
64
web-ui/src/overlay/components/AINewsCard.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="ainews-header">🤖 AI 资讯</div>
|
||||||
|
<div class="ainews-item" v-for="(n, i) in state.aiNews.slice(0, 5)" :key="i">
|
||||||
|
<img v-if="safeImageURL(n.picUrl)"
|
||||||
|
class="ainews-img" :src="n.picUrl" loading="lazy"
|
||||||
|
@error="($event.target as HTMLImageElement).style.display='none'" />
|
||||||
|
<div class="ainews-body">
|
||||||
|
<div class="ainews-title-row">
|
||||||
|
<span class="ainews-title">{{ n.title }}</span>
|
||||||
|
<span class="ainews-source">{{ n.source }} · {{ formatTime(n.ctime) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ainews-desc" v-if="n.description">{{ n.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { state } from '../composables/useOverlayState'
|
||||||
|
import { safeImageURL } from '@shared/utils'
|
||||||
|
|
||||||
|
function formatTime(t: string) {
|
||||||
|
return t?.length > 10 ? t.substring(5, 10) : t
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ainews-header {
|
||||||
|
font-size: 11px; font-weight: 700;
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: 0; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.ainews-item {
|
||||||
|
display: flex; gap: 14px;
|
||||||
|
margin-bottom: 12px; padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--card-line-soft);
|
||||||
|
min-height: 58px;
|
||||||
|
}
|
||||||
|
.ainews-item:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
|
||||||
|
.ainews-img {
|
||||||
|
width: 92px; height: 58px;
|
||||||
|
border-radius: 6px; object-fit: cover;
|
||||||
|
flex-shrink: 0; opacity: 0.92;
|
||||||
|
box-shadow: 0 8px 22px rgba(0,0,0,0.22);
|
||||||
|
}
|
||||||
|
.ainews-body { flex: 1; min-width: 0; }
|
||||||
|
.ainews-title-row {
|
||||||
|
display: flex; align-items: baseline; gap: 10px;
|
||||||
|
}
|
||||||
|
.ainews-title {
|
||||||
|
font-size: 14px; font-weight: 650;
|
||||||
|
color: var(--text-main); line-height: 1.4;
|
||||||
|
white-space: nowrap; overflow: hidden;
|
||||||
|
text-overflow: ellipsis; flex: 1; min-width: 0;
|
||||||
|
}
|
||||||
|
.ainews-source { font-size: 10px; color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
.ainews-desc {
|
||||||
|
font-size: 12px; color: var(--text-soft);
|
||||||
|
line-height: 1.5; margin-top: 5px;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
31
web-ui/src/overlay/components/KnowledgeCard.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="knowledge-header">
|
||||||
|
💡 知识卡片
|
||||||
|
<span class="knowledge-keyword-tag" v-if="state.knowledge.keyword">#{{ state.knowledge.keyword }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="knowledge-content">{{ state.knowledge.content || '请设置知识关键字' }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { state } from '../composables/useOverlayState'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.knowledge-header {
|
||||||
|
font-size: 11px; color: var(--text-faint);
|
||||||
|
margin-bottom: 10px; display: flex;
|
||||||
|
align-items: center; gap: 6px;
|
||||||
|
letter-spacing: 0; font-weight: 700;
|
||||||
|
}
|
||||||
|
.knowledge-keyword-tag {
|
||||||
|
background: rgba(255,255,255,0.09);
|
||||||
|
padding: 3px 8px; border-radius: 6px;
|
||||||
|
font-size: 10px; color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.knowledge-content {
|
||||||
|
font-size: 16px; color: var(--text-main);
|
||||||
|
line-height: 1.55; text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
86
web-ui/src/overlay/components/MultiLayout.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<div id="layout-multi">
|
||||||
|
<div id="card-time" class="card" v-if="state.cardVisibility.time">
|
||||||
|
<TimeCard />
|
||||||
|
</div>
|
||||||
|
<div id="card-zodiac" class="card" v-if="state.cardVisibility.zodiac">
|
||||||
|
<ZodiacCard />
|
||||||
|
</div>
|
||||||
|
<div id="card-knowledge" class="card" v-if="state.cardVisibility.knowledge">
|
||||||
|
<KnowledgeCard />
|
||||||
|
</div>
|
||||||
|
<div id="card-ainews" class="card" v-if="state.cardVisibility.ainews">
|
||||||
|
<AINewsCard />
|
||||||
|
</div>
|
||||||
|
<div id="card-weather" class="card" v-if="state.cardVisibility.weather">
|
||||||
|
<WeatherCard />
|
||||||
|
</div>
|
||||||
|
<PhotoCard />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { state } from '../composables/useOverlayState'
|
||||||
|
import TimeCard from './TimeCard.vue'
|
||||||
|
import WeatherCard from './WeatherCard.vue'
|
||||||
|
import ZodiacCard from './ZodiacCard.vue'
|
||||||
|
import KnowledgeCard from './KnowledgeCard.vue'
|
||||||
|
import AINewsCard from './AINewsCard.vue'
|
||||||
|
import PhotoCard from './PhotoCard.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#layout-multi {
|
||||||
|
position: fixed;
|
||||||
|
inset: var(--layout-top) var(--layout-x) var(--layout-bottom);
|
||||||
|
z-index: 10;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(420px, 1fr) minmax(360px, var(--knowledge-panel)) var(--right-panel);
|
||||||
|
grid-template-rows: var(--time-panel-h) var(--layout-gap) var(--zodiac-panel-h) minmax(32px, 1fr) auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"photo knowledge time"
|
||||||
|
"photo knowledge ."
|
||||||
|
"photo knowledge zodiac"
|
||||||
|
". . ."
|
||||||
|
"news weather weather";
|
||||||
|
column-gap: var(--layout-col-gap);
|
||||||
|
row-gap: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#layout-multi > .card { pointer-events: none; }
|
||||||
|
#card-time {
|
||||||
|
grid-area: time; text-align: left;
|
||||||
|
width: 100%; height: 100%; padding: 22px 30px;
|
||||||
|
}
|
||||||
|
#card-zodiac {
|
||||||
|
grid-area: zodiac; width: 100%; height: 100%; padding: 22px 28px;
|
||||||
|
}
|
||||||
|
#card-knowledge {
|
||||||
|
grid-area: knowledge; align-self: stretch;
|
||||||
|
width: 100%; min-height: 0; padding: 22px 26px;
|
||||||
|
}
|
||||||
|
#card-ainews {
|
||||||
|
grid-area: news; align-self: end;
|
||||||
|
width: 100%; max-height: 390px;
|
||||||
|
}
|
||||||
|
#card-weather {
|
||||||
|
grid-area: weather; align-self: end;
|
||||||
|
width: 100%; min-width: 0; text-align: right;
|
||||||
|
padding: 22px 28px 24px;
|
||||||
|
}
|
||||||
|
#card-knowledge :deep(.knowledge-content) {
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 8;
|
||||||
|
-webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
#layout-multi :deep(#card-photo) {
|
||||||
|
grid-area: photo;
|
||||||
|
position: relative; top: auto; left: auto;
|
||||||
|
align-self: start;
|
||||||
|
width: 100%;
|
||||||
|
max-height: var(--photo-panel-h);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
#layout-multi :deep(#card-photo img) {
|
||||||
|
max-height: calc(var(--photo-panel-h) - 32px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
68
web-ui/src/overlay/components/PhotoCard.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<div id="card-photo" class="card" v-if="state.photo?.src">
|
||||||
|
<div class="photo-wrap">
|
||||||
|
<img :src="state.photo.src" alt="" @load="imgLoaded = true" :style="{ opacity: imgLoaded ? 1 : 0 }" />
|
||||||
|
<div class="photo-info">
|
||||||
|
<span class="photo-counter">{{ state.photo.counter }}</span>
|
||||||
|
<div class="photo-progress">
|
||||||
|
<div class="photo-progress-bar" ref="progressBar"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { state } from '../composables/useOverlayState'
|
||||||
|
|
||||||
|
const imgLoaded = ref(false)
|
||||||
|
const progressBar = ref<HTMLDivElement>()
|
||||||
|
|
||||||
|
watch(() => state.photo?.src, () => {
|
||||||
|
imgLoaded.value = false
|
||||||
|
if (!progressBar.value) return
|
||||||
|
const bar = progressBar.value
|
||||||
|
bar.style.transition = 'none'
|
||||||
|
bar.style.width = '0%'
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const interval = state.photo?.interval || 15
|
||||||
|
bar.style.transition = `width ${interval}s linear`
|
||||||
|
bar.style.width = '100%'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#card-photo {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.photo-wrap {
|
||||||
|
position: relative; border-radius: 8px; overflow: hidden;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
width: 100%; max-height: 450px;
|
||||||
|
object-fit: cover; display: block;
|
||||||
|
border-radius: 8px; transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
.photo-info {
|
||||||
|
position: absolute; bottom: 0; left: 0; right: 0;
|
||||||
|
padding: 20px 14px 10px;
|
||||||
|
background: linear-gradient(transparent, rgba(0,0,0,0.5));
|
||||||
|
}
|
||||||
|
.photo-counter {
|
||||||
|
font-size: 11px; opacity: 0.7;
|
||||||
|
display: block; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.photo-progress {
|
||||||
|
height: 3px; background: rgba(255,255,255,0.15);
|
||||||
|
border-radius: 2px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.photo-progress-bar {
|
||||||
|
height: 100%; width: 0%;
|
||||||
|
background: rgba(255,255,255,0.6);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
53
web-ui/src/overlay/components/PhotoFrame.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<template v-if="state.photoFrameMode">
|
||||||
|
<img id="photo-frame-bg" :src="state.photo?.src" alt="" :style="{ opacity: visible ? 1 : 0 }" />
|
||||||
|
<img id="photo-frame-img" :src="state.photo?.src" alt="" :style="{ opacity: visible ? 1 : 0 }" />
|
||||||
|
<div class="photo-frame-clock">{{ timeStr }}</div>
|
||||||
|
<div class="photo-frame-date">{{ dateStr }}</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { state } from '../composables/useOverlayState'
|
||||||
|
import { useTime } from '../composables/useTime'
|
||||||
|
|
||||||
|
const { timeStr, dateStr } = useTime()
|
||||||
|
const visible = ref(false)
|
||||||
|
|
||||||
|
watch(() => state.photo?.src, () => {
|
||||||
|
if (!state.photoFrameMode || !state.photo?.src) return
|
||||||
|
visible.value = false
|
||||||
|
setTimeout(() => { visible.value = true }, 400)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#photo-frame-bg, #photo-frame-img {
|
||||||
|
position: fixed; top: 0; left: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
z-index: 5; object-fit: contain;
|
||||||
|
transition: opacity 0.8s ease;
|
||||||
|
}
|
||||||
|
#photo-frame-bg {
|
||||||
|
z-index: 4; object-fit: cover;
|
||||||
|
filter: blur(30px) brightness(0.5);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
.photo-frame-clock {
|
||||||
|
position: fixed; top: 28px; right: 36px; z-index: 10;
|
||||||
|
font-family: -apple-system, "SF Pro Display", "Segoe UI", sans-serif;
|
||||||
|
font-size: 52px; font-weight: 200; letter-spacing: 2px;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 0 20px rgba(0,0,0,0.8), 0 0 40px rgba(0,0,0,0.5), 0 2px 4px rgba(0,0,0,0.9);
|
||||||
|
pointer-events: none; opacity: 0; transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
.photo-frame-date {
|
||||||
|
position: fixed; top: 86px; right: 38px; z-index: 10;
|
||||||
|
font-family: -apple-system, "SF Pro Display", "Segoe UI", sans-serif;
|
||||||
|
font-size: 15px; font-weight: 400;
|
||||||
|
color: rgba(255,255,255,0.8);
|
||||||
|
text-shadow: 0 0 12px rgba(0,0,0,0.7), 0 1px 3px rgba(0,0,0,0.8);
|
||||||
|
pointer-events: none; opacity: 0; transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
36
web-ui/src/overlay/components/SingleLayout.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<div id="layout-single">
|
||||||
|
<div id="info" class="card">
|
||||||
|
<TimeCard v-if="state.cardVisibility.time" />
|
||||||
|
<div class="divider"></div>
|
||||||
|
<AINewsCard v-if="state.cardVisibility.ainews" />
|
||||||
|
<div class="divider" v-if="state.cardVisibility.ainews"></div>
|
||||||
|
<KnowledgeCard v-if="state.cardVisibility.knowledge" />
|
||||||
|
<div class="divider" v-if="state.cardVisibility.knowledge"></div>
|
||||||
|
<WeatherCard v-if="state.cardVisibility.weather" />
|
||||||
|
<div class="divider" v-if="state.cardVisibility.weather"></div>
|
||||||
|
<ZodiacCard v-if="state.cardVisibility.zodiac" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { state } from '../composables/useOverlayState'
|
||||||
|
import TimeCard from './TimeCard.vue'
|
||||||
|
import WeatherCard from './WeatherCard.vue'
|
||||||
|
import ZodiacCard from './ZodiacCard.vue'
|
||||||
|
import KnowledgeCard from './KnowledgeCard.vue'
|
||||||
|
import AINewsCard from './AINewsCard.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#layout-single { display: block; }
|
||||||
|
#info {
|
||||||
|
position: fixed;
|
||||||
|
top: 42px; right: 42px;
|
||||||
|
text-align: left;
|
||||||
|
width: min(760px, calc(50vw - 52px));
|
||||||
|
max-height: calc(100vh - 84px);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
60
web-ui/src/overlay/components/TimeCard.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div class="date" v-html="dateDisplay"></div>
|
||||||
|
<div class="time" :class="{ 'hourly-glow': glowing }">{{ timeStr }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
|
import { state } from '../composables/useOverlayState'
|
||||||
|
import { useTime } from '../composables/useTime'
|
||||||
|
|
||||||
|
const { timeStr, dateStr, holidayStr } = useTime()
|
||||||
|
const glowing = ref(false)
|
||||||
|
let glowTimer: number | null = null
|
||||||
|
|
||||||
|
const dateDisplay = computed(() => {
|
||||||
|
return holidayStr.value
|
||||||
|
? `${dateStr.value} <span style="opacity:0.5;font-size:12px">「${holidayStr.value}」</span>`
|
||||||
|
: dateStr.value
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(timeStr, (val) => {
|
||||||
|
if (val.endsWith(':00:00') || val.endsWith(':00')) {
|
||||||
|
glowing.value = false
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
glowing.value = true
|
||||||
|
})
|
||||||
|
if (glowTimer) clearTimeout(glowTimer)
|
||||||
|
glowTimer = window.setTimeout(() => { glowing.value = false }, 6000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (glowTimer) clearTimeout(glowTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.time {
|
||||||
|
font-size: 66px;
|
||||||
|
font-weight: 200;
|
||||||
|
text-shadow: 0 3px 28px rgba(0,0,0,0.42);
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: rgba(255,255,255,0.96);
|
||||||
|
}
|
||||||
|
.time.hourly-glow {
|
||||||
|
animation: hourlyGlow 6s ease-out forwards;
|
||||||
|
}
|
||||||
|
.date {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-shadow: 0 1px 6px rgba(0,0,0,0.5);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 1500px) {
|
||||||
|
.time { font-size: 64px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
80
web-ui/src/overlay/components/WeatherCard.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<div class="current-weather" v-if="state.weather?.current">{{ state.weather.current }}</div>
|
||||||
|
<template v-if="state.weather?.hourly?.length">
|
||||||
|
<div class="forecast-title">24小时预报</div>
|
||||||
|
<div class="weather-forecast">
|
||||||
|
<div class="forecast-item" v-for="item in state.weather.hourly" :key="item.time">
|
||||||
|
<div class="forecast-time">{{ item.time }}</div>
|
||||||
|
<div class="forecast-icon">{{ item.icon }}</div>
|
||||||
|
<div class="forecast-temp">{{ item.temp }}</div>
|
||||||
|
<div class="forecast-pop" v-if="item.pop && item.pop !== '0'">{{ item.pop }}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="state.weather?.daily?.length">
|
||||||
|
<div class="forecast-title" style="margin-top:12px">7日预报</div>
|
||||||
|
<div class="daily-forecast">
|
||||||
|
<div class="daily-item" v-for="item in state.weather.daily" :key="item.date">
|
||||||
|
<div style="opacity:0.6;font-size:10px">{{ item.date }}</div>
|
||||||
|
<div class="daily-icon">{{ item.icon }}</div>
|
||||||
|
<div class="forecast-temp">{{ item.tempMin }}°~{{ item.tempMax }}°</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { state } from '../composables/useOverlayState'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.current-weather {
|
||||||
|
font-size: 17px; font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.forecast-title {
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: 0;
|
||||||
|
margin-bottom: 9px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.weather-forecast {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, minmax(60px, 1fr));
|
||||||
|
gap: 7px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.forecast-item {
|
||||||
|
background: rgba(255,255,255,0.055);
|
||||||
|
border-radius: 8px; padding: 9px 8px;
|
||||||
|
text-align: center; min-width: 0;
|
||||||
|
font-size: 12px; color: var(--text-main);
|
||||||
|
border: 1px solid var(--card-line-soft);
|
||||||
|
}
|
||||||
|
.forecast-icon { font-size: 22px; margin: 5px 0; }
|
||||||
|
.forecast-time { color: var(--text-faint); font-size: 10px; }
|
||||||
|
.forecast-temp { font-weight: 700; margin-top: 2px; font-size: 14px; }
|
||||||
|
.forecast-pop { font-size: 10px; color: var(--text-faint); margin-top: 2px; }
|
||||||
|
.daily-forecast {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(64px, 1fr));
|
||||||
|
gap: 7px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.daily-item {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border-radius: 8px; padding: 9px 8px;
|
||||||
|
text-align: center; min-width: 0;
|
||||||
|
font-size: 12px; color: var(--text-main);
|
||||||
|
border: 1px solid var(--card-line-soft);
|
||||||
|
}
|
||||||
|
.daily-icon { font-size: 22px; margin: 5px 0; }
|
||||||
|
.weather-forecast::-webkit-scrollbar,
|
||||||
|
.daily-forecast::-webkit-scrollbar { display: none; }
|
||||||
|
@media (max-width: 1500px) {
|
||||||
|
.weather-forecast { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
.daily-forecast { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
81
web-ui/src/overlay/components/ZodiacCard.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div class="zodiac-text">
|
||||||
|
<div class="zodiac-title">{{ info.icon }} {{ state.userZodiac }}运势</div>
|
||||||
|
<div class="zodiac-date">{{ info.date }}</div>
|
||||||
|
<template v-if="data">
|
||||||
|
<div class="zodiac-bar" v-for="bar in bars" :key="bar.label">
|
||||||
|
<span class="zodiac-bar-label">{{ bar.label }}</span>
|
||||||
|
<div class="zodiac-bar-track">
|
||||||
|
<div class="zodiac-bar-fill" :style="{ width: bar.val + '%', background: bar.color }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="zodiac-bar-val">{{ bar.val }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="zodiac-tags" v-if="hasTags">
|
||||||
|
<span class="zodiac-tag" v-if="data.luckyColor">🎨 {{ data.luckyColor }}</span>
|
||||||
|
<span class="zodiac-tag" v-if="data.luckyNum">🔢 {{ data.luckyNum }}</span>
|
||||||
|
<span class="zodiac-tag" v-if="data.noble">⭐ {{ data.noble }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="zodiac-summary" v-if="data.summary">{{ data.summary }}</div>
|
||||||
|
</template>
|
||||||
|
<div v-else style="opacity:0.4;font-size:12px;margin-top:8px">运势加载中...</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { state } from '../composables/useOverlayState'
|
||||||
|
import { useHoroscope, zodiacMap, barColors } from '../composables/useHoroscope'
|
||||||
|
|
||||||
|
const { zodiacInfo: info, data } = useHoroscope()
|
||||||
|
|
||||||
|
const bars = computed(() => {
|
||||||
|
if (!data.value) return []
|
||||||
|
return [
|
||||||
|
{ label: '综合', val: parseInt(data.value.all) || 0, color: barColors.all },
|
||||||
|
{ label: '爱情', val: parseInt(data.value.love) || 0, color: barColors.love },
|
||||||
|
{ label: '工作', val: parseInt(data.value.work) || 0, color: barColors.work },
|
||||||
|
{ label: '财运', val: parseInt(data.value.money) || 0, color: barColors.money },
|
||||||
|
{ label: '健康', val: parseInt(data.value.health) || 0, color: barColors.health },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasTags = computed(() => data.value?.luckyColor || data.value?.luckyNum || data.value?.noble)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.zodiac-text {
|
||||||
|
font-size: 14px; color: var(--text-main);
|
||||||
|
line-height: 1.6; text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
.zodiac-title { font-size: 16px; font-weight: 650; margin-bottom: 3px; }
|
||||||
|
.zodiac-date { font-size: 11px; color: var(--text-faint); margin-bottom: 12px; }
|
||||||
|
.zodiac-bar {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
margin-bottom: 7px; font-size: 12px;
|
||||||
|
}
|
||||||
|
.zodiac-bar-label { width: 28px; color: var(--text-soft); flex-shrink: 0; }
|
||||||
|
.zodiac-bar-track {
|
||||||
|
flex: 1; height: 5px;
|
||||||
|
background: rgba(255,255,255,0.10);
|
||||||
|
border-radius: 999px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.zodiac-bar-fill {
|
||||||
|
height: 100%; border-radius: 999px;
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
}
|
||||||
|
.zodiac-bar-val {
|
||||||
|
width: 30px; text-align: right; font-size: 11px;
|
||||||
|
color: var(--text-soft); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.zodiac-tags { display: flex; gap: 7px; flex-wrap: wrap; margin: 10px 0 9px; }
|
||||||
|
.zodiac-tag {
|
||||||
|
font-size: 10px; background: rgba(255,255,255,0.08);
|
||||||
|
padding: 4px 8px; border-radius: 6px;
|
||||||
|
color: var(--text-soft); border: 1px solid rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
.zodiac-summary {
|
||||||
|
font-size: 12px; color: var(--text-soft); line-height: 1.65;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
28
web-ui/src/overlay/composables/useHoroscope.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import { state } from './useOverlayState'
|
||||||
|
|
||||||
|
export const zodiacMap: Record<string, { icon: string; date: string }> = {
|
||||||
|
'白羊座': { icon: '♈', date: '3.21-4.19' },
|
||||||
|
'金牛座': { icon: '♉', date: '4.20-5.20' },
|
||||||
|
'双子座': { icon: '♊', date: '5.21-6.21' },
|
||||||
|
'巨蟹座': { icon: '♋', date: '6.22-7.22' },
|
||||||
|
'狮子座': { icon: '♌', date: '7.23-8.22' },
|
||||||
|
'处女座': { icon: '♍', date: '8.23-9.22' },
|
||||||
|
'天秤座': { icon: '♎', date: '9.23-10.23' },
|
||||||
|
'天蝎座': { icon: '♏', date: '10.24-11.22' },
|
||||||
|
'射手座': { icon: '♐', date: '11.23-12.21' },
|
||||||
|
'摩羯座': { icon: '♑', date: '12.22-1.19' },
|
||||||
|
'水瓶座': { icon: '♒', date: '1.20-2.18' },
|
||||||
|
'双鱼座': { icon: '♓', date: '2.19-3.20' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const barColors: Record<string, string> = {
|
||||||
|
all: '#e0e0e0', love: '#ff6b9d', work: '#4fc3f7', money: '#ffd54f', health: '#81c784',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHoroscope() {
|
||||||
|
const zodiacInfo = computed(() => zodiacMap[state.userZodiac] || { icon: '✨', date: '' })
|
||||||
|
const data = computed(() => state.horoscope)
|
||||||
|
|
||||||
|
return { zodiacInfo, data }
|
||||||
|
}
|
||||||
36
web-ui/src/overlay/composables/useOverlayState.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
import type { WeatherData, HoroscopeData, AINewsItem, KnowledgeData, PhotoData, CardVisibility } from '@shared/types'
|
||||||
|
|
||||||
|
export const state = reactive({
|
||||||
|
layout: 'single' as 'single' | 'multi',
|
||||||
|
showSeconds: true,
|
||||||
|
userZodiac: '射手座',
|
||||||
|
wallpaperVisible: true,
|
||||||
|
photoFrameMode: false,
|
||||||
|
cardVisibility: {
|
||||||
|
time: true,
|
||||||
|
weather: true,
|
||||||
|
zodiac: true,
|
||||||
|
knowledge: true,
|
||||||
|
ainews: true,
|
||||||
|
photo: true,
|
||||||
|
} as CardVisibility,
|
||||||
|
backgroundHtml: '',
|
||||||
|
weather: null as WeatherData | null,
|
||||||
|
horoscope: null as HoroscopeData | null,
|
||||||
|
aiNews: [] as AINewsItem[],
|
||||||
|
knowledge: { content: '', keyword: '' } as KnowledgeData,
|
||||||
|
photo: null as PhotoData | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function loadInitialData() {
|
||||||
|
const d = (window as any).__INITIAL_DATA__
|
||||||
|
if (!d) return
|
||||||
|
if (d.layout) state.layout = d.layout
|
||||||
|
if (d.showSeconds !== undefined) state.showSeconds = d.showSeconds
|
||||||
|
if (d.userZodiac) state.userZodiac = d.userZodiac
|
||||||
|
if (d.wallpaperVisible !== undefined) state.wallpaperVisible = d.wallpaperVisible
|
||||||
|
if (d.photoFrameMode !== undefined) state.photoFrameMode = d.photoFrameMode
|
||||||
|
if (d.cardVisibility) Object.assign(state.cardVisibility, d.cardVisibility)
|
||||||
|
if (d.backgroundHtml) state.backgroundHtml = d.backgroundHtml
|
||||||
|
}
|
||||||
77
web-ui/src/overlay/composables/useTime.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { state } from './useOverlayState'
|
||||||
|
|
||||||
|
export function useTime() {
|
||||||
|
const timeStr = ref('00:00')
|
||||||
|
const dateStr = ref('1月1日 周一')
|
||||||
|
const holidayStr = ref('')
|
||||||
|
let timer: number | null = null
|
||||||
|
let lastTime = ''
|
||||||
|
let lastDate = ''
|
||||||
|
|
||||||
|
const holidays = [
|
||||||
|
{ m: 1, d: 1, name: '元旦' },
|
||||||
|
{ m: 2, d: 14, name: '情人节' },
|
||||||
|
{ m: 3, d: 8, name: '妇女节' },
|
||||||
|
{ m: 4, d: 5, name: '清明节' },
|
||||||
|
{ m: 5, d: 1, name: '劳动节' },
|
||||||
|
{ m: 5, d: 4, name: '青年节' },
|
||||||
|
{ m: 6, d: 1, name: '儿童节' },
|
||||||
|
{ m: 7, d: 1, name: '建党节' },
|
||||||
|
{ m: 8, d: 1, name: '建军节' },
|
||||||
|
{ m: 9, d: 10, name: '教师节' },
|
||||||
|
{ m: 10, d: 1, name: '国庆节' },
|
||||||
|
{ m: 10, d: 31, name: '万圣节' },
|
||||||
|
{ m: 12, d: 25, name: '圣诞节' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function getNextHoliday(now: Date) {
|
||||||
|
const y = now.getFullYear()
|
||||||
|
const results: { diff: number; name: string }[] = []
|
||||||
|
for (const h of holidays) {
|
||||||
|
const target = new Date(y, h.m - 1, h.d)
|
||||||
|
let diff = Math.ceil((target.getTime() - now.getTime()) / 86400000)
|
||||||
|
if (diff > 0 && diff <= 60) results.push({ diff, name: h.name })
|
||||||
|
if (diff < 0) {
|
||||||
|
const next = new Date(y + 1, h.m - 1, h.d)
|
||||||
|
diff = Math.ceil((next.getTime() - now.getTime()) / 86400000)
|
||||||
|
if (diff > 0 && diff <= 60) results.push({ diff, name: h.name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.sort((a, b) => a.diff - b.diff)
|
||||||
|
return results.length > 0 ? `距${results[0].name}还有${results[0].diff}天` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
const now = new Date()
|
||||||
|
const hh = String(now.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(now.getMinutes()).padStart(2, '0')
|
||||||
|
const ss = String(now.getSeconds()).padStart(2, '0')
|
||||||
|
const t = state.showSeconds ? `${hh}:${mm}:${ss}` : `${hh}:${mm}`
|
||||||
|
const month = now.getMonth() + 1
|
||||||
|
const day = now.getDate()
|
||||||
|
const week = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][now.getDay()]
|
||||||
|
const d = `${month}月${day}日 ${week}`
|
||||||
|
|
||||||
|
if (t !== lastTime) {
|
||||||
|
timeStr.value = t
|
||||||
|
lastTime = t
|
||||||
|
}
|
||||||
|
if (d !== lastDate) {
|
||||||
|
const h = getNextHoliday(now)
|
||||||
|
dateStr.value = d
|
||||||
|
holidayStr.value = h
|
||||||
|
lastDate = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
update()
|
||||||
|
timer = window.setInterval(update, 1000)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { timeStr, dateStr, holidayStr }
|
||||||
|
}
|
||||||
7
web-ui/src/overlay/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import '../tailwind.css'
|
||||||
|
import { registerGoBridge } from './bridge'
|
||||||
|
|
||||||
|
registerGoBridge()
|
||||||
|
createApp(App).mount('#app')
|
||||||
86
web-ui/src/overlay/overlay.css
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
* { margin: 0; padding: 0; }
|
||||||
|
html, body {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||||
|
color: #fff;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--card-bg: rgba(22, 19, 28, 0.56);
|
||||||
|
--card-bg-strong: rgba(24, 20, 30, 0.68);
|
||||||
|
--card-line: rgba(255,255,255,0.10);
|
||||||
|
--card-line-soft: rgba(255,255,255,0.06);
|
||||||
|
--text-main: rgba(255,255,255,0.92);
|
||||||
|
--text-soft: rgba(255,255,255,0.66);
|
||||||
|
--text-faint: rgba(255,255,255,0.42);
|
||||||
|
--accent-warm: #ffd86b;
|
||||||
|
--accent-cool: #6bdcff;
|
||||||
|
--shadow-card: 0 18px 60px rgba(0,0,0,0.32), inset 0 1px 0 rgba(255,255,255,0.10);
|
||||||
|
--layout-x: 48px;
|
||||||
|
--layout-top: 38px;
|
||||||
|
--layout-bottom: 72px;
|
||||||
|
--layout-gap: 18px;
|
||||||
|
--layout-col-gap: 24px;
|
||||||
|
--right-panel: 400px;
|
||||||
|
--knowledge-panel: 460px;
|
||||||
|
--time-panel-h: 154px;
|
||||||
|
--zodiac-panel-h: 326px;
|
||||||
|
--photo-panel-h: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(145deg, rgba(255,255,255,0.075), rgba(255,255,255,0.025)), var(--card-bg);
|
||||||
|
backdrop-filter: blur(28px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(28px) saturate(1.2);
|
||||||
|
border: 1px solid var(--card-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 22px 24px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: inherit;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.13), transparent 34%),
|
||||||
|
radial-gradient(circle at 12% 0%, rgba(255,216,107,0.13), transparent 38%);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.card > * { position: relative; }
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.14), transparent);
|
||||||
|
margin: 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hourlyGlow {
|
||||||
|
0% { text-shadow: 0 0 30px rgba(255,255,255,1), 0 0 80px rgba(180,200,255,0.8), 0 0 120px rgba(100,150,255,0.5); color: #fff; }
|
||||||
|
8% { text-shadow: 0 0 50px rgba(255,255,255,1), 0 0 100px rgba(180,200,255,1), 0 0 160px rgba(100,150,255,0.6); }
|
||||||
|
18% { text-shadow: 0 0 20px rgba(255,255,255,0.6), 0 0 40px rgba(180,200,255,0.3); }
|
||||||
|
28% { text-shadow: 0 0 35px rgba(255,255,255,0.85), 0 0 70px rgba(180,200,255,0.6), 0 0 100px rgba(100,150,255,0.35); }
|
||||||
|
38% { text-shadow: 0 0 15px rgba(255,255,255,0.4), 0 0 30px rgba(180,200,255,0.2); }
|
||||||
|
48% { text-shadow: 0 0 25px rgba(255,255,255,0.6), 0 0 50px rgba(180,200,255,0.4); }
|
||||||
|
60% { text-shadow: 0 0 10px rgba(255,255,255,0.25), 0 0 20px rgba(180,200,255,0.12); }
|
||||||
|
75% { text-shadow: 0 0 15px rgba(255,255,255,0.35), 0 0 30px rgba(180,200,255,0.2); }
|
||||||
|
100% { text-shadow: 0 2px 20px rgba(0,0,0,0.5); color: #fff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1500px) {
|
||||||
|
:root {
|
||||||
|
--layout-x: 32px;
|
||||||
|
--layout-col-gap: 18px;
|
||||||
|
--right-panel: 360px;
|
||||||
|
--knowledge-panel: 380px;
|
||||||
|
--time-panel-h: 144px;
|
||||||
|
--zodiac-panel-h: 312px;
|
||||||
|
--photo-panel-h: 320px;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
web-ui/src/settings/App.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-4 select-none overflow-y-auto">
|
||||||
|
<div class="mb-3.5">
|
||||||
|
<h1 class="text-base font-semibold text-[var(--text-strong)]">桌面设置</h1>
|
||||||
|
<p class="text-[11px] text-[var(--text-weak)] mt-0.5">壁纸 · 布局 · 信息显示</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToggleSection :s="s" />
|
||||||
|
<WallpaperSection :s="s" @set-wp-type="setWpType" :current-color="currentColor" @pick-solid="onPickSolid" @pick-gradient="onPickGradient" @save-color="onSaveColor" @apply-color="onApplySavedColor" @remove-color="onRemoveSavedColor" />
|
||||||
|
<LayoutSection :s="s" />
|
||||||
|
<PhotoSection :s="s" />
|
||||||
|
<PersonalizeSection :s="s" />
|
||||||
|
|
||||||
|
<div class="text-center text-[11px] text-[var(--footer-color)] mt-3 py-1.5 tracking-wide">u-desktop v1.0</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref, onMounted } from 'vue'
|
||||||
|
import { go } from './composables/useGoBridge'
|
||||||
|
import ToggleSection from './components/ToggleSection.vue'
|
||||||
|
import WallpaperSection from './components/WallpaperSection.vue'
|
||||||
|
import LayoutSection from './components/LayoutSection.vue'
|
||||||
|
import PhotoSection from './components/PhotoSection.vue'
|
||||||
|
import PersonalizeSection from './components/PersonalizeSection.vue'
|
||||||
|
import type { SettingsData } from '@shared/types'
|
||||||
|
|
||||||
|
const initDone = ref(false)
|
||||||
|
|
||||||
|
const s = reactive<SettingsData>({
|
||||||
|
lightTheme: false, wallpaper: true, time: true, weather: true,
|
||||||
|
zodiacCard: true, knowledgeCard: true, layout: 'single', zodiac: '射手座',
|
||||||
|
city: '', provinces: [], citiesByProv: {}, wallpaperType: 'theme', theme: '',
|
||||||
|
themes: [], color1: '', color2: '', colorGradient: false, savedColors: [],
|
||||||
|
bingAutoRefresh: false, knowledgeKeyword: '', knowledgePrompt: '',
|
||||||
|
wallpaperText: '', imagePath: '', showSeconds: true, ainewsCard: true,
|
||||||
|
photoDir: '', photoInterval: 15, photoCard: true, photoFrameMode: false, autoStart: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Shared state for color section
|
||||||
|
const currentColor = reactive({ c1: '', c2: '', gradient: false })
|
||||||
|
|
||||||
|
function setWpType(type: string) {
|
||||||
|
const prev = s.wallpaperType
|
||||||
|
s.wallpaperType = type
|
||||||
|
if (!initDone.value) return
|
||||||
|
if (type === 'theme') go.saveWallpaperType('theme', s.theme)
|
||||||
|
else if (type === 'bing' && prev !== 'bing') go.enableBing()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPickSolid() {
|
||||||
|
const c = await go.pickSolidColor()
|
||||||
|
if (c) { currentColor.c1 = c; currentColor.c2 = ''; currentColor.gradient = false }
|
||||||
|
}
|
||||||
|
async function onPickGradient() {
|
||||||
|
const res = await go.pickGradientColor()
|
||||||
|
if (res) {
|
||||||
|
const parts = res.split(',')
|
||||||
|
currentColor.c1 = parts[0]; currentColor.c2 = parts[1] || ''; currentColor.gradient = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onSaveColor() {
|
||||||
|
if (!currentColor.c1) return
|
||||||
|
go.addSavedColor(currentColor.c1, currentColor.c2, currentColor.gradient)
|
||||||
|
s.savedColors.push({ color1: currentColor.c1, color2: currentColor.c2, gradient: currentColor.gradient })
|
||||||
|
}
|
||||||
|
function onApplySavedColor(idx: number) {
|
||||||
|
go.applySavedColor(idx)
|
||||||
|
const sc = s.savedColors[idx]
|
||||||
|
currentColor.c1 = sc.color1; currentColor.c2 = sc.color2; currentColor.gradient = sc.gradient
|
||||||
|
}
|
||||||
|
async function onRemoveSavedColor(idx: number) {
|
||||||
|
await go.removeSavedColor(idx)
|
||||||
|
s.savedColors.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.documentElement
|
||||||
|
if (go.resizeToFit) go.resizeToFit(el.scrollWidth, el.scrollHeight + 8)
|
||||||
|
}, 100)
|
||||||
|
initDone.value = true
|
||||||
|
})
|
||||||
|
</script>
|
||||||
21
web-ui/src/settings/components/LayoutSection.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="text-[10px] font-semibold text-[var(--text-weak)] uppercase tracking-[1.5px] mb-1 pl-0.5">布局</div>
|
||||||
|
<div class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg">
|
||||||
|
<div class="flex justify-between items-center px-3.5 py-2">
|
||||||
|
<div class="text-xs font-medium text-[var(--text-muted)]">信息布局</div>
|
||||||
|
<select v-model="s.layout" @change="go.saveLayout(s.layout)"
|
||||||
|
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-0.5 px-1.5 outline-none min-w-[80px] max-w-[160px] focus:border-[var(--input-border-focus)]">
|
||||||
|
<option value="single">合并卡片</option>
|
||||||
|
<option value="multi">独立卡片</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { go } from '../composables/useGoBridge'
|
||||||
|
import type { SettingsData } from '@shared/types'
|
||||||
|
defineProps<{ s: SettingsData }>()
|
||||||
|
</script>
|
||||||
111
web-ui/src/settings/components/PersonalizeSection.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="text-[10px] font-semibold text-[var(--text-weak)] uppercase tracking-[1.5px] mb-1 pl-0.5">个性化</div>
|
||||||
|
<div class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg">
|
||||||
|
<div class="flex justify-between items-center px-3.5 py-2">
|
||||||
|
<div class="text-xs font-medium text-[var(--text-muted)]">我的星座</div>
|
||||||
|
<select v-model="s.zodiac" @change="go.saveZodiac(s.zodiac)"
|
||||||
|
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-0.5 px-1.5 outline-none min-w-[80px] max-w-[160px] focus:border-[var(--input-border-focus)]">
|
||||||
|
<option v-for="z in zodiacs" :key="z" :value="z">{{ z }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||||
|
<div><div class="text-xs font-medium text-[var(--text-muted)]">知识关键字</div><div class="text-[10px] text-[var(--text-weak)] mt-px">AI 将根据关键字生成知识小卡片</div></div>
|
||||||
|
<input type="text" v-model="s.knowledgeKeyword" @input="debounced(go.saveKnowledgeKeyword, s.knowledgeKeyword)"
|
||||||
|
placeholder="如: 历史、科学、冷知识"
|
||||||
|
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-1 px-2 outline-none w-[140px] focus:border-[var(--input-border-focus)]">
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||||
|
<div><div class="text-xs font-medium text-[var(--text-muted)]">知识提示词</div><div class="text-[10px] text-[var(--text-weak)] mt-px">补充风格或方向,系统会保证内容密度</div></div>
|
||||||
|
<input type="text" v-model="s.knowledgePrompt" @input="debounced(go.saveKnowledgePrompt, s.knowledgePrompt)"
|
||||||
|
placeholder="如: 偏实践、给出判断标准"
|
||||||
|
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-1 px-2 outline-none w-[140px] focus:border-[var(--input-border-focus)]">
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)] relative">
|
||||||
|
<div class="text-xs font-medium text-[var(--text-muted)]">天气城市</div>
|
||||||
|
<div class="city-picker" :class="{ open: cityOpen }" tabindex="0" @click="cityOpen = !cityOpen">
|
||||||
|
<span class="text-[var(--text)] text-[11px]">{{ cityDisplay }}</span>
|
||||||
|
<span class="absolute right-1.5 top-1/2 -translate-y-1/2 text-[var(--text-weak)] text-[10px] pointer-events-none">▾</span>
|
||||||
|
<div class="city-panel">
|
||||||
|
<div class="city-col">
|
||||||
|
<div v-for="p in s.provinces" :key="p" :class="{ active: p === activeProv }" @click.stop="activeProv = p">{{ p }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="city-col">
|
||||||
|
<div v-for="c in currentCities" :key="c.id" :class="{ active: c.id === s.city }" @click.stop="onCitySelect(c)">{{ c.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { go } from '../composables/useGoBridge'
|
||||||
|
import { debounced } from '../composables/useDebounce'
|
||||||
|
import type { SettingsData } from '@shared/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ s: SettingsData }>()
|
||||||
|
const zodiacs = ['白羊座','金牛座','双子座','巨蟹座','狮子座','处女座','天秤座','天蝎座','射手座','摩羯座','水瓶座','双鱼座']
|
||||||
|
const cityOpen = ref(false)
|
||||||
|
const activeProv = ref('')
|
||||||
|
const selectedCityName = ref('')
|
||||||
|
|
||||||
|
const currentCities = computed(() => {
|
||||||
|
if (!activeProv.value) return []
|
||||||
|
return (props.s.citiesByProv as any)[activeProv.value] || []
|
||||||
|
})
|
||||||
|
|
||||||
|
const cityDisplay = computed(() => {
|
||||||
|
if (activeProv.value && selectedCityName.value) return `${activeProv.value} · ${selectedCityName.value}`
|
||||||
|
return '未选择'
|
||||||
|
})
|
||||||
|
|
||||||
|
function onCitySelect(c: { id: string; name: string }) {
|
||||||
|
props.s.city = c.id
|
||||||
|
selectedCityName.value = c.name
|
||||||
|
cityOpen.value = false
|
||||||
|
go.saveCity(c.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocClick(e: MouseEvent) {
|
||||||
|
const picker = document.querySelector('.city-picker')
|
||||||
|
if (picker && !picker.contains(e.target as Node)) cityOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', onDocClick)
|
||||||
|
// Find initial city
|
||||||
|
for (const p of Object.keys(props.s.citiesByProv)) {
|
||||||
|
for (const c of (props.s.citiesByProv as any)[p]) {
|
||||||
|
if (c.id === props.s.city) { activeProv.value = p; selectedCityName.value = c.name; break }
|
||||||
|
}
|
||||||
|
if (activeProv.value) break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onUnmounted(() => document.removeEventListener('click', onDocClick))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.city-picker {
|
||||||
|
position: relative; background: var(--input-bg); border: 1px solid var(--input-border);
|
||||||
|
border-radius: 6px; padding: 4px 24px 4px 8px; cursor: pointer;
|
||||||
|
font-size: 11px; min-width: 140px; max-width: 180px;
|
||||||
|
}
|
||||||
|
.city-picker:focus, .city-picker.open { border-color: var(--input-border-focus); }
|
||||||
|
.city-panel {
|
||||||
|
display: none; position: absolute; right: -1px; bottom: calc(100% + 4px);
|
||||||
|
background: var(--option-bg); border: 1px solid var(--input-border);
|
||||||
|
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.25);
|
||||||
|
z-index: 1000; width: 260px; height: 200px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.city-picker.open .city-panel { display: flex; }
|
||||||
|
.city-col { flex: 1; overflow-y: auto; border-right: 1px solid var(--card-divider); }
|
||||||
|
.city-col:last-child { border-right: none; }
|
||||||
|
.city-col div {
|
||||||
|
padding: 5px 10px; font-size: 11px; color: var(--text); cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.city-col div:hover { background: var(--input-bg); }
|
||||||
|
.city-col div.active { color: var(--accent); font-weight: 600; }
|
||||||
|
</style>
|
||||||
61
web-ui/src/settings/components/PhotoSection.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="text-[10px] font-semibold text-[var(--text-weak)] uppercase tracking-[1.5px] mb-1 pl-0.5">相册</div>
|
||||||
|
<div class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg">
|
||||||
|
<div class="flex justify-between items-center px-3.5 py-2">
|
||||||
|
<div><div class="text-xs font-medium text-[var(--text-muted)]">相册展示</div></div>
|
||||||
|
<ToggleSwitch v-model="s.photoCard" />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||||
|
<div><div class="text-xs font-medium text-[var(--text-muted)]">电子相册模式</div><div class="text-[10px] text-[var(--text-weak)] mt-px">照片铺满整个壁纸</div></div>
|
||||||
|
<ToggleSwitch v-model="s.photoFrameMode" />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||||
|
<div class="text-[10px] text-[var(--text-weak)]">{{ s.photoDir || '未选择目录' }}</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button class="btn" @click="onPickDir">选择目录</button>
|
||||||
|
<button v-if="s.photoDir" class="btn-sm" @click="onClearDir">清除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||||
|
<div class="text-xs font-medium text-[var(--text-muted)]">切换间隔</div>
|
||||||
|
<select v-model="s.photoInterval" @change="go.savePhotoInterval(Number(s.photoInterval))"
|
||||||
|
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-0.5 px-1.5 outline-none min-w-[80px] max-w-[160px] focus:border-[var(--input-border-focus)]">
|
||||||
|
<option v-for="v in [5,10,15,20,30,60]" :key="v" :value="v">{{ v }} 秒</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { go } from '../composables/useGoBridge'
|
||||||
|
import ToggleSwitch from './ToggleSwitch.vue'
|
||||||
|
import type { SettingsData } from '@shared/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ s: SettingsData }>()
|
||||||
|
|
||||||
|
async function onPickDir() {
|
||||||
|
const dir = await go.pickPhotoDir()
|
||||||
|
if (dir) props.s.photoDir = dir
|
||||||
|
}
|
||||||
|
async function onClearDir() {
|
||||||
|
await go.clearPhotoDir()
|
||||||
|
props.s.photoDir = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.btn {
|
||||||
|
background: var(--input-bg); border: 1px solid var(--input-border);
|
||||||
|
border-radius: 6px; color: var(--text); font-size: 11px; padding: 4px 10px;
|
||||||
|
font-family: inherit; cursor: pointer; transition: background 0.15s; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--card-border); }
|
||||||
|
.btn-sm {
|
||||||
|
background: var(--input-bg); border: 1px solid var(--input-border);
|
||||||
|
border-radius: 6px; color: var(--text); font-size: 10px; padding: 3px 8px;
|
||||||
|
font-family: inherit; cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-sm:hover { background: var(--card-border); }
|
||||||
|
</style>
|
||||||
47
web-ui/src/settings/components/ToggleSection.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="text-[10px] font-semibold text-[var(--text-weak)] uppercase tracking-[1.5px] mb-1 pl-0.5">显示控制</div>
|
||||||
|
<div class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg">
|
||||||
|
<div v-for="t in toggles" :key="t.key"
|
||||||
|
class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)] first:border-t-0"
|
||||||
|
:class="{ 'pl-8': t.sub }">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-medium text-[var(--text-muted)]">{{ t.label }}</div>
|
||||||
|
<div v-if="t.desc" class="text-[10px] text-[var(--text-weak)] mt-px">{{ t.desc }}</div>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch v-model="(s as any)[t.key]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { watch } from 'vue'
|
||||||
|
import ToggleSwitch from './ToggleSwitch.vue'
|
||||||
|
import { go } from '../composables/useGoBridge'
|
||||||
|
import type { SettingsData } from '@shared/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ s: SettingsData }>()
|
||||||
|
|
||||||
|
const toggles: { key: keyof SettingsData; label: string; desc?: string; sub?: boolean }[] = [
|
||||||
|
{ key: 'autoStart', label: '开机启动', desc: '系统启动时自动运行' },
|
||||||
|
{ key: 'wallpaper', label: '显示壁纸' },
|
||||||
|
{ key: 'time', label: '时间日期' },
|
||||||
|
{ key: 'showSeconds', label: '显示秒', sub: true },
|
||||||
|
{ key: 'weather', label: '天气信息' },
|
||||||
|
{ key: 'zodiacCard', label: '星座运势' },
|
||||||
|
{ key: 'knowledgeCard', label: '知识卡片' },
|
||||||
|
{ key: 'ainewsCard', label: 'AI 资讯' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const toggleKeys = toggles.map(t => t.key)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => toggleKeys.map(k => (props.s as any)[k]),
|
||||||
|
() => {
|
||||||
|
const data: Record<string, boolean> = {}
|
||||||
|
for (const k of toggleKeys) data[k] = (props.s as any)[k]
|
||||||
|
go.saveToggles(data)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
28
web-ui/src/settings/components/ToggleSwitch.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" :checked="modelValue" @change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)">
|
||||||
|
<span class="track"><span class="thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ modelValue: boolean }>()
|
||||||
|
defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; }
|
||||||
|
.switch input { display: none; }
|
||||||
|
.switch .track {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: var(--toggle-track);
|
||||||
|
border-radius: 10px; cursor: pointer; transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.switch .thumb {
|
||||||
|
position: absolute; width: 14px; height: 14px; top: 3px; left: 3px;
|
||||||
|
background: var(--toggle-thumb);
|
||||||
|
border-radius: 50%; transition: all 0.2s; pointer-events: none;
|
||||||
|
}
|
||||||
|
.switch input:checked + .track { background: var(--accent); }
|
||||||
|
.switch input:checked + .track .thumb { transform: translateX(16px); background: var(--text-strong); }
|
||||||
|
</style>
|
||||||
198
web-ui/src/settings/components/WallpaperSection.vue
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="text-[10px] font-semibold text-[var(--text-weak)] uppercase tracking-[1.5px] mb-1 pl-0.5">壁纸</div>
|
||||||
|
<div class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg">
|
||||||
|
<!-- 类型选择 -->
|
||||||
|
<div class="flex justify-between items-center px-3.5 py-2">
|
||||||
|
<div class="text-xs font-medium text-[var(--text-muted)]">类型选择</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button v-for="t in wpTypes" :key="t.value" @click="$emit('set-wp-type', t.value)"
|
||||||
|
:class="s.wallpaperType === t.value
|
||||||
|
? 'bg-[var(--accent)] border-[var(--accent)] text-[var(--text-strong)]'
|
||||||
|
: 'bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--text)] hover:bg-[var(--card-border)]'"
|
||||||
|
class="border rounded-md text-[11px] py-1 px-2.5 font-[inherit] cursor-pointer whitespace-nowrap transition-colors">{{ t.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主题 -->
|
||||||
|
<div v-if="s.wallpaperType === 'theme'" class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg mt-1.5">
|
||||||
|
<div class="flex justify-between items-center px-3.5 py-2">
|
||||||
|
<div class="text-xs font-medium text-[var(--text-muted)]">选择主题</div>
|
||||||
|
<select v-model="s.theme" @change="go.saveWallpaperType('theme', s.theme)"
|
||||||
|
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-0.5 px-1.5 outline-none min-w-[80px] max-w-[160px] focus:border-[var(--input-border-focus)]">
|
||||||
|
<option v-for="t in s.themes" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="s.theme === 'text'" class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||||
|
<div class="text-xs font-medium text-[var(--text-muted)]">自定义文字</div>
|
||||||
|
<input type="text" v-model="s.wallpaperText" @input="debounced(go.saveWallpaperText, s.wallpaperText)"
|
||||||
|
placeholder="输入显示文字"
|
||||||
|
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-1 px-2 outline-none w-[140px] focus:border-[var(--input-border-focus)]">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 本地图片 -->
|
||||||
|
<div v-if="s.wallpaperType === 'image'" class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg mt-1.5">
|
||||||
|
<div class="flex justify-between items-center px-3.5 py-2">
|
||||||
|
<div class="text-[10px] text-[var(--text-weak)] truncate mr-2">{{ s.imagePath || '未选择图片' }}</div>
|
||||||
|
<button class="btn" @click="onPickImage">选择图片</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bing -->
|
||||||
|
<div v-if="s.wallpaperType === 'bing'" class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg mt-1.5">
|
||||||
|
<div class="flex justify-between items-center px-3.5 py-2">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<img v-if="bing.url" :src="bingThumb" class="w-16 h-10 object-cover rounded flex-shrink-0">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-xs font-medium text-[var(--text-muted)] truncate">{{ bing.copyright || 'Bing 每日壁纸' }}</div>
|
||||||
|
<div class="text-[10px] text-[var(--text-weak)]">{{ bing.idx > 0 ? `${bing.idx} / ${bing.total}` : '' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 flex-shrink-0">
|
||||||
|
<button class="btn-sm" @click="onBingPrev">◀</button>
|
||||||
|
<button class="btn-sm" @click="onBingNext">▶</button>
|
||||||
|
<button class="btn-sm" @click="onBingFav">{{ bing.fav ? '★' : '☆' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||||
|
<div><div class="text-xs font-medium text-[var(--text-muted)]">定时切换</div><div class="text-[10px] text-[var(--text-weak)] mt-px">每小时自动切换壁纸</div></div>
|
||||||
|
<ToggleSwitch v-model="s.bingAutoRefresh" />
|
||||||
|
</div>
|
||||||
|
<!-- 收藏列表 -->
|
||||||
|
<div v-if="bingFavs.length" class="px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||||
|
<div class="text-[10px] text-[var(--text-weak)] mb-1.5">收藏列表</div>
|
||||||
|
<div class="grid grid-cols-4 gap-1.5">
|
||||||
|
<div v-for="(f, i) in bingFavs" :key="i" class="cursor-pointer rounded overflow-hidden hover:opacity-80" @click="onBingSetByIdx(f.idx)">
|
||||||
|
<img :src="f.thumb" class="w-full h-10 object-cover rounded">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 纯色/渐变 -->
|
||||||
|
<div v-if="s.wallpaperType === 'color'" class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg mt-1.5">
|
||||||
|
<div class="flex justify-between items-center px-3.5 py-2">
|
||||||
|
<div class="text-xs font-medium text-[var(--text-muted)]">选择颜色</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button class="btn" @click="$emit('pick-solid')">纯色</button>
|
||||||
|
<button class="btn" @click="$emit('pick-gradient')">渐变</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="currentColor.c1" class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="inline-block w-6 h-4 rounded-sm border border-[var(--input-border)]" :style="swatchStyle"></span>
|
||||||
|
<span class="text-[10px] text-[var(--text-weak)]">当前颜色</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn" @click="$emit('save-color')">收藏</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 已收藏颜色 -->
|
||||||
|
<div v-if="s.savedColors.length && s.wallpaperType === 'color'" class="flex flex-wrap gap-1.5 mt-1.5 px-0.5">
|
||||||
|
<div v-for="(c, i) in s.savedColors" :key="i" class="color-swatch" :style="swatchOf(c)"
|
||||||
|
@click="$emit('apply-color', i)" @contextmenu.prevent="$emit('remove-color', i)">
|
||||||
|
<span class="del">×</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { go } from '../composables/useGoBridge'
|
||||||
|
import { debounced } from '../composables/useDebounce'
|
||||||
|
import ToggleSwitch from './ToggleSwitch.vue'
|
||||||
|
import type { SettingsData, BingState } from '@shared/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
s: SettingsData
|
||||||
|
currentColor: { c1: string; c2: string; gradient: boolean }
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'set-wp-type': [type: string]
|
||||||
|
'pick-solid': []
|
||||||
|
'pick-gradient': []
|
||||||
|
'save-color': []
|
||||||
|
'apply-color': [idx: number]
|
||||||
|
'remove-color': [idx: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const wpTypes = [
|
||||||
|
{ value: 'theme', label: '主题' },
|
||||||
|
{ value: 'image', label: '本地图片' },
|
||||||
|
{ value: 'bing', label: 'Bing' },
|
||||||
|
{ value: 'color', label: '纯色/渐变' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Bing
|
||||||
|
const bing = ref<BingState>({ date: '', title: '', copyright: '', url: '', fav: false, idx: 0, total: 0 })
|
||||||
|
const bingThumb = ref('')
|
||||||
|
const bingFavs = ref<{ idx: number; thumb: string }[]>([])
|
||||||
|
|
||||||
|
async function loadBingInfo() {
|
||||||
|
const raw = await go.getBingInfo()
|
||||||
|
const st = JSON.parse(raw) as BingState
|
||||||
|
bing.value = st
|
||||||
|
if (st.url) bingThumb.value = st.url.replace('_1920x1080', '_400x240')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onBingPrev() { bing.value = JSON.parse(await go.bingPrev()) }
|
||||||
|
async function onBingNext() { bing.value = JSON.parse(await go.bingNext()) }
|
||||||
|
async function onBingFav() { bing.value = JSON.parse(await go.bingToggleFavorite()); await loadBingFavs() }
|
||||||
|
async function onBingSetByIdx(idx: number) { bing.value = JSON.parse(await go.bingSetByIdx(idx)) }
|
||||||
|
|
||||||
|
async function loadBingFavs() {
|
||||||
|
const raw = await go.getBingFavorites()
|
||||||
|
const list = JSON.parse(raw) as string[]
|
||||||
|
bingFavs.value = await Promise.all(list.map(async f => ({
|
||||||
|
idx: parseInt(f.match(/(\d+)/)?.[1] || '0', 10),
|
||||||
|
thumb: await go.bingThumbDataURI(f)
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.s.bingAutoRefresh, v => go.saveBingAutoRefresh(v))
|
||||||
|
watch(() => props.s.wallpaperType, t => {
|
||||||
|
if (t === 'bing') { loadBingInfo(); loadBingFavs() }
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
async function onPickImage() {
|
||||||
|
const p = await go.pickLocalImage()
|
||||||
|
if (p) props.s.imagePath = p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color swatches
|
||||||
|
function gradientBg(c1: string, c2: string, gradient: boolean) {
|
||||||
|
return { background: gradient && c2 ? `linear-gradient(135deg,${c1},${c2})` : c1 }
|
||||||
|
}
|
||||||
|
const swatchStyle = computed(() => props.currentColor.c1 ? gradientBg(props.currentColor.c1, props.currentColor.c2, props.currentColor.gradient) : {})
|
||||||
|
function swatchOf(c: { color1: string; color2: string; gradient: boolean }) { return gradientBg(c.color1, c.color2, c.gradient) }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.btn {
|
||||||
|
background: var(--input-bg); border: 1px solid var(--input-border);
|
||||||
|
border-radius: 6px; color: var(--text); font-size: 11px; padding: 4px 10px;
|
||||||
|
font-family: inherit; cursor: pointer; transition: background 0.15s; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--card-border); }
|
||||||
|
.btn-sm {
|
||||||
|
background: var(--input-bg); border: 1px solid var(--input-border);
|
||||||
|
border-radius: 6px; color: var(--text); font-size: 10px; padding: 3px 8px;
|
||||||
|
font-family: inherit; cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-sm:hover { background: var(--card-border); }
|
||||||
|
.color-swatch {
|
||||||
|
width: 28px; height: 28px; border-radius: 6px; cursor: pointer;
|
||||||
|
border: 2px solid transparent; transition: border-color 0.15s; position: relative;
|
||||||
|
}
|
||||||
|
.color-swatch:hover { border-color: var(--accent); }
|
||||||
|
.color-swatch .del {
|
||||||
|
display: none; position: absolute; top: -6px; right: -6px;
|
||||||
|
width: 14px; height: 14px; border-radius: 50%;
|
||||||
|
background: #e53e3e; color: #fff; font-size: 9px; line-height: 14px;
|
||||||
|
text-align: center; cursor: pointer;
|
||||||
|
}
|
||||||
|
.color-swatch:hover .del { display: block; }
|
||||||
|
</style>
|
||||||
5
web-ui/src/settings/composables/useDebounce.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
export function debounced(fn: (v: string) => void, val: string, ms = 500) {
|
||||||
|
clearTimeout(timers.get(fn.name))
|
||||||
|
timers.set(fn.name, setTimeout(() => fn(val), ms))
|
||||||
|
}
|
||||||
32
web-ui/src/settings/composables/useGoBridge.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
const w = window as any
|
||||||
|
|
||||||
|
export const go = {
|
||||||
|
loadAllSettings: (): Promise<string> => w.loadAllSettings(),
|
||||||
|
saveToggles: (data: Record<string, boolean>) => w.saveToggles(JSON.stringify(data)),
|
||||||
|
saveLayout: (layout: string) => w.saveLayout(layout),
|
||||||
|
saveZodiac: (zodiac: string) => w.saveZodiac(zodiac),
|
||||||
|
saveCity: (cityId: string) => w.saveCity(cityId),
|
||||||
|
saveWallpaperType: (type: string, theme: string) => w.saveWallpaperType(type, theme),
|
||||||
|
pickLocalImage: (): Promise<string> => w.pickLocalImage(),
|
||||||
|
enableBing: () => w.enableBing(),
|
||||||
|
bingPrev: (): Promise<string> => w.bingPrev(),
|
||||||
|
bingNext: (): Promise<string> => w.bingNext(),
|
||||||
|
bingToggleFavorite: (): Promise<string> => w.bingToggleFavorite(),
|
||||||
|
getBingInfo: (): Promise<string> => w.getBingInfo(),
|
||||||
|
saveBingAutoRefresh: (val: boolean) => w.saveBingAutoRefresh(val),
|
||||||
|
getBingFavorites: (): Promise<string> => w.getBingFavorites(),
|
||||||
|
bingSetByIdx: (idx: number): Promise<string> => w.bingSetByIdx(idx),
|
||||||
|
bingThumbDataURI: (filename: string): Promise<string> => w.bingThumbDataURI(filename),
|
||||||
|
pickSolidColor: (): Promise<string> => w.pickSolidColor(),
|
||||||
|
pickGradientColor: (): Promise<string> => w.pickGradientColor(),
|
||||||
|
addSavedColor: (c1: string, c2: string, gradient: boolean) => w.addSavedColor(c1, c2, gradient),
|
||||||
|
removeSavedColor: (idx: number): Promise<string> => w.removeSavedColor(idx),
|
||||||
|
applySavedColor: (idx: number) => w.applySavedColor(idx),
|
||||||
|
saveWallpaperText: (text: string) => w.saveWallpaperText(text),
|
||||||
|
saveKnowledgeKeyword: (keyword: string) => w.saveKnowledgeKeyword(keyword),
|
||||||
|
saveKnowledgePrompt: (prompt: string) => w.saveKnowledgePrompt(prompt),
|
||||||
|
pickPhotoDir: (): Promise<string> => w.pickPhotoDir(),
|
||||||
|
clearPhotoDir: () => w.clearPhotoDir(),
|
||||||
|
savePhotoInterval: (val: number) => w.savePhotoInterval(val),
|
||||||
|
resizeToFit: (w: number, h: number) => w.resizeToFit(w, h),
|
||||||
|
}
|
||||||
6
web-ui/src/settings/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import '../tailwind.css'
|
||||||
|
import './settings.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
48
web-ui/src/settings/settings.css
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f0f1a;
|
||||||
|
--card-bg: rgba(255,255,255,0.04);
|
||||||
|
--card-border: rgba(255,255,255,0.06);
|
||||||
|
--card-divider: rgba(255,255,255,0.04);
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--text-strong: #fff;
|
||||||
|
--text-weak: rgba(255,255,255,0.25);
|
||||||
|
--text-muted: rgba(255,255,255,0.5);
|
||||||
|
--input-bg: rgba(255,255,255,0.08);
|
||||||
|
--input-border: rgba(255,255,255,0.1);
|
||||||
|
--input-border-focus: #4f8cff;
|
||||||
|
--accent: #4f8cff;
|
||||||
|
--toggle-track: rgba(255,255,255,0.1);
|
||||||
|
--toggle-thumb: rgba(255,255,255,0.5);
|
||||||
|
--option-bg: #1a1a2e;
|
||||||
|
--footer-color: rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
.light {
|
||||||
|
--bg: #f5f5f5;
|
||||||
|
--card-bg: rgba(0,0,0,0.03);
|
||||||
|
--card-border: rgba(0,0,0,0.08);
|
||||||
|
--card-divider: rgba(0,0,0,0.05);
|
||||||
|
--text: #333;
|
||||||
|
--text-strong: #111;
|
||||||
|
--text-weak: rgba(0,0,0,0.35);
|
||||||
|
--text-muted: rgba(0,0,0,0.55);
|
||||||
|
--input-bg: rgba(0,0,0,0.04);
|
||||||
|
--input-border: rgba(0,0,0,0.12);
|
||||||
|
--input-border-focus: #2b6cb0;
|
||||||
|
--accent: #2b6cb0;
|
||||||
|
--toggle-track: rgba(0,0,0,0.12);
|
||||||
|
--toggle-thumb: rgba(0,0,0,0.45);
|
||||||
|
--option-bg: #fff;
|
||||||
|
--footer-color: rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||||
|
background: var(--bg); color: var(--text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.22); }
|
||||||
|
select option { background: var(--option-bg); color: var(--text); }
|
||||||
|
.light ::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); }
|
||||||
|
.light ::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.22); }
|
||||||
94
web-ui/src/shared/types.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
export interface WeatherData {
|
||||||
|
current: string
|
||||||
|
hourly: HourlyItem[]
|
||||||
|
daily: DailyItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HourlyItem {
|
||||||
|
time: string
|
||||||
|
icon: string
|
||||||
|
temp: string
|
||||||
|
pop?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyItem {
|
||||||
|
date: string
|
||||||
|
icon: string
|
||||||
|
tempMin: string
|
||||||
|
tempMax: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HoroscopeData {
|
||||||
|
zodiac: string
|
||||||
|
all: string
|
||||||
|
love: string
|
||||||
|
work: string
|
||||||
|
money: string
|
||||||
|
health: string
|
||||||
|
luckyColor: string
|
||||||
|
luckyNum: string
|
||||||
|
noble: string
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AINewsItem {
|
||||||
|
title: string
|
||||||
|
source: string
|
||||||
|
ctime: string
|
||||||
|
description: string
|
||||||
|
picUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeData {
|
||||||
|
content: string
|
||||||
|
keyword: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhotoData {
|
||||||
|
src: string
|
||||||
|
counter: string
|
||||||
|
interval: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsData {
|
||||||
|
lightTheme: boolean
|
||||||
|
wallpaper: boolean
|
||||||
|
time: boolean
|
||||||
|
weather: boolean
|
||||||
|
zodiacCard: boolean
|
||||||
|
knowledgeCard: boolean
|
||||||
|
layout: string
|
||||||
|
zodiac: string
|
||||||
|
city: string
|
||||||
|
provinces: string[]
|
||||||
|
citiesByProv: Record<string, { id: string; name: string }[]>
|
||||||
|
wallpaperType: string
|
||||||
|
theme: string
|
||||||
|
themes: { value: string; label: string }[]
|
||||||
|
color1: string
|
||||||
|
color2: string
|
||||||
|
colorGradient: boolean
|
||||||
|
savedColors: { color1: string; color2: string; gradient: boolean }[]
|
||||||
|
bingAutoRefresh: boolean
|
||||||
|
knowledgeKeyword: string
|
||||||
|
knowledgePrompt: string
|
||||||
|
wallpaperText: string
|
||||||
|
imagePath: string
|
||||||
|
showSeconds: boolean
|
||||||
|
ainewsCard: boolean
|
||||||
|
photoDir: string
|
||||||
|
photoInterval: number
|
||||||
|
photoCard: boolean
|
||||||
|
photoFrameMode: boolean
|
||||||
|
autoStart: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BingState {
|
||||||
|
date: string
|
||||||
|
title: string
|
||||||
|
copyright: string
|
||||||
|
url: string
|
||||||
|
fav: boolean
|
||||||
|
idx: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
4
web-ui/src/shared/utils.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export function safeImageURL(value: unknown): string {
|
||||||
|
const url = String(value || '').trim()
|
||||||
|
return /^(https?:|data:image\/)/i.test(url) ? url : ''
|
||||||
|
}
|
||||||
3
web-ui/src/tailwind.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
15
web-ui/tailwind.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./src/**/*.{vue,ts}', './*.html'],
|
||||||
|
corePlugins: {
|
||||||
|
preflight: false,
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['"Segoe UI"', '"Microsoft YaHei"', 'sans-serif'],
|
||||||
|
display: ['-apple-system', '"SF Pro Display"', '"Segoe UI"', 'sans-serif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
20
web-ui/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"paths": {
|
||||||
|
"@shared/*": ["./src/shared/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
31
web-ui/vite.config.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { viteSingleFile } from 'vite-plugin-singlefile'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => ({
|
||||||
|
plugins: [vue(), viteSingleFile()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@shared': resolve(__dirname, 'src/shared'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: resolve(__dirname, '../web'),
|
||||||
|
emptyOutDir: false,
|
||||||
|
cssCodeSplit: false,
|
||||||
|
assetsInlineLimit: 10_000_000,
|
||||||
|
rollupOptions: {
|
||||||
|
input: mode === 'overlay'
|
||||||
|
? resolve(__dirname, 'overlay.html')
|
||||||
|
: resolve(__dirname, 'settings.html'),
|
||||||
|
output: {
|
||||||
|
inlineDynamicImports: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
target: 'esnext',
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
postcss: './postcss.config.js',
|
||||||
|
},
|
||||||
|
}))
|
||||||
147
web/overlay.html
28
web/settings.html
Normal file
161
web/themes/fractal.html
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<canvas id="c" style="position:fixed;top:0;left:0;width:100%;height:100%;z-index:1;"></canvas>
|
||||||
|
<script>
|
||||||
|
var canvas = document.getElementById('c');
|
||||||
|
var gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||||
|
|
||||||
|
var mouseX = 0, mouseY = 0, lastMove = 0;
|
||||||
|
var clickX = 0, clickY = 0, clickTime = 0;
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', function(e) {
|
||||||
|
mouseX = e.clientX / window.innerWidth;
|
||||||
|
mouseY = 1.0 - e.clientY / window.innerHeight;
|
||||||
|
lastMove = performance.now();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
clickX = e.clientX / window.innerWidth;
|
||||||
|
clickY = 1.0 - e.clientY / window.innerHeight;
|
||||||
|
clickTime = performance.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
var vsSrc = 'attribute vec2 a_pos;void main(){gl_Position=vec4(a_pos,0.0,1.0);}';
|
||||||
|
var fsSrc = `
|
||||||
|
precision highp float;
|
||||||
|
uniform float u_time;
|
||||||
|
uniform vec2 u_resolution;
|
||||||
|
uniform vec2 u_mouse;
|
||||||
|
uniform float u_click;
|
||||||
|
uniform vec2 u_clickPos;
|
||||||
|
|
||||||
|
vec3 mod289(vec3 x){return x-floor(x*(1.0/289.0))*289.0;}
|
||||||
|
vec2 mod289(vec2 x){return x-floor(x*(1.0/289.0))*289.0;}
|
||||||
|
vec3 permute(vec3 x){return mod289(((x*34.0)+1.0)*x);}
|
||||||
|
|
||||||
|
float snoise(vec2 v){
|
||||||
|
const vec4 C=vec4(0.211324865405187,0.366025403784439,-0.577350269189626,0.024390243902439);
|
||||||
|
vec2 i=floor(v+dot(v,C.yy));
|
||||||
|
vec2 x0=v-i+dot(i,C.xx);
|
||||||
|
vec2 i1=(x0.x>x0.y)?vec2(1.0,0.0):vec2(0.0,1.0);
|
||||||
|
vec4 x12=x0.xyxy+C.xxzz;
|
||||||
|
x12.xy-=i1;
|
||||||
|
i=mod289(i);
|
||||||
|
vec3 p=permute(permute(i.y+vec3(0.0,i1.y,1.0))+i.x+vec3(0.0,i1.x,1.0));
|
||||||
|
vec3 m=max(0.5-vec3(dot(x0,x0),dot(x12.xy,x12.xy),dot(x12.zw,x12.zw)),0.0);
|
||||||
|
m=m*m;m=m*m;
|
||||||
|
vec3 x=2.0*fract(p*C.www)-1.0;
|
||||||
|
vec3 h=abs(x)-0.5;
|
||||||
|
vec3 ox=floor(x+0.5);
|
||||||
|
vec3 a0=x-ox;
|
||||||
|
m*=1.79284291400159-0.85373472095314*(a0*a0+h*h);
|
||||||
|
vec3 g;
|
||||||
|
g.x=a0.x*x0.x+h.x*x0.y;
|
||||||
|
g.yz=a0.yz*x12.xz+h.yz*x12.yw;
|
||||||
|
return 130.0*dot(m,g);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main(){
|
||||||
|
vec2 uv=gl_FragCoord.xy/u_resolution;
|
||||||
|
vec2 p=uv*2.0-1.0;
|
||||||
|
p.x*=u_resolution.x/u_resolution.y;
|
||||||
|
float t=u_time*0.3;
|
||||||
|
|
||||||
|
vec2 mp=u_mouse*2.0-1.0;
|
||||||
|
mp.x*=u_resolution.x/u_resolution.y;
|
||||||
|
float mouseDist=length(p-mp);
|
||||||
|
float mouseInfluence=smoothstep(0.8,0.0,mouseDist)*0.3;
|
||||||
|
|
||||||
|
float ripple=0.0;
|
||||||
|
if(u_click>0.0){
|
||||||
|
vec2 cp=u_clickPos*2.0-1.0;
|
||||||
|
cp.x*=u_resolution.x/u_resolution.y;
|
||||||
|
float d=length(p-cp);
|
||||||
|
ripple=sin(d*30.0-u_click*8.0)*exp(-d*3.0)*exp(-u_click*2.0)*0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
float n1=snoise(p*1.5+vec2(t*0.5,t*0.3))*0.5+0.5;
|
||||||
|
float n2=snoise(p*3.0+vec2(-t*0.7,t*0.5))*0.5+0.5;
|
||||||
|
float n3=snoise(p*0.8+vec2(t*0.2,-t*0.4)+mouseInfluence)*0.5+0.5;
|
||||||
|
|
||||||
|
vec3 c1=vec3(0.05,0.2,0.4);
|
||||||
|
vec3 c2=vec3(0.0,0.8,0.5);
|
||||||
|
vec3 c3=vec3(0.3,0.1,0.6);
|
||||||
|
vec3 c4=vec3(0.1,0.5,0.9);
|
||||||
|
|
||||||
|
float aurora=smoothstep(0.3,0.7,n3)*smoothstep(0.8,0.4,n1);
|
||||||
|
vec3 auroraColor=mix(c2,c4,n2)*aurora*1.2;
|
||||||
|
vec3 bg=mix(c1,c3,uv.y*0.5+n1*0.3);
|
||||||
|
bg+=auroraColor;
|
||||||
|
|
||||||
|
float stars=pow(snoise(p*20.0),12.0)*0.8;
|
||||||
|
bg+=vec3(stars);
|
||||||
|
bg+=vec3(ripple*2.0,ripple*3.0,ripple*4.0);
|
||||||
|
bg+=vec3(0.1,0.3,0.5)*mouseInfluence;
|
||||||
|
|
||||||
|
float vignette=1.0-smoothstep(0.5,1.5,length(p*0.7));
|
||||||
|
bg*=vignette*0.9+0.1;
|
||||||
|
|
||||||
|
gl_FragColor=vec4(bg,1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function mkShader(type,src){
|
||||||
|
var s=gl.createShader(type);
|
||||||
|
gl.shaderSource(s,src);
|
||||||
|
gl.compileShader(s);
|
||||||
|
if(!gl.getShaderParameter(s,gl.COMPILE_STATUS)){gl.deleteShader(s);return null;}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
var vs=mkShader(gl.VERTEX_SHADER,vsSrc);
|
||||||
|
var fs=mkShader(gl.FRAGMENT_SHADER,fsSrc);
|
||||||
|
var prog=gl.createProgram();
|
||||||
|
gl.attachShader(prog,vs);
|
||||||
|
gl.attachShader(prog,fs);
|
||||||
|
gl.linkProgram(prog);
|
||||||
|
gl.useProgram(prog);
|
||||||
|
|
||||||
|
var buf=gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER,buf);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);
|
||||||
|
var aPos=gl.getAttribLocation(prog,'a_pos');
|
||||||
|
gl.enableVertexAttribArray(aPos);
|
||||||
|
gl.vertexAttribPointer(aPos,2,gl.FLOAT,false,0,0);
|
||||||
|
|
||||||
|
var uTime=gl.getUniformLocation(prog,'u_time');
|
||||||
|
var uRes=gl.getUniformLocation(prog,'u_resolution');
|
||||||
|
var uMouse=gl.getUniformLocation(prog,'u_mouse');
|
||||||
|
var uClick=gl.getUniformLocation(prog,'u_click');
|
||||||
|
var uClickPos=gl.getUniformLocation(prog,'u_clickPos');
|
||||||
|
|
||||||
|
function resize(){
|
||||||
|
var dpr=window.devicePixelRatio||1;
|
||||||
|
canvas.width=window.innerWidth*dpr;
|
||||||
|
canvas.height=window.innerHeight*dpr;
|
||||||
|
gl.viewport(0,0,canvas.width,canvas.height);
|
||||||
|
}
|
||||||
|
window.addEventListener('resize',resize);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
var lastFrame=0, targetFPS=30;
|
||||||
|
|
||||||
|
function render(now){
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
if(window._paused||window._fullscreen)return;
|
||||||
|
|
||||||
|
var interval=1000/targetFPS;
|
||||||
|
if(now-lastFrame<interval)return;
|
||||||
|
lastFrame=now;
|
||||||
|
|
||||||
|
if(now-lastMove>5000){targetFPS=10;}else{targetFPS=30;}
|
||||||
|
|
||||||
|
var t=now/1000.0;
|
||||||
|
var clickElapsed=clickTime>0?(now-clickTime)/1000.0:0.0;
|
||||||
|
|
||||||
|
gl.uniform1f(uTime,t);
|
||||||
|
gl.uniform2f(uRes,canvas.width,canvas.height);
|
||||||
|
gl.uniform2f(uMouse,mouseX,mouseY);
|
||||||
|
gl.uniform1f(uClick,clickElapsed>3.0?0.0:clickElapsed);
|
||||||
|
gl.uniform2f(uClickPos,clickX,clickY);
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
</script>
|
||||||
239
web/themes/fractal_src.html
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Dynamic Wallpaper</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; }
|
||||||
|
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
|
||||||
|
canvas { display: block; width: 100vw; height: 100vh; }
|
||||||
|
#info {
|
||||||
|
position: fixed; top: 20px; right: 20px;
|
||||||
|
color: rgba(255,255,255,0.6); font: 14px monospace;
|
||||||
|
pointer-events: none; user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="c"></canvas>
|
||||||
|
<div id="info"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const canvas = document.getElementById('c');
|
||||||
|
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||||
|
if (!gl) { document.body.innerHTML = '<h1 style="color:#fff;text-align:center;margin-top:40vh">WebGL 不可用</h1>'; }
|
||||||
|
|
||||||
|
let paused = false;
|
||||||
|
let fullscreen = false;
|
||||||
|
let mouseX = 0, mouseY = 0;
|
||||||
|
let lastMove = 0;
|
||||||
|
|
||||||
|
// --- 状态控制 (Go Bridge) ---
|
||||||
|
window.setPaused = function(v) { paused = v; };
|
||||||
|
window.setFullscreen = function(v) { fullscreen = v; };
|
||||||
|
|
||||||
|
// --- 鼠标 ---
|
||||||
|
document.addEventListener('mousemove', e => {
|
||||||
|
mouseX = e.clientX / window.innerWidth;
|
||||||
|
mouseY = 1.0 - e.clientY / window.innerHeight;
|
||||||
|
lastMove = performance.now();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
// 点击涟漪效果 — 传给 shader
|
||||||
|
clickX = e.clientX / window.innerWidth;
|
||||||
|
clickY = 1.0 - e.clientY / window.innerHeight;
|
||||||
|
clickTime = performance.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
let clickX = 0, clickY = 0, clickTime = 0;
|
||||||
|
|
||||||
|
// --- Shader ---
|
||||||
|
const vertSrc = `
|
||||||
|
attribute vec2 a_pos;
|
||||||
|
void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 极光 + 流体噪声 shader
|
||||||
|
const fragSrc = `
|
||||||
|
precision highp float;
|
||||||
|
uniform float u_time;
|
||||||
|
uniform vec2 u_resolution;
|
||||||
|
uniform vec2 u_mouse;
|
||||||
|
uniform float u_click;
|
||||||
|
uniform vec2 u_clickPos;
|
||||||
|
|
||||||
|
// simplex-like noise
|
||||||
|
vec3 mod289(vec3 x) { return x - floor(x * (1.0/289.0)) * 289.0; }
|
||||||
|
vec2 mod289(vec2 x) { return x - floor(x * (1.0/289.0)) * 289.0; }
|
||||||
|
vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); }
|
||||||
|
|
||||||
|
float snoise(vec2 v) {
|
||||||
|
const vec4 C = vec4(0.211324865405187, 0.366025403784439,
|
||||||
|
-0.577350269189626, 0.024390243902439);
|
||||||
|
vec2 i = floor(v + dot(v, C.yy));
|
||||||
|
vec2 x0 = v - i + dot(i, C.xx);
|
||||||
|
vec2 i1;
|
||||||
|
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
||||||
|
vec4 x12 = x0.xyxy + C.xxzz;
|
||||||
|
x12.xy -= i1;
|
||||||
|
i = mod289(i);
|
||||||
|
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
|
||||||
|
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
|
||||||
|
m = m*m; m = m*m;
|
||||||
|
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
||||||
|
vec3 h = abs(x) - 0.5;
|
||||||
|
vec3 ox = floor(x + 0.5);
|
||||||
|
vec3 a0 = x - ox;
|
||||||
|
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
|
||||||
|
vec3 g;
|
||||||
|
g.x = a0.x * x0.x + h.x * x0.y;
|
||||||
|
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
|
||||||
|
return 130.0 * dot(m, g);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 uv = gl_FragCoord.xy / u_resolution;
|
||||||
|
vec2 p = uv * 2.0 - 1.0;
|
||||||
|
p.x *= u_resolution.x / u_resolution.y;
|
||||||
|
|
||||||
|
float t = u_time * 0.3;
|
||||||
|
|
||||||
|
// 鼠标影响
|
||||||
|
vec2 mp = u_mouse * 2.0 - 1.0;
|
||||||
|
mp.x *= u_resolution.x / u_resolution.y;
|
||||||
|
float mouseDist = length(p - mp);
|
||||||
|
float mouseInfluence = smoothstep(0.8, 0.0, mouseDist) * 0.3;
|
||||||
|
|
||||||
|
// 点击涟漪
|
||||||
|
float ripple = 0.0;
|
||||||
|
if (u_click > 0.0) {
|
||||||
|
vec2 cp = u_clickPos * 2.0 - 1.0;
|
||||||
|
cp.x *= u_resolution.x / u_resolution.y;
|
||||||
|
float d = length(p - cp);
|
||||||
|
ripple = sin(d * 30.0 - u_click * 8.0) * exp(-d * 3.0) * exp(-u_click * 2.0) * 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多层噪声
|
||||||
|
float n1 = snoise(p * 1.5 + vec2(t * 0.5, t * 0.3)) * 0.5 + 0.5;
|
||||||
|
float n2 = snoise(p * 3.0 + vec2(-t * 0.7, t * 0.5)) * 0.5 + 0.5;
|
||||||
|
float n3 = snoise(p * 0.8 + vec2(t * 0.2, -t * 0.4) + mouseInfluence) * 0.5 + 0.5;
|
||||||
|
|
||||||
|
// 极光色彩
|
||||||
|
vec3 c1 = vec3(0.05, 0.2, 0.4); // 深蓝
|
||||||
|
vec3 c2 = vec3(0.0, 0.8, 0.5); // 翠绿
|
||||||
|
vec3 c3 = vec3(0.3, 0.1, 0.6); // 紫色
|
||||||
|
vec3 c4 = vec3(0.1, 0.5, 0.9); // 天蓝
|
||||||
|
|
||||||
|
// 极光带
|
||||||
|
float aurora = smoothstep(0.3, 0.7, n3) * smoothstep(0.8, 0.4, n1);
|
||||||
|
vec3 auroraColor = mix(c2, c4, n2) * aurora * 1.2;
|
||||||
|
|
||||||
|
// 底层渐变
|
||||||
|
vec3 bg = mix(c1, c3, uv.y * 0.5 + n1 * 0.3);
|
||||||
|
bg += auroraColor;
|
||||||
|
|
||||||
|
// 星星效果
|
||||||
|
float stars = pow(snoise(p * 20.0), 12.0) * 0.8;
|
||||||
|
bg += vec3(stars);
|
||||||
|
|
||||||
|
// 涟漪叠加
|
||||||
|
bg += vec3(ripple * 2.0, ripple * 3.0, ripple * 4.0);
|
||||||
|
|
||||||
|
// 鼠标光晕
|
||||||
|
bg += vec3(0.1, 0.3, 0.5) * mouseInfluence;
|
||||||
|
|
||||||
|
// 轻微暗角
|
||||||
|
float vignette = 1.0 - smoothstep(0.5, 1.5, length(p * 0.7));
|
||||||
|
bg *= vignette * 0.9 + 0.1;
|
||||||
|
|
||||||
|
gl_FragColor = vec4(bg, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// --- 编译 Shader ---
|
||||||
|
function createShader(type, src) {
|
||||||
|
const s = gl.createShader(type);
|
||||||
|
gl.shaderSource(s, src);
|
||||||
|
gl.compileShader(s);
|
||||||
|
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
||||||
|
console.error('Shader error:', gl.getShaderInfoLog(s));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vs = createShader(gl.VERTEX_SHADER, vertSrc);
|
||||||
|
const fs = createShader(gl.FRAGMENT_SHADER, fragSrc);
|
||||||
|
const prog = gl.createProgram();
|
||||||
|
gl.attachShader(prog, vs);
|
||||||
|
gl.attachShader(prog, fs);
|
||||||
|
gl.linkProgram(prog);
|
||||||
|
gl.useProgram(prog);
|
||||||
|
|
||||||
|
// 全屏四边形
|
||||||
|
const buf = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
|
||||||
|
const aPos = gl.getAttribLocation(prog, 'a_pos');
|
||||||
|
gl.enableVertexAttribArray(aPos);
|
||||||
|
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
// Uniforms
|
||||||
|
const uTime = gl.getUniformLocation(prog, 'u_time');
|
||||||
|
const uRes = gl.getUniformLocation(prog, 'u_resolution');
|
||||||
|
const uMouse = gl.getUniformLocation(prog, 'u_mouse');
|
||||||
|
const uClick = gl.getUniformLocation(prog, 'u_click');
|
||||||
|
const uClickPos = gl.getUniformLocation(prog, 'u_clickPos');
|
||||||
|
|
||||||
|
// --- 渲染循环 ---
|
||||||
|
let lastFrame = 0;
|
||||||
|
let targetFPS = 30;
|
||||||
|
let currentFPS = 30;
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = window.innerWidth * dpr;
|
||||||
|
canvas.height = window.innerHeight * dpr;
|
||||||
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
function render(now) {
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
if (paused || fullscreen) return;
|
||||||
|
|
||||||
|
// 帧率控制
|
||||||
|
const interval = 1000 / targetFPS;
|
||||||
|
if (now - lastFrame < interval) return;
|
||||||
|
lastFrame = now;
|
||||||
|
|
||||||
|
// 自适应帧率:鼠标静止 5s 后降帧
|
||||||
|
const idleTime = now - lastMove;
|
||||||
|
if (idleTime > 5000) {
|
||||||
|
targetFPS = 10;
|
||||||
|
} else {
|
||||||
|
targetFPS = 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = now / 1000.0;
|
||||||
|
const clickElapsed = clickTime > 0 ? (now - clickTime) / 1000.0 : 0.0;
|
||||||
|
|
||||||
|
gl.uniform1f(uTime, t);
|
||||||
|
gl.uniform2f(uRes, canvas.width, canvas.height);
|
||||||
|
gl.uniform2f(uMouse, mouseX, mouseY);
|
||||||
|
gl.uniform1f(uClick, clickElapsed > 3.0 ? 0.0 : clickElapsed);
|
||||||
|
gl.uniform2f(uClickPos, clickX, clickY);
|
||||||
|
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
|
||||||
|
// 通知 Go 层壁纸就绪
|
||||||
|
if (window.wallpaperReady) {
|
||||||
|
wallpaperReady().then(() => console.log('wallpaper embedded'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
37
web/themes/text.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<style>
|
||||||
|
@keyframes textGlow {
|
||||||
|
0%, 100% { text-shadow: 0 0 40px rgba(120,140,255,0.3), 0 0 80px rgba(120,140,255,0.1); }
|
||||||
|
50% { text-shadow: 0 0 60px rgba(120,140,255,0.5), 0 0 120px rgba(120,140,255,0.2); }
|
||||||
|
}
|
||||||
|
@keyframes textFloat {
|
||||||
|
0%, 100% { transform: translate(-50%, -50%) translateY(0px); }
|
||||||
|
50% { transform: translate(-50%, -50%) translateY(-8px); }
|
||||||
|
}
|
||||||
|
#wallpaper-text {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 1;
|
||||||
|
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||||
|
font-size: 120px;
|
||||||
|
font-weight: 200;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
letter-spacing: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
animation: textGlow 4s ease-in-out infinite, textFloat 6s ease-in-out infinite;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div id="wallpaper-text"></div>
|
||||||
|
<script>
|
||||||
|
var el = document.getElementById('wallpaper-text');
|
||||||
|
if (el) {
|
||||||
|
var text = 'DREAM';
|
||||||
|
if (window.wallpaperText) text = window.wallpaperText;
|
||||||
|
el.textContent = text;
|
||||||
|
var len = text.length;
|
||||||
|
var size = Math.min(120, Math.floor(window.innerWidth * 0.8 / len));
|
||||||
|
el.style.fontSize = size + 'px';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
94
win32.go
@@ -21,6 +21,10 @@ var (
|
|||||||
procShowWindow = user32.NewProc("ShowWindow")
|
procShowWindow = user32.NewProc("ShowWindow")
|
||||||
procSetProcessDPIAware = user32.NewProc("SetProcessDPIAware")
|
procSetProcessDPIAware = user32.NewProc("SetProcessDPIAware")
|
||||||
procSetWindowLongPtrW = user32.NewProc("SetWindowLongPtrW")
|
procSetWindowLongPtrW = user32.NewProc("SetWindowLongPtrW")
|
||||||
|
procGetWindowLongPtrW = user32.NewProc("GetWindowLongPtrW")
|
||||||
|
procGetDpiForWindow = user32.NewProc("GetDpiForWindow")
|
||||||
|
procGetClientRect = user32.NewProc("GetClientRect")
|
||||||
|
procMessageBoxW = user32.NewProc("MessageBoxW")
|
||||||
procGetMessageW = user32.NewProc("GetMessageW")
|
procGetMessageW = user32.NewProc("GetMessageW")
|
||||||
procPostMessageW = user32.NewProc("PostMessageW")
|
procPostMessageW = user32.NewProc("PostMessageW")
|
||||||
procTranslateMessage = user32.NewProc("TranslateMessage")
|
procTranslateMessage = user32.NewProc("TranslateMessage")
|
||||||
@@ -32,11 +36,28 @@ var wvHwnd uintptr
|
|||||||
var jsQueue = make(chan string, 64)
|
var jsQueue = make(chan string, 64)
|
||||||
var paused int32
|
var paused int32
|
||||||
|
|
||||||
const wmEvalJS = 0x0401
|
const (
|
||||||
const wmSetHtml = 0x0402
|
wmEvalJS = 0x0401
|
||||||
|
wmSetHtml = 0x0402
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
gwlStyle = uintptr(0xFFFFFFF0) // GWL_STYLE = -16
|
||||||
|
wsPopup = uintptr(0x80000000)
|
||||||
|
wsVisible = uintptr(0x10000000)
|
||||||
|
wsChild = uintptr(0x02000000)
|
||||||
|
wsSizebox = uintptr(0x00040000)
|
||||||
|
wsMaxbox = uintptr(0x00010000)
|
||||||
|
)
|
||||||
|
|
||||||
var htmlQueue = make(chan string, 1)
|
var htmlQueue = make(chan string, 1)
|
||||||
|
|
||||||
|
var (
|
||||||
|
classProgman = uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("Progman")))
|
||||||
|
classShellDefView = uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("SHELLDLL_DefView")))
|
||||||
|
classWorkerW = uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("WorkerW")))
|
||||||
|
)
|
||||||
|
|
||||||
func evalJS(js string) {
|
func evalJS(js string) {
|
||||||
select {
|
select {
|
||||||
case jsQueue <- js:
|
case jsQueue <- js:
|
||||||
@@ -51,24 +72,75 @@ func evalJS(js string) {
|
|||||||
|
|
||||||
func findWorkerW() uintptr {
|
func findWorkerW() uintptr {
|
||||||
progman, _, _ := procFindWindowW.Call(
|
progman, _, _ := procFindWindowW.Call(
|
||||||
uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("Progman"))), 0)
|
classProgman, 0)
|
||||||
if progman == 0 {
|
if progman == 0 {
|
||||||
|
log.Println("findWorkerW: Progman not found")
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
// 发送 0x052C 触发 Progman 创建 WorkerW
|
||||||
var result uintptr
|
var result uintptr
|
||||||
procSendMessageTimeoutW.Call(progman, 0x052C, 0, 0, 0x0000, 1000, uintptr(unsafe.Pointer(&result)))
|
procSendMessageTimeoutW.Call(progman, 0x052C, 0, 0, 0x0000, 1000, uintptr(unsafe.Pointer(&result)))
|
||||||
shellDefView, _, _ := procFindWindowExW.Call(progman, 0,
|
|
||||||
uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("SHELLDLL_DefView"))), 0)
|
// 方法1: Progman 下找 SHELLDLL_DefView,再找其后的 WorkerW
|
||||||
workerwAfterShell, _, _ := procFindWindowExW.Call(progman, shellDefView,
|
shellDefView, _, _ := procFindWindowExW.Call(progman, 0, classShellDefView, 0)
|
||||||
uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("WorkerW"))), 0)
|
if shellDefView != 0 {
|
||||||
if workerwAfterShell != 0 {
|
ww, _, _ := procFindWindowExW.Call(progman, shellDefView, classWorkerW, 0)
|
||||||
return workerwAfterShell
|
if ww != 0 {
|
||||||
|
log.Printf("findWorkerW: 方法1成功, WorkerW=0x%x", ww)
|
||||||
|
return ww
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法2: 遍历顶层 WorkerW,找到含 SHELLDLL_DefView 的那个,取它后面的 WorkerW
|
||||||
|
var prev uintptr
|
||||||
|
for {
|
||||||
|
ww, _, _ := procFindWindowExW.Call(0, prev, classWorkerW, 0)
|
||||||
|
if ww == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
child, _, _ := procFindWindowExW.Call(ww, 0, classShellDefView, 0)
|
||||||
|
if child != 0 {
|
||||||
|
// 这个 WorkerW 包含 SHELLDLL_DefView,找它后面的 WorkerW
|
||||||
|
next, _, _ := procFindWindowExW.Call(0, ww, classWorkerW, 0)
|
||||||
|
if next != 0 {
|
||||||
|
log.Printf("findWorkerW: 方法2成功, WorkerW=0x%x (after 0x%x)", next, ww)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev = ww
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法3: 遍历顶层 WorkerW,找任意不含 SHELLDLL_DefView 的可见 WorkerW
|
||||||
|
prev = 0
|
||||||
|
for {
|
||||||
|
ww, _, _ := procFindWindowExW.Call(0, prev, classWorkerW, 0)
|
||||||
|
if ww == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
child, _, _ := procFindWindowExW.Call(ww, 0, classShellDefView, 0)
|
||||||
|
if child == 0 {
|
||||||
|
log.Printf("findWorkerW: 方法3成功, WorkerW=0x%x", ww)
|
||||||
|
return ww
|
||||||
|
}
|
||||||
|
prev = ww
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法4: 兜底,Progman 下的第一个 WorkerW
|
||||||
|
ww, _, _ := procFindWindowExW.Call(progman, 0, classWorkerW, 0)
|
||||||
|
if ww != 0 {
|
||||||
|
log.Printf("findWorkerW: 方法4(兜底), WorkerW=0x%x", ww)
|
||||||
}
|
}
|
||||||
ww, _, _ := procFindWindowExW.Call(progman, 0,
|
|
||||||
uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("WorkerW"))), 0)
|
|
||||||
return ww
|
return ww
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getDPI(hwnd uintptr) int {
|
||||||
|
dpi, _, _ := procGetDpiForWindow.Call(hwnd)
|
||||||
|
if dpi == 0 {
|
||||||
|
return 96
|
||||||
|
}
|
||||||
|
return int(dpi)
|
||||||
|
}
|
||||||
|
|
||||||
func getScreenSize() (int32, int32) {
|
func getScreenSize() (int32, int32) {
|
||||||
w, _, _ := procGetSystemMetrics.Call(0)
|
w, _, _ := procGetSystemMetrics.Call(0)
|
||||||
h, _, _ := procGetSystemMetrics.Call(1)
|
h, _, _ := procGetSystemMetrics.Call(1)
|
||||||
|
|||||||