diff --git a/ainews.go b/ainews.go index c70f8cc..e82196e 100644 --- a/ainews.go +++ b/ainews.go @@ -55,7 +55,12 @@ func fetchAINews() []aiNewsItem { } aiNewsMu.Unlock() - url := fmt.Sprintf("%s?key=%s", tianapiAIURL, tianapiKey) + 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) @@ -124,25 +129,33 @@ func pushAINews(items []aiNewsItem) { func aiNewsLoop() { cfg := loadConfig() - if cfg.HideAINews { - return - } - - // 先推送缓存 - if cached := loadAINewsCache(); cached != nil { - pushAINews(cached) + 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 { - return - } - - items := fetchAINews() - if items != nil { - pushAINews(items) + 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) diff --git a/assets/icons/tray-icon-128.png b/assets/icons/tray-icon-128.png new file mode 100644 index 0000000..5936954 Binary files /dev/null and b/assets/icons/tray-icon-128.png differ diff --git a/assets/icons/tray-icon-16.png b/assets/icons/tray-icon-16.png new file mode 100644 index 0000000..659413e Binary files /dev/null and b/assets/icons/tray-icon-16.png differ diff --git a/assets/icons/tray-icon-20.png b/assets/icons/tray-icon-20.png new file mode 100644 index 0000000..09110b8 Binary files /dev/null and b/assets/icons/tray-icon-20.png differ diff --git a/assets/icons/tray-icon-24.png b/assets/icons/tray-icon-24.png new file mode 100644 index 0000000..86fd39f Binary files /dev/null and b/assets/icons/tray-icon-24.png differ diff --git a/assets/icons/tray-icon-256.png b/assets/icons/tray-icon-256.png new file mode 100644 index 0000000..7628eb9 Binary files /dev/null and b/assets/icons/tray-icon-256.png differ diff --git a/assets/icons/tray-icon-32.png b/assets/icons/tray-icon-32.png new file mode 100644 index 0000000..3ed59ee Binary files /dev/null and b/assets/icons/tray-icon-32.png differ diff --git a/assets/icons/tray-icon-40.png b/assets/icons/tray-icon-40.png new file mode 100644 index 0000000..81be0ea Binary files /dev/null and b/assets/icons/tray-icon-40.png differ diff --git a/assets/icons/tray-icon-48.png b/assets/icons/tray-icon-48.png new file mode 100644 index 0000000..19fb3e9 Binary files /dev/null and b/assets/icons/tray-icon-48.png differ diff --git a/assets/icons/tray-icon-64.png b/assets/icons/tray-icon-64.png new file mode 100644 index 0000000..6b002ab Binary files /dev/null and b/assets/icons/tray-icon-64.png differ diff --git a/assets/icons/tray-icon.png b/assets/icons/tray-icon.png new file mode 100644 index 0000000..400a9b6 Binary files /dev/null and b/assets/icons/tray-icon.png differ diff --git a/assets/icons/tray-source.png b/assets/icons/tray-source.png new file mode 100644 index 0000000..ae01e29 Binary files /dev/null and b/assets/icons/tray-source.png differ diff --git a/assets/icons/tray.ico b/assets/icons/tray.ico new file mode 100644 index 0000000..bae6b50 Binary files /dev/null and b/assets/icons/tray.ico differ diff --git a/bing.go b/bing.go index 0cb32d5..95fee0c 100644 --- a/bing.go +++ b/bing.go @@ -213,25 +213,14 @@ func bingToggleFavorite() string { r := &h.Records[h.CurrentIdx] r.Favorited = !r.Favorited saveBingHistory(h) - state := "收藏" - if r.Favorited { - state = "取消收藏" - } - log.Printf("Bing 壁纸: %s (date=%s)", state, r.Date) if r.Favorited { + log.Printf("Bing 壁纸: 已收藏 (date=%s)", r.Date) return "☆ 取消收藏" } + log.Printf("Bing 壁纸: 已取消收藏 (date=%s)", r.Date) return "★ 收藏当前壁纸" } -func bingCopyrightInfo() string { - r := getCurrentBingRecord() - if r != nil { - return fmt.Sprintf("%s (%s)", r.Copyright, r.Date) - } - return "" -} - func bingCurrentState() string { h := loadBingHistory() if h.CurrentIdx < 0 || h.CurrentIdx >= len(h.Records) { @@ -305,30 +294,34 @@ func bingWallpaperLoop() { } // 定时切换壁纸 (1 小时间隔) - ticker := time.NewTicker(1 * time.Hour) - for range ticker.C { - cfg := loadConfig() - if cfg.WallpaperType != WPBing || !cfg.BingAutoRefresh { - continue + go func() { + ticker := time.NewTicker(1 * time.Hour) + for range ticker.C { + 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)) } - h := loadBingHistory() - if len(h.Records) <= 1 { - continue - } - // 顺序切换到下一张 - nextIdx := (h.CurrentIdx + 1) % len(h.Records) - h.CurrentIdx = nextIdx - saveBingHistory(h) - bingReloadImage() - log.Printf("Bing 自动切换: idx=%d/%d", nextIdx, len(h.Records)) - } + }() - // 定时拉取新壁纸 (4 小时间隔) - fetchTicker := time.NewTicker(4 * time.Hour) - for range fetchTicker.C { - cfg := loadConfig() - if cfg.WallpaperType == WPBing { - go fetchBingHistory() + go func() { + fetchTicker := time.NewTicker(4 * time.Hour) + for range fetchTicker.C { + cfg := loadConfig() + if cfg.WallpaperType == WPBing { + go fetchBingHistory() + } } - } + }() } diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..e588d8f --- /dev/null +++ b/build.ps1 @@ -0,0 +1,73 @@ +<# +.SYNOPSIS + u-desktop 构建脚本 +.PARAMETER Pack + 打包为 zip(含 WebView2 bootstrapper) +#> +param( + [switch]$Pack +) + +$ErrorActionPreference = "Stop" +$project = "u-desktop" +$buildDir = "dist" + +# 清理 +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 diff --git a/config.go b/config.go index 26a7149..8f2bba2 100644 --- a/config.go +++ b/config.go @@ -1,12 +1,9 @@ package main import ( - "bytes" - "encoding/binary" + _ "embed" "encoding/json" - "image" - "image/color" - "image/png" + "log" "os" "path/filepath" ) @@ -39,42 +36,50 @@ const ( ) type SavedColor struct { - Color1 string `json:"color1"` - Color2 string `json:"color2"` - Gradient bool `json:"gradient"` + Color1 string `json:"color1"` + Color2 string `json:"color2"` + Gradient bool `json:"gradient"` } type Config struct { - Zodiac string `json:"zodiac"` - City string `json:"city"` - WallpaperType WallpaperType `json:"wallpaperType"` - Theme ThemeName `json:"theme"` - ImagePath string `json:"imagePath"` - Color1 string `json:"color1"` - Color2 string `json:"color2"` - 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"` - KnowledgeKeyword string `json:"knowledgeKeyword"` - KnowledgePrompt string `json:"knowledgePrompt"` - HideKnowledge bool `json:"hideKnowledge"` - SavedColors []SavedColor `json:"savedColors"` - BingAutoRefresh bool `json:"bingAutoRefresh"` + Zodiac string `json:"zodiac"` + City string `json:"city"` + WallpaperType WallpaperType `json:"wallpaperType"` + Theme ThemeName `json:"theme"` + ImagePath string `json:"imagePath"` + Color1 string `json:"color1"` + Color2 string `json:"color2"` + 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 = "射手座" var configPath string +//go:embed assets/icons/tray.ico +var trayIcon []byte + func configDir() string { return filepath.Dir(configPath) } @@ -117,30 +122,29 @@ func defaultConfig() *Config { } 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) } -func generateIcon() []byte { - img := image.NewRGBA(image.Rect(0, 0, 16, 16)) - c := color.RGBA{R: 88, G: 101, B: 242, A: 255} - for y := 0; y < 16; y++ { - for x := 0; x < 16; x++ { - img.Set(x, y, c) - } +func getSecret(envName, cfgValue string) string { + if v := os.Getenv(envName); v != "" { + return v } - var buf bytes.Buffer - png.Encode(&buf, img) - pngData := buf.Bytes() - ico := make([]byte, 22+len(pngData)) - binary.LittleEndian.PutUint16(ico[0:], 0) - binary.LittleEndian.PutUint16(ico[2:], 1) - binary.LittleEndian.PutUint16(ico[4:], 1) - ico[6], ico[7], ico[8], ico[9] = 16, 16, 0, 0 - binary.LittleEndian.PutUint16(ico[10:], 1) - binary.LittleEndian.PutUint16(ico[12:], 32) - binary.LittleEndian.PutUint32(ico[14:], uint32(len(pngData))) - binary.LittleEndian.PutUint32(ico[18:], 22) - copy(ico[22:], pngData) - return ico + return cfgValue +} + +func (c *Config) qweatherKey() string { + return getSecret("U_DESKTOP_QWEATHER_KEY", c.QWeatherKey) +} + +func (c *Config) tianapiKey() string { + return getSecret("U_DESKTOP_TIANAPI_KEY", c.TianapiKey) +} + +func (c *Config) cpaKey() string { + return getSecret("U_DESKTOP_CPA_KEY", c.CPAKey) } diff --git a/config_icon_test.go b/config_icon_test.go new file mode 100644 index 0000000..da16079 --- /dev/null +++ b/config_icon_test.go @@ -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) + } + } +} diff --git a/dialog.go b/dialog.go index 2c8aad4..2d2b5fc 100644 --- a/dialog.go +++ b/dialog.go @@ -10,11 +10,11 @@ import ( var ( comdlg32 = windows.NewLazySystemDLL("comdlg32.dll") - procGetOpenFileNameW = comdlg32.NewProc("GetOpenFileNameW") - procChooseColorW = comdlg32.NewProc("ChooseColorW") + procGetOpenFileNameW = comdlg32.NewProc("GetOpenFileNameW") + procChooseColorW = comdlg32.NewProc("ChooseColorW") - shell32 = windows.NewLazySystemDLL("shell32.dll") - procSHBrowseForFolderW = shell32.NewProc("SHBrowseForFolderW") + shell32 = windows.NewLazySystemDLL("shell32.dll") + procSHBrowseForFolderW = shell32.NewProc("SHBrowseForFolderW") procSHGetPathFromIDListW = shell32.NewProc("SHGetPathFromIDListW") ole32dll = windows.NewLazySystemDLL("ole32.dll") @@ -24,10 +24,14 @@ var ( func slicePtr(s interface{}) uintptr { switch v := s.(type) { case []uint16: - if len(v) == 0 { return 0 } + if len(v) == 0 { + return 0 + } return uintptr(unsafe.Pointer(&v[0])) case []uint32: - if len(v) == 0 { return 0 } + if len(v) == 0 { + return 0 + } return uintptr(unsafe.Pointer(&v[0])) } return 0 diff --git a/horoscope.go b/horoscope.go index 5f9702f..c0214db 100644 --- a/horoscope.go +++ b/horoscope.go @@ -7,11 +7,9 @@ import ( "os" "path/filepath" "strings" - "sync" "time" ) -const tianapiKey = "da21ff665b09cbdcc29952a105aad97b" const tianapiStarURL = "https://apis.tianapi.com/star/index" var zodiacToSign = map[string]string{ @@ -20,8 +18,6 @@ var zodiacToSign = map[string]string{ "射手座": "sagittarius", "摩羯座": "capricorn", "水瓶座": "aquarius", "双鱼座": "pisces", } -var horoscopeMu sync.Mutex - type tianapiStarResp struct { Code int `json:"code"` Result struct { @@ -68,17 +64,18 @@ func loadHoroscopeCache() *horoscopeInfo { return &info } -func isCacheToday(info *horoscopeInfo) bool { - return info != nil -} - func fetchHoroscope(zodiac string) *horoscopeInfo { sign := zodiacToSign[zodiac] if sign == "" { sign = "sagittarius" } - url := fmt.Sprintf("%s?key=%s&astro=%s", tianapiStarURL, tianapiKey, sign) + 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) @@ -138,29 +135,27 @@ func pushHoroscope(zodiac string) { func horoscopeLoop() { cfg := loadConfig() - if cfg.HideZodiac { - return - } cached := loadHoroscopeCache() - if cached != nil { + if cached != nil && !cfg.HideZodiac { pushHoroscopeInfo(cached) } time.Sleep(5 * time.Second) cfg = loadConfig() - if cfg.HideZodiac { - return - } - today := time.Now().Format("2006-01-02") - if cached != nil && cached.Date == today && cached.Zodiac == cfg.Zodiac { - return + 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) + } } - pushHoroscope(cfg.Zodiac) - ticker := time.NewTicker(24 * time.Hour) for range ticker.C { cfg := loadConfig() diff --git a/knowledge.go b/knowledge.go index be4a528..4bb97ef 100644 --- a/knowledge.go +++ b/knowledge.go @@ -9,14 +9,16 @@ import ( "math/rand" "net/http" "path/filepath" + "strings" "time" + "unicode/utf8" _ "modernc.org/sqlite" ) const cpaURL = "https://cpa.1216.top/v1/chat/completions" -const cpaKey = "alink-shared-key-1" const cpaModel = "glm-4.5-air" +const minKnowledgeRunes = 80 type knowledgeData struct { Content string `json:"content"` @@ -59,15 +61,24 @@ func getRandomKnowledgeCard(keyword string) string { if knowledgeDB == nil { return "" } - var content string - err := knowledgeDB.QueryRow( - "SELECT content FROM knowledge_cards WHERE keyword = ? ORDER BY RANDOM() LIMIT 1", + rows, err := knowledgeDB.Query( + "SELECT content FROM knowledge_cards WHERE keyword = ? ORDER BY RANDOM() LIMIT 20", keyword, - ).Scan(&content) + ) if err != nil { return "" } - return content + 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 { @@ -86,21 +97,60 @@ func getKnowledgeCardCount(keyword string) int { } func fetchKnowledgeFromLLM(keyword string, cfg *Config) string { - basePrompt := fmt.Sprintf( - "根据关键词「%s」,生成一条有趣的知识小卡片。要求:控制在80字以内,简洁有趣,有知识性。直接输出内容,不要加标题、序号或其他格式。", - keyword, - ) - if cfg.KnowledgePrompt != "" { - basePrompt += "\n附加要求:" + cfg.KnowledgePrompt + basePrompt := buildKnowledgePrompt(keyword, cfg.KnowledgePrompt) + messages := []map[string]string{ + { + "role": "system", + "content": "你是严谨的中文知识卡片作者,输出必须具体、准确、有信息密度。不要写空泛鸡汤,不要只给一句定义。", + }, + {"role": "user", "content": basePrompt}, } body := map[string]interface{}{ - "model": cpaModel, - "max_tokens": 256, - "messages": []map[string]string{ - {"role": "user", "content": basePrompt}, - }, + "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)) @@ -108,7 +158,12 @@ func fetchKnowledgeFromLLM(keyword string, cfg *Config) string { log.Println("知识API请求创建失败:", err) return "" } - req.Header.Set("Authorization", "Bearer "+cpaKey) + 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) @@ -135,6 +190,41 @@ func fetchKnowledgeFromLLM(keyword string, cfg *Config) string { 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))) diff --git a/main.go b/main.go index adfc7af..01faf04 100644 --- a/main.go +++ b/main.go @@ -1,24 +1,57 @@ package main import ( + "fmt" "log" "os" "path/filepath" + "unsafe" "github.com/getlantern/systray" + "github.com/jchv/go-webview2/webviewloader" "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() { - 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") mutex, err := windows.CreateMutex(nil, false, mutexName) if err != nil { - log.Fatal("创建互斥锁失败:", err) + showError("创建互斥锁失败: " + err.Error()) + os.Exit(1) } if windows.GetLastError() == windows.ERROR_ALREADY_EXISTS { - log.Println("已有实例运行,退出") windows.CloseHandle(mutex) return } @@ -29,5 +62,7 @@ func main() { os.MkdirAll(cfgDir, 0755) configPath = filepath.Join(cfgDir, "settings.json") procSetProcessDPIAware.Call() + + log.Println("启动 systray...") systray.Run(onSystrayReady, nil) } diff --git a/photo.go b/photo.go index d73a7bb..eb3e1b4 100644 --- a/photo.go +++ b/photo.go @@ -1,7 +1,6 @@ package main import ( - "encoding/base64" "encoding/json" "fmt" "log" @@ -14,12 +13,15 @@ import ( ) var ( - photoMu sync.Mutex - photoFiles []string - photoIdx int - photoDir string - photoStop chan struct{} - photoDone chan struct{} + 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 { @@ -42,25 +44,31 @@ func scanPhotoDir(dir string) []string { return files } -func photoToDataURI(dir, name string) string { - path := filepath.Join(dir, name) - data, err := os.ReadFile(path) - if err != nil { - return "" +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 + } } - ext := strings.ToLower(filepath.Ext(name)) - mime := "image/jpeg" - switch ext { - case ".png": - mime = "image/png" - case ".gif": - mime = "image/gif" - case ".webp": - mime = "image/webp" - case ".bmp": - mime = "image/bmp" + 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 + } } - return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data)) + photoCacheMu.Unlock() + return imageToDataURI(filepath.Join(dir, name)) } func pushCurrentPhoto(interval int) { @@ -78,7 +86,7 @@ func pushCurrentPhoto(interval int) { idx = 0 } - src := photoToDataURI(dir, files[idx]) + src := getCachedPhotoURI(dir, files[idx]) if src == "" { return } @@ -118,7 +126,13 @@ func startPhotoLoop() { 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() { @@ -149,8 +163,16 @@ func stopPhotoLoop() { 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) } diff --git a/scripts/build_tray_icon.py b/scripts/build_tray_icon.py new file mode 100644 index 0000000..8418d17 --- /dev/null +++ b/scripts/build_tray_icon.py @@ -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() diff --git a/scripts/check-resources.ps1 b/scripts/check-resources.ps1 new file mode 100644 index 0000000..0bdeafc --- /dev/null +++ b/scripts/check-resources.ps1 @@ -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)" diff --git a/settings.go b/settings.go index 54b32cf..387cfac 100644 --- a/settings.go +++ b/settings.go @@ -24,6 +24,40 @@ var ( 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 { @@ -49,6 +83,25 @@ var themeNames = []struct { {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 { @@ -113,34 +166,36 @@ func openSettingsWindow() { } 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, + "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, + "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) }) @@ -151,6 +206,7 @@ func openSettingsWindow() { return "" } cfg := loadConfig() + oldCfg := *cfg if v, ok := data["wallpaper"]; ok { cfg.HideWallpaper = !v evalJS(fmt.Sprintf("if(window.setWallpaperVisible) setWallpaperVisible(%v)", v)) @@ -179,9 +235,30 @@ func openSettingsWindow() { 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() @@ -189,8 +266,10 @@ func openSettingsWindow() { stopPhotoLoop() } } + } else { + saveConfig(cfg) + refreshVisibleCards(cfg, &oldCfg) } - saveConfig(cfg) return "" }) @@ -416,8 +495,8 @@ func openSettingsWindow() { hwnd := uintptr(w.Window()) // disable resize - style, _, _ := procGetWindowLongPtrW.Call(hwnd, uintptr(0xFFFFFFF0)) - procSetWindowLongPtrW.Call(hwnd, uintptr(0xFFFFFFF0), style & ^uintptr(0x00040000|0x00010000)) + 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 { diff --git a/systray.go b/systray.go index 1fa9ddd..a81abbf 100644 --- a/systray.go +++ b/systray.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "log" "os" "os/exec" @@ -16,7 +15,7 @@ import ( ) func onSystrayReady() { - systray.SetIcon(generateIcon()) + systray.SetIcon(trayIcon) systray.SetTooltip("动态壁纸引擎") mSettings := systray.AddMenuItem("桌面设置", "打开设置窗口") @@ -58,15 +57,20 @@ func onSystrayReady() { func startWebView() { runtime.LockOSThread() + log.Println("startWebView: 开始") workerw := findWorkerW() 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() log.Printf("Screen: %dx%d", screenW, screenH) + log.Println("创建 WebView2...") wv = webview2.NewWithOptions(webview2.WebViewOptions{ AutoFocus: false, WindowOptions: webview2.WindowOptions{ @@ -76,8 +80,20 @@ func startWebView() { }, }) 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 { cfg := loadConfig() @@ -85,33 +101,22 @@ func startWebView() { return saveConfig(cfg) }) - wv.SetHtml(buildWallpaperHTML(loadConfig())) - time.Sleep(1 * time.Second) + log.Println("设置 HTML...") + 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) - procMoveWindow.Call(wvHwnd, uintptr(^uint(0)), uintptr(^uint(0)), uintptr(screenW+2), uintptr(screenH+2), 1) - log.Printf("壁纸已嵌入: HWND=0x%x, %dx%d", wvHwnd, screenW, screenH) + log.Println("壁纸窗口已显示") go func() { time.Sleep(500 * time.Millisecond) - cfg := loadConfig() - evalJS(fmt.Sprintf(`window.userZodiac = %q;`, cfg.Zodiac)) + reloadAllCards() }() 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 { hwnd uintptr message uint32 @@ -139,15 +144,15 @@ func startWebView() { } } } - if m.message == wmSetHtml { - select { - case html := <-htmlQueue: - wv.SetHtml(html) - default: + if m.message == wmSetHtml { + select { + case html := <-htmlQueue: + wv.SetHtml(html) + default: + } + goto nextMsg } - goto nextMsg - } -nextMsg: + nextMsg: procTranslateMessage.Call(uintptr(unsafe.Pointer(&m))) procDispatchMessageW.Call(uintptr(unsafe.Pointer(&m))) } diff --git a/wallpaper.go b/wallpaper.go index f66d6fd..a0ced6a 100644 --- a/wallpaper.go +++ b/wallpaper.go @@ -3,6 +3,7 @@ package main import ( _ "embed" "encoding/base64" + "encoding/json" "fmt" "log" "os" @@ -44,35 +45,14 @@ var themeMap = map[ThemeName]string{ func buildWallpaperHTML(cfg *Config) string { var bg string - switch cfg.WallpaperType { - case WPTheme: + if cfg.WallpaperType == WPTheme { if t, ok := themeMap[cfg.Theme]; ok { bg = t } else { bg = themeAurora } - case WPImage: - if cfg.ImagePath != "" { - src := imageToDataURI(cfg.ImagePath) - if src != "" { - bg = fmt.Sprintf(``, src) - } - } - case WPBing: - if p := getCurrentBingPath(); p != "" { - if _, err := os.Stat(p); err == nil { - src := imageToDataURI(p) - if src != "" { - bg = fmt.Sprintf(``, src) - } - } - } - case WPColor: - if cfg.ColorGradient && cfg.Color2 != "" { - bg = fmt.Sprintf(`
`, cfg.Color1, cfg.Color2) - } else { - bg = fmt.Sprintf(`
`, cfg.Color1) - } + } else { + bg = buildBgHTML(cfg) } if bg == "" { @@ -109,6 +89,9 @@ func buildWallpaperHTML(cfg *Config) string { if cfg.HidePhoto { bodyClasses = append(bodyClasses, "hide-photo") } + if cfg.PhotoFrameMode && cfg.PhotoDir != "" { + bodyClasses = append(bodyClasses, "photo-frame-mode") + } if len(bodyClasses) > 0 { cls := strings.Join(bodyClasses, " ") html = strings.Replace(html, "{{BODY_CLASSES}}", cls, 1) @@ -118,9 +101,8 @@ func buildWallpaperHTML(cfg *Config) string { // 注入自定义文字 if cfg.WallpaperType == WPTheme && cfg.Theme == ThemeText && cfg.WallpaperText != "" { - escaped := strings.ReplaceAll(cfg.WallpaperText, `\`, `\\`) - escaped = strings.ReplaceAll(escaped, `"`, `\"`) - html = strings.Replace(html, "", `window.wallpaperText = "`+escaped+`";`, 1) + escaped, _ := json.Marshal(cfg.WallpaperText) + html = strings.Replace(html, "", `window.wallpaperText = `+string(escaped)+`;`, 1) } return html @@ -152,21 +134,95 @@ func reloadWallpaper() { return } cfg := loadConfig() + + // 非主题壁纸切换:仅替换 #bg-layer,不销毁卡片状态 + if cfg.WallpaperType != WPTheme { + updateBackground(cfg) + return + } + + // 主题切换需要 SetHtml(canvas+script),之后恢复卡片数据 html := buildWallpaperHTML(cfg) select { case htmlQueue <- html: default: } procPostMessageW.Call(wvHwnd, wmSetHtml, 0, 0) - go func() { - time.Sleep(1 * time.Second) - evalJS(fmt.Sprintf(`window.userZodiac = %q;`, cfg.Zodiac)) - if cfg.Theme == ThemeText && cfg.WallpaperText != "" { - evalJS(fmt.Sprintf(`window.wallpaperText = %q; var el=document.getElementById("wallpaper-text"); if(el){el.textContent=%q;}`, cfg.WallpaperText, cfg.WallpaperText)) + go reloadAllCards() +} + +func buildBgHTML(cfg *Config) string { + switch cfg.WallpaperType { + case WPImage: + if cfg.ImagePath != "" { + src := imageToDataURI(cfg.ImagePath) + if src != "" { + return buildCoverImgHTML(src) + } } - city := getCurrentCity() + 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(`
`, cfg.Color1, cfg.Color2) + } + return fmt.Sprintf(`
`, cfg.Color1) + } + return "" +} + +const coverImgTpl = `` + +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(`
%s
`, display, bg) + evalJS(fmt.Sprintf(`var el=document.getElementById('bg-layer'); if(el){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() { diff --git a/weather.go b/weather.go index 662d84a..4baabf2 100644 --- a/weather.go +++ b/weather.go @@ -8,11 +8,10 @@ import ( "net/http" "regexp" "strings" -"sync" + "sync" "time" ) -const qweatherKey = "3b67b65a53c04170b602d2d1a7e6096f" const qweatherHost = "https://pb4nmv4qnu.re.qweatherapi.com" type City struct { @@ -84,14 +83,18 @@ func getLocation() City { return defaultCity } +var ( + reIPIP = regexp.MustCompile(`来自于[::]\s*(.+?)\s*$`) + reIPAddr = regexp.MustCompile(`(\d+\.\d+\.\d+\.\d+)`) +) + func locateByIPIP() *City { data, err := httpGet("https://myip.ipip.net") if err != nil { return nil } text := string(data) - re := regexp.MustCompile(`来自于[::]\s*(.+?)\s*$`) - m := re.FindStringSubmatch(text) + m := reIPIP.FindStringSubmatch(text) if m == nil { return nil } @@ -114,8 +117,7 @@ func locateByIPIP() *City { func locateByGeoAPI() *City { data, err := httpGet("https://myip.ipip.net") if err == nil { - re := regexp.MustCompile(`(\d+\.\d+\.\d+\.\d+)`) - if m := re.FindStringSubmatch(string(data)); m != nil { + if m := reIPAddr.FindStringSubmatch(string(data)); m != nil { return geoLookup(m[1]) } } @@ -123,7 +125,12 @@ func locateByGeoAPI() *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) if err != nil { return nil @@ -239,7 +246,12 @@ func fetchAndPushWeather(city City) { } 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) if err != nil { return nil @@ -258,7 +270,11 @@ func fetchCurrentWeather(cityID string) *currentWeather { } 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) if err != nil { return nil @@ -293,7 +309,11 @@ func fetchHourlyForecast(cityID string) []hourlyItem { } 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) if err != nil { return nil diff --git a/web/overlay.html b/web/overlay.html index 612bc2a..1db8759 100644 --- a/web/overlay.html +++ b/web/overlay.html @@ -10,28 +10,69 @@ html, body { height: 100%; overflow: hidden; background: #000; - font-family: "Microsoft YaHei", sans-serif; + 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 { - background: rgba(0, 0, 0, 0.25); - backdrop-filter: blur(24px); - -webkit-backdrop-filter: blur(24px); - border-radius: 20px; - padding: 24px 28px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(255,255,255,0.08); + 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; } .time { - font-size: 72px; + font-size: 66px; font-weight: 200; - text-shadow: 0 2px 20px rgba(0,0,0,0.5); - letter-spacing: -2px; + text-shadow: 0 3px 28px rgba(0,0,0,0.42); + letter-spacing: 0; line-height: 1; font-variant-numeric: tabular-nums; - font-family: "Segoe UI", "Microsoft YaHei", sans-serif; + color: rgba(255,255,255,0.96); } @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; } @@ -48,147 +89,155 @@ html, body { animation: hourlyGlow 6s ease-out forwards; } .date { - font-size: 14px; - color: rgba(255,255,255,0.7); - margin-bottom: 6px; + font-size: 13px; + color: var(--text-soft); + margin-bottom: 8px; text-shadow: 0 1px 6px rgba(0,0,0,0.5); - letter-spacing: 0.5px; + letter-spacing: 0; } .current-weather { - font-size: 16px; - font-weight: 500; - color: rgba(255,255,255,0.95); + font-size: 17px; + font-weight: 600; + color: var(--text-main); text-shadow: 0 1px 4px rgba(0,0,0,0.5); - margin-bottom: 14px; + margin-bottom: 16px; + text-align: right; } .forecast-title { font-size: 11px; - font-weight: 500; - color: rgba(255,255,255,0.45); - letter-spacing: 1px; - margin-bottom: 8px; + font-weight: 600; + color: var(--text-faint); + letter-spacing: 0; + margin-bottom: 9px; + text-align: right; } .weather-forecast { - display: flex; - gap: 6px; - overflow-x: auto; - padding-bottom: 6px; - justify-content: flex-end; + display: grid; + grid-template-columns: repeat(8, minmax(60px, 1fr)); + gap: 7px; + overflow: hidden; + padding-bottom: 0; } .forecast-item { - background: rgba(255,255,255,0.06); - border-radius: 10px; - padding: 10px 12px; + background: rgba(255,255,255,0.055); + border-radius: 8px; + padding: 9px 8px; text-align: center; - min-width: 58px; + min-width: 0; font-size: 12px; - color: rgba(255,255,255,0.85); - border: 1px solid rgba(255,255,255,0.04); + color: var(--text-main); + border: 1px solid var(--card-line-soft); } -.forecast-icon { font-size: 20px; margin: 4px 0; } -.forecast-time { opacity: 0.6; font-size: 10px; } -.forecast-temp { font-weight: 600; margin-top: 2px; font-size: 13px; } -.forecast-pop { font-size: 10px; opacity: 0.6; margin-top: 1px; } +.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: flex; - gap: 6px; - overflow-x: auto; - padding-bottom: 6px; - justify-content: flex-end; + display: grid; + grid-template-columns: repeat(7, minmax(64px, 1fr)); + gap: 7px; + overflow: hidden; + padding-bottom: 0; } .daily-item { background: rgba(255,255,255,0.04); border-radius: 8px; - padding: 8px 10px; + padding: 9px 8px; text-align: center; - min-width: 54px; + min-width: 0; font-size: 12px; - color: rgba(255,255,255,0.85); - border: 1px solid rgba(255,255,255,0.03); + color: var(--text-main); + border: 1px solid var(--card-line-soft); } -.daily-icon { font-size: 20px; margin: 3px 0; } +.daily-icon { font-size: 22px; margin: 5px 0; } .zodiac-text { font-size: 14px; - color: rgba(255,255,255,0.9); + color: var(--text-main); line-height: 1.6; text-shadow: 0 1px 4px rgba(0,0,0,0.5); } .zodiac-title { - font-size: 15px; - font-weight: 500; - margin-bottom: 2px; + font-size: 16px; + font-weight: 650; + margin-bottom: 3px; } .zodiac-date { font-size: 11px; - opacity: 0.4; - margin-bottom: 14px; + color: var(--text-faint); + margin-bottom: 12px; } .zodiac-bar { display: flex; align-items: center; - gap: 8px; + gap: 10px; margin-bottom: 7px; font-size: 12px; } .zodiac-bar-label { width: 28px; - opacity: 0.55; + color: var(--text-soft); flex-shrink: 0; } .zodiac-bar-track { flex: 1; - height: 4px; - background: rgba(255,255,255,0.08); - border-radius: 2px; + height: 5px; + background: rgba(255,255,255,0.10); + border-radius: 999px; overflow: hidden; } .zodiac-bar-fill { height: 100%; - border-radius: 2px; + border-radius: 999px; transition: width 0.6s ease; } .zodiac-bar-val { width: 30px; text-align: right; font-size: 11px; - opacity: 0.7; + color: var(--text-soft); flex-shrink: 0; } .zodiac-tags { display: flex; - gap: 6px; + gap: 7px; flex-wrap: wrap; - margin: 12px 0; + margin: 10px 0 9px; } .zodiac-tag { font-size: 10px; - background: rgba(255,255,255,0.06); - padding: 2px 8px; - border-radius: 5px; - opacity: 0.6; + 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; - opacity: 0.6; - line-height: 1.7; + color: var(--text-soft); + line-height: 1.65; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; } /* ===== AI 资讯 ===== */ .ainews-header { font-size: 11px; - font-weight: 500; - color: rgba(255,255,255,0.45); - letter-spacing: 1px; - margin-bottom: 10px; + font-weight: 700; + color: var(--text-faint); + letter-spacing: 0; + margin-bottom: 14px; } .ainews-item { display: flex; - gap: 12px; - margin-bottom: 10px; - padding-bottom: 10px; - border-bottom: 1px solid rgba(255,255,255,0.06); + 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; @@ -196,12 +245,13 @@ html, body { border-bottom: none; } .ainews-img { - width: 80px; - height: 54px; + width: 92px; + height: 58px; border-radius: 6px; object-fit: cover; flex-shrink: 0; - opacity: 0.85; + opacity: 0.92; + box-shadow: 0 8px 22px rgba(0,0,0,0.22); } .ainews-body { flex: 1; @@ -210,11 +260,12 @@ html, body { .ainews-title-row { display: flex; align-items: baseline; - gap: 6px; + gap: 10px; } .ainews-title { - font-size: 13px; - color: rgba(255,255,255,0.9); + font-size: 14px; + font-weight: 650; + color: var(--text-main); line-height: 1.4; white-space: nowrap; overflow: hidden; @@ -224,48 +275,70 @@ html, body { } .ainews-source { font-size: 10px; - opacity: 0.4; + color: var(--text-faint); flex-shrink: 0; } .ainews-desc { - font-size: 11px; - opacity: 0.5; + font-size: 12px; + color: var(--text-soft); line-height: 1.5; - margin-top: 4px; + margin-top: 5px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +@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; + } + .time { + font-size: 64px; + } + .weather-forecast { + grid-template-columns: repeat(4, 1fr); + } + .daily-forecast { + grid-template-columns: repeat(4, 1fr); + } +} + /* ===== 知识卡片 ===== */ .knowledge-header { font-size: 11px; - color: rgba(255,255,255,0.45); - margin-bottom: 8px; + color: var(--text-faint); + margin-bottom: 10px; display: flex; align-items: center; gap: 6px; - letter-spacing: 0.5px; + letter-spacing: 0; + font-weight: 700; } .knowledge-keyword-tag { - background: rgba(255,255,255,0.08); - padding: 1px 8px; - border-radius: 4px; + background: rgba(255,255,255,0.09); + padding: 3px 8px; + border-radius: 6px; font-size: 10px; - color: rgba(255,255,255,0.6); + color: var(--text-soft); } .knowledge-content { - font-size: 14px; - color: rgba(255,255,255,0.9); - line-height: 1.7; + font-size: 16px; + color: var(--text-main); + line-height: 1.55; text-shadow: 0 1px 4px rgba(0,0,0,0.5); } .divider { height: 1px; - background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent); - margin: 16px 0; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.14), transparent); + margin: 18px 0; } #author { @@ -273,8 +346,8 @@ html, body { bottom: 60px; right: 30px; font-size: 16px; - color: rgba(255,255,255,0.5); - letter-spacing: 1px; + color: rgba(255,255,255,0.42); + letter-spacing: 0; z-index: 10; pointer-events: none; } @@ -288,10 +361,11 @@ body.layout-single #layout-multi { display: none; } body.layout-single #info { position: fixed; - top: 40px; - right: 40px; + top: 42px; + right: 42px; text-align: left; - width: calc(50vw - 60px); + width: min(760px, calc(50vw - 52px)); + max-height: calc(100vh - 84px); z-index: 10; } body.layout-single #info .date { @@ -300,57 +374,80 @@ body.layout-single #info .date { /* ===== MULTI 布局(独立卡片) ===== */ body.layout-multi #layout-single { display: none; } -body.layout-multi #layout-multi { display: block; } +body.layout-multi #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; +} +body.layout-multi #layout-multi > .card { + pointer-events: none; +} body.layout-multi #card-time { - position: fixed; - top: 40px; - right: 40px; + grid-area: time; text-align: left; - min-width: 280px; - z-index: 10; + width: 100%; + height: 100%; + padding: 22px 30px; } body.layout-multi #card-zodiac { - position: fixed; - top: 160px; - right: 40px; - width: 280px; - z-index: 10; + grid-area: zodiac; + width: 100%; + height: 100%; + padding: 22px 28px; +} +body.layout-multi #card-knowledge { + grid-area: knowledge; + align-self: stretch; + width: 100%; + min-height: 0; + padding: 22px 26px; +} +body.layout-multi #card-knowledge .knowledge-content { + display: -webkit-box; + -webkit-line-clamp: 8; + -webkit-box-orient: vertical; + overflow: hidden; } body.layout-multi #card-ainews { - position: fixed; - bottom: 40px; - left: 40px; - width: calc(50vw - 80px); - z-index: 10; + grid-area: news; + align-self: end; + width: 100%; + max-height: 390px; } -body.layout-multi #card-bottom { - position: fixed; - bottom: 40px; - right: 40px; - width: calc(50vw - 60px); - display: flex; - gap: 20px; - align-items: flex-end; - z-index: 10; -} -body.layout-multi #card-right-col { - display: flex; - flex-direction: column; - gap: 20px; - flex: 1; +body.layout-multi #card-weather { + grid-area: weather; + align-self: end; + width: 100%; min-width: 0; -} -body.layout-multi #card-right-col #card-knowledge { - text-align: left; -} -body.layout-multi #card-right-col #card-ainews { - text-align: left; -} -body.layout-multi #card-bottom #card-weather { text-align: right; + padding: 22px 28px 24px; +} +body.layout-multi #card-photo { + grid-area: photo; + position: relative; + top: auto; + left: auto; + align-self: start; + width: 100%; + max-height: var(--photo-panel-h); + padding: 16px; +} +body.layout-multi #card-photo img { + max-height: calc(var(--photo-panel-h) - 32px); } - /* ===== 卡片隐藏 ===== */ body.hide-time #card-time, body.hide-time #info .time, @@ -372,15 +469,16 @@ body.hide-photo #card-photo { display: none !important; } /* ===== 相册 ===== */ #card-photo { position: fixed; - top: 40px; - left: 40px; - width: calc(50vw - 80px); + top: 44px; + left: 48px; + width: min(860px, calc(50vw - 72px)); + box-sizing: border-box; z-index: 10; padding: 16px; } .photo-wrap { position: relative; - border-radius: 12px; + border-radius: 8px; overflow: hidden; } #card-photo img { @@ -388,7 +486,7 @@ body.hide-photo #card-photo { display: none !important; } max-height: 450px; object-fit: cover; display: block; - border-radius: 12px; + border-radius: 8px; transition: opacity 0.5s ease; } .photo-info { @@ -417,6 +515,58 @@ body.hide-photo #card-photo { display: none !important; } background: rgba(255,255,255,0.6); border-radius: 2px; } + +/* ===== 电子相册模式 ===== */ +body.photo-frame-mode #bg-layer { display: none !important; } +body.photo-frame-mode #card-photo { display: none !important; } +body.photo-frame-mode #layout-multi { display: none !important; } +body.photo-frame-mode #layout-single { display: none !important; } +body.photo-frame-mode #author { display: none !important; } +#photo-frame-bg { + display: none; + position: fixed; + top: 0; left: 0; + width: 100%; height: 100%; + z-index: 4; + object-fit: cover; + filter: blur(30px) brightness(0.5); + transform: scale(1.1); + transition: opacity 0.8s ease; +} +#photo-frame-img { + display: none; + position: fixed; + top: 0; left: 0; + width: 100%; height: 100%; + z-index: 5; + object-fit: contain; + transition: opacity 0.8s ease; +} +body.photo-frame-mode #photo-frame-bg { display: block; } +body.photo-frame-mode #photo-frame-img { display: block; } +/* 相册时钟叠加层 */ +.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; +} +body.photo-frame-mode .photo-frame-clock { opacity: 1; } +.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; +} +body.photo-frame-mode .photo-frame-date { opacity: 1; } @@ -459,39 +609,41 @@ body.hide-photo #card-photo { display: none !important; }
加载中...
+
+
💡 知识卡片
+
请设置知识关键字
+
🤖 AI 资讯
加载中...
-
-
-
-
💡 知识卡片
-
请设置知识关键字
-
-
-
加载中...
-
24小时预报
-
-
7日预报
-
-
-
+
+
加载中...
+
24小时预报
+
+
7日预报
+
-
- -