重构: 前端Vue3+Tailwind+Vite构建管线+设置组件拆分
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,3 +22,7 @@ vendor/
|
||||
|
||||
# Build
|
||||
u-desktop
|
||||
|
||||
# Web UI
|
||||
web-ui/node_modules/
|
||||
web-ui/dist/
|
||||
|
||||
@@ -12,6 +12,14 @@ $ErrorActionPreference = "Stop"
|
||||
$project = "u-desktop"
|
||||
$buildDir = "dist"
|
||||
|
||||
# 构建 Web UI
|
||||
Write-Host "=== 构建 Web UI ===" -ForegroundColor Cyan
|
||||
Push-Location web-ui
|
||||
npm ci --prefer-offline
|
||||
npm run build
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "Web UI 构建失败" -ForegroundColor Red; exit 1 }
|
||||
Pop-Location
|
||||
|
||||
# 清理
|
||||
if (Test-Path $buildDir) { Remove-Item $buildDir -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $buildDir | Out-Null
|
||||
|
||||
60
wallpaper.go
60
wallpaper.go
@@ -62,50 +62,33 @@ func buildWallpaperHTML(cfg *Config) string {
|
||||
if cfg.HideWallpaper {
|
||||
bgWrapped = `<div id="bg-layer" style="display:none"></div>`
|
||||
}
|
||||
html := strings.Replace(overlayHTML, "{{BACKGROUND}}", bgWrapped, 1)
|
||||
html = strings.Replace(html, "{{LAYOUT}}", string(cfg.Layout), 1)
|
||||
|
||||
var bodyClasses []string
|
||||
if cfg.HideTime {
|
||||
bodyClasses = append(bodyClasses, "hide-time")
|
||||
}
|
||||
if cfg.HideWeather {
|
||||
bodyClasses = append(bodyClasses, "hide-weather")
|
||||
}
|
||||
if cfg.HideZodiac {
|
||||
bodyClasses = append(bodyClasses, "hide-zodiac")
|
||||
}
|
||||
showSec := "false"
|
||||
if cfg.ShowSeconds {
|
||||
showSec = "true"
|
||||
}
|
||||
html = strings.Replace(html, "{{SHOW_SECONDS}}", showSec, 1)
|
||||
if cfg.HideAINews {
|
||||
bodyClasses = append(bodyClasses, "hide-ainews")
|
||||
}
|
||||
if cfg.HideKnowledge {
|
||||
bodyClasses = append(bodyClasses, "hide-knowledge")
|
||||
}
|
||||
if cfg.HidePhoto {
|
||||
bodyClasses = append(bodyClasses, "hide-photo")
|
||||
}
|
||||
if cfg.PhotoFrameMode && cfg.PhotoDir != "" {
|
||||
bodyClasses = append(bodyClasses, "photo-frame-mode")
|
||||
}
|
||||
if len(bodyClasses) > 0 {
|
||||
cls := strings.Join(bodyClasses, " ")
|
||||
html = strings.Replace(html, "{{BODY_CLASSES}}", cls, 1)
|
||||
} else {
|
||||
html = strings.Replace(html, " {{BODY_CLASSES}}", "", 1)
|
||||
initialData := map[string]interface{}{
|
||||
"backgroundHtml": bgWrapped,
|
||||
"layout": string(cfg.Layout),
|
||||
"showSeconds": cfg.ShowSeconds,
|
||||
"userZodiac": cfg.Zodiac,
|
||||
"wallpaperVisible": !cfg.HideWallpaper,
|
||||
"photoFrameMode": cfg.PhotoFrameMode && cfg.PhotoDir != "",
|
||||
"cardVisibility": map[string]bool{
|
||||
"time": !cfg.HideTime,
|
||||
"weather": !cfg.HideWeather,
|
||||
"zodiac": !cfg.HideZodiac,
|
||||
"knowledge": !cfg.HideKnowledge,
|
||||
"ainews": !cfg.HideAINews,
|
||||
"photo": !cfg.HidePhoto,
|
||||
},
|
||||
}
|
||||
dataJSON, _ := json.Marshal(initialData)
|
||||
inject := fmt.Sprintf(`<script>window.__INITIAL_DATA__=%s;</script>`, string(dataJSON))
|
||||
|
||||
// 注入自定义文字
|
||||
if cfg.WallpaperType == WPTheme && cfg.Theme == ThemeText && cfg.WallpaperText != "" {
|
||||
escaped, _ := json.Marshal(cfg.WallpaperText)
|
||||
html = strings.Replace(html, "</script>", `window.wallpaperText = `+string(escaped)+`;</script>`, 1)
|
||||
inject += fmt.Sprintf(`<script>window.wallpaperText=%s;</script>`, string(escaped))
|
||||
}
|
||||
|
||||
return html
|
||||
return strings.Replace(overlayHTML, "</head>", inject+"</head>", 1)
|
||||
}
|
||||
|
||||
func imageToDataURI(path string) string {
|
||||
@@ -194,7 +177,10 @@ func updateBackground(cfg *Config) {
|
||||
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))
|
||||
// Update Vue reactive state via bridge
|
||||
evalJS(fmt.Sprintf(`if(window.__updateBackgroundHtml) window.__updateBackgroundHtml(%q);`, html))
|
||||
// Fallback: direct DOM update for non-Vue contexts
|
||||
evalJS(fmt.Sprintf(`var el=document.getElementById('bg-layer'); if(el && !window.__updateBackgroundHtml){el.outerHTML=%q;}`, html))
|
||||
}
|
||||
|
||||
func reloadAllCards() {
|
||||
|
||||
11
web-ui/overlay.html
Normal file
11
web-ui/overlay.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/overlay/index.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2451
web-ui/package-lock.json
generated
Normal file
2451
web-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
web-ui/package.json
Normal file
23
web-ui/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "u-desktop-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build --mode overlay && vite build --mode settings",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-singlefile": "^2.2.0"
|
||||
}
|
||||
}
|
||||
6
web-ui/postcss.config.js
Normal file
6
web-ui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
11
web-ui/settings.html
Normal file
11
web-ui/settings.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/settings/index.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
7
web-ui/src/env.d.ts
vendored
Normal file
7
web-ui/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
59
web-ui/src/overlay/App.vue
Normal file
59
web-ui/src/overlay/App.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div id="bg-mount" ref="bgMount"></div>
|
||||
|
||||
<template v-if="!state.photoFrameMode">
|
||||
<SingleLayout v-if="state.layout === 'single'" />
|
||||
<MultiLayout v-else />
|
||||
</template>
|
||||
|
||||
<PhotoFrame />
|
||||
|
||||
<div id="author">绝尘</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { state, loadInitialData } from './composables/useOverlayState'
|
||||
import SingleLayout from './components/SingleLayout.vue'
|
||||
import MultiLayout from './components/MultiLayout.vue'
|
||||
import PhotoFrame from './components/PhotoFrame.vue'
|
||||
import './overlay.css'
|
||||
|
||||
const bgMount = ref<HTMLElement>()
|
||||
|
||||
function mountBackground(html: string) {
|
||||
const el = bgMount.value
|
||||
if (!el) return
|
||||
el.innerHTML = html
|
||||
|
||||
// v-html skips <script>; manually execute them
|
||||
el.querySelectorAll('script').forEach(old => {
|
||||
const ns = document.createElement('script')
|
||||
if (old.src) ns.src = old.src
|
||||
else ns.textContent = old.textContent
|
||||
// copy attributes
|
||||
for (const attr of old.attributes) {
|
||||
ns.setAttribute(attr.name, attr.value)
|
||||
}
|
||||
old.parentNode?.replaceChild(ns, old)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadInitialData()
|
||||
if (state.backgroundHtml && state.wallpaperVisible) {
|
||||
mountBackground(state.backgroundHtml)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => state.backgroundHtml, (html) => {
|
||||
if (state.wallpaperVisible) mountBackground(html)
|
||||
})
|
||||
|
||||
watch(() => state.wallpaperVisible, (visible) => {
|
||||
const el = bgMount.value
|
||||
if (!el) return
|
||||
if (visible && state.backgroundHtml) mountBackground(state.backgroundHtml)
|
||||
else el.innerHTML = ''
|
||||
})
|
||||
</script>
|
||||
57
web-ui/src/overlay/bridge.ts
Normal file
57
web-ui/src/overlay/bridge.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { state } from './composables/useOverlayState'
|
||||
|
||||
function parseArg(data: any) {
|
||||
return typeof data === 'string' ? JSON.parse(data) : data
|
||||
}
|
||||
|
||||
export function registerGoBridge() {
|
||||
const w = window as any
|
||||
|
||||
w.updateHoroscopeFromGo = (data: any) => {
|
||||
state.horoscope = parseArg(data)
|
||||
}
|
||||
|
||||
w.updateAINewsFromGo = (items: any) => {
|
||||
const parsed = parseArg(items)
|
||||
if (parsed?.length) state.aiNews = parsed
|
||||
}
|
||||
|
||||
w.updateKnowledgeFromGo = (data: any) => {
|
||||
state.knowledge = parseArg(data)
|
||||
}
|
||||
|
||||
w.updateWeatherFromGo = (data: any) => {
|
||||
state.weather = parseArg(data)
|
||||
}
|
||||
|
||||
w.updatePhotoFromGo = (data: any) => {
|
||||
state.photo = parseArg(data)
|
||||
}
|
||||
|
||||
w.setCardVisible = (card: string, visible: boolean) => {
|
||||
if (card in state.cardVisibility) {
|
||||
(state.cardVisibility as any)[card] = visible
|
||||
}
|
||||
}
|
||||
|
||||
w.setWallpaperVisible = (visible: boolean) => {
|
||||
state.wallpaperVisible = visible
|
||||
}
|
||||
|
||||
w.setPhotoFrameMode = (enabled: boolean) => {
|
||||
state.photoFrameMode = enabled
|
||||
}
|
||||
|
||||
w.setShowSeconds = (v: boolean) => {
|
||||
state.showSeconds = v
|
||||
}
|
||||
|
||||
Object.defineProperty(w, 'userZodiac', {
|
||||
set(v: string) { state.userZodiac = v },
|
||||
get() { return state.userZodiac },
|
||||
})
|
||||
|
||||
w.__updateBackgroundHtml = (html: string) => {
|
||||
state.backgroundHtml = html
|
||||
}
|
||||
}
|
||||
64
web-ui/src/overlay/components/AINewsCard.vue
Normal file
64
web-ui/src/overlay/components/AINewsCard.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="ainews-header">🤖 AI 资讯</div>
|
||||
<div class="ainews-item" v-for="(n, i) in state.aiNews.slice(0, 5)" :key="i">
|
||||
<img v-if="safeImageURL(n.picUrl)"
|
||||
class="ainews-img" :src="n.picUrl" loading="lazy"
|
||||
@error="($event.target as HTMLImageElement).style.display='none'" />
|
||||
<div class="ainews-body">
|
||||
<div class="ainews-title-row">
|
||||
<span class="ainews-title">{{ n.title }}</span>
|
||||
<span class="ainews-source">{{ n.source }} · {{ formatTime(n.ctime) }}</span>
|
||||
</div>
|
||||
<div class="ainews-desc" v-if="n.description">{{ n.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { state } from '../composables/useOverlayState'
|
||||
import { safeImageURL } from '@shared/utils'
|
||||
|
||||
function formatTime(t: string) {
|
||||
return t?.length > 10 ? t.substring(5, 10) : t
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ainews-header {
|
||||
font-size: 11px; font-weight: 700;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: 0; margin-bottom: 14px;
|
||||
}
|
||||
.ainews-item {
|
||||
display: flex; gap: 14px;
|
||||
margin-bottom: 12px; padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--card-line-soft);
|
||||
min-height: 58px;
|
||||
}
|
||||
.ainews-item:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
|
||||
.ainews-img {
|
||||
width: 92px; height: 58px;
|
||||
border-radius: 6px; object-fit: cover;
|
||||
flex-shrink: 0; opacity: 0.92;
|
||||
box-shadow: 0 8px 22px rgba(0,0,0,0.22);
|
||||
}
|
||||
.ainews-body { flex: 1; min-width: 0; }
|
||||
.ainews-title-row {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
}
|
||||
.ainews-title {
|
||||
font-size: 14px; font-weight: 650;
|
||||
color: var(--text-main); line-height: 1.4;
|
||||
white-space: nowrap; overflow: hidden;
|
||||
text-overflow: ellipsis; flex: 1; min-width: 0;
|
||||
}
|
||||
.ainews-source { font-size: 10px; color: var(--text-faint); flex-shrink: 0; }
|
||||
.ainews-desc {
|
||||
font-size: 12px; color: var(--text-soft);
|
||||
line-height: 1.5; margin-top: 5px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
31
web-ui/src/overlay/components/KnowledgeCard.vue
Normal file
31
web-ui/src/overlay/components/KnowledgeCard.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="knowledge-header">
|
||||
💡 知识卡片
|
||||
<span class="knowledge-keyword-tag" v-if="state.knowledge.keyword">#{{ state.knowledge.keyword }}</span>
|
||||
</div>
|
||||
<div class="knowledge-content">{{ state.knowledge.content || '请设置知识关键字' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { state } from '../composables/useOverlayState'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.knowledge-header {
|
||||
font-size: 11px; color: var(--text-faint);
|
||||
margin-bottom: 10px; display: flex;
|
||||
align-items: center; gap: 6px;
|
||||
letter-spacing: 0; font-weight: 700;
|
||||
}
|
||||
.knowledge-keyword-tag {
|
||||
background: rgba(255,255,255,0.09);
|
||||
padding: 3px 8px; border-radius: 6px;
|
||||
font-size: 10px; color: var(--text-soft);
|
||||
}
|
||||
.knowledge-content {
|
||||
font-size: 16px; color: var(--text-main);
|
||||
line-height: 1.55; text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||
}
|
||||
</style>
|
||||
86
web-ui/src/overlay/components/MultiLayout.vue
Normal file
86
web-ui/src/overlay/components/MultiLayout.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div id="layout-multi">
|
||||
<div id="card-time" class="card" v-if="state.cardVisibility.time">
|
||||
<TimeCard />
|
||||
</div>
|
||||
<div id="card-zodiac" class="card" v-if="state.cardVisibility.zodiac">
|
||||
<ZodiacCard />
|
||||
</div>
|
||||
<div id="card-knowledge" class="card" v-if="state.cardVisibility.knowledge">
|
||||
<KnowledgeCard />
|
||||
</div>
|
||||
<div id="card-ainews" class="card" v-if="state.cardVisibility.ainews">
|
||||
<AINewsCard />
|
||||
</div>
|
||||
<div id="card-weather" class="card" v-if="state.cardVisibility.weather">
|
||||
<WeatherCard />
|
||||
</div>
|
||||
<PhotoCard />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { state } from '../composables/useOverlayState'
|
||||
import TimeCard from './TimeCard.vue'
|
||||
import WeatherCard from './WeatherCard.vue'
|
||||
import ZodiacCard from './ZodiacCard.vue'
|
||||
import KnowledgeCard from './KnowledgeCard.vue'
|
||||
import AINewsCard from './AINewsCard.vue'
|
||||
import PhotoCard from './PhotoCard.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#layout-multi {
|
||||
position: fixed;
|
||||
inset: var(--layout-top) var(--layout-x) var(--layout-bottom);
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 1fr) minmax(360px, var(--knowledge-panel)) var(--right-panel);
|
||||
grid-template-rows: var(--time-panel-h) var(--layout-gap) var(--zodiac-panel-h) minmax(32px, 1fr) auto;
|
||||
grid-template-areas:
|
||||
"photo knowledge time"
|
||||
"photo knowledge ."
|
||||
"photo knowledge zodiac"
|
||||
". . ."
|
||||
"news weather weather";
|
||||
column-gap: var(--layout-col-gap);
|
||||
row-gap: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
#layout-multi > .card { pointer-events: none; }
|
||||
#card-time {
|
||||
grid-area: time; text-align: left;
|
||||
width: 100%; height: 100%; padding: 22px 30px;
|
||||
}
|
||||
#card-zodiac {
|
||||
grid-area: zodiac; width: 100%; height: 100%; padding: 22px 28px;
|
||||
}
|
||||
#card-knowledge {
|
||||
grid-area: knowledge; align-self: stretch;
|
||||
width: 100%; min-height: 0; padding: 22px 26px;
|
||||
}
|
||||
#card-ainews {
|
||||
grid-area: news; align-self: end;
|
||||
width: 100%; max-height: 390px;
|
||||
}
|
||||
#card-weather {
|
||||
grid-area: weather; align-self: end;
|
||||
width: 100%; min-width: 0; text-align: right;
|
||||
padding: 22px 28px 24px;
|
||||
}
|
||||
#card-knowledge :deep(.knowledge-content) {
|
||||
display: -webkit-box; -webkit-line-clamp: 8;
|
||||
-webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
#layout-multi :deep(#card-photo) {
|
||||
grid-area: photo;
|
||||
position: relative; top: auto; left: auto;
|
||||
align-self: start;
|
||||
width: 100%;
|
||||
max-height: var(--photo-panel-h);
|
||||
padding: 16px;
|
||||
}
|
||||
#layout-multi :deep(#card-photo img) {
|
||||
max-height: calc(var(--photo-panel-h) - 32px);
|
||||
}
|
||||
</style>
|
||||
68
web-ui/src/overlay/components/PhotoCard.vue
Normal file
68
web-ui/src/overlay/components/PhotoCard.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div id="card-photo" class="card" v-if="state.photo?.src">
|
||||
<div class="photo-wrap">
|
||||
<img :src="state.photo.src" alt="" @load="imgLoaded = true" :style="{ opacity: imgLoaded ? 1 : 0 }" />
|
||||
<div class="photo-info">
|
||||
<span class="photo-counter">{{ state.photo.counter }}</span>
|
||||
<div class="photo-progress">
|
||||
<div class="photo-progress-bar" ref="progressBar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { state } from '../composables/useOverlayState'
|
||||
|
||||
const imgLoaded = ref(false)
|
||||
const progressBar = ref<HTMLDivElement>()
|
||||
|
||||
watch(() => state.photo?.src, () => {
|
||||
imgLoaded.value = false
|
||||
if (!progressBar.value) return
|
||||
const bar = progressBar.value
|
||||
bar.style.transition = 'none'
|
||||
bar.style.width = '0%'
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const interval = state.photo?.interval || 15
|
||||
bar.style.transition = `width ${interval}s linear`
|
||||
bar.style.width = '100%'
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#card-photo {
|
||||
padding: 16px;
|
||||
}
|
||||
.photo-wrap {
|
||||
position: relative; border-radius: 8px; overflow: hidden;
|
||||
}
|
||||
img {
|
||||
width: 100%; max-height: 450px;
|
||||
object-fit: cover; display: block;
|
||||
border-radius: 8px; transition: opacity 0.5s ease;
|
||||
}
|
||||
.photo-info {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
padding: 20px 14px 10px;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,0.5));
|
||||
}
|
||||
.photo-counter {
|
||||
font-size: 11px; opacity: 0.7;
|
||||
display: block; margin-bottom: 6px;
|
||||
}
|
||||
.photo-progress {
|
||||
height: 3px; background: rgba(255,255,255,0.15);
|
||||
border-radius: 2px; overflow: hidden;
|
||||
}
|
||||
.photo-progress-bar {
|
||||
height: 100%; width: 0%;
|
||||
background: rgba(255,255,255,0.6);
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
53
web-ui/src/overlay/components/PhotoFrame.vue
Normal file
53
web-ui/src/overlay/components/PhotoFrame.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<template v-if="state.photoFrameMode">
|
||||
<img id="photo-frame-bg" :src="state.photo?.src" alt="" :style="{ opacity: visible ? 1 : 0 }" />
|
||||
<img id="photo-frame-img" :src="state.photo?.src" alt="" :style="{ opacity: visible ? 1 : 0 }" />
|
||||
<div class="photo-frame-clock">{{ timeStr }}</div>
|
||||
<div class="photo-frame-date">{{ dateStr }}</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { state } from '../composables/useOverlayState'
|
||||
import { useTime } from '../composables/useTime'
|
||||
|
||||
const { timeStr, dateStr } = useTime()
|
||||
const visible = ref(false)
|
||||
|
||||
watch(() => state.photo?.src, () => {
|
||||
if (!state.photoFrameMode || !state.photo?.src) return
|
||||
visible.value = false
|
||||
setTimeout(() => { visible.value = true }, 400)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#photo-frame-bg, #photo-frame-img {
|
||||
position: fixed; top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
z-index: 5; object-fit: contain;
|
||||
transition: opacity 0.8s ease;
|
||||
}
|
||||
#photo-frame-bg {
|
||||
z-index: 4; object-fit: cover;
|
||||
filter: blur(30px) brightness(0.5);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.photo-frame-clock {
|
||||
position: fixed; top: 28px; right: 36px; z-index: 10;
|
||||
font-family: -apple-system, "SF Pro Display", "Segoe UI", sans-serif;
|
||||
font-size: 52px; font-weight: 200; letter-spacing: 2px;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 20px rgba(0,0,0,0.8), 0 0 40px rgba(0,0,0,0.5), 0 2px 4px rgba(0,0,0,0.9);
|
||||
pointer-events: none; opacity: 0; transition: opacity 0.5s ease;
|
||||
}
|
||||
.photo-frame-date {
|
||||
position: fixed; top: 86px; right: 38px; z-index: 10;
|
||||
font-family: -apple-system, "SF Pro Display", "Segoe UI", sans-serif;
|
||||
font-size: 15px; font-weight: 400;
|
||||
color: rgba(255,255,255,0.8);
|
||||
text-shadow: 0 0 12px rgba(0,0,0,0.7), 0 1px 3px rgba(0,0,0,0.8);
|
||||
pointer-events: none; opacity: 0; transition: opacity 0.5s ease;
|
||||
}
|
||||
</style>
|
||||
36
web-ui/src/overlay/components/SingleLayout.vue
Normal file
36
web-ui/src/overlay/components/SingleLayout.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div id="layout-single">
|
||||
<div id="info" class="card">
|
||||
<TimeCard v-if="state.cardVisibility.time" />
|
||||
<div class="divider"></div>
|
||||
<AINewsCard v-if="state.cardVisibility.ainews" />
|
||||
<div class="divider" v-if="state.cardVisibility.ainews"></div>
|
||||
<KnowledgeCard v-if="state.cardVisibility.knowledge" />
|
||||
<div class="divider" v-if="state.cardVisibility.knowledge"></div>
|
||||
<WeatherCard v-if="state.cardVisibility.weather" />
|
||||
<div class="divider" v-if="state.cardVisibility.weather"></div>
|
||||
<ZodiacCard v-if="state.cardVisibility.zodiac" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { state } from '../composables/useOverlayState'
|
||||
import TimeCard from './TimeCard.vue'
|
||||
import WeatherCard from './WeatherCard.vue'
|
||||
import ZodiacCard from './ZodiacCard.vue'
|
||||
import KnowledgeCard from './KnowledgeCard.vue'
|
||||
import AINewsCard from './AINewsCard.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#layout-single { display: block; }
|
||||
#info {
|
||||
position: fixed;
|
||||
top: 42px; right: 42px;
|
||||
text-align: left;
|
||||
width: min(760px, calc(50vw - 52px));
|
||||
max-height: calc(100vh - 84px);
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
60
web-ui/src/overlay/components/TimeCard.vue
Normal file
60
web-ui/src/overlay/components/TimeCard.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="date" v-html="dateDisplay"></div>
|
||||
<div class="time" :class="{ 'hourly-glow': glowing }">{{ timeStr }}</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { state } from '../composables/useOverlayState'
|
||||
import { useTime } from '../composables/useTime'
|
||||
|
||||
const { timeStr, dateStr, holidayStr } = useTime()
|
||||
const glowing = ref(false)
|
||||
let glowTimer: number | null = null
|
||||
|
||||
const dateDisplay = computed(() => {
|
||||
return holidayStr.value
|
||||
? `${dateStr.value} <span style="opacity:0.5;font-size:12px">「${holidayStr.value}」</span>`
|
||||
: dateStr.value
|
||||
})
|
||||
|
||||
watch(timeStr, (val) => {
|
||||
if (val.endsWith(':00:00') || val.endsWith(':00')) {
|
||||
glowing.value = false
|
||||
requestAnimationFrame(() => {
|
||||
glowing.value = true
|
||||
})
|
||||
if (glowTimer) clearTimeout(glowTimer)
|
||||
glowTimer = window.setTimeout(() => { glowing.value = false }, 6000)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (glowTimer) clearTimeout(glowTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.time {
|
||||
font-size: 66px;
|
||||
font-weight: 200;
|
||||
text-shadow: 0 3px 28px rgba(0,0,0,0.42);
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgba(255,255,255,0.96);
|
||||
}
|
||||
.time.hourly-glow {
|
||||
animation: hourlyGlow 6s ease-out forwards;
|
||||
}
|
||||
.date {
|
||||
font-size: 13px;
|
||||
color: var(--text-soft);
|
||||
margin-bottom: 8px;
|
||||
text-shadow: 0 1px 6px rgba(0,0,0,0.5);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
@media (max-width: 1500px) {
|
||||
.time { font-size: 64px; }
|
||||
}
|
||||
</style>
|
||||
80
web-ui/src/overlay/components/WeatherCard.vue
Normal file
80
web-ui/src/overlay/components/WeatherCard.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="current-weather" v-if="state.weather?.current">{{ state.weather.current }}</div>
|
||||
<template v-if="state.weather?.hourly?.length">
|
||||
<div class="forecast-title">24小时预报</div>
|
||||
<div class="weather-forecast">
|
||||
<div class="forecast-item" v-for="item in state.weather.hourly" :key="item.time">
|
||||
<div class="forecast-time">{{ item.time }}</div>
|
||||
<div class="forecast-icon">{{ item.icon }}</div>
|
||||
<div class="forecast-temp">{{ item.temp }}</div>
|
||||
<div class="forecast-pop" v-if="item.pop && item.pop !== '0'">{{ item.pop }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="state.weather?.daily?.length">
|
||||
<div class="forecast-title" style="margin-top:12px">7日预报</div>
|
||||
<div class="daily-forecast">
|
||||
<div class="daily-item" v-for="item in state.weather.daily" :key="item.date">
|
||||
<div style="opacity:0.6;font-size:10px">{{ item.date }}</div>
|
||||
<div class="daily-icon">{{ item.icon }}</div>
|
||||
<div class="forecast-temp">{{ item.tempMin }}°~{{ item.tempMax }}°</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { state } from '../composables/useOverlayState'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.current-weather {
|
||||
font-size: 17px; font-weight: 600;
|
||||
color: var(--text-main);
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||
margin-bottom: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
.forecast-title {
|
||||
font-size: 11px; font-weight: 600;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: 0;
|
||||
margin-bottom: 9px;
|
||||
text-align: right;
|
||||
}
|
||||
.weather-forecast {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(60px, 1fr));
|
||||
gap: 7px; overflow: hidden;
|
||||
}
|
||||
.forecast-item {
|
||||
background: rgba(255,255,255,0.055);
|
||||
border-radius: 8px; padding: 9px 8px;
|
||||
text-align: center; min-width: 0;
|
||||
font-size: 12px; color: var(--text-main);
|
||||
border: 1px solid var(--card-line-soft);
|
||||
}
|
||||
.forecast-icon { font-size: 22px; margin: 5px 0; }
|
||||
.forecast-time { color: var(--text-faint); font-size: 10px; }
|
||||
.forecast-temp { font-weight: 700; margin-top: 2px; font-size: 14px; }
|
||||
.forecast-pop { font-size: 10px; color: var(--text-faint); margin-top: 2px; }
|
||||
.daily-forecast {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(64px, 1fr));
|
||||
gap: 7px; overflow: hidden;
|
||||
}
|
||||
.daily-item {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border-radius: 8px; padding: 9px 8px;
|
||||
text-align: center; min-width: 0;
|
||||
font-size: 12px; color: var(--text-main);
|
||||
border: 1px solid var(--card-line-soft);
|
||||
}
|
||||
.daily-icon { font-size: 22px; margin: 5px 0; }
|
||||
.weather-forecast::-webkit-scrollbar,
|
||||
.daily-forecast::-webkit-scrollbar { display: none; }
|
||||
@media (max-width: 1500px) {
|
||||
.weather-forecast { grid-template-columns: repeat(4, 1fr); }
|
||||
.daily-forecast { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
</style>
|
||||
81
web-ui/src/overlay/components/ZodiacCard.vue
Normal file
81
web-ui/src/overlay/components/ZodiacCard.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="zodiac-text">
|
||||
<div class="zodiac-title">{{ info.icon }} {{ state.userZodiac }}运势</div>
|
||||
<div class="zodiac-date">{{ info.date }}</div>
|
||||
<template v-if="data">
|
||||
<div class="zodiac-bar" v-for="bar in bars" :key="bar.label">
|
||||
<span class="zodiac-bar-label">{{ bar.label }}</span>
|
||||
<div class="zodiac-bar-track">
|
||||
<div class="zodiac-bar-fill" :style="{ width: bar.val + '%', background: bar.color }"></div>
|
||||
</div>
|
||||
<span class="zodiac-bar-val">{{ bar.val }}%</span>
|
||||
</div>
|
||||
<div class="zodiac-tags" v-if="hasTags">
|
||||
<span class="zodiac-tag" v-if="data.luckyColor">🎨 {{ data.luckyColor }}</span>
|
||||
<span class="zodiac-tag" v-if="data.luckyNum">🔢 {{ data.luckyNum }}</span>
|
||||
<span class="zodiac-tag" v-if="data.noble">⭐ {{ data.noble }}</span>
|
||||
</div>
|
||||
<div class="zodiac-summary" v-if="data.summary">{{ data.summary }}</div>
|
||||
</template>
|
||||
<div v-else style="opacity:0.4;font-size:12px;margin-top:8px">运势加载中...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { state } from '../composables/useOverlayState'
|
||||
import { useHoroscope, zodiacMap, barColors } from '../composables/useHoroscope'
|
||||
|
||||
const { zodiacInfo: info, data } = useHoroscope()
|
||||
|
||||
const bars = computed(() => {
|
||||
if (!data.value) return []
|
||||
return [
|
||||
{ label: '综合', val: parseInt(data.value.all) || 0, color: barColors.all },
|
||||
{ label: '爱情', val: parseInt(data.value.love) || 0, color: barColors.love },
|
||||
{ label: '工作', val: parseInt(data.value.work) || 0, color: barColors.work },
|
||||
{ label: '财运', val: parseInt(data.value.money) || 0, color: barColors.money },
|
||||
{ label: '健康', val: parseInt(data.value.health) || 0, color: barColors.health },
|
||||
]
|
||||
})
|
||||
|
||||
const hasTags = computed(() => data.value?.luckyColor || data.value?.luckyNum || data.value?.noble)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.zodiac-text {
|
||||
font-size: 14px; color: var(--text-main);
|
||||
line-height: 1.6; text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||
}
|
||||
.zodiac-title { font-size: 16px; font-weight: 650; margin-bottom: 3px; }
|
||||
.zodiac-date { font-size: 11px; color: var(--text-faint); margin-bottom: 12px; }
|
||||
.zodiac-bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 7px; font-size: 12px;
|
||||
}
|
||||
.zodiac-bar-label { width: 28px; color: var(--text-soft); flex-shrink: 0; }
|
||||
.zodiac-bar-track {
|
||||
flex: 1; height: 5px;
|
||||
background: rgba(255,255,255,0.10);
|
||||
border-radius: 999px; overflow: hidden;
|
||||
}
|
||||
.zodiac-bar-fill {
|
||||
height: 100%; border-radius: 999px;
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
.zodiac-bar-val {
|
||||
width: 30px; text-align: right; font-size: 11px;
|
||||
color: var(--text-soft); flex-shrink: 0;
|
||||
}
|
||||
.zodiac-tags { display: flex; gap: 7px; flex-wrap: wrap; margin: 10px 0 9px; }
|
||||
.zodiac-tag {
|
||||
font-size: 10px; background: rgba(255,255,255,0.08);
|
||||
padding: 4px 8px; border-radius: 6px;
|
||||
color: var(--text-soft); border: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.zodiac-summary {
|
||||
font-size: 12px; color: var(--text-soft); line-height: 1.65;
|
||||
display: -webkit-box; -webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
28
web-ui/src/overlay/composables/useHoroscope.ts
Normal file
28
web-ui/src/overlay/composables/useHoroscope.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { computed } from 'vue'
|
||||
import { state } from './useOverlayState'
|
||||
|
||||
export const zodiacMap: Record<string, { icon: string; date: string }> = {
|
||||
'白羊座': { icon: '♈', date: '3.21-4.19' },
|
||||
'金牛座': { icon: '♉', date: '4.20-5.20' },
|
||||
'双子座': { icon: '♊', date: '5.21-6.21' },
|
||||
'巨蟹座': { icon: '♋', date: '6.22-7.22' },
|
||||
'狮子座': { icon: '♌', date: '7.23-8.22' },
|
||||
'处女座': { icon: '♍', date: '8.23-9.22' },
|
||||
'天秤座': { icon: '♎', date: '9.23-10.23' },
|
||||
'天蝎座': { icon: '♏', date: '10.24-11.22' },
|
||||
'射手座': { icon: '♐', date: '11.23-12.21' },
|
||||
'摩羯座': { icon: '♑', date: '12.22-1.19' },
|
||||
'水瓶座': { icon: '♒', date: '1.20-2.18' },
|
||||
'双鱼座': { icon: '♓', date: '2.19-3.20' },
|
||||
}
|
||||
|
||||
export const barColors: Record<string, string> = {
|
||||
all: '#e0e0e0', love: '#ff6b9d', work: '#4fc3f7', money: '#ffd54f', health: '#81c784',
|
||||
}
|
||||
|
||||
export function useHoroscope() {
|
||||
const zodiacInfo = computed(() => zodiacMap[state.userZodiac] || { icon: '✨', date: '' })
|
||||
const data = computed(() => state.horoscope)
|
||||
|
||||
return { zodiacInfo, data }
|
||||
}
|
||||
36
web-ui/src/overlay/composables/useOverlayState.ts
Normal file
36
web-ui/src/overlay/composables/useOverlayState.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { reactive } from 'vue'
|
||||
import type { WeatherData, HoroscopeData, AINewsItem, KnowledgeData, PhotoData, CardVisibility } from '@shared/types'
|
||||
|
||||
export const state = reactive({
|
||||
layout: 'single' as 'single' | 'multi',
|
||||
showSeconds: true,
|
||||
userZodiac: '射手座',
|
||||
wallpaperVisible: true,
|
||||
photoFrameMode: false,
|
||||
cardVisibility: {
|
||||
time: true,
|
||||
weather: true,
|
||||
zodiac: true,
|
||||
knowledge: true,
|
||||
ainews: true,
|
||||
photo: true,
|
||||
} as CardVisibility,
|
||||
backgroundHtml: '',
|
||||
weather: null as WeatherData | null,
|
||||
horoscope: null as HoroscopeData | null,
|
||||
aiNews: [] as AINewsItem[],
|
||||
knowledge: { content: '', keyword: '' } as KnowledgeData,
|
||||
photo: null as PhotoData | null,
|
||||
})
|
||||
|
||||
export function loadInitialData() {
|
||||
const d = (window as any).__INITIAL_DATA__
|
||||
if (!d) return
|
||||
if (d.layout) state.layout = d.layout
|
||||
if (d.showSeconds !== undefined) state.showSeconds = d.showSeconds
|
||||
if (d.userZodiac) state.userZodiac = d.userZodiac
|
||||
if (d.wallpaperVisible !== undefined) state.wallpaperVisible = d.wallpaperVisible
|
||||
if (d.photoFrameMode !== undefined) state.photoFrameMode = d.photoFrameMode
|
||||
if (d.cardVisibility) Object.assign(state.cardVisibility, d.cardVisibility)
|
||||
if (d.backgroundHtml) state.backgroundHtml = d.backgroundHtml
|
||||
}
|
||||
77
web-ui/src/overlay/composables/useTime.ts
Normal file
77
web-ui/src/overlay/composables/useTime.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { state } from './useOverlayState'
|
||||
|
||||
export function useTime() {
|
||||
const timeStr = ref('00:00')
|
||||
const dateStr = ref('1月1日 周一')
|
||||
const holidayStr = ref('')
|
||||
let timer: number | null = null
|
||||
let lastTime = ''
|
||||
let lastDate = ''
|
||||
|
||||
const holidays = [
|
||||
{ m: 1, d: 1, name: '元旦' },
|
||||
{ m: 2, d: 14, name: '情人节' },
|
||||
{ m: 3, d: 8, name: '妇女节' },
|
||||
{ m: 4, d: 5, name: '清明节' },
|
||||
{ m: 5, d: 1, name: '劳动节' },
|
||||
{ m: 5, d: 4, name: '青年节' },
|
||||
{ m: 6, d: 1, name: '儿童节' },
|
||||
{ m: 7, d: 1, name: '建党节' },
|
||||
{ m: 8, d: 1, name: '建军节' },
|
||||
{ m: 9, d: 10, name: '教师节' },
|
||||
{ m: 10, d: 1, name: '国庆节' },
|
||||
{ m: 10, d: 31, name: '万圣节' },
|
||||
{ m: 12, d: 25, name: '圣诞节' },
|
||||
]
|
||||
|
||||
function getNextHoliday(now: Date) {
|
||||
const y = now.getFullYear()
|
||||
const results: { diff: number; name: string }[] = []
|
||||
for (const h of holidays) {
|
||||
const target = new Date(y, h.m - 1, h.d)
|
||||
let diff = Math.ceil((target.getTime() - now.getTime()) / 86400000)
|
||||
if (diff > 0 && diff <= 60) results.push({ diff, name: h.name })
|
||||
if (diff < 0) {
|
||||
const next = new Date(y + 1, h.m - 1, h.d)
|
||||
diff = Math.ceil((next.getTime() - now.getTime()) / 86400000)
|
||||
if (diff > 0 && diff <= 60) results.push({ diff, name: h.name })
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => a.diff - b.diff)
|
||||
return results.length > 0 ? `距${results[0].name}还有${results[0].diff}天` : ''
|
||||
}
|
||||
|
||||
function update() {
|
||||
const now = new Date()
|
||||
const hh = String(now.getHours()).padStart(2, '0')
|
||||
const mm = String(now.getMinutes()).padStart(2, '0')
|
||||
const ss = String(now.getSeconds()).padStart(2, '0')
|
||||
const t = state.showSeconds ? `${hh}:${mm}:${ss}` : `${hh}:${mm}`
|
||||
const month = now.getMonth() + 1
|
||||
const day = now.getDate()
|
||||
const week = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][now.getDay()]
|
||||
const d = `${month}月${day}日 ${week}`
|
||||
|
||||
if (t !== lastTime) {
|
||||
timeStr.value = t
|
||||
lastTime = t
|
||||
}
|
||||
if (d !== lastDate) {
|
||||
const h = getNextHoliday(now)
|
||||
dateStr.value = d
|
||||
holidayStr.value = h
|
||||
lastDate = d
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
update()
|
||||
timer = window.setInterval(update, 1000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
return { timeStr, dateStr, holidayStr }
|
||||
}
|
||||
7
web-ui/src/overlay/index.ts
Normal file
7
web-ui/src/overlay/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import '../tailwind.css'
|
||||
import { registerGoBridge } from './bridge'
|
||||
|
||||
registerGoBridge()
|
||||
createApp(App).mount('#app')
|
||||
86
web-ui/src/overlay/overlay.css
Normal file
86
web-ui/src/overlay/overlay.css
Normal file
@@ -0,0 +1,86 @@
|
||||
* { margin: 0; padding: 0; }
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
color: #fff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
:root {
|
||||
--card-bg: rgba(22, 19, 28, 0.56);
|
||||
--card-bg-strong: rgba(24, 20, 30, 0.68);
|
||||
--card-line: rgba(255,255,255,0.10);
|
||||
--card-line-soft: rgba(255,255,255,0.06);
|
||||
--text-main: rgba(255,255,255,0.92);
|
||||
--text-soft: rgba(255,255,255,0.66);
|
||||
--text-faint: rgba(255,255,255,0.42);
|
||||
--accent-warm: #ffd86b;
|
||||
--accent-cool: #6bdcff;
|
||||
--shadow-card: 0 18px 60px rgba(0,0,0,0.32), inset 0 1px 0 rgba(255,255,255,0.10);
|
||||
--layout-x: 48px;
|
||||
--layout-top: 38px;
|
||||
--layout-bottom: 72px;
|
||||
--layout-gap: 18px;
|
||||
--layout-col-gap: 24px;
|
||||
--right-panel: 400px;
|
||||
--knowledge-panel: 460px;
|
||||
--time-panel-h: 154px;
|
||||
--zodiac-panel-h: 326px;
|
||||
--photo-panel-h: 360px;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(145deg, rgba(255,255,255,0.075), rgba(255,255,255,0.025)), var(--card-bg);
|
||||
backdrop-filter: blur(28px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(1.2);
|
||||
border: 1px solid var(--card-line);
|
||||
border-radius: 8px;
|
||||
padding: 22px 24px;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255,255,255,0.13), transparent 34%),
|
||||
radial-gradient(circle at 12% 0%, rgba(255,216,107,0.13), transparent 38%);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.card > * { position: relative; }
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.14), transparent);
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
@keyframes hourlyGlow {
|
||||
0% { text-shadow: 0 0 30px rgba(255,255,255,1), 0 0 80px rgba(180,200,255,0.8), 0 0 120px rgba(100,150,255,0.5); color: #fff; }
|
||||
8% { text-shadow: 0 0 50px rgba(255,255,255,1), 0 0 100px rgba(180,200,255,1), 0 0 160px rgba(100,150,255,0.6); }
|
||||
18% { text-shadow: 0 0 20px rgba(255,255,255,0.6), 0 0 40px rgba(180,200,255,0.3); }
|
||||
28% { text-shadow: 0 0 35px rgba(255,255,255,0.85), 0 0 70px rgba(180,200,255,0.6), 0 0 100px rgba(100,150,255,0.35); }
|
||||
38% { text-shadow: 0 0 15px rgba(255,255,255,0.4), 0 0 30px rgba(180,200,255,0.2); }
|
||||
48% { text-shadow: 0 0 25px rgba(255,255,255,0.6), 0 0 50px rgba(180,200,255,0.4); }
|
||||
60% { text-shadow: 0 0 10px rgba(255,255,255,0.25), 0 0 20px rgba(180,200,255,0.12); }
|
||||
75% { text-shadow: 0 0 15px rgba(255,255,255,0.35), 0 0 30px rgba(180,200,255,0.2); }
|
||||
100% { text-shadow: 0 2px 20px rgba(0,0,0,0.5); color: #fff; }
|
||||
}
|
||||
|
||||
@media (max-width: 1500px) {
|
||||
:root {
|
||||
--layout-x: 32px;
|
||||
--layout-col-gap: 18px;
|
||||
--right-panel: 360px;
|
||||
--knowledge-panel: 380px;
|
||||
--time-panel-h: 144px;
|
||||
--zodiac-panel-h: 312px;
|
||||
--photo-panel-h: 320px;
|
||||
}
|
||||
}
|
||||
89
web-ui/src/settings/App.vue
Normal file
89
web-ui/src/settings/App.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="p-4 select-none overflow-y-auto">
|
||||
<div class="mb-3.5">
|
||||
<h1 class="text-base font-semibold text-[var(--text-strong)]">桌面设置</h1>
|
||||
<p class="text-[11px] text-[var(--text-weak)] mt-0.5">壁纸 · 布局 · 信息显示</p>
|
||||
</div>
|
||||
|
||||
<ToggleSection :s="s" />
|
||||
<WallpaperSection :s="s" @set-wp-type="setWpType" :current-color="currentColor" @pick-solid="onPickSolid" @pick-gradient="onPickGradient" @save-color="onSaveColor" @apply-color="onApplySavedColor" @remove-color="onRemoveSavedColor" />
|
||||
<LayoutSection :s="s" />
|
||||
<PhotoSection :s="s" />
|
||||
<PersonalizeSection :s="s" />
|
||||
|
||||
<div class="text-center text-[11px] text-[var(--footer-color)] mt-3 py-1.5 tracking-wide">u-desktop v1.0</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { go } from './composables/useGoBridge'
|
||||
import ToggleSection from './components/ToggleSection.vue'
|
||||
import WallpaperSection from './components/WallpaperSection.vue'
|
||||
import LayoutSection from './components/LayoutSection.vue'
|
||||
import PhotoSection from './components/PhotoSection.vue'
|
||||
import PersonalizeSection from './components/PersonalizeSection.vue'
|
||||
import type { SettingsData } from '@shared/types'
|
||||
|
||||
const initDone = ref(false)
|
||||
|
||||
const s = reactive<SettingsData>({
|
||||
lightTheme: false, wallpaper: true, time: true, weather: true,
|
||||
zodiacCard: true, knowledgeCard: true, layout: 'single', zodiac: '射手座',
|
||||
city: '', provinces: [], citiesByProv: {}, wallpaperType: 'theme', theme: '',
|
||||
themes: [], color1: '', color2: '', colorGradient: false, savedColors: [],
|
||||
bingAutoRefresh: false, knowledgeKeyword: '', knowledgePrompt: '',
|
||||
wallpaperText: '', imagePath: '', showSeconds: true, ainewsCard: true,
|
||||
photoDir: '', photoInterval: 15, photoCard: true, photoFrameMode: false, autoStart: false,
|
||||
})
|
||||
|
||||
// Shared state for color section
|
||||
const currentColor = reactive({ c1: '', c2: '', gradient: false })
|
||||
|
||||
function setWpType(type: string) {
|
||||
const prev = s.wallpaperType
|
||||
s.wallpaperType = type
|
||||
if (!initDone.value) return
|
||||
if (type === 'theme') go.saveWallpaperType('theme', s.theme)
|
||||
else if (type === 'bing' && prev !== 'bing') go.enableBing()
|
||||
}
|
||||
|
||||
async function onPickSolid() {
|
||||
const c = await go.pickSolidColor()
|
||||
if (c) { currentColor.c1 = c; currentColor.c2 = ''; currentColor.gradient = false }
|
||||
}
|
||||
async function onPickGradient() {
|
||||
const res = await go.pickGradientColor()
|
||||
if (res) {
|
||||
const parts = res.split(',')
|
||||
currentColor.c1 = parts[0]; currentColor.c2 = parts[1] || ''; currentColor.gradient = true
|
||||
}
|
||||
}
|
||||
function onSaveColor() {
|
||||
if (!currentColor.c1) return
|
||||
go.addSavedColor(currentColor.c1, currentColor.c2, currentColor.gradient)
|
||||
s.savedColors.push({ color1: currentColor.c1, color2: currentColor.c2, gradient: currentColor.gradient })
|
||||
}
|
||||
function onApplySavedColor(idx: number) {
|
||||
go.applySavedColor(idx)
|
||||
const sc = s.savedColors[idx]
|
||||
currentColor.c1 = sc.color1; currentColor.c2 = sc.color2; currentColor.gradient = sc.gradient
|
||||
}
|
||||
async function onRemoveSavedColor(idx: number) {
|
||||
await go.removeSavedColor(idx)
|
||||
s.savedColors.splice(idx, 1)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const raw = await go.loadAllSettings()
|
||||
const data = JSON.parse(raw) as SettingsData
|
||||
if (data.lightTheme) document.documentElement.className = 'light'
|
||||
Object.assign(s, { ...data, citiesByProv: data.citiesByProv || {}, provinces: data.provinces || [], themes: data.themes || [], savedColors: data.savedColors || [] })
|
||||
if (data.color1) { currentColor.c1 = data.color1; currentColor.c2 = data.color2 || ''; currentColor.gradient = data.colorGradient || false }
|
||||
setTimeout(() => {
|
||||
const el = document.documentElement
|
||||
if (go.resizeToFit) go.resizeToFit(el.scrollWidth, el.scrollHeight + 8)
|
||||
}, 100)
|
||||
initDone.value = true
|
||||
})
|
||||
</script>
|
||||
21
web-ui/src/settings/components/LayoutSection.vue
Normal file
21
web-ui/src/settings/components/LayoutSection.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<div class="text-[10px] font-semibold text-[var(--text-weak)] uppercase tracking-[1.5px] mb-1 pl-0.5">布局</div>
|
||||
<div class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg">
|
||||
<div class="flex justify-between items-center px-3.5 py-2">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">信息布局</div>
|
||||
<select v-model="s.layout" @change="go.saveLayout(s.layout)"
|
||||
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-0.5 px-1.5 outline-none min-w-[80px] max-w-[160px] focus:border-[var(--input-border-focus)]">
|
||||
<option value="single">合并卡片</option>
|
||||
<option value="multi">独立卡片</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { go } from '../composables/useGoBridge'
|
||||
import type { SettingsData } from '@shared/types'
|
||||
defineProps<{ s: SettingsData }>()
|
||||
</script>
|
||||
111
web-ui/src/settings/components/PersonalizeSection.vue
Normal file
111
web-ui/src/settings/components/PersonalizeSection.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<div class="text-[10px] font-semibold text-[var(--text-weak)] uppercase tracking-[1.5px] mb-1 pl-0.5">个性化</div>
|
||||
<div class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg">
|
||||
<div class="flex justify-between items-center px-3.5 py-2">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">我的星座</div>
|
||||
<select v-model="s.zodiac" @change="go.saveZodiac(s.zodiac)"
|
||||
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-0.5 px-1.5 outline-none min-w-[80px] max-w-[160px] focus:border-[var(--input-border-focus)]">
|
||||
<option v-for="z in zodiacs" :key="z" :value="z">{{ z }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div><div class="text-xs font-medium text-[var(--text-muted)]">知识关键字</div><div class="text-[10px] text-[var(--text-weak)] mt-px">AI 将根据关键字生成知识小卡片</div></div>
|
||||
<input type="text" v-model="s.knowledgeKeyword" @input="debounced(go.saveKnowledgeKeyword, s.knowledgeKeyword)"
|
||||
placeholder="如: 历史、科学、冷知识"
|
||||
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-1 px-2 outline-none w-[140px] focus:border-[var(--input-border-focus)]">
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div><div class="text-xs font-medium text-[var(--text-muted)]">知识提示词</div><div class="text-[10px] text-[var(--text-weak)] mt-px">补充风格或方向,系统会保证内容密度</div></div>
|
||||
<input type="text" v-model="s.knowledgePrompt" @input="debounced(go.saveKnowledgePrompt, s.knowledgePrompt)"
|
||||
placeholder="如: 偏实践、给出判断标准"
|
||||
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-1 px-2 outline-none w-[140px] focus:border-[var(--input-border-focus)]">
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)] relative">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">天气城市</div>
|
||||
<div class="city-picker" :class="{ open: cityOpen }" tabindex="0" @click="cityOpen = !cityOpen">
|
||||
<span class="text-[var(--text)] text-[11px]">{{ cityDisplay }}</span>
|
||||
<span class="absolute right-1.5 top-1/2 -translate-y-1/2 text-[var(--text-weak)] text-[10px] pointer-events-none">▾</span>
|
||||
<div class="city-panel">
|
||||
<div class="city-col">
|
||||
<div v-for="p in s.provinces" :key="p" :class="{ active: p === activeProv }" @click.stop="activeProv = p">{{ p }}</div>
|
||||
</div>
|
||||
<div class="city-col">
|
||||
<div v-for="c in currentCities" :key="c.id" :class="{ active: c.id === s.city }" @click.stop="onCitySelect(c)">{{ c.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { go } from '../composables/useGoBridge'
|
||||
import { debounced } from '../composables/useDebounce'
|
||||
import type { SettingsData } from '@shared/types'
|
||||
|
||||
const props = defineProps<{ s: SettingsData }>()
|
||||
const zodiacs = ['白羊座','金牛座','双子座','巨蟹座','狮子座','处女座','天秤座','天蝎座','射手座','摩羯座','水瓶座','双鱼座']
|
||||
const cityOpen = ref(false)
|
||||
const activeProv = ref('')
|
||||
const selectedCityName = ref('')
|
||||
|
||||
const currentCities = computed(() => {
|
||||
if (!activeProv.value) return []
|
||||
return (props.s.citiesByProv as any)[activeProv.value] || []
|
||||
})
|
||||
|
||||
const cityDisplay = computed(() => {
|
||||
if (activeProv.value && selectedCityName.value) return `${activeProv.value} · ${selectedCityName.value}`
|
||||
return '未选择'
|
||||
})
|
||||
|
||||
function onCitySelect(c: { id: string; name: string }) {
|
||||
props.s.city = c.id
|
||||
selectedCityName.value = c.name
|
||||
cityOpen.value = false
|
||||
go.saveCity(c.id)
|
||||
}
|
||||
|
||||
function onDocClick(e: MouseEvent) {
|
||||
const picker = document.querySelector('.city-picker')
|
||||
if (picker && !picker.contains(e.target as Node)) cityOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocClick)
|
||||
// Find initial city
|
||||
for (const p of Object.keys(props.s.citiesByProv)) {
|
||||
for (const c of (props.s.citiesByProv as any)[p]) {
|
||||
if (c.id === props.s.city) { activeProv.value = p; selectedCityName.value = c.name; break }
|
||||
}
|
||||
if (activeProv.value) break
|
||||
}
|
||||
})
|
||||
onUnmounted(() => document.removeEventListener('click', onDocClick))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.city-picker {
|
||||
position: relative; background: var(--input-bg); border: 1px solid var(--input-border);
|
||||
border-radius: 6px; padding: 4px 24px 4px 8px; cursor: pointer;
|
||||
font-size: 11px; min-width: 140px; max-width: 180px;
|
||||
}
|
||||
.city-picker:focus, .city-picker.open { border-color: var(--input-border-focus); }
|
||||
.city-panel {
|
||||
display: none; position: absolute; right: -1px; bottom: calc(100% + 4px);
|
||||
background: var(--option-bg); border: 1px solid var(--input-border);
|
||||
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.25);
|
||||
z-index: 1000; width: 260px; height: 200px; overflow: hidden;
|
||||
}
|
||||
.city-picker.open .city-panel { display: flex; }
|
||||
.city-col { flex: 1; overflow-y: auto; border-right: 1px solid var(--card-divider); }
|
||||
.city-col:last-child { border-right: none; }
|
||||
.city-col div {
|
||||
padding: 5px 10px; font-size: 11px; color: var(--text); cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.city-col div:hover { background: var(--input-bg); }
|
||||
.city-col div.active { color: var(--accent); font-weight: 600; }
|
||||
</style>
|
||||
61
web-ui/src/settings/components/PhotoSection.vue
Normal file
61
web-ui/src/settings/components/PhotoSection.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<div class="text-[10px] font-semibold text-[var(--text-weak)] uppercase tracking-[1.5px] mb-1 pl-0.5">相册</div>
|
||||
<div class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg">
|
||||
<div class="flex justify-between items-center px-3.5 py-2">
|
||||
<div><div class="text-xs font-medium text-[var(--text-muted)]">相册展示</div></div>
|
||||
<ToggleSwitch v-model="s.photoCard" />
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div><div class="text-xs font-medium text-[var(--text-muted)]">电子相册模式</div><div class="text-[10px] text-[var(--text-weak)] mt-px">照片铺满整个壁纸</div></div>
|
||||
<ToggleSwitch v-model="s.photoFrameMode" />
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div class="text-[10px] text-[var(--text-weak)]">{{ s.photoDir || '未选择目录' }}</div>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn" @click="onPickDir">选择目录</button>
|
||||
<button v-if="s.photoDir" class="btn-sm" @click="onClearDir">清除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">切换间隔</div>
|
||||
<select v-model="s.photoInterval" @change="go.savePhotoInterval(Number(s.photoInterval))"
|
||||
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-0.5 px-1.5 outline-none min-w-[80px] max-w-[160px] focus:border-[var(--input-border-focus)]">
|
||||
<option v-for="v in [5,10,15,20,30,60]" :key="v" :value="v">{{ v }} 秒</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { go } from '../composables/useGoBridge'
|
||||
import ToggleSwitch from './ToggleSwitch.vue'
|
||||
import type { SettingsData } from '@shared/types'
|
||||
|
||||
const props = defineProps<{ s: SettingsData }>()
|
||||
|
||||
async function onPickDir() {
|
||||
const dir = await go.pickPhotoDir()
|
||||
if (dir) props.s.photoDir = dir
|
||||
}
|
||||
async function onClearDir() {
|
||||
await go.clearPhotoDir()
|
||||
props.s.photoDir = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.btn {
|
||||
background: var(--input-bg); border: 1px solid var(--input-border);
|
||||
border-radius: 6px; color: var(--text); font-size: 11px; padding: 4px 10px;
|
||||
font-family: inherit; cursor: pointer; transition: background 0.15s; white-space: nowrap;
|
||||
}
|
||||
.btn:hover { background: var(--card-border); }
|
||||
.btn-sm {
|
||||
background: var(--input-bg); border: 1px solid var(--input-border);
|
||||
border-radius: 6px; color: var(--text); font-size: 10px; padding: 3px 8px;
|
||||
font-family: inherit; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.btn-sm:hover { background: var(--card-border); }
|
||||
</style>
|
||||
47
web-ui/src/settings/components/ToggleSection.vue
Normal file
47
web-ui/src/settings/components/ToggleSection.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<div class="text-[10px] font-semibold text-[var(--text-weak)] uppercase tracking-[1.5px] mb-1 pl-0.5">显示控制</div>
|
||||
<div class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg">
|
||||
<div v-for="t in toggles" :key="t.key"
|
||||
class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)] first:border-t-0"
|
||||
:class="{ 'pl-8': t.sub }">
|
||||
<div>
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">{{ t.label }}</div>
|
||||
<div v-if="t.desc" class="text-[10px] text-[var(--text-weak)] mt-px">{{ t.desc }}</div>
|
||||
</div>
|
||||
<ToggleSwitch v-model="(s as any)[t.key]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import ToggleSwitch from './ToggleSwitch.vue'
|
||||
import { go } from '../composables/useGoBridge'
|
||||
import type { SettingsData } from '@shared/types'
|
||||
|
||||
const props = defineProps<{ s: SettingsData }>()
|
||||
|
||||
const toggles: { key: keyof SettingsData; label: string; desc?: string; sub?: boolean }[] = [
|
||||
{ key: 'autoStart', label: '开机启动', desc: '系统启动时自动运行' },
|
||||
{ key: 'wallpaper', label: '显示壁纸' },
|
||||
{ key: 'time', label: '时间日期' },
|
||||
{ key: 'showSeconds', label: '显示秒', sub: true },
|
||||
{ key: 'weather', label: '天气信息' },
|
||||
{ key: 'zodiacCard', label: '星座运势' },
|
||||
{ key: 'knowledgeCard', label: '知识卡片' },
|
||||
{ key: 'ainewsCard', label: 'AI 资讯' },
|
||||
]
|
||||
|
||||
const toggleKeys = toggles.map(t => t.key)
|
||||
|
||||
watch(
|
||||
() => toggleKeys.map(k => (props.s as any)[k]),
|
||||
() => {
|
||||
const data: Record<string, boolean> = {}
|
||||
for (const k of toggleKeys) data[k] = (props.s as any)[k]
|
||||
go.saveToggles(data)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
28
web-ui/src/settings/components/ToggleSwitch.vue
Normal file
28
web-ui/src/settings/components/ToggleSwitch.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<label class="switch">
|
||||
<input type="checkbox" :checked="modelValue" @change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)">
|
||||
<span class="track"><span class="thumb"></span></span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ modelValue: boolean }>()
|
||||
defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; }
|
||||
.switch input { display: none; }
|
||||
.switch .track {
|
||||
position: absolute; inset: 0;
|
||||
background: var(--toggle-track);
|
||||
border-radius: 10px; cursor: pointer; transition: background 0.2s;
|
||||
}
|
||||
.switch .thumb {
|
||||
position: absolute; width: 14px; height: 14px; top: 3px; left: 3px;
|
||||
background: var(--toggle-thumb);
|
||||
border-radius: 50%; transition: all 0.2s; pointer-events: none;
|
||||
}
|
||||
.switch input:checked + .track { background: var(--accent); }
|
||||
.switch input:checked + .track .thumb { transform: translateX(16px); background: var(--text-strong); }
|
||||
</style>
|
||||
198
web-ui/src/settings/components/WallpaperSection.vue
Normal file
198
web-ui/src/settings/components/WallpaperSection.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<div class="text-[10px] font-semibold text-[var(--text-weak)] uppercase tracking-[1.5px] mb-1 pl-0.5">壁纸</div>
|
||||
<div class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg">
|
||||
<!-- 类型选择 -->
|
||||
<div class="flex justify-between items-center px-3.5 py-2">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">类型选择</div>
|
||||
<div class="flex gap-1">
|
||||
<button v-for="t in wpTypes" :key="t.value" @click="$emit('set-wp-type', t.value)"
|
||||
:class="s.wallpaperType === t.value
|
||||
? 'bg-[var(--accent)] border-[var(--accent)] text-[var(--text-strong)]'
|
||||
: 'bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--text)] hover:bg-[var(--card-border)]'"
|
||||
class="border rounded-md text-[11px] py-1 px-2.5 font-[inherit] cursor-pointer whitespace-nowrap transition-colors">{{ t.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主题 -->
|
||||
<div v-if="s.wallpaperType === 'theme'" class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg mt-1.5">
|
||||
<div class="flex justify-between items-center px-3.5 py-2">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">选择主题</div>
|
||||
<select v-model="s.theme" @change="go.saveWallpaperType('theme', s.theme)"
|
||||
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-0.5 px-1.5 outline-none min-w-[80px] max-w-[160px] focus:border-[var(--input-border-focus)]">
|
||||
<option v-for="t in s.themes" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="s.theme === 'text'" class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">自定义文字</div>
|
||||
<input type="text" v-model="s.wallpaperText" @input="debounced(go.saveWallpaperText, s.wallpaperText)"
|
||||
placeholder="输入显示文字"
|
||||
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-1 px-2 outline-none w-[140px] focus:border-[var(--input-border-focus)]">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本地图片 -->
|
||||
<div v-if="s.wallpaperType === 'image'" class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg mt-1.5">
|
||||
<div class="flex justify-between items-center px-3.5 py-2">
|
||||
<div class="text-[10px] text-[var(--text-weak)] truncate mr-2">{{ s.imagePath || '未选择图片' }}</div>
|
||||
<button class="btn" @click="onPickImage">选择图片</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bing -->
|
||||
<div v-if="s.wallpaperType === 'bing'" class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg mt-1.5">
|
||||
<div class="flex justify-between items-center px-3.5 py-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<img v-if="bing.url" :src="bingThumb" class="w-16 h-10 object-cover rounded flex-shrink-0">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)] truncate">{{ bing.copyright || 'Bing 每日壁纸' }}</div>
|
||||
<div class="text-[10px] text-[var(--text-weak)]">{{ bing.idx > 0 ? `${bing.idx} / ${bing.total}` : '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1 flex-shrink-0">
|
||||
<button class="btn-sm" @click="onBingPrev">◀</button>
|
||||
<button class="btn-sm" @click="onBingNext">▶</button>
|
||||
<button class="btn-sm" @click="onBingFav">{{ bing.fav ? '★' : '☆' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div><div class="text-xs font-medium text-[var(--text-muted)]">定时切换</div><div class="text-[10px] text-[var(--text-weak)] mt-px">每小时自动切换壁纸</div></div>
|
||||
<ToggleSwitch v-model="s.bingAutoRefresh" />
|
||||
</div>
|
||||
<!-- 收藏列表 -->
|
||||
<div v-if="bingFavs.length" class="px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div class="text-[10px] text-[var(--text-weak)] mb-1.5">收藏列表</div>
|
||||
<div class="grid grid-cols-4 gap-1.5">
|
||||
<div v-for="(f, i) in bingFavs" :key="i" class="cursor-pointer rounded overflow-hidden hover:opacity-80" @click="onBingSetByIdx(f.idx)">
|
||||
<img :src="f.thumb" class="w-full h-10 object-cover rounded">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 纯色/渐变 -->
|
||||
<div v-if="s.wallpaperType === 'color'" class="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg mt-1.5">
|
||||
<div class="flex justify-between items-center px-3.5 py-2">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">选择颜色</div>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn" @click="$emit('pick-solid')">纯色</button>
|
||||
<button class="btn" @click="$emit('pick-gradient')">渐变</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="currentColor.c1" class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="inline-block w-6 h-4 rounded-sm border border-[var(--input-border)]" :style="swatchStyle"></span>
|
||||
<span class="text-[10px] text-[var(--text-weak)]">当前颜色</span>
|
||||
</div>
|
||||
<button class="btn" @click="$emit('save-color')">收藏</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 已收藏颜色 -->
|
||||
<div v-if="s.savedColors.length && s.wallpaperType === 'color'" class="flex flex-wrap gap-1.5 mt-1.5 px-0.5">
|
||||
<div v-for="(c, i) in s.savedColors" :key="i" class="color-swatch" :style="swatchOf(c)"
|
||||
@click="$emit('apply-color', i)" @contextmenu.prevent="$emit('remove-color', i)">
|
||||
<span class="del">×</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { go } from '../composables/useGoBridge'
|
||||
import { debounced } from '../composables/useDebounce'
|
||||
import ToggleSwitch from './ToggleSwitch.vue'
|
||||
import type { SettingsData, BingState } from '@shared/types'
|
||||
|
||||
const props = defineProps<{
|
||||
s: SettingsData
|
||||
currentColor: { c1: string; c2: string; gradient: boolean }
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'set-wp-type': [type: string]
|
||||
'pick-solid': []
|
||||
'pick-gradient': []
|
||||
'save-color': []
|
||||
'apply-color': [idx: number]
|
||||
'remove-color': [idx: number]
|
||||
}>()
|
||||
|
||||
const wpTypes = [
|
||||
{ value: 'theme', label: '主题' },
|
||||
{ value: 'image', label: '本地图片' },
|
||||
{ value: 'bing', label: 'Bing' },
|
||||
{ value: 'color', label: '纯色/渐变' },
|
||||
]
|
||||
|
||||
// Bing
|
||||
const bing = ref<BingState>({ date: '', title: '', copyright: '', url: '', fav: false, idx: 0, total: 0 })
|
||||
const bingThumb = ref('')
|
||||
const bingFavs = ref<{ idx: number; thumb: string }[]>([])
|
||||
|
||||
async function loadBingInfo() {
|
||||
const raw = await go.getBingInfo()
|
||||
const st = JSON.parse(raw) as BingState
|
||||
bing.value = st
|
||||
if (st.url) bingThumb.value = st.url.replace('_1920x1080', '_400x240')
|
||||
}
|
||||
|
||||
async function onBingPrev() { bing.value = JSON.parse(await go.bingPrev()) }
|
||||
async function onBingNext() { bing.value = JSON.parse(await go.bingNext()) }
|
||||
async function onBingFav() { bing.value = JSON.parse(await go.bingToggleFavorite()); await loadBingFavs() }
|
||||
async function onBingSetByIdx(idx: number) { bing.value = JSON.parse(await go.bingSetByIdx(idx)) }
|
||||
|
||||
async function loadBingFavs() {
|
||||
const raw = await go.getBingFavorites()
|
||||
const list = JSON.parse(raw) as string[]
|
||||
bingFavs.value = await Promise.all(list.map(async f => ({
|
||||
idx: parseInt(f.match(/(\d+)/)?.[1] || '0', 10),
|
||||
thumb: await go.bingThumbDataURI(f)
|
||||
})))
|
||||
}
|
||||
|
||||
watch(() => props.s.bingAutoRefresh, v => go.saveBingAutoRefresh(v))
|
||||
watch(() => props.s.wallpaperType, t => {
|
||||
if (t === 'bing') { loadBingInfo(); loadBingFavs() }
|
||||
}, { immediate: true })
|
||||
|
||||
async function onPickImage() {
|
||||
const p = await go.pickLocalImage()
|
||||
if (p) props.s.imagePath = p
|
||||
}
|
||||
|
||||
// Color swatches
|
||||
function gradientBg(c1: string, c2: string, gradient: boolean) {
|
||||
return { background: gradient && c2 ? `linear-gradient(135deg,${c1},${c2})` : c1 }
|
||||
}
|
||||
const swatchStyle = computed(() => props.currentColor.c1 ? gradientBg(props.currentColor.c1, props.currentColor.c2, props.currentColor.gradient) : {})
|
||||
function swatchOf(c: { color1: string; color2: string; gradient: boolean }) { return gradientBg(c.color1, c.color2, c.gradient) }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.btn {
|
||||
background: var(--input-bg); border: 1px solid var(--input-border);
|
||||
border-radius: 6px; color: var(--text); font-size: 11px; padding: 4px 10px;
|
||||
font-family: inherit; cursor: pointer; transition: background 0.15s; white-space: nowrap;
|
||||
}
|
||||
.btn:hover { background: var(--card-border); }
|
||||
.btn-sm {
|
||||
background: var(--input-bg); border: 1px solid var(--input-border);
|
||||
border-radius: 6px; color: var(--text); font-size: 10px; padding: 3px 8px;
|
||||
font-family: inherit; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.btn-sm:hover { background: var(--card-border); }
|
||||
.color-swatch {
|
||||
width: 28px; height: 28px; border-radius: 6px; cursor: pointer;
|
||||
border: 2px solid transparent; transition: border-color 0.15s; position: relative;
|
||||
}
|
||||
.color-swatch:hover { border-color: var(--accent); }
|
||||
.color-swatch .del {
|
||||
display: none; position: absolute; top: -6px; right: -6px;
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
background: #e53e3e; color: #fff; font-size: 9px; line-height: 14px;
|
||||
text-align: center; cursor: pointer;
|
||||
}
|
||||
.color-swatch:hover .del { display: block; }
|
||||
</style>
|
||||
5
web-ui/src/settings/composables/useDebounce.ts
Normal file
5
web-ui/src/settings/composables/useDebounce.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
export function debounced(fn: (v: string) => void, val: string, ms = 500) {
|
||||
clearTimeout(timers.get(fn.name))
|
||||
timers.set(fn.name, setTimeout(() => fn(val), ms))
|
||||
}
|
||||
32
web-ui/src/settings/composables/useGoBridge.ts
Normal file
32
web-ui/src/settings/composables/useGoBridge.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
const w = window as any
|
||||
|
||||
export const go = {
|
||||
loadAllSettings: (): Promise<string> => w.loadAllSettings(),
|
||||
saveToggles: (data: Record<string, boolean>) => w.saveToggles(JSON.stringify(data)),
|
||||
saveLayout: (layout: string) => w.saveLayout(layout),
|
||||
saveZodiac: (zodiac: string) => w.saveZodiac(zodiac),
|
||||
saveCity: (cityId: string) => w.saveCity(cityId),
|
||||
saveWallpaperType: (type: string, theme: string) => w.saveWallpaperType(type, theme),
|
||||
pickLocalImage: (): Promise<string> => w.pickLocalImage(),
|
||||
enableBing: () => w.enableBing(),
|
||||
bingPrev: (): Promise<string> => w.bingPrev(),
|
||||
bingNext: (): Promise<string> => w.bingNext(),
|
||||
bingToggleFavorite: (): Promise<string> => w.bingToggleFavorite(),
|
||||
getBingInfo: (): Promise<string> => w.getBingInfo(),
|
||||
saveBingAutoRefresh: (val: boolean) => w.saveBingAutoRefresh(val),
|
||||
getBingFavorites: (): Promise<string> => w.getBingFavorites(),
|
||||
bingSetByIdx: (idx: number): Promise<string> => w.bingSetByIdx(idx),
|
||||
bingThumbDataURI: (filename: string): Promise<string> => w.bingThumbDataURI(filename),
|
||||
pickSolidColor: (): Promise<string> => w.pickSolidColor(),
|
||||
pickGradientColor: (): Promise<string> => w.pickGradientColor(),
|
||||
addSavedColor: (c1: string, c2: string, gradient: boolean) => w.addSavedColor(c1, c2, gradient),
|
||||
removeSavedColor: (idx: number): Promise<string> => w.removeSavedColor(idx),
|
||||
applySavedColor: (idx: number) => w.applySavedColor(idx),
|
||||
saveWallpaperText: (text: string) => w.saveWallpaperText(text),
|
||||
saveKnowledgeKeyword: (keyword: string) => w.saveKnowledgeKeyword(keyword),
|
||||
saveKnowledgePrompt: (prompt: string) => w.saveKnowledgePrompt(prompt),
|
||||
pickPhotoDir: (): Promise<string> => w.pickPhotoDir(),
|
||||
clearPhotoDir: () => w.clearPhotoDir(),
|
||||
savePhotoInterval: (val: number) => w.savePhotoInterval(val),
|
||||
resizeToFit: (w: number, h: number) => w.resizeToFit(w, h),
|
||||
}
|
||||
6
web-ui/src/settings/index.ts
Normal file
6
web-ui/src/settings/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import '../tailwind.css'
|
||||
import './settings.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
48
web-ui/src/settings/settings.css
Normal file
48
web-ui/src/settings/settings.css
Normal file
@@ -0,0 +1,48 @@
|
||||
:root {
|
||||
--bg: #0f0f1a;
|
||||
--card-bg: rgba(255,255,255,0.04);
|
||||
--card-border: rgba(255,255,255,0.06);
|
||||
--card-divider: rgba(255,255,255,0.04);
|
||||
--text: #e0e0e0;
|
||||
--text-strong: #fff;
|
||||
--text-weak: rgba(255,255,255,0.25);
|
||||
--text-muted: rgba(255,255,255,0.5);
|
||||
--input-bg: rgba(255,255,255,0.08);
|
||||
--input-border: rgba(255,255,255,0.1);
|
||||
--input-border-focus: #4f8cff;
|
||||
--accent: #4f8cff;
|
||||
--toggle-track: rgba(255,255,255,0.1);
|
||||
--toggle-thumb: rgba(255,255,255,0.5);
|
||||
--option-bg: #1a1a2e;
|
||||
--footer-color: rgba(255,255,255,0.3);
|
||||
}
|
||||
.light {
|
||||
--bg: #f5f5f5;
|
||||
--card-bg: rgba(0,0,0,0.03);
|
||||
--card-border: rgba(0,0,0,0.08);
|
||||
--card-divider: rgba(0,0,0,0.05);
|
||||
--text: #333;
|
||||
--text-strong: #111;
|
||||
--text-weak: rgba(0,0,0,0.35);
|
||||
--text-muted: rgba(0,0,0,0.55);
|
||||
--input-bg: rgba(0,0,0,0.04);
|
||||
--input-border: rgba(0,0,0,0.12);
|
||||
--input-border-focus: #2b6cb0;
|
||||
--accent: #2b6cb0;
|
||||
--toggle-track: rgba(0,0,0,0.12);
|
||||
--toggle-thumb: rgba(0,0,0,0.45);
|
||||
--option-bg: #fff;
|
||||
--footer-color: rgba(0,0,0,0.35);
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg); color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.22); }
|
||||
select option { background: var(--option-bg); color: var(--text); }
|
||||
.light ::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); }
|
||||
.light ::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.22); }
|
||||
94
web-ui/src/shared/types.ts
Normal file
94
web-ui/src/shared/types.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
export interface WeatherData {
|
||||
current: string
|
||||
hourly: HourlyItem[]
|
||||
daily: DailyItem[]
|
||||
}
|
||||
|
||||
export interface HourlyItem {
|
||||
time: string
|
||||
icon: string
|
||||
temp: string
|
||||
pop?: string
|
||||
}
|
||||
|
||||
export interface DailyItem {
|
||||
date: string
|
||||
icon: string
|
||||
tempMin: string
|
||||
tempMax: string
|
||||
}
|
||||
|
||||
export interface HoroscopeData {
|
||||
zodiac: string
|
||||
all: string
|
||||
love: string
|
||||
work: string
|
||||
money: string
|
||||
health: string
|
||||
luckyColor: string
|
||||
luckyNum: string
|
||||
noble: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface AINewsItem {
|
||||
title: string
|
||||
source: string
|
||||
ctime: string
|
||||
description: string
|
||||
picUrl: string
|
||||
}
|
||||
|
||||
export interface KnowledgeData {
|
||||
content: string
|
||||
keyword: string
|
||||
}
|
||||
|
||||
export interface PhotoData {
|
||||
src: string
|
||||
counter: string
|
||||
interval: number
|
||||
}
|
||||
|
||||
export interface SettingsData {
|
||||
lightTheme: boolean
|
||||
wallpaper: boolean
|
||||
time: boolean
|
||||
weather: boolean
|
||||
zodiacCard: boolean
|
||||
knowledgeCard: boolean
|
||||
layout: string
|
||||
zodiac: string
|
||||
city: string
|
||||
provinces: string[]
|
||||
citiesByProv: Record<string, { id: string; name: string }[]>
|
||||
wallpaperType: string
|
||||
theme: string
|
||||
themes: { value: string; label: string }[]
|
||||
color1: string
|
||||
color2: string
|
||||
colorGradient: boolean
|
||||
savedColors: { color1: string; color2: string; gradient: boolean }[]
|
||||
bingAutoRefresh: boolean
|
||||
knowledgeKeyword: string
|
||||
knowledgePrompt: string
|
||||
wallpaperText: string
|
||||
imagePath: string
|
||||
showSeconds: boolean
|
||||
ainewsCard: boolean
|
||||
photoDir: string
|
||||
photoInterval: number
|
||||
photoCard: boolean
|
||||
photoFrameMode: boolean
|
||||
autoStart: boolean
|
||||
}
|
||||
|
||||
export interface BingState {
|
||||
date: string
|
||||
title: string
|
||||
copyright: string
|
||||
url: string
|
||||
fav: boolean
|
||||
idx: number
|
||||
total: number
|
||||
}
|
||||
4
web-ui/src/shared/utils.ts
Normal file
4
web-ui/src/shared/utils.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function safeImageURL(value: unknown): string {
|
||||
const url = String(value || '').trim()
|
||||
return /^(https?:|data:image\/)/i.test(url) ? url : ''
|
||||
}
|
||||
3
web-ui/src/tailwind.css
Normal file
3
web-ui/src/tailwind.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
15
web-ui/tailwind.config.js
Normal file
15
web-ui/tailwind.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{vue,ts}', './*.html'],
|
||||
corePlugins: {
|
||||
preflight: false,
|
||||
},
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['"Segoe UI"', '"Microsoft YaHei"', 'sans-serif'],
|
||||
display: ['-apple-system', '"SF Pro Display"', '"Segoe UI"', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
20
web-ui/tsconfig.json
Normal file
20
web-ui/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@shared/*": ["./src/shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
31
web-ui/vite.config.ts
Normal file
31
web-ui/vite.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { viteSingleFile } from 'vite-plugin-singlefile'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [vue(), viteSingleFile()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': resolve(__dirname, 'src/shared'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: resolve(__dirname, '../web'),
|
||||
emptyOutDir: false,
|
||||
cssCodeSplit: false,
|
||||
assetsInlineLimit: 10_000_000,
|
||||
rollupOptions: {
|
||||
input: mode === 'overlay'
|
||||
? resolve(__dirname, 'overlay.html')
|
||||
: resolve(__dirname, 'settings.html'),
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
target: 'esnext',
|
||||
},
|
||||
css: {
|
||||
postcss: './postcss.config.js',
|
||||
},
|
||||
}))
|
||||
950
web/overlay.html
950
web/overlay.html
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user