新增: 电子相册全屏模式+开机启动+Win10兼容
31
ainews.go
@@ -55,7 +55,12 @@ func fetchAINews() []aiNewsItem {
|
|||||||
}
|
}
|
||||||
aiNewsMu.Unlock()
|
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)
|
data, err := httpGet(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("AI资讯请求失败:", err)
|
log.Println("AI资讯请求失败:", err)
|
||||||
@@ -124,26 +129,34 @@ func pushAINews(items []aiNewsItem) {
|
|||||||
|
|
||||||
func aiNewsLoop() {
|
func aiNewsLoop() {
|
||||||
cfg := loadConfig()
|
cfg := loadConfig()
|
||||||
if cfg.HideAINews {
|
if !cfg.HideAINews {
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先推送缓存
|
// 先推送缓存
|
||||||
if cached := loadAINewsCache(); cached != nil {
|
if cached := loadAINewsCache(); cached != nil {
|
||||||
pushAINews(cached)
|
pushAINews(cached)
|
||||||
}
|
}
|
||||||
|
} else if cached := loadAINewsCache(); cached != nil {
|
||||||
|
aiNewsMu.Lock()
|
||||||
|
aiNewsCache = cached
|
||||||
|
aiNewsCacheAt = time.Now()
|
||||||
|
aiNewsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
time.Sleep(8 * time.Second)
|
time.Sleep(8 * time.Second)
|
||||||
|
|
||||||
cfg = loadConfig()
|
cfg = loadConfig()
|
||||||
if cfg.HideAINews {
|
if !cfg.HideAINews {
|
||||||
return
|
var items []aiNewsItem
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
items = fetchAINews()
|
||||||
|
if items != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
items := fetchAINews()
|
|
||||||
if items != nil {
|
if items != nil {
|
||||||
pushAINews(items)
|
pushAINews(items)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ticker := time.NewTicker(2 * time.Hour)
|
ticker := time.NewTicker(2 * time.Hour)
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
|
|||||||
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 |
29
bing.go
@@ -213,25 +213,14 @@ func bingToggleFavorite() string {
|
|||||||
r := &h.Records[h.CurrentIdx]
|
r := &h.Records[h.CurrentIdx]
|
||||||
r.Favorited = !r.Favorited
|
r.Favorited = !r.Favorited
|
||||||
saveBingHistory(h)
|
saveBingHistory(h)
|
||||||
state := "收藏"
|
|
||||||
if r.Favorited {
|
|
||||||
state = "取消收藏"
|
|
||||||
}
|
|
||||||
log.Printf("Bing 壁纸: %s (date=%s)", state, r.Date)
|
|
||||||
if r.Favorited {
|
if r.Favorited {
|
||||||
|
log.Printf("Bing 壁纸: 已收藏 (date=%s)", r.Date)
|
||||||
return "☆ 取消收藏"
|
return "☆ 取消收藏"
|
||||||
}
|
}
|
||||||
|
log.Printf("Bing 壁纸: 已取消收藏 (date=%s)", r.Date)
|
||||||
return "★ 收藏当前壁纸"
|
return "★ 收藏当前壁纸"
|
||||||
}
|
}
|
||||||
|
|
||||||
func bingCopyrightInfo() string {
|
|
||||||
r := getCurrentBingRecord()
|
|
||||||
if r != nil {
|
|
||||||
return fmt.Sprintf("%s (%s)", r.Copyright, r.Date)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func bingCurrentState() string {
|
func bingCurrentState() string {
|
||||||
h := loadBingHistory()
|
h := loadBingHistory()
|
||||||
if h.CurrentIdx < 0 || h.CurrentIdx >= len(h.Records) {
|
if h.CurrentIdx < 0 || h.CurrentIdx >= len(h.Records) {
|
||||||
@@ -305,25 +294,28 @@ func bingWallpaperLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 定时切换壁纸 (1 小时间隔)
|
// 定时切换壁纸 (1 小时间隔)
|
||||||
|
go func() {
|
||||||
ticker := time.NewTicker(1 * time.Hour)
|
ticker := time.NewTicker(1 * time.Hour)
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
cfg := loadConfig()
|
cfg := loadConfig()
|
||||||
if cfg.WallpaperType != WPBing || !cfg.BingAutoRefresh {
|
if cfg.WallpaperType != WPBing || !cfg.BingAutoRefresh {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
bingMu.Lock()
|
||||||
h := loadBingHistory()
|
h := loadBingHistory()
|
||||||
if len(h.Records) <= 1 {
|
if len(h.Records) <= 1 {
|
||||||
|
bingMu.Unlock()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 顺序切换到下一张
|
h.CurrentIdx = (h.CurrentIdx + 1) % len(h.Records)
|
||||||
nextIdx := (h.CurrentIdx + 1) % len(h.Records)
|
|
||||||
h.CurrentIdx = nextIdx
|
|
||||||
saveBingHistory(h)
|
saveBingHistory(h)
|
||||||
|
bingMu.Unlock()
|
||||||
bingReloadImage()
|
bingReloadImage()
|
||||||
log.Printf("Bing 自动切换: idx=%d/%d", nextIdx, len(h.Records))
|
log.Printf("Bing 自动切换: idx=%d/%d", h.CurrentIdx, len(h.Records))
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// 定时拉取新壁纸 (4 小时间隔)
|
go func() {
|
||||||
fetchTicker := time.NewTicker(4 * time.Hour)
|
fetchTicker := time.NewTicker(4 * time.Hour)
|
||||||
for range fetchTicker.C {
|
for range fetchTicker.C {
|
||||||
cfg := loadConfig()
|
cfg := loadConfig()
|
||||||
@@ -331,4 +323,5 @@ func bingWallpaperLoop() {
|
|||||||
go fetchBingHistory()
|
go fetchBingHistory()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
73
build.ps1
Normal file
@@ -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
|
||||||
56
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"
|
||||||
)
|
)
|
||||||
@@ -64,17 +61,25 @@ type Config struct {
|
|||||||
PhotoDir string `json:"photoDir"`
|
PhotoDir string `json:"photoDir"`
|
||||||
PhotoInterval int `json:"photoInterval"`
|
PhotoInterval int `json:"photoInterval"`
|
||||||
HidePhoto bool `json:"hidePhoto"`
|
HidePhoto bool `json:"hidePhoto"`
|
||||||
|
PhotoFrameMode bool `json:"photoFrameMode"`
|
||||||
KnowledgeKeyword string `json:"knowledgeKeyword"`
|
KnowledgeKeyword string `json:"knowledgeKeyword"`
|
||||||
KnowledgePrompt string `json:"knowledgePrompt"`
|
KnowledgePrompt string `json:"knowledgePrompt"`
|
||||||
HideKnowledge bool `json:"hideKnowledge"`
|
HideKnowledge bool `json:"hideKnowledge"`
|
||||||
SavedColors []SavedColor `json:"savedColors"`
|
SavedColors []SavedColor `json:"savedColors"`
|
||||||
BingAutoRefresh bool `json:"bingAutoRefresh"`
|
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)
|
||||||
}
|
}
|
||||||
@@ -117,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)
|
|
||||||
}
|
}
|
||||||
|
return cfgValue
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
|
||||||
png.Encode(&buf, img)
|
func (c *Config) qweatherKey() string {
|
||||||
pngData := buf.Bytes()
|
return getSecret("U_DESKTOP_QWEATHER_KEY", c.QWeatherKey)
|
||||||
ico := make([]byte, 22+len(pngData))
|
}
|
||||||
binary.LittleEndian.PutUint16(ico[0:], 0)
|
|
||||||
binary.LittleEndian.PutUint16(ico[2:], 1)
|
func (c *Config) tianapiKey() string {
|
||||||
binary.LittleEndian.PutUint16(ico[4:], 1)
|
return getSecret("U_DESKTOP_TIANAPI_KEY", c.TianapiKey)
|
||||||
ico[6], ico[7], ico[8], ico[9] = 16, 16, 0, 0
|
}
|
||||||
binary.LittleEndian.PutUint16(ico[10:], 1)
|
|
||||||
binary.LittleEndian.PutUint16(ico[12:], 32)
|
func (c *Config) cpaKey() string {
|
||||||
binary.LittleEndian.PutUint32(ico[14:], uint32(len(pngData)))
|
return getSecret("U_DESKTOP_CPA_KEY", c.CPAKey)
|
||||||
binary.LittleEndian.PutUint32(ico[18:], 22)
|
|
||||||
copy(ico[22:], pngData)
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,10 +24,14 @@ var (
|
|||||||
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
|
||||||
|
|||||||
37
horoscope.go
@@ -7,11 +7,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const tianapiKey = "da21ff665b09cbdcc29952a105aad97b"
|
|
||||||
const tianapiStarURL = "https://apis.tianapi.com/star/index"
|
const tianapiStarURL = "https://apis.tianapi.com/star/index"
|
||||||
|
|
||||||
var zodiacToSign = map[string]string{
|
var zodiacToSign = map[string]string{
|
||||||
@@ -20,8 +18,6 @@ var zodiacToSign = map[string]string{
|
|||||||
"射手座": "sagittarius", "摩羯座": "capricorn", "水瓶座": "aquarius", "双鱼座": "pisces",
|
"射手座": "sagittarius", "摩羯座": "capricorn", "水瓶座": "aquarius", "双鱼座": "pisces",
|
||||||
}
|
}
|
||||||
|
|
||||||
var horoscopeMu sync.Mutex
|
|
||||||
|
|
||||||
type tianapiStarResp struct {
|
type tianapiStarResp struct {
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
Result struct {
|
Result struct {
|
||||||
@@ -68,17 +64,18 @@ func loadHoroscopeCache() *horoscopeInfo {
|
|||||||
return &info
|
return &info
|
||||||
}
|
}
|
||||||
|
|
||||||
func isCacheToday(info *horoscopeInfo) bool {
|
|
||||||
return info != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchHoroscope(zodiac string) *horoscopeInfo {
|
func fetchHoroscope(zodiac string) *horoscopeInfo {
|
||||||
sign := zodiacToSign[zodiac]
|
sign := zodiacToSign[zodiac]
|
||||||
if sign == "" {
|
if sign == "" {
|
||||||
sign = "sagittarius"
|
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)
|
data, err := httpGet(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("星座运势请求失败:", err)
|
log.Println("星座运势请求失败:", err)
|
||||||
@@ -138,28 +135,26 @@ func pushHoroscope(zodiac string) {
|
|||||||
|
|
||||||
func horoscopeLoop() {
|
func horoscopeLoop() {
|
||||||
cfg := loadConfig()
|
cfg := loadConfig()
|
||||||
if cfg.HideZodiac {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cached := loadHoroscopeCache()
|
cached := loadHoroscopeCache()
|
||||||
if cached != nil {
|
if cached != nil && !cfg.HideZodiac {
|
||||||
pushHoroscopeInfo(cached)
|
pushHoroscopeInfo(cached)
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
|
|
||||||
cfg = loadConfig()
|
cfg = loadConfig()
|
||||||
if cfg.HideZodiac {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
today := time.Now().Format("2006-01-02")
|
today := time.Now().Format("2006-01-02")
|
||||||
if cached != nil && cached.Date == today && cached.Zodiac == cfg.Zodiac {
|
if !cfg.HideZodiac && !(cached != nil && cached.Date == today && cached.Zodiac == cfg.Zodiac) {
|
||||||
return
|
for i := 0; i < 3; i++ {
|
||||||
}
|
|
||||||
|
|
||||||
pushHoroscope(cfg.Zodiac)
|
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)
|
ticker := time.NewTicker(24 * time.Hour)
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
|
|||||||
122
knowledge.go
@@ -9,14 +9,16 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
const cpaURL = "https://cpa.1216.top/v1/chat/completions"
|
const cpaURL = "https://cpa.1216.top/v1/chat/completions"
|
||||||
const cpaKey = "alink-shared-key-1"
|
|
||||||
const cpaModel = "glm-4.5-air"
|
const cpaModel = "glm-4.5-air"
|
||||||
|
const minKnowledgeRunes = 80
|
||||||
|
|
||||||
type knowledgeData struct {
|
type knowledgeData struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
@@ -59,16 +61,25 @@ func getRandomKnowledgeCard(keyword string) string {
|
|||||||
if knowledgeDB == nil {
|
if knowledgeDB == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
var content string
|
rows, err := knowledgeDB.Query(
|
||||||
err := knowledgeDB.QueryRow(
|
"SELECT content FROM knowledge_cards WHERE keyword = ? ORDER BY RANDOM() LIMIT 20",
|
||||||
"SELECT content FROM knowledge_cards WHERE keyword = ? ORDER BY RANDOM() LIMIT 1",
|
|
||||||
keyword,
|
keyword,
|
||||||
).Scan(&content)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var content string
|
||||||
|
if rows.Scan(&content) == nil {
|
||||||
|
content = normalizeKnowledgeContent(content)
|
||||||
|
if isQualityKnowledgeCard(content) {
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func getKnowledgeCardCount(keyword string) int {
|
func getKnowledgeCardCount(keyword string) int {
|
||||||
if knowledgeDB == nil {
|
if knowledgeDB == nil {
|
||||||
@@ -86,21 +97,60 @@ func getKnowledgeCardCount(keyword string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fetchKnowledgeFromLLM(keyword string, cfg *Config) string {
|
func fetchKnowledgeFromLLM(keyword string, cfg *Config) string {
|
||||||
basePrompt := fmt.Sprintf(
|
basePrompt := buildKnowledgePrompt(keyword, cfg.KnowledgePrompt)
|
||||||
"根据关键词「%s」,生成一条有趣的知识小卡片。要求:控制在80字以内,简洁有趣,有知识性。直接输出内容,不要加标题、序号或其他格式。",
|
messages := []map[string]string{
|
||||||
keyword,
|
{
|
||||||
)
|
"role": "system",
|
||||||
if cfg.KnowledgePrompt != "" {
|
"content": "你是严谨的中文知识卡片作者,输出必须具体、准确、有信息密度。不要写空泛鸡汤,不要只给一句定义。",
|
||||||
basePrompt += "\n附加要求:" + cfg.KnowledgePrompt
|
},
|
||||||
|
{"role": "user", "content": basePrompt},
|
||||||
}
|
}
|
||||||
|
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
"model": cpaModel,
|
"model": cpaModel,
|
||||||
"max_tokens": 256,
|
"max_tokens": 512,
|
||||||
"messages": []map[string]string{
|
"temperature": 0.55,
|
||||||
{"role": "user", "content": basePrompt},
|
"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)
|
jsonData, _ := json.Marshal(body)
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", cpaURL, bytes.NewReader(jsonData))
|
req, err := http.NewRequest("POST", cpaURL, bytes.NewReader(jsonData))
|
||||||
@@ -108,7 +158,12 @@ func fetchKnowledgeFromLLM(keyword string, cfg *Config) string {
|
|||||||
log.Println("知识API请求创建失败:", err)
|
log.Println("知识API请求创建失败:", err)
|
||||||
return ""
|
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")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := httpClient.Do(req)
|
resp, err := httpClient.Do(req)
|
||||||
@@ -135,6 +190,41 @@ func fetchKnowledgeFromLLM(keyword string, cfg *Config) string {
|
|||||||
return ""
|
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) {
|
func pushKnowledgeJSON(content, keyword string) {
|
||||||
data, _ := json.Marshal(knowledgeData{Content: content, Keyword: keyword})
|
data, _ := json.Marshal(knowledgeData{Content: content, Keyword: keyword})
|
||||||
evalJS(fmt.Sprintf(`if(window.updateKnowledgeFromGo) window.updateKnowledgeFromGo(%s)`, string(data)))
|
evalJS(fmt.Sprintf(`if(window.updateKnowledgeFromGo) window.updateKnowledgeFromGo(%s)`, string(data)))
|
||||||
|
|||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
60
photo.go
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -20,6 +19,9 @@ var (
|
|||||||
photoDir string
|
photoDir string
|
||||||
photoStop chan struct{}
|
photoStop chan struct{}
|
||||||
photoDone chan struct{}
|
photoDone chan struct{}
|
||||||
|
photoCacheMu sync.Mutex
|
||||||
|
photoCacheMap map[string]string
|
||||||
|
photoCacheDir string
|
||||||
)
|
)
|
||||||
|
|
||||||
func scanPhotoDir(dir string) []string {
|
func scanPhotoDir(dir string) []string {
|
||||||
@@ -42,25 +44,31 @@ func scanPhotoDir(dir string) []string {
|
|||||||
return files
|
return files
|
||||||
}
|
}
|
||||||
|
|
||||||
func photoToDataURI(dir, name string) string {
|
func preCachePhotos(dir string, files []string) {
|
||||||
path := filepath.Join(dir, name)
|
cache := make(map[string]string, len(files))
|
||||||
data, err := os.ReadFile(path)
|
for _, name := range files {
|
||||||
if err != nil {
|
uri := imageToDataURI(filepath.Join(dir, name))
|
||||||
return ""
|
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"
|
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data))
|
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) {
|
func pushCurrentPhoto(interval int) {
|
||||||
@@ -78,7 +86,7 @@ func pushCurrentPhoto(interval int) {
|
|||||||
idx = 0
|
idx = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
src := photoToDataURI(dir, files[idx])
|
src := getCachedPhotoURI(dir, files[idx])
|
||||||
if src == "" {
|
if src == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -118,7 +126,13 @@ func startPhotoLoop() {
|
|||||||
photoDone = done
|
photoDone = done
|
||||||
photoMu.Unlock()
|
photoMu.Unlock()
|
||||||
|
|
||||||
|
photoCacheMu.Lock()
|
||||||
|
photoCacheMap = nil
|
||||||
|
photoCacheDir = cfg.PhotoDir
|
||||||
|
photoCacheMu.Unlock()
|
||||||
|
|
||||||
log.Printf("相册: 共 %d 张, 间隔 %ds", len(files), interval)
|
log.Printf("相册: 共 %d 张, 间隔 %ds", len(files), interval)
|
||||||
|
go preCachePhotos(cfg.PhotoDir, files)
|
||||||
pushCurrentPhoto(interval)
|
pushCurrentPhoto(interval)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -149,8 +163,16 @@ func stopPhotoLoop() {
|
|||||||
done := photoDone
|
done := photoDone
|
||||||
photoStop = nil
|
photoStop = nil
|
||||||
photoDone = nil
|
photoDone = nil
|
||||||
|
photoFiles = nil
|
||||||
|
photoIdx = 0
|
||||||
|
photoDir = ""
|
||||||
photoMu.Unlock()
|
photoMu.Unlock()
|
||||||
|
|
||||||
|
photoCacheMu.Lock()
|
||||||
|
photoCacheMap = nil
|
||||||
|
photoCacheDir = ""
|
||||||
|
photoCacheMu.Unlock()
|
||||||
|
|
||||||
if stop != nil {
|
if stop != nil {
|
||||||
close(stop)
|
close(stop)
|
||||||
}
|
}
|
||||||
|
|||||||
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)"
|
||||||
85
settings.go
@@ -24,6 +24,40 @@ var (
|
|||||||
settingsHwnd uintptr
|
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 {
|
func isSystemLightTheme() bool {
|
||||||
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Themes\Personalize`, registry.QUERY_VALUE)
|
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Themes\Personalize`, registry.QUERY_VALUE)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -49,6 +83,25 @@ var themeNames = []struct {
|
|||||||
{ThemeText, "文字"},
|
{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() {
|
func openSettingsWindow() {
|
||||||
settingsMu.Lock()
|
settingsMu.Lock()
|
||||||
if settingsOpen && settingsHwnd != 0 {
|
if settingsOpen && settingsHwnd != 0 {
|
||||||
@@ -141,6 +194,8 @@ func openSettingsWindow() {
|
|||||||
"photoDir": cfg.PhotoDir,
|
"photoDir": cfg.PhotoDir,
|
||||||
"photoInterval": cfg.PhotoInterval,
|
"photoInterval": cfg.PhotoInterval,
|
||||||
"photoCard": !cfg.HidePhoto,
|
"photoCard": !cfg.HidePhoto,
|
||||||
|
"photoFrameMode": cfg.PhotoFrameMode,
|
||||||
|
"autoStart": isAutoStartEnabled(),
|
||||||
})
|
})
|
||||||
return string(data)
|
return string(data)
|
||||||
})
|
})
|
||||||
@@ -151,6 +206,7 @@ func openSettingsWindow() {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
cfg := loadConfig()
|
cfg := loadConfig()
|
||||||
|
oldCfg := *cfg
|
||||||
if v, ok := data["wallpaper"]; ok {
|
if v, ok := data["wallpaper"]; ok {
|
||||||
cfg.HideWallpaper = !v
|
cfg.HideWallpaper = !v
|
||||||
evalJS(fmt.Sprintf("if(window.setWallpaperVisible) setWallpaperVisible(%v)", v))
|
evalJS(fmt.Sprintf("if(window.setWallpaperVisible) setWallpaperVisible(%v)", v))
|
||||||
@@ -179,9 +235,30 @@ func openSettingsWindow() {
|
|||||||
cfg.ShowSeconds = v
|
cfg.ShowSeconds = v
|
||||||
evalJS(fmt.Sprintf("if(window.setShowSeconds) setShowSeconds(%v)", 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 {
|
if v, ok := data["photoCard"]; ok {
|
||||||
cfg.HidePhoto = !v
|
cfg.HidePhoto = !v
|
||||||
|
saveConfig(cfg)
|
||||||
evalJS(fmt.Sprintf("if(window.setCardVisible) setCardVisible('photo',%v)", v))
|
evalJS(fmt.Sprintf("if(window.setCardVisible) setCardVisible('photo',%v)", v))
|
||||||
|
refreshVisibleCards(cfg, &oldCfg)
|
||||||
if cfg.PhotoDir != "" {
|
if cfg.PhotoDir != "" {
|
||||||
if v {
|
if v {
|
||||||
restartPhotoLoop()
|
restartPhotoLoop()
|
||||||
@@ -189,8 +266,10 @@ func openSettingsWindow() {
|
|||||||
stopPhotoLoop()
|
stopPhotoLoop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
saveConfig(cfg)
|
saveConfig(cfg)
|
||||||
|
refreshVisibleCards(cfg, &oldCfg)
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -416,8 +495,8 @@ func openSettingsWindow() {
|
|||||||
|
|
||||||
hwnd := uintptr(w.Window())
|
hwnd := uintptr(w.Window())
|
||||||
// disable resize
|
// disable resize
|
||||||
style, _, _ := procGetWindowLongPtrW.Call(hwnd, uintptr(0xFFFFFFF0))
|
style, _, _ := procGetWindowLongPtrW.Call(hwnd, gwlStyle)
|
||||||
procSetWindowLongPtrW.Call(hwnd, uintptr(0xFFFFFFF0), style & ^uintptr(0x00040000|0x00010000))
|
procSetWindowLongPtrW.Call(hwnd, gwlStyle, style & ^uintptr(wsSizebox|wsMaxbox))
|
||||||
|
|
||||||
// resizeToFit: JS measures content, Go adjusts window frame
|
// resizeToFit: JS measures content, Go adjusts window frame
|
||||||
w.Bind("resizeToFit", func(contentW, contentH int) string {
|
w.Bind("resizeToFit", func(contentW, contentH int) string {
|
||||||
|
|||||||
49
systray.go
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -16,7 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func onSystrayReady() {
|
func onSystrayReady() {
|
||||||
systray.SetIcon(generateIcon())
|
systray.SetIcon(trayIcon)
|
||||||
systray.SetTooltip("动态壁纸引擎")
|
systray.SetTooltip("动态壁纸引擎")
|
||||||
|
|
||||||
mSettings := systray.AddMenuItem("桌面设置", "打开设置窗口")
|
mSettings := systray.AddMenuItem("桌面设置", "打开设置窗口")
|
||||||
@@ -58,15 +57,20 @@ func onSystrayReady() {
|
|||||||
|
|
||||||
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{
|
||||||
@@ -76,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()
|
||||||
@@ -85,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
|
||||||
|
|||||||
120
wallpaper.go
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -44,35 +45,14 @@ var themeMap = map[ThemeName]string{
|
|||||||
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:
|
|
||||||
if cfg.ImagePath != "" {
|
|
||||||
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:
|
|
||||||
if p := getCurrentBingPath(); p != "" {
|
|
||||||
if _, err := os.Stat(p); err == nil {
|
|
||||||
src := imageToDataURI(p)
|
|
||||||
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 {
|
} else {
|
||||||
bg = fmt.Sprintf(`<div style="position:fixed;top:0;left:0;width:100%%;height:100%%;z-index:1;background:%s;"></div>`, cfg.Color1)
|
bg = buildBgHTML(cfg)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if bg == "" {
|
if bg == "" {
|
||||||
@@ -109,6 +89,9 @@ func buildWallpaperHTML(cfg *Config) string {
|
|||||||
if cfg.HidePhoto {
|
if cfg.HidePhoto {
|
||||||
bodyClasses = append(bodyClasses, "hide-photo")
|
bodyClasses = append(bodyClasses, "hide-photo")
|
||||||
}
|
}
|
||||||
|
if cfg.PhotoFrameMode && cfg.PhotoDir != "" {
|
||||||
|
bodyClasses = append(bodyClasses, "photo-frame-mode")
|
||||||
|
}
|
||||||
if len(bodyClasses) > 0 {
|
if len(bodyClasses) > 0 {
|
||||||
cls := strings.Join(bodyClasses, " ")
|
cls := strings.Join(bodyClasses, " ")
|
||||||
html = strings.Replace(html, "{{BODY_CLASSES}}", cls, 1)
|
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 != "" {
|
if cfg.WallpaperType == WPTheme && cfg.Theme == ThemeText && cfg.WallpaperText != "" {
|
||||||
escaped := strings.ReplaceAll(cfg.WallpaperText, `\`, `\\`)
|
escaped, _ := json.Marshal(cfg.WallpaperText)
|
||||||
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
|
html = strings.Replace(html, "</script>", `window.wallpaperText = `+string(escaped)+`;</script>`, 1)
|
||||||
html = strings.Replace(html, "</script>", `window.wallpaperText = "`+escaped+`";</script>`, 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return html
|
return html
|
||||||
@@ -152,22 +134,96 @@ 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))
|
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
city := getCurrentCity()
|
|
||||||
|
func buildBgHTML(cfg *Config) string {
|
||||||
|
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)
|
||||||
|
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)
|
go fetchAndPushWeather(city)
|
||||||
|
}
|
||||||
|
if cfg.PhotoDir != "" && !cfg.HidePhoto {
|
||||||
|
go func() {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
pushCurrentPhoto(cfg.PhotoInterval)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func bingReloadImage() {
|
func bingReloadImage() {
|
||||||
if wv == nil || wvHwnd == 0 {
|
if wv == nil || wvHwnd == 0 {
|
||||||
|
|||||||
38
weather.go
@@ -12,7 +12,6 @@ import (
|
|||||||
"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 {
|
||||||
@@ -84,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
|
||||||
}
|
}
|
||||||
@@ -114,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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,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
|
||||||
@@ -239,7 +246,12 @@ func fetchAndPushWeather(city City) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -258,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
|
||||||
@@ -293,7 +309,11 @@ func fetchHourlyForecast(cityID string) []hourlyItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
540
web/overlay.html
@@ -10,28 +10,69 @@ html, body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
font-family: "Microsoft YaHei", sans-serif;
|
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||||
color: #fff;
|
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 {
|
.card {
|
||||||
background: rgba(0, 0, 0, 0.25);
|
position: relative;
|
||||||
backdrop-filter: blur(24px);
|
box-sizing: border-box;
|
||||||
-webkit-backdrop-filter: blur(24px);
|
overflow: hidden;
|
||||||
border-radius: 20px;
|
background: linear-gradient(145deg, rgba(255,255,255,0.075), rgba(255,255,255,0.025)), var(--card-bg);
|
||||||
padding: 24px 28px;
|
backdrop-filter: blur(28px) saturate(1.2);
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(255,255,255,0.08);
|
-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 {
|
.time {
|
||||||
font-size: 72px;
|
font-size: 66px;
|
||||||
font-weight: 200;
|
font-weight: 200;
|
||||||
text-shadow: 0 2px 20px rgba(0,0,0,0.5);
|
text-shadow: 0 3px 28px rgba(0,0,0,0.42);
|
||||||
letter-spacing: -2px;
|
letter-spacing: 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
color: rgba(255,255,255,0.96);
|
||||||
}
|
}
|
||||||
@keyframes hourlyGlow {
|
@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; }
|
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;
|
animation: hourlyGlow 6s ease-out forwards;
|
||||||
}
|
}
|
||||||
.date {
|
.date {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
color: rgba(255,255,255,0.7);
|
color: var(--text-soft);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 8px;
|
||||||
text-shadow: 0 1px 6px rgba(0,0,0,0.5);
|
text-shadow: 0 1px 6px rgba(0,0,0,0.5);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-weather {
|
.current-weather {
|
||||||
font-size: 16px;
|
font-size: 17px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: rgba(255,255,255,0.95);
|
color: var(--text-main);
|
||||||
text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||||
margin-bottom: 14px;
|
margin-bottom: 16px;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
.forecast-title {
|
.forecast-title {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: rgba(255,255,255,0.45);
|
color: var(--text-faint);
|
||||||
letter-spacing: 1px;
|
letter-spacing: 0;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 9px;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
.weather-forecast {
|
.weather-forecast {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 6px;
|
grid-template-columns: repeat(8, minmax(60px, 1fr));
|
||||||
overflow-x: auto;
|
gap: 7px;
|
||||||
padding-bottom: 6px;
|
overflow: hidden;
|
||||||
justify-content: flex-end;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
.forecast-item {
|
.forecast-item {
|
||||||
background: rgba(255,255,255,0.06);
|
background: rgba(255,255,255,0.055);
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
padding: 10px 12px;
|
padding: 9px 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-width: 58px;
|
min-width: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255,255,255,0.85);
|
color: var(--text-main);
|
||||||
border: 1px solid rgba(255,255,255,0.04);
|
border: 1px solid var(--card-line-soft);
|
||||||
}
|
}
|
||||||
.forecast-icon { font-size: 20px; margin: 4px 0; }
|
.forecast-icon { font-size: 22px; margin: 5px 0; }
|
||||||
.forecast-time { opacity: 0.6; font-size: 10px; }
|
.forecast-time { color: var(--text-faint); font-size: 10px; }
|
||||||
.forecast-temp { font-weight: 600; margin-top: 2px; font-size: 13px; }
|
.forecast-temp { font-weight: 700; margin-top: 2px; font-size: 14px; }
|
||||||
.forecast-pop { font-size: 10px; opacity: 0.6; margin-top: 1px; }
|
.forecast-pop { font-size: 10px; color: var(--text-faint); margin-top: 2px; }
|
||||||
.daily-forecast {
|
.daily-forecast {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 6px;
|
grid-template-columns: repeat(7, minmax(64px, 1fr));
|
||||||
overflow-x: auto;
|
gap: 7px;
|
||||||
padding-bottom: 6px;
|
overflow: hidden;
|
||||||
justify-content: flex-end;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
.daily-item {
|
.daily-item {
|
||||||
background: rgba(255,255,255,0.04);
|
background: rgba(255,255,255,0.04);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px 10px;
|
padding: 9px 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-width: 54px;
|
min-width: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255,255,255,0.85);
|
color: var(--text-main);
|
||||||
border: 1px solid rgba(255,255,255,0.03);
|
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 {
|
.zodiac-text {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: rgba(255,255,255,0.9);
|
color: var(--text-main);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
.zodiac-title {
|
.zodiac-title {
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 650;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 3px;
|
||||||
}
|
}
|
||||||
.zodiac-date {
|
.zodiac-date {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
opacity: 0.4;
|
color: var(--text-faint);
|
||||||
margin-bottom: 14px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.zodiac-bar {
|
.zodiac-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
margin-bottom: 7px;
|
margin-bottom: 7px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.zodiac-bar-label {
|
.zodiac-bar-label {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
opacity: 0.55;
|
color: var(--text-soft);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.zodiac-bar-track {
|
.zodiac-bar-track {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 4px;
|
height: 5px;
|
||||||
background: rgba(255,255,255,0.08);
|
background: rgba(255,255,255,0.10);
|
||||||
border-radius: 2px;
|
border-radius: 999px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.zodiac-bar-fill {
|
.zodiac-bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 2px;
|
border-radius: 999px;
|
||||||
transition: width 0.6s ease;
|
transition: width 0.6s ease;
|
||||||
}
|
}
|
||||||
.zodiac-bar-val {
|
.zodiac-bar-val {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
opacity: 0.7;
|
color: var(--text-soft);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.zodiac-tags {
|
.zodiac-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 7px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin: 12px 0;
|
margin: 10px 0 9px;
|
||||||
}
|
}
|
||||||
.zodiac-tag {
|
.zodiac-tag {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
background: rgba(255,255,255,0.06);
|
background: rgba(255,255,255,0.08);
|
||||||
padding: 2px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 5px;
|
border-radius: 6px;
|
||||||
opacity: 0.6;
|
color: var(--text-soft);
|
||||||
|
border: 1px solid rgba(255,255,255,0.05);
|
||||||
}
|
}
|
||||||
.zodiac-summary {
|
.zodiac-summary {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0.6;
|
color: var(--text-soft);
|
||||||
line-height: 1.7;
|
line-height: 1.65;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== AI 资讯 ===== */
|
/* ===== AI 资讯 ===== */
|
||||||
.ainews-header {
|
.ainews-header {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 700;
|
||||||
color: rgba(255,255,255,0.45);
|
color: var(--text-faint);
|
||||||
letter-spacing: 1px;
|
letter-spacing: 0;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
.ainews-item {
|
.ainews-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 12px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 12px;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
border-bottom: 1px solid var(--card-line-soft);
|
||||||
|
min-height: 58px;
|
||||||
}
|
}
|
||||||
.ainews-item:last-child {
|
.ainews-item:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@@ -196,12 +245,13 @@ html, body {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
.ainews-img {
|
.ainews-img {
|
||||||
width: 80px;
|
width: 92px;
|
||||||
height: 54px;
|
height: 58px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
opacity: 0.85;
|
opacity: 0.92;
|
||||||
|
box-shadow: 0 8px 22px rgba(0,0,0,0.22);
|
||||||
}
|
}
|
||||||
.ainews-body {
|
.ainews-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -210,11 +260,12 @@ html, body {
|
|||||||
.ainews-title-row {
|
.ainews-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 6px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.ainews-title {
|
.ainews-title {
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
color: rgba(255,255,255,0.9);
|
font-weight: 650;
|
||||||
|
color: var(--text-main);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -224,48 +275,70 @@ html, body {
|
|||||||
}
|
}
|
||||||
.ainews-source {
|
.ainews-source {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
opacity: 0.4;
|
color: var(--text-faint);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.ainews-desc {
|
.ainews-desc {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
opacity: 0.5;
|
color: var(--text-soft);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin-top: 4px;
|
margin-top: 5px;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
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 {
|
.knowledge-header {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: rgba(255,255,255,0.45);
|
color: var(--text-faint);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.knowledge-keyword-tag {
|
.knowledge-keyword-tag {
|
||||||
background: rgba(255,255,255,0.08);
|
background: rgba(255,255,255,0.09);
|
||||||
padding: 1px 8px;
|
padding: 3px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: rgba(255,255,255,0.6);
|
color: var(--text-soft);
|
||||||
}
|
}
|
||||||
.knowledge-content {
|
.knowledge-content {
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
color: rgba(255,255,255,0.9);
|
color: var(--text-main);
|
||||||
line-height: 1.7;
|
line-height: 1.55;
|
||||||
text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent);
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.14), transparent);
|
||||||
margin: 16px 0;
|
margin: 18px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#author {
|
#author {
|
||||||
@@ -273,8 +346,8 @@ html, body {
|
|||||||
bottom: 60px;
|
bottom: 60px;
|
||||||
right: 30px;
|
right: 30px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: rgba(255,255,255,0.5);
|
color: rgba(255,255,255,0.42);
|
||||||
letter-spacing: 1px;
|
letter-spacing: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -288,10 +361,11 @@ body.layout-single #layout-multi { display: none; }
|
|||||||
|
|
||||||
body.layout-single #info {
|
body.layout-single #info {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 40px;
|
top: 42px;
|
||||||
right: 40px;
|
right: 42px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
width: calc(50vw - 60px);
|
width: min(760px, calc(50vw - 52px));
|
||||||
|
max-height: calc(100vh - 84px);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
body.layout-single #info .date {
|
body.layout-single #info .date {
|
||||||
@@ -300,57 +374,80 @@ body.layout-single #info .date {
|
|||||||
|
|
||||||
/* ===== MULTI 布局(独立卡片) ===== */
|
/* ===== MULTI 布局(独立卡片) ===== */
|
||||||
body.layout-multi #layout-single { display: none; }
|
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 {
|
body.layout-multi #card-time {
|
||||||
position: fixed;
|
grid-area: time;
|
||||||
top: 40px;
|
|
||||||
right: 40px;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
min-width: 280px;
|
width: 100%;
|
||||||
z-index: 10;
|
height: 100%;
|
||||||
|
padding: 22px 30px;
|
||||||
}
|
}
|
||||||
body.layout-multi #card-zodiac {
|
body.layout-multi #card-zodiac {
|
||||||
position: fixed;
|
grid-area: zodiac;
|
||||||
top: 160px;
|
width: 100%;
|
||||||
right: 40px;
|
height: 100%;
|
||||||
width: 280px;
|
padding: 22px 28px;
|
||||||
z-index: 10;
|
}
|
||||||
|
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 {
|
body.layout-multi #card-ainews {
|
||||||
position: fixed;
|
grid-area: news;
|
||||||
bottom: 40px;
|
align-self: end;
|
||||||
left: 40px;
|
width: 100%;
|
||||||
width: calc(50vw - 80px);
|
max-height: 390px;
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
body.layout-multi #card-bottom {
|
body.layout-multi #card-weather {
|
||||||
position: fixed;
|
grid-area: weather;
|
||||||
bottom: 40px;
|
align-self: end;
|
||||||
right: 40px;
|
width: 100%;
|
||||||
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;
|
|
||||||
min-width: 0;
|
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;
|
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 #card-time,
|
||||||
body.hide-time #info .time,
|
body.hide-time #info .time,
|
||||||
@@ -372,15 +469,16 @@ body.hide-photo #card-photo { display: none !important; }
|
|||||||
/* ===== 相册 ===== */
|
/* ===== 相册 ===== */
|
||||||
#card-photo {
|
#card-photo {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 40px;
|
top: 44px;
|
||||||
left: 40px;
|
left: 48px;
|
||||||
width: calc(50vw - 80px);
|
width: min(860px, calc(50vw - 72px));
|
||||||
|
box-sizing: border-box;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
.photo-wrap {
|
.photo-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
#card-photo img {
|
#card-photo img {
|
||||||
@@ -388,7 +486,7 @@ body.hide-photo #card-photo { display: none !important; }
|
|||||||
max-height: 450px;
|
max-height: 450px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
transition: opacity 0.5s ease;
|
transition: opacity 0.5s ease;
|
||||||
}
|
}
|
||||||
.photo-info {
|
.photo-info {
|
||||||
@@ -417,6 +515,58 @@ body.hide-photo #card-photo { display: none !important; }
|
|||||||
background: rgba(255,255,255,0.6);
|
background: rgba(255,255,255,0.6);
|
||||||
border-radius: 2px;
|
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; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="layout-{{LAYOUT}} {{BODY_CLASSES}}">
|
<body class="layout-{{LAYOUT}} {{BODY_CLASSES}}">
|
||||||
@@ -459,16 +609,14 @@ body.hide-photo #card-photo { display: none !important; }
|
|||||||
<div id="card-zodiac" class="card">
|
<div id="card-zodiac" class="card">
|
||||||
<div class="zodiac-text" id="zodiac2">加载中...</div>
|
<div class="zodiac-text" id="zodiac2">加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="card-ainews" class="card">
|
|
||||||
<div class="ainews-header">🤖 AI 资讯</div>
|
|
||||||
<div id="ainews2">加载中...</div>
|
|
||||||
</div>
|
|
||||||
<div id="card-bottom">
|
|
||||||
<div id="card-right-col">
|
|
||||||
<div id="card-knowledge" class="card">
|
<div id="card-knowledge" class="card">
|
||||||
<div class="knowledge-header">💡 知识卡片 <span class="knowledge-keyword-tag" id="knowledgeTag2"></span></div>
|
<div class="knowledge-header">💡 知识卡片 <span class="knowledge-keyword-tag" id="knowledgeTag2"></span></div>
|
||||||
<div class="knowledge-content" id="knowledge2">请设置知识关键字</div>
|
<div class="knowledge-content" id="knowledge2">请设置知识关键字</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="card-ainews" class="card">
|
||||||
|
<div class="ainews-header">🤖 AI 资讯</div>
|
||||||
|
<div id="ainews2">加载中...</div>
|
||||||
|
</div>
|
||||||
<div id="card-weather" class="card">
|
<div id="card-weather" class="card">
|
||||||
<div class="current-weather" id="currentWeather2">加载中...</div>
|
<div class="current-weather" id="currentWeather2">加载中...</div>
|
||||||
<div class="forecast-title">24小时预报</div>
|
<div class="forecast-title">24小时预报</div>
|
||||||
@@ -476,10 +624,6 @@ body.hide-photo #card-photo { display: none !important; }
|
|||||||
<div class="forecast-title" style="margin-top:12px">7日预报</div>
|
<div class="forecast-title" style="margin-top:12px">7日预报</div>
|
||||||
<div class="daily-forecast" id="dailyForecast2"></div>
|
<div class="daily-forecast" id="dailyForecast2"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="card-photo" class="card" style="display:none">
|
<div id="card-photo" class="card" style="display:none">
|
||||||
<div class="photo-wrap">
|
<div class="photo-wrap">
|
||||||
<img id="photoImg" src="" alt="">
|
<img id="photoImg" src="" alt="">
|
||||||
@@ -489,9 +633,17 @@ body.hide-photo #card-photo { display: none !important; }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="author">绝尘</div>
|
<div id="author">绝尘</div>
|
||||||
|
|
||||||
|
<!-- 相册模式全屏图片 -->
|
||||||
|
<img id="photo-frame-bg" src="" alt="">
|
||||||
|
<img id="photo-frame-img" src="" alt="">
|
||||||
|
<!-- 相册模式时钟 -->
|
||||||
|
<div class="photo-frame-clock" id="photoFrameClock"></div>
|
||||||
|
<div class="photo-frame-date" id="photoFrameDate"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var lastTimeStr='', lastDateStr='', lastZodiac='';
|
var lastTimeStr='', lastDateStr='', lastZodiac='';
|
||||||
var horoscopeInfo=null;
|
var horoscopeInfo=null;
|
||||||
@@ -523,6 +675,18 @@ function setText(id,txt){
|
|||||||
var e=document.getElementById(id);
|
var e=document.getElementById(id);
|
||||||
if(e) e.textContent=txt;
|
if(e) e.textContent=txt;
|
||||||
}
|
}
|
||||||
|
function escapeHTML(value){
|
||||||
|
return String(value == null ? '' : value)
|
||||||
|
.replace(/&/g,'&')
|
||||||
|
.replace(/</g,'<')
|
||||||
|
.replace(/>/g,'>')
|
||||||
|
.replace(/"/g,'"')
|
||||||
|
.replace(/'/g,''');
|
||||||
|
}
|
||||||
|
function safeImageURL(value){
|
||||||
|
var url=String(value || '').trim();
|
||||||
|
return /^(https?:|data:image\/)/i.test(url) ? url : '';
|
||||||
|
}
|
||||||
|
|
||||||
function buildBar(label,val,color){
|
function buildBar(label,val,color){
|
||||||
var v=parseInt(val)||0;
|
var v=parseInt(val)||0;
|
||||||
@@ -543,25 +707,17 @@ function buildZodiacHTML(name){
|
|||||||
html+=buildBar('财运',horoscopeInfo.money,barColors.money);
|
html+=buildBar('财运',horoscopeInfo.money,barColors.money);
|
||||||
html+=buildBar('健康',horoscopeInfo.health,barColors.health);
|
html+=buildBar('健康',horoscopeInfo.health,barColors.health);
|
||||||
html+='<div class="zodiac-tags">';
|
html+='<div class="zodiac-tags">';
|
||||||
if(horoscopeInfo.luckyColor) html+='<span class="zodiac-tag">🎨 '+horoscopeInfo.luckyColor+'</span>';
|
if(horoscopeInfo.luckyColor) html+='<span class="zodiac-tag">🎨 '+escapeHTML(horoscopeInfo.luckyColor)+'</span>';
|
||||||
if(horoscopeInfo.luckyNum) html+='<span class="zodiac-tag">🔢 '+horoscopeInfo.luckyNum+'</span>';
|
if(horoscopeInfo.luckyNum) html+='<span class="zodiac-tag">🔢 '+escapeHTML(horoscopeInfo.luckyNum)+'</span>';
|
||||||
if(horoscopeInfo.noble) html+='<span class="zodiac-tag">⭐ '+horoscopeInfo.noble+'</span>';
|
if(horoscopeInfo.noble) html+='<span class="zodiac-tag">⭐ '+escapeHTML(horoscopeInfo.noble)+'</span>';
|
||||||
html+='</div>';
|
html+='</div>';
|
||||||
if(horoscopeInfo.summary) html+='<div class="zodiac-summary">'+horoscopeInfo.summary+'</div>';
|
if(horoscopeInfo.summary) html+='<div class="zodiac-summary">'+escapeHTML(horoscopeInfo.summary)+'</div>';
|
||||||
} else {
|
} else {
|
||||||
html+='<div style="opacity:0.4;font-size:12px;margin-top:8px">运势加载中...</div>';
|
html+='<div style="opacity:0.4;font-size:12px;margin-top:8px">运势加载中...</div>';
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncZodiacHeight(){
|
|
||||||
var zw=document.getElementById('card-zodiac');
|
|
||||||
var ww=document.getElementById('card-weather');
|
|
||||||
if(!zw||!ww||!document.body.classList.contains('layout-multi')) return;
|
|
||||||
var wh=ww.getBoundingClientRect().height;
|
|
||||||
zw.style.minHeight=wh+'px';
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateZodiacDisplay(){
|
function updateZodiacDisplay(){
|
||||||
var name=getUserZodiac();
|
var name=getUserZodiac();
|
||||||
if(name===lastZodiac) return;
|
if(name===lastZodiac) return;
|
||||||
@@ -569,7 +725,6 @@ function updateZodiacDisplay(){
|
|||||||
var html=buildZodiacHTML(name);
|
var html=buildZodiacHTML(name);
|
||||||
setEl('zodiac',html);
|
setEl('zodiac',html);
|
||||||
setEl('zodiac2',html);
|
setEl('zodiac2',html);
|
||||||
syncZodiacHeight();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var holidays=[
|
var holidays=[
|
||||||
@@ -615,6 +770,14 @@ window.setWallpaperVisible=function(visible){
|
|||||||
if(bg){bg.style.display=visible?'':'none';}
|
if(bg){bg.style.display=visible?'':'none';}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.setPhotoFrameMode=function(enabled){
|
||||||
|
if(enabled){
|
||||||
|
document.body.classList.add('photo-frame-mode');
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('photo-frame-mode');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window._showSeconds={{SHOW_SECONDS}};
|
window._showSeconds={{SHOW_SECONDS}};
|
||||||
window.setShowSeconds=function(v){window._showSeconds=v; updateTime();};
|
window.setShowSeconds=function(v){window._showSeconds=v; updateTime();};
|
||||||
|
|
||||||
@@ -648,6 +811,11 @@ function updateTime(){
|
|||||||
setEl('date',displayStr); setEl('date2',displayStr); lastDateStr=dateStr;
|
setEl('date',displayStr); setEl('date2',displayStr); lastDateStr=dateStr;
|
||||||
}
|
}
|
||||||
updateZodiacDisplay();
|
updateZodiacDisplay();
|
||||||
|
// 相册模式时钟
|
||||||
|
var fc=document.getElementById('photoFrameClock');
|
||||||
|
var fd=document.getElementById('photoFrameDate');
|
||||||
|
if(fc) fc.textContent=timeStr;
|
||||||
|
if(fd) fd.textContent=dateStr;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.updateHoroscopeFromGo=function(data){
|
window.updateHoroscopeFromGo=function(data){
|
||||||
@@ -657,7 +825,6 @@ window.updateHoroscopeFromGo=function(data){
|
|||||||
lastZodiac='';
|
lastZodiac='';
|
||||||
window.userZodiac=data.zodiac;
|
window.userZodiac=data.zodiac;
|
||||||
updateZodiacDisplay();
|
updateZodiacDisplay();
|
||||||
syncZodiacHeight();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.updateAINewsFromGo=function(items){
|
window.updateAINewsFromGo=function(items){
|
||||||
@@ -670,12 +837,13 @@ window.updateAINewsFromGo=function(items){
|
|||||||
var time=n.ctime||'';
|
var time=n.ctime||'';
|
||||||
if(time.length>10) time=time.substring(5,10);
|
if(time.length>10) time=time.substring(5,10);
|
||||||
html+='<div class="ainews-item">';
|
html+='<div class="ainews-item">';
|
||||||
if(n.picUrl){
|
var picUrl=safeImageURL(n.picUrl);
|
||||||
html+='<img class="ainews-img" src="'+n.picUrl+'" loading="lazy" onerror="this.style.display=\'none\'">';
|
if(picUrl){
|
||||||
|
html+='<img class="ainews-img" src="'+escapeHTML(picUrl)+'" loading="lazy" onerror="this.style.display=\'none\'">';
|
||||||
}
|
}
|
||||||
html+='<div class="ainews-body">';
|
html+='<div class="ainews-body">';
|
||||||
html+='<div class="ainews-title-row"><span class="ainews-title">'+n.title+'</span><span class="ainews-source">'+n.source+' · '+time+'</span></div>';
|
html+='<div class="ainews-title-row"><span class="ainews-title">'+escapeHTML(n.title)+'</span><span class="ainews-source">'+escapeHTML(n.source)+' · '+escapeHTML(time)+'</span></div>';
|
||||||
if(n.description) html+='<div class="ainews-desc">'+n.description+'</div>';
|
if(n.description) html+='<div class="ainews-desc">'+escapeHTML(n.description)+'</div>';
|
||||||
html+='</div></div>';
|
html+='</div></div>';
|
||||||
}
|
}
|
||||||
setEl('ainews',html);
|
setEl('ainews',html);
|
||||||
@@ -703,7 +871,7 @@ window.updateWeatherFromGo=function(data){
|
|||||||
if(fel){
|
if(fel){
|
||||||
if(data.hourly&&data.hourly.length>0){
|
if(data.hourly&&data.hourly.length>0){
|
||||||
fel.innerHTML=data.hourly.map(function(item){
|
fel.innerHTML=data.hourly.map(function(item){
|
||||||
return '<div class="forecast-item"><div class="forecast-time">'+item.time+'</div><div class="forecast-icon">'+item.icon+'</div><div class="forecast-temp">'+item.temp+'</div>'+(item.pop&&item.pop!=='0'?'<div class="forecast-pop">'+item.pop+'%</div>':'')+'</div>';
|
return '<div class="forecast-item"><div class="forecast-time">'+escapeHTML(item.time)+'</div><div class="forecast-icon">'+escapeHTML(item.icon)+'</div><div class="forecast-temp">'+escapeHTML(item.temp)+'</div>'+(item.pop&&item.pop!=='0'?'<div class="forecast-pop">'+escapeHTML(item.pop)+'%</div>':'')+'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
} else { fel.innerHTML='<div style="font-size:11px;opacity:0.4">暂无数据</div>'; }
|
} else { fel.innerHTML='<div style="font-size:11px;opacity:0.4">暂无数据</div>'; }
|
||||||
}
|
}
|
||||||
@@ -711,7 +879,7 @@ window.updateWeatherFromGo=function(data){
|
|||||||
if(del){
|
if(del){
|
||||||
if(data.daily&&data.daily.length>0){
|
if(data.daily&&data.daily.length>0){
|
||||||
del.innerHTML=data.daily.map(function(item){
|
del.innerHTML=data.daily.map(function(item){
|
||||||
return '<div class="daily-item"><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>';
|
return '<div class="daily-item"><div style="opacity:0.6;font-size:10px">'+escapeHTML(item.date)+'</div><div class="daily-icon">'+escapeHTML(item.icon)+'</div><div class="forecast-temp">'+escapeHTML(item.tempMin)+'°~'+escapeHTML(item.tempMax)+'°</div></div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
} else { del.innerHTML='<div style="font-size:11px;opacity:0.4">暂无数据</div>'; }
|
} else { del.innerHTML='<div style="font-size:11px;opacity:0.4">暂无数据</div>'; }
|
||||||
}
|
}
|
||||||
@@ -719,7 +887,6 @@ window.updateWeatherFromGo=function(data){
|
|||||||
|
|
||||||
renderWeather('currentWeather','hourlyForecast','dailyForecast');
|
renderWeather('currentWeather','hourlyForecast','dailyForecast');
|
||||||
renderWeather('currentWeather2','hourlyForecast2','dailyForecast2');
|
renderWeather('currentWeather2','hourlyForecast2','dailyForecast2');
|
||||||
syncZodiacHeight();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
updateTime();
|
updateTime();
|
||||||
@@ -727,6 +894,19 @@ setInterval(updateTime,1000);
|
|||||||
|
|
||||||
window.updatePhotoFromGo=function(data){
|
window.updatePhotoFromGo=function(data){
|
||||||
if(typeof data==='string') data=JSON.parse(data);
|
if(typeof data==='string') data=JSON.parse(data);
|
||||||
|
var pfImg=document.getElementById('photo-frame-img');
|
||||||
|
var pfBg=document.getElementById('photo-frame-bg');
|
||||||
|
if(document.body.classList.contains('photo-frame-mode')&&pfImg){
|
||||||
|
if(!data||!data.src) return;
|
||||||
|
pfImg.style.opacity='0';
|
||||||
|
pfBg.style.opacity='0';
|
||||||
|
setTimeout(function(){
|
||||||
|
pfBg.src=data.src;
|
||||||
|
pfImg.src=data.src;
|
||||||
|
pfImg.onload=function(){pfImg.style.opacity='1';pfBg.style.opacity='1';};
|
||||||
|
},400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
var card=document.getElementById('card-photo');
|
var card=document.getElementById('card-photo');
|
||||||
if(!card) return;
|
if(!card) return;
|
||||||
if(!data||!data.src){card.style.display='none';return;}
|
if(!data||!data.src){card.style.display='none';return;}
|
||||||
|
|||||||
@@ -189,6 +189,10 @@ input[type="text"]:focus { border-color: var(--input-border-focus); }
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-label">显示控制</div>
|
<div class="section-label">显示控制</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div class="item">
|
||||||
|
<div><div class="item-label">开机启动</div><div class="item-desc">系统启动时自动运行</div></div>
|
||||||
|
<label class="switch"><input type="checkbox" id="autoStart"><span class="track"><span class="thumb"></span></span></label>
|
||||||
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<div><div class="item-label">显示壁纸</div></div>
|
<div><div class="item-label">显示壁纸</div></div>
|
||||||
<label class="switch"><input type="checkbox" id="wallpaper" checked><span class="track"><span class="thumb"></span></span></label>
|
<label class="switch"><input type="checkbox" id="wallpaper" checked><span class="track"><span class="thumb"></span></span></label>
|
||||||
@@ -338,6 +342,10 @@ input[type="text"]:focus { border-color: var(--input-border-focus); }
|
|||||||
<div><div class="item-label">相册展示</div></div>
|
<div><div class="item-label">相册展示</div></div>
|
||||||
<label class="switch"><input type="checkbox" id="photoCard" checked><span class="track"><span class="thumb"></span></span></label>
|
<label class="switch"><input type="checkbox" id="photoCard" checked><span class="track"><span class="thumb"></span></span></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<div><div class="item-label">电子相册模式</div><div class="item-desc">照片铺满整个壁纸</div></div>
|
||||||
|
<label class="switch"><input type="checkbox" id="photoFrameMode"><span class="track"><span class="thumb"></span></span></label>
|
||||||
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<div class="item-desc" id="photoDirDisplay">未选择目录</div>
|
<div class="item-desc" id="photoDirDisplay">未选择目录</div>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
@@ -377,8 +385,8 @@ input[type="text"]:focus { border-color: var(--input-border-focus); }
|
|||||||
<input type="text" id="knowledgeKeyword" placeholder="如: 历史、科学、冷知识">
|
<input type="text" id="knowledgeKeyword" placeholder="如: 历史、科学、冷知识">
|
||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<div><div class="item-label">知识提示词</div><div class="item-desc">自定义生成风格,不会显示在桌面</div></div>
|
<div><div class="item-label">知识提示词</div><div class="item-desc">补充风格或方向,系统会保证内容密度</div></div>
|
||||||
<input type="text" id="knowledgePrompt" placeholder="如: 用幽默口吻、面向程序员">
|
<input type="text" id="knowledgePrompt" placeholder="如: 偏实践、给出判断标准">
|
||||||
</div>
|
</div>
|
||||||
<div class="item" style="position:relative">
|
<div class="item" style="position:relative">
|
||||||
<div class="item-label">天气城市</div>
|
<div class="item-label">天气城市</div>
|
||||||
@@ -397,7 +405,7 @@ input[type="text"]:focus { border-color: var(--input-border-focus); }
|
|||||||
<div class="footer">u-desktop v1.0</div>
|
<div class="footer">u-desktop v1.0</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var toggleKeys = ['wallpaper', 'time', 'showSeconds', 'weather', 'zodiacCard', 'knowledgeCard', 'ainewsCard', 'photoCard'];
|
var toggleKeys = ['autoStart', 'wallpaper', 'time', 'showSeconds', 'weather', 'zodiacCard', 'knowledgeCard', 'ainewsCard', 'photoCard', 'photoFrameMode'];
|
||||||
var initDone = false;
|
var initDone = false;
|
||||||
function sendToggle() {
|
function sendToggle() {
|
||||||
if (!initDone) return;
|
if (!initDone) return;
|
||||||
@@ -585,10 +593,10 @@ function loadBingFavorites() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.getElementById('btnBingPrev').addEventListener('click', function() {
|
document.getElementById('btnBingPrev').addEventListener('click', function() {
|
||||||
if (window.bingNext) window.bingNext().then(function(s) { updateBingUI(s); });
|
if (window.bingPrev) window.bingPrev().then(function(s) { updateBingUI(s); });
|
||||||
});
|
});
|
||||||
document.getElementById('btnBingNext').addEventListener('click', function() {
|
document.getElementById('btnBingNext').addEventListener('click', function() {
|
||||||
if (window.bingPrev) window.bingPrev().then(function(s) { updateBingUI(s); });
|
if (window.bingNext) window.bingNext().then(function(s) { updateBingUI(s); });
|
||||||
});
|
});
|
||||||
document.getElementById('btnBingFav').addEventListener('click', function() {
|
document.getElementById('btnBingFav').addEventListener('click', function() {
|
||||||
if (window.bingToggleFavorite) {
|
if (window.bingToggleFavorite) {
|
||||||
|
|||||||
83
win32.go
@@ -24,6 +24,7 @@ var (
|
|||||||
procGetWindowLongPtrW = user32.NewProc("GetWindowLongPtrW")
|
procGetWindowLongPtrW = user32.NewProc("GetWindowLongPtrW")
|
||||||
procGetDpiForWindow = user32.NewProc("GetDpiForWindow")
|
procGetDpiForWindow = user32.NewProc("GetDpiForWindow")
|
||||||
procGetClientRect = user32.NewProc("GetClientRect")
|
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")
|
||||||
@@ -35,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:
|
||||||
@@ -54,21 +72,64 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||