Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import AppLayout from './layout/AppLayout.vue'
import { useSettingsStore } from '@/stores/settings'
import { useAppearance } from '@/composables/useAppearance'
import { setLocale } from '@/i18n'

// 启动后从后端 /api/settings hydrate(权威源,覆盖 main.ts 的 localStorage 初值,跨设备一致)。
// load(false) 应用主题不回写、setLocale 仅本地不 PUT → 无 echo 回环。
const settings = useSettingsStore()
onMounted(async () => {
const s = await settings.load().catch(() => null)
if (!s) return
if (typeof s.theme === 'string') useAppearance().load(s.theme)
if (s.language === 'zh' || s.language === 'en') setLocale(s.language as 'zh' | 'en')
})
</script>

<template>
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/api/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { api } from './http'

// /api/settings 是一个自由 key-value 对象(theme/language/各开关/端口/webFetchBackend/updateUrl…)。
// GET 返裸 settings 对象;PUT 浅合并传入的 partial,返 {success, settings(合并后), webFetchSyncWarning?}。
export type Settings = Record<string, unknown>

export const getSettings = () => api<Settings>('GET', '/api/settings')

export async function saveSettings(
partial: Settings,
): Promise<{ settings: Settings; webFetchSyncWarning?: string }> {
const data = await api<{ settings?: Settings; webFetchSyncWarning?: string }>(
'PUT',
'/api/settings',
partial,
)
return { settings: data.settings || {}, webFetchSyncWarning: data.webFetchSyncWarning }
}
204 changes: 181 additions & 23 deletions frontend/src/pages/SettingsPage.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,65 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, onMounted } from 'vue'
import { i18nState, setLocale, t } from '@/i18n'
import { useAppearance, type Appearance } from '@/composables/useAppearance'
import { useSettingsStore } from '@/stores/settings'
import type { Settings } from '@/api/settings'
import { useToast } from '@/composables/useToast'
import SettingsGroup from '@/components/ui/SettingsGroup.vue'
import SettingsRow from '@/components/ui/SettingsRow.vue'
import SegmentedControl from '@/components/ui/SegmentedControl.vue'
import AppSwitch from '@/components/ui/AppSwitch.vue'

const { current, set } = useAppearance()
const store = useSettingsStore()
const { current: appearance, set: setAppearance } = useAppearance()
const { show: toast } = useToast()

onMounted(() => {
if (!store.loaded) store.load().catch(() => {})
})

// 保存 partial → 后端浅合并;store.save 已做乐观更新 + 失败回滚,这里只 toast。
async function persist(partial: Settings) {
try {
const warn = await store.save(partial)
if (warn) toast(warn, 'error')
} catch (e) {
toast((e as Error).message || '保存失败', 'error')
}
}

// boolean 开关 writable computed(默认值复刻旧 renderSettings 的 !==false / ===true 语义)
function toggle(key: string, def: boolean) {
return computed<boolean>({
get: () => store.bool(key, def),
set: (v) => void persist({ [key]: v }),
})
}
const autoApplyOnStart = toggle('autoApplyOnStart', true)
const restoreCodexOnExit = toggle('restoreCodexOnExit', true)
const autoUnlockCodexPlugins = toggle('autoUnlockCodexPlugins', false)
const autoWakeCodexPet = toggle('autoWakeCodexPet', true)
const codexQuotaEnabled = toggle('codexQuotaEnabled', false)
const codexNetworkAccess = toggle('codexNetworkAccess', false)
const exposeAllProviderModels = toggle('exposeAllProviderModels', false)
const showGrayProviders = toggle('showGrayProviders', false)
const mcpCredentialsPortableStore = toggle('mcpCredentialsPortableStore', true)

// theme / language 双向(同步本地状态 + 持久化服务端)
const theme = computed<Appearance>({
get: () => appearance.value,
set: (v) => {
setAppearance(v)
void persist({ theme: v })
Comment thread
Cmochance marked this conversation as resolved.
},
})
const language = computed<'zh' | 'en'>({
get: () => i18nState.locale,
set: (v) => {
setLocale(v)
void persist({ language: v })
},
})
const themeOptions: { value: Appearance; label: string }[] = [
{ value: 'light', label: '白' },
{ value: 'dark', label: '黑' },
Expand All @@ -19,37 +70,144 @@ const langOptions: { value: 'zh' | 'en'; label: string }[] = [
{ value: 'en', label: 'EN' },
]

// 占位开关(Stage 3 接 settings store + /api/settings)
const autoApply = ref(true)
const autoUnlock = ref(false)
// webFetchBackend(off/auto/curl/wreq/headless;仅 off/auto 有 i18n,余技术名)
// 默认 auto(对齐后端 schema DEFAULT_WEB_FETCH_BACKEND + 旧前端;key 缺失时运行时实为 auto)
const webFetchBackend = computed<string>({
get: () => store.str('webFetchBackend', 'auto'),
set: (v) => void persist({ webFetchBackend: v }),
Comment thread
Cmochance marked this conversation as resolved.
})
const webFetchOptions: { value: string; label: string }[] = [
{ value: 'off', label: t('settings.webFetchBackend.off') },
{ value: 'auto', label: t('settings.webFetchBackend.auto') },
{ value: 'curl', label: 'curl' },
{ value: 'wreq', label: 'wreq' },
{ value: 'headless', label: 'headless' },
]

function onPort(key: 'proxyPort' | 'adminPort', e: Event) {
const v = Number((e.target as HTMLInputElement).value)
if (Number.isFinite(v) && v > 0) void persist({ [key]: v })
}
function onUpdateUrl(e: Event) {
void persist({ updateUrl: (e.target as HTMLInputElement).value.trim() })
}
</script>

<template>
<div>
<SettingsGroup :title="t('nav.settings')">
<SettingsRow title="外观" description="应用主题:白 / 黑 / 国风">
<SegmentedControl
:model-value="current"
:options="themeOptions"
@update:model-value="(v) => set(v as Appearance)"
/>
<h1 class="page-title">{{ t('settings.title') }}</h1>

<SettingsGroup title="外观与语言">
<SettingsRow :title="t('settings.theme')" description="应用主题:白 / 黑 / 国风">
<SegmentedControl v-model="theme" :options="themeOptions" />
</SettingsRow>
<SettingsRow title="语言 / Language" description="界面显示语言">
<SegmentedControl
:model-value="i18nState.locale"
:options="langOptions"
@update:model-value="(v) => setLocale(v as 'zh' | 'en')"
/>
<SettingsRow :title="t('settings.language')" description="界面显示语言">
<SegmentedControl v-model="language" :options="langOptions" />
</SettingsRow>
</SettingsGroup>

<SettingsGroup title="启动">
<SettingsRow title="启动时自动应用" description="启动 transfer 时自动把当前提供商写入 Codex 配置">
<AppSwitch v-model="autoApply" />
<SettingsGroup title="启动与配置">
<SettingsRow :title="t('settings.autoApplyOnStart')" :description="t('settings.autoApplyOnStartHint')">
<AppSwitch v-model="autoApplyOnStart" />
</SettingsRow>
<SettingsRow title="自动解锁 Codex 插件" description="无真实账号时通过 CDP 注入解锁(高延迟, 默认关)">
<AppSwitch v-model="autoUnlock" />
<SettingsRow :title="t('settings.restoreCodexOnExit')" :description="t('settings.restoreCodexOnExitHint')">
<AppSwitch v-model="restoreCodexOnExit" />
</SettingsRow>
</SettingsGroup>

<SettingsGroup title="Codex 集成">
<SettingsRow :title="t('settings.autoUnlockCodexPlugins')" :description="t('settings.autoUnlockCodexPluginsHint')">
<AppSwitch v-model="autoUnlockCodexPlugins" />
</SettingsRow>
<SettingsRow :title="t('settings.autoWakeCodexPet')" :description="t('settings.autoWakeCodexPetHint')">
<AppSwitch v-model="autoWakeCodexPet" />
</SettingsRow>
<SettingsRow :title="t('settings.codexQuotaEnabled')" :description="t('settings.codexQuotaEnabledHint')">
<AppSwitch v-model="codexQuotaEnabled" />
</SettingsRow>
<SettingsRow :title="t('settings.codexNetworkAccess')" :description="t('settings.codexNetworkAccessHint')">
<AppSwitch v-model="codexNetworkAccess" />
</SettingsRow>
</SettingsGroup>

<SettingsGroup title="提供商">
<SettingsRow :title="t('settings.exposeAllModels')" description="OpenAI 模型菜单展示全部模型">
<AppSwitch v-model="exposeAllProviderModels" />
</SettingsRow>
<SettingsRow :title="t('settings.showGrayProviders')" :description="t('settings.showGrayProvidersHint')">
<AppSwitch v-model="showGrayProviders" />
</SettingsRow>
</SettingsGroup>

<SettingsGroup title="高级">
<SettingsRow :title="t('settings.mcpCredentialsPortableStore')" :description="t('settings.mcpCredentialsPortableStoreHint')">
<AppSwitch v-model="mcpCredentialsPortableStore" />
</SettingsRow>
<SettingsRow :title="t('settings.webFetchBackend')" :description="t('settings.webFetchBackendHint')">
<SegmentedControl v-model="webFetchBackend" :options="webFetchOptions" />
</SettingsRow>
<SettingsRow :title="t('settings.proxyPort')" description="本地转发代理监听端口(改后需重启生效)">
<input
type="number"
class="settings-num"
:value="store.num('proxyPort', 0) || ''"
min="1"
max="65535"
@change="onPort('proxyPort', $event)"
/>
</SettingsRow>
<SettingsRow :title="t('settings.adminPort')" description="管理 API 端口(改后需重启生效)">
<input
type="number"
class="settings-num"
:value="store.num('adminPort', 0) || ''"
min="1"
max="65535"
@change="onPort('adminPort', $event)"
/>
</SettingsRow>
<SettingsRow :title="t('settings.updateUrl')" description="自定义更新检查地址(留空用默认)">
<input
type="text"
class="settings-input"
:value="store.str('updateUrl')"
placeholder="https://..."
spellcheck="false"
@change="onUpdateUrl"
/>
</SettingsRow>
</SettingsGroup>
</div>
</template>

<style scoped>
.page-title {
font-size: var(--fs-xl);
font-weight: 600;
margin: 0 0 var(--space-5);
}
.settings-num {
width: 110px;
}
.settings-input {
width: 240px;
max-width: 100%;
}
.settings-num,
.settings-input {
height: 30px;
padding: 0 var(--space-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
font-size: var(--fs-base);
font-family: inherit;
}
.settings-num:focus,
.settings-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
</style>
46 changes: 46 additions & 0 deletions frontend/src/stores/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as settingsApi from '@/api/settings'
import type { Settings } from '@/api/settings'

export const useSettingsStore = defineStore('settings', () => {
const settings = ref<Settings>({})
const loaded = ref(false)

async function load() {
settings.value = await settingsApi.getSettings()
loaded.value = true
return settings.value
}

// PUT partial(浅合并)→ 后端返合并后 settings;返回可选 webFetchSyncWarning 供 UI toast。
// 乐观更新(开关即时响应)+ 失败回滚(防 UI 与服务端不一致)。
async function save(partial: Settings): Promise<string | undefined> {
const prev = { ...settings.value }
settings.value = { ...settings.value, ...partial }
try {
const { settings: merged, webFetchSyncWarning } = await settingsApi.saveSettings(partial)
settings.value = merged
return webFetchSyncWarning
} catch (e) {
settings.value = prev
throw e
}
}

// 带默认值的 typed getter(旧 app.js renderSettings 的 `!== false` / `=== true` 默认语义)
function bool(key: string, def: boolean): boolean {
const v = settings.value[key]
return typeof v === 'boolean' ? v : def
}
function str(key: string, def = ''): string {
const v = settings.value[key]
return typeof v === 'string' ? v : def
}
function num(key: string, def = 0): number {
const v = settings.value[key]
return typeof v === 'number' ? v : def
}

return { settings, loaded, load, save, bool, str, num }
})
Loading