refactor: change dark theme color

This commit is contained in:
2026-07-07 01:09:22 +08:00
parent 712a2cbb6a
commit 777d5cb6c3
4 changed files with 220 additions and 40 deletions
+77 -12
View File
@@ -11,7 +11,7 @@
* emits a `capture:overlay` event with the screenshot payload so the * emits a `capture:overlay` event with the screenshot payload so the
* frontend knows when (and what) to render. * frontend knows when (and what) to render.
*/ */
import { onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import SettingsView from './views/SettingsView.vue' import SettingsView from './views/SettingsView.vue'
import Toast from './components/Toast.vue' import Toast from './components/Toast.vue'
import CaptureOverlay from './views/CaptureOverlay.vue' import CaptureOverlay from './views/CaptureOverlay.vue'
@@ -24,6 +24,7 @@ import {
SaveRegionToRemote, SaveRegionToRemote,
SummarizeRegion, SummarizeRegion,
CancelRegion, CancelRegion,
GetConfig,
} from '../wailsjs/go/main/App' } from '../wailsjs/go/main/App'
type HotkeyStatus = type HotkeyStatus =
@@ -35,6 +36,25 @@ const hotkeyStatus = ref<HotkeyStatus>({ state: 'unknown' })
type Mode = 'settings' | 'overlay' type Mode = 'settings' | 'overlay'
const mode = ref<Mode>('settings') const mode = ref<Mode>('settings')
type ThemeMode = 'auto' | 'light' | 'dark'
const themeMode = ref<ThemeMode>('auto')
const appThemeClass = computed(() => `theme-${themeMode.value}`)
function normalizeTheme(theme: unknown): ThemeMode {
return theme === 'light' || theme === 'dark' || theme === 'auto'
? theme
: 'auto'
}
async function loadThemePreference() {
try {
const cfg = await GetConfig()
themeMode.value = normalizeTheme((cfg as any)?.theme)
} catch {
themeMode.value = 'auto'
}
}
// `SettingsTab` is hoisted to the App shell so the sidebar (which lives // `SettingsTab` is hoisted to the App shell so the sidebar (which lives
// here) and the inner SettingsView (which renders the matching card) can // here) and the inner SettingsView (which renders the matching card) can
// share a single source of truth without an event bus. // share a single source of truth without an event bus.
@@ -224,6 +244,7 @@ async function onOverlayCancel() {
} }
onMounted(() => { onMounted(() => {
void loadThemePreference()
EventsOn('capture:start', () => { EventsOn('capture:start', () => {
capturing.value = true capturing.value = true
}) })
@@ -286,7 +307,7 @@ onUnmounted(() => {
/> />
<!-- Settings mode: standard desktop window; macOS title bar is native. --> <!-- Settings mode: standard desktop window; macOS title bar is native. -->
<div v-else class="app-root"> <div v-else class="app-root" :class="appThemeClass">
<div v-if="hotkeyStatus.state === 'error'" class="permission-banner"> <div v-if="hotkeyStatus.state === 'error'" class="permission-banner">
<div> <div>
<strong>Global hotkey is not active.</strong> <strong>Global hotkey is not active.</strong>
@@ -312,7 +333,11 @@ onUnmounted(() => {
</div> </div>
</aside> </aside>
<section class="content"> <section class="content">
<SettingsView :tab="activeTab" /> <SettingsView
:tab="activeTab"
:theme="themeMode"
@theme-change="themeMode = normalizeTheme($event)"
/>
</section> </section>
</main> </main>
@@ -344,6 +369,15 @@ onUnmounted(() => {
color: #111827; color: #111827;
font-size: 13px; font-size: 13px;
} }
.app-root.theme-light {
color-scheme: light;
}
.app-root.theme-dark {
color-scheme: dark;
}
.app-root.theme-auto {
color-scheme: light dark;
}
.status-pill { .status-pill {
font-size: 11px; font-size: 11px;
@@ -498,33 +532,64 @@ onUnmounted(() => {
} }
} }
@media (prefers-color-scheme: dark) { .app-root.theme-dark {
.app-root {
background: #1c1d22; background: #1c1d22;
color: #e5e7eb; color: #e5e7eb;
} }
.titlebar { .app-root.theme-dark .titlebar {
background: rgba(28, 29, 34, 0.7); background: rgba(28, 29, 34, 0.7);
border-bottom-color: #2c2f36; border-bottom-color: #2c2f36;
} }
.titlebar-title { .app-root.theme-dark .titlebar-title {
color: #f3f4f6; color: #f3f4f6;
} }
.sidebar { .app-root.theme-dark .sidebar {
background: rgba(0, 0, 0, 0.15); background: rgba(0, 0, 0, 0.15);
border-right-color: #2c2f36; border-right-color: #2c2f36;
} }
.sidebar-item { .app-root.theme-dark .sidebar-item {
color: #d1d5db; color: #d1d5db;
} }
.sidebar-item:hover:not(.active) { .app-root.theme-dark .sidebar-item:hover:not(.active) {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
} }
.sidebar-item.active { .app-root.theme-dark .sidebar-item.active {
background: rgba(59, 130, 246, 0.18); background: rgba(59, 130, 246, 0.18);
color: #93c5fd; color: #93c5fd;
} }
.permission-banner { .app-root.theme-dark .permission-banner {
background: rgba(120, 53, 15, 0.3);
border-color: rgba(253, 186, 116, 0.4);
color: #fed7aa;
}
@media (prefers-color-scheme: dark) {
.app-root.theme-auto {
background: #1c1d22;
color: #e5e7eb;
}
.app-root.theme-auto .titlebar {
background: rgba(28, 29, 34, 0.7);
border-bottom-color: #2c2f36;
}
.app-root.theme-auto .titlebar-title {
color: #f3f4f6;
}
.app-root.theme-auto .sidebar {
background: rgba(0, 0, 0, 0.15);
border-right-color: #2c2f36;
}
.app-root.theme-auto .sidebar-item {
color: #d1d5db;
}
.app-root.theme-auto .sidebar-item:hover:not(.active) {
background: rgba(255, 255, 255, 0.05);
}
.app-root.theme-auto .sidebar-item.active {
background: rgba(59, 130, 246, 0.18);
color: #93c5fd;
}
.app-root.theme-auto .permission-banner {
background: rgba(120, 53, 15, 0.3); background: rgba(120, 53, 15, 0.3);
border-color: rgba(253, 186, 116, 0.4); border-color: rgba(253, 186, 116, 0.4);
color: #fed7aa; color: #fed7aa;
+121 -22
View File
@@ -12,7 +12,7 @@
* *
* State flow: load() pulls config from Go on mount, save() pushes back. * State flow: load() pulls config from Go on mount, save() pushes back.
*/ */
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { import {
GetConfig, GetConfig,
SaveConfig, SaveConfig,
@@ -24,9 +24,15 @@ import {
// Tab discriminator shared with the App shell. Kept as a string union so // Tab discriminator shared with the App shell. Kept as a string union so
// the parent can pass the value without importing a type from this view. // the parent can pass the value without importing a type from this view.
type TabId = 'general' | 's3' | 'ssh' | 'llm' type TabId = 'general' | 's3' | 'ssh' | 'llm'
type ThemeMode = 'auto' | 'light' | 'dark'
const props = defineProps<{ const props = defineProps<{
tab: TabId tab: TabId
theme: ThemeMode
}>()
const emit = defineEmits<{
(event: 'theme-change', theme: ThemeMode): void
}>() }>()
interface S3Config { interface S3Config {
@@ -70,6 +76,7 @@ interface LLMConfig {
interface AppConfig { interface AppConfig {
hotkey: string hotkey: string
theme: ThemeMode
s3: S3Config s3: S3Config
ssh: SSHConfig ssh: SSHConfig
llm: LLMConfig llm: LLMConfig
@@ -81,6 +88,12 @@ const llmProviderOptions = [
{ id: 'openai', label: 'ChatGPT / OpenAI-compatible' }, { id: 'openai', label: 'ChatGPT / OpenAI-compatible' },
] ]
const themeOptions: Array<{ id: ThemeMode; label: string }> = [
{ id: 'light', label: '浅色' },
{ id: 'dark', label: '深色' },
{ id: 'auto', label: '自动' },
]
const DEFAULT_SUMMARY_PROMPT = `你是一个截图内容总结助手。请阅读截图,并用中文输出一段适合直接粘贴到聊天、工单、Issue 或 PR 中的说明。 const DEFAULT_SUMMARY_PROMPT = `你是一个截图内容总结助手。请阅读截图,并用中文输出一段适合直接粘贴到聊天、工单、Issue 或 PR 中的说明。
要求: 要求:
@@ -97,6 +110,7 @@ const DEFAULT_SUMMARY_PROMPT = `你是一个截图内容总结助手。请阅读
function defaultConfig(): AppConfig { function defaultConfig(): AppConfig {
return { return {
hotkey: 'cmd+shift+a', hotkey: 'cmd+shift+a',
theme: 'auto',
s3: { s3: {
endpoint: '', endpoint: '',
region: 'us-east-1', region: 'us-east-1',
@@ -184,6 +198,12 @@ const activeLLMProvider = computed(() => {
return config.value.llm.providers[id] return config.value.llm.providers[id]
}) })
function normalizeTheme(theme: unknown): ThemeMode {
return theme === 'light' || theme === 'dark' || theme === 'auto'
? theme
: 'auto'
}
function flash(kind: 'ok' | 'err', text: string) { function flash(kind: 'ok' | 'err', text: string) {
message.value = { kind, text } message.value = { kind, text }
window.setTimeout(() => { window.setTimeout(() => {
@@ -205,7 +225,9 @@ async function load() {
...defaultConfig().llm.providers, ...defaultConfig().llm.providers,
...((cfg as any).llm?.providers ?? {}), ...((cfg as any).llm?.providers ?? {}),
} }
merged.theme = normalizeTheme((cfg as any).theme)
config.value = merged config.value = merged
emit('theme-change', config.value.theme)
} }
} }
@@ -249,11 +271,16 @@ async function manualCapture() {
await CaptureNow() await CaptureNow()
} }
watch(
() => config.value.theme,
(theme) => emit('theme-change', normalizeTheme(theme))
)
onMounted(load) onMounted(load)
</script> </script>
<template> <template>
<div class="settings"> <div class="settings" :class="`theme-${props.theme}`">
<header class="hero"> <header class="hero">
<div> <div>
<h1>SnapGo</h1> <h1>SnapGo</h1>
@@ -265,9 +292,10 @@ onMounted(load)
<button class="btn primary" @click="manualCapture">Capture now</button> <button class="btn primary" @click="manualCapture">Capture now</button>
</header> </header>
<!-- General tab only the shortcut configuration lives here. --> <!-- General tab shortcut and appearance configuration. -->
<section v-if="props.tab === 'general'" class="card"> <section v-if="props.tab === 'general'" class="card">
<h2>Shortcut</h2> <h2>General</h2>
<div class="grid">
<label class="field"> <label class="field">
<span>Global hotkey</span> <span>Global hotkey</span>
<input <input
@@ -276,6 +304,19 @@ onMounted(load)
spellcheck="false" spellcheck="false"
/> />
</label> </label>
<label class="field">
<span>主题选择</span>
<select v-model="config.theme">
<option
v-for="themeOption in themeOptions"
:key="themeOption.id"
:value="themeOption.id"
>
{{ themeOption.label }}
</option>
</select>
</label>
</div>
<p class="hint"> <p class="hint">
Tokens (case-insensitive, "+"-separated): cmd / ctrl / option / shift + Tokens (case-insensitive, "+"-separated): cmd / ctrl / option / shift +
az or 09. Example: <code>cmd+shift+a</code>. az or 09. Example: <code>cmd+shift+a</code>.
@@ -564,6 +605,15 @@ onMounted(load)
padding: 28px 32px 60px; padding: 28px 32px 60px;
color: #1f2937; color: #1f2937;
} }
.settings.theme-light {
color-scheme: light;
}
.settings.theme-dark {
color-scheme: dark;
}
.settings.theme-auto {
color-scheme: light dark;
}
.hero { .hero {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -779,51 +829,100 @@ kbd {
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
@media (prefers-color-scheme: dark) { .settings.theme-dark {
.settings {
color: #e5e7eb; color: #e5e7eb;
} }
.card { .settings.theme-dark .card {
background: #1f2937; background: #1f2937;
border-color: #374151; border-color: #374151;
} }
.field input, .settings.theme-dark .field span {
.field textarea { color: #d1d5db;
}
.settings.theme-dark .field input[type='text'],
.settings.theme-dark .field input[type='number'],
.settings.theme-dark .field input:not([type='checkbox']),
.settings.theme-dark .field select,
.settings.theme-dark .field textarea {
background: #111827; background: #111827;
border-color: #374151; border-color: #374151;
color: #fff; color: #fff;
} }
.field select { .settings.theme-dark .field input::placeholder,
background: #111827; .settings.theme-dark .field textarea::placeholder {
border-color: #374151;
color: #fff;
}
.field input::placeholder,
.field textarea::placeholder {
color: #9ca3af; color: #9ca3af;
} }
.field input:disabled { .settings.theme-dark .field input:disabled {
background: #1f2937; background: #1f2937;
color: #6b7280; color: #6b7280;
} }
.prefixed-input { .settings.theme-dark .prefixed-input {
background: #111827; background: #111827;
border-color: #374151; border-color: #374151;
} }
.input-prefix { .settings.theme-dark .input-prefix {
background: #1f2937; background: #1f2937;
border-right-color: #374151; border-right-color: #374151;
color: #d1d5db; color: #d1d5db;
} }
.btn { .settings.theme-dark .btn:not(.primary) {
background: #1f2937; background: #1f2937;
border-color: #374151; border-color: #374151;
color: #e5e7eb; color: #e5e7eb;
} }
.btn:hover:not(:disabled) { .settings.theme-dark .btn:not(.primary):hover:not(:disabled) {
background: #374151; background: #374151;
} }
.subtitle { .settings.theme-dark .subtitle {
color: #9ca3af;
}
@media (prefers-color-scheme: dark) {
.settings.theme-auto {
color: #e5e7eb;
}
.settings.theme-auto .card {
background: #1f2937;
border-color: #374151;
}
.settings.theme-auto .field span {
color: #d1d5db;
}
.settings.theme-auto .field input[type='text'],
.settings.theme-auto .field input[type='number'],
.settings.theme-auto .field input:not([type='checkbox']),
.settings.theme-auto .field select,
.settings.theme-auto .field textarea {
background: #111827;
border-color: #374151;
color: #fff;
}
.settings.theme-auto .field input::placeholder,
.settings.theme-auto .field textarea::placeholder {
color: #9ca3af;
}
.settings.theme-auto .field input:disabled {
background: #1f2937;
color: #6b7280;
}
.settings.theme-auto .prefixed-input {
background: #111827;
border-color: #374151;
}
.settings.theme-auto .input-prefix {
background: #1f2937;
border-right-color: #374151;
color: #d1d5db;
}
.settings.theme-auto .btn:not(.primary) {
background: #1f2937;
border-color: #374151;
color: #e5e7eb;
}
.settings.theme-auto .btn:not(.primary):hover:not(:disabled) {
background: #374151;
}
.settings.theme-auto .subtitle {
color: #9ca3af; color: #9ca3af;
} }
} }
+2
View File
@@ -167,6 +167,7 @@ export namespace domain {
} }
export class AppConfig { export class AppConfig {
hotkey: string; hotkey: string;
theme: string;
s3: S3Config; s3: S3Config;
ssh: SSHConfig; ssh: SSHConfig;
llm: LLMConfig; llm: LLMConfig;
@@ -178,6 +179,7 @@ export namespace domain {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.hotkey = source["hotkey"]; this.hotkey = source["hotkey"];
this.theme = source["theme"];
this.s3 = this.convertValues(source["s3"], S3Config); this.s3 = this.convertValues(source["s3"], S3Config);
this.ssh = this.convertValues(source["ssh"], SSHConfig); this.ssh = this.convertValues(source["ssh"], SSHConfig);
this.llm = this.convertValues(source["llm"], LLMConfig); this.llm = this.convertValues(source["llm"], LLMConfig);
+14
View File
@@ -117,6 +117,13 @@ const (
LLMProviderOpenAI = "openai" LLMProviderOpenAI = "openai"
) )
// Theme preference identifiers stored in AppConfig.Theme.
const (
ThemeAuto = "auto"
ThemeLight = "light"
ThemeDark = "dark"
)
const DefaultSummaryPrompt = `你是一个截图内容总结助手。请阅读截图,并用中文输出一段适合直接粘贴到聊天、工单、Issue 或 PR 中的说明。 const DefaultSummaryPrompt = `你是一个截图内容总结助手。请阅读截图,并用中文输出一段适合直接粘贴到聊天、工单、Issue 或 PR 中的说明。
要求: 要求:
@@ -141,6 +148,9 @@ type AppConfig struct {
// in the infrastructure hotkey adapter. // in the infrastructure hotkey adapter.
Hotkey string `json:"hotkey"` Hotkey string `json:"hotkey"`
// Theme controls the settings UI appearance: "auto", "light", or "dark".
Theme string `json:"theme"`
// S3 holds the active S3-compatible storage configuration. // S3 holds the active S3-compatible storage configuration.
S3 S3Config `json:"s3"` S3 S3Config `json:"s3"`
@@ -156,6 +166,7 @@ type AppConfig struct {
func DefaultAppConfig() AppConfig { func DefaultAppConfig() AppConfig {
cfg := AppConfig{ cfg := AppConfig{
Hotkey: "cmd+shift+a", Hotkey: "cmd+shift+a",
Theme: ThemeAuto,
S3: S3Config{ S3: S3Config{
PathPrefix: "snapgo/", PathPrefix: "snapgo/",
UsePathStyle: true, UsePathStyle: true,
@@ -214,6 +225,9 @@ func (c *AppConfig) Normalize() {
if c.Hotkey == "" { if c.Hotkey == "" {
c.Hotkey = "cmd+shift+a" c.Hotkey = "cmd+shift+a"
} }
if c.Theme != ThemeLight && c.Theme != ThemeDark && c.Theme != ThemeAuto {
c.Theme = ThemeAuto
}
if c.S3.PathPrefix == "" { if c.S3.PathPrefix == "" {
c.S3.PathPrefix = "snapgo/" c.S3.PathPrefix = "snapgo/"
} }