Files
code-utils/frontend/src/views/Settings.vue
2026-08-14 17:54:06 +08:00

157 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Database, Plus, Trash2, Upload, BarChart3, Folder, Languages, CheckCircle2, Info, Copy, Timer, Rocket, Sparkles, CloudUpload, Wifi } from 'lucide-vue-next'
import { call, isNative } from '../api'
import { useAppStore } from '../store'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
const route=useRoute()
// tab 记忆:优先 URL 深链,其次上次停留的 tabsync 已迁往个人主页,做合法性回退)
// 分区切换入口在侧边栏“设置”一级导航的二级菜单,页内不再有 tabbar。
const TABS=['rules','appearance','ai','filestorage','database']
const TAB_TITLE={rules:'tabRules',appearance:'tabAppearance',ai:'aiAnalysis',filestorage:'tabFileStorage',database:'tabDatabase'}
const pickTab=v=>TABS.includes(String(v))?String(v):''
const tab=ref(pickTab(route.query.tab)||pickTab(localStorage.getItem('cc-settings-tab'))||'rules'),rules=ref([]),form=reactive({pattern:'',category:'custom'})
const aiOpen=ref(false)
watch(tab,v=>localStorage.setItem('cc-settings-tab',v))
const settings=reactive({theme:'dark',locale:'zh-CN',gitScope:'current',databasePath:'',autoRefresh:true,glassOpacity:55,loadingStyle:'fullscreen-orbit',minimizeToTray:true,autoUpdateEnabled:false,autoUpdateMode:'daily',autoUpdateInterval:1,autoUpdateTime:'09:00',aiProvider:'spark',sparkKey:'',deepSeekKey:'',syncApiKeys:false,avatarMode:'',avatarValue:'',imageMode:'base64'})
const store=useAppStore(),{locale,t}=useI18n(),native=isNative(),dbMessage=ref('')
const autostart=ref(false),autostartBusy=ref(false)
const groups=computed(()=>Object.groupBy?Object.groupBy(rules.value,x=>x.category):rules.value.reduce((a,x)=>((a[x.category]??=[]).push(x),a),{}))
const loadingOptions=[
{value:'fullscreen-orbit',title:'loadingStyleOrbit',desc:'loadingStyleOrbitDesc'},
{value:'fullscreen-grid',title:'loadingStyleGrid',desc:'loadingStyleGridDesc'},
{value:'fullscreen-warp',title:'loadingStyleWarp',desc:'loadingStyleWarpDesc'},
{value:'fullscreen-matrix',title:'loadingStyleMatrix',desc:'loadingStyleMatrixDesc'},
{value:'bar',title:'loadingStyleBar',desc:'loadingStyleBarDesc'}
]
async function load(){
rules.value=await call('GetRules')
const [saved,bootstrap]=await Promise.all([call('GetSettings'),call('GetBootstrapStatus')])
Object.assign(settings,saved)
settings.databasePath=bootstrap.databasePath||saved.databasePath||bootstrap.defaultPath||''
try{autostart.value=await call('GetAutostart')}catch{autostart.value=false}
}
async function toggleAutostart(){
if(autostartBusy.value)return
autostartBusy.value=true
try{
await call('SetAutostart',!autostart.value)
autostart.value=!autostart.value
store.showToast({type:'success',key:autostart.value?'autostartOnToast':'autostartOffToast'})
}catch(e){store.showToast({type:'error',key:'autostartFail',params:{err:String(e)}})}
finally{autostartBusy.value=false}
}
// ---- 全局文件存储(仅管理员 id=1权威配置存远端 MySQL保存即全员生效 ----
const isAdmin=computed(()=>store.syncStatus.userId===1)
const fsCfg=reactive({mode:'local',baseUrl:'',apiKey:''})
const fsBusy=ref(''),fsLoaded=ref(false)
async function loadFileStorage(){
try{
const c=await call('GetFileStorageConfig')
Object.assign(fsCfg,{mode:c.mode||'local',baseUrl:c.baseUrl||'',apiKey:c.apiKey||''})
}catch{}
fsLoaded.value=true
}
async function saveFileStorage(){
if(fsBusy.value)return
fsBusy.value='save'
try{
await call('SaveFileStorageConfig',{...fsCfg})
store.showToast({type:'success',key:'fsSavedToast'})
}catch(e){store.showToast({type:'error',text:errText(e)})}
finally{fsBusy.value=''}
}
async function testFileStorage(){
if(fsBusy.value)return
fsBusy.value='test'
try{
await call('TestFileStorage',{...fsCfg})
store.showToast({type:'success',key:'fsTestOkToast'})
}catch(e){store.showToast({type:'error',text:errText(e)})}
finally{fsBusy.value=''}
}
const errText=e=>{const code=String(e).split(':')[0].trim();return t('errors.'+code)!=='errors.'+code?t('errors.'+code):String(e)}
watch(tab,v=>{if(v==='filestorage')loadFileStorage()})
async function add(){if(!form.pattern)return;await call('AddRule',form.pattern,form.category);form.pattern='';await load()}
async function remove(r){if(!r.builtin){await call('DeleteRule',r.id);await load()}}
async function save(){await call('SaveSettings',{...settings,databasePath:''});store.applyAppearance(settings);apply();localStorage.setItem('cc-settings',JSON.stringify(settings))}
function apply(){locale.value=settings.locale;let theme=settings.theme;if(theme==='system')theme=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';document.documentElement.dataset.theme=theme;document.documentElement.style.setProperty('--glass-user-opacity',String((settings.glassOpacity||55)/100))}
async function migrate(){
dbMessage.value=''
try{
const p=await call('SelectInitialDatabaseFile',settings.databasePath)
if(!p)return
await call('MigrateDatabase',p)
const status=await call('GetBootstrapStatus')
settings.databasePath=status.databasePath
dbMessage.value=t('dbMigratedMsg')
store.showToast({type:'success',text:dbMessage.value})
}catch(e){dbMessage.value=String(e);store.showToast({type:'error',key:'dbMigrateFail'})}
}
async function copyPath(){try{await navigator.clipboard.writeText(settings.databasePath);store.showToast({type:'success',key:'dbPathCopied'})}catch{store.showToast({type:'error',key:'dbCopyFail'})}}
async function clear(mode){const id=mode==='project'?Number(prompt(t('projectIdPrompt'))):0;if(mode==='project'&&!id)return;if(confirm(t('confirmIrreversible'))){await call('ClearData',mode,id);await store.refresh()}}
watch(()=>[settings.theme,settings.locale,settings.glassOpacity,settings.loadingStyle,settings.gitScope,settings.minimizeToTray,settings.autoUpdateEnabled,settings.autoUpdateMode,settings.autoUpdateInterval,settings.autoUpdateTime,settings.aiProvider,settings.syncApiKeys,settings.avatarMode,settings.avatarValue,settings.imageMode],save)
let aiKeyTimer=null
watch(()=>[settings.sparkKey,settings.deepSeekKey],()=>{clearTimeout(aiKeyTimer);aiKeyTimer=setTimeout(save,600)})
watch(()=>route.query.tab,v=>{const n=pickTab(v);if(n)tab.value=n})
onMounted(async()=>{await load();apply();if(tab.value==='filestorage')loadFileStorage()})
onUnmounted(()=>{clearTimeout(aiKeyTimer)})
</script>
<template><div class="page settings-page">
<header class="page-head sticky-head"><div><h1>{{t('settings')}} · {{t(TAB_TITLE[tab])}}</h1><p>{{t('settingsSubtitle')}}</p></div>
<div class="actions"><button class="btn secondary" @click="aiOpen=true"><Sparkles/>{{t('aiScopeBtn')}}</button></div>
</header>
<template v-if="tab==='rules'"><section class="panel rule-add"><h2><Plus/>{{t('addRule')}}</h2><div><input v-model="form.pattern" :placeholder="t('rulePatternPh')" @keyup.enter="add"/><select v-model="form.category"><option value="general">{{t('catGeneral')}}</option><option value="php">PHP</option><option value="go">Go</option><option value="vue">Vue/JS</option><option value="custom">{{t('catCustom')}}</option></select><button class="btn primary" @click="add"><Plus/>{{t('add')}}</button></div><small>{{t('ruleHint')}}</small></section><section v-for="(items,name) in groups" :key="name" class="panel rule-group"><h2>{{name}} <small>{{t('rulesCount',{n:items.length})}}</small></h2><div><button v-for="r in items" :key="r.id" :class="{builtin:r.builtin}" @click="remove(r)">{{r.pattern}}<small v-if="r.builtin">{{t('builtinTag')}}</small><Trash2 v-else/></button></div></section></template>
<template v-else-if="tab==='appearance'"><section class="panel form-panel"><h2><Languages/>{{t('langThemeTitle')}}</h2><label>{{t('uiLanguage')}}<select v-model="settings.locale"><option value="zh-CN">简体中文</option><option value="en">English</option></select></label><label>{{t('theme')}}<select v-model="settings.theme"><option value="dark">{{t('themeDark')}}</option><option value="light">{{t('themeLight')}}</option><option value="system">{{t('themeSystem')}}</option></select></label><label>{{t('gitScopeDefault')}}<select v-model="settings.gitScope"><option value="current">{{t('scopeCurrent')}}</option><option value="all">{{t('scopeAll')}}</option></select></label><div class="loading-style-setting"><span>{{t('loadingStyle')}}</span><div class="loading-style-grid" role="radiogroup" :aria-label="t('loadingStyle')"><button v-for="option in loadingOptions" :key="option.value" type="button" role="radio" :aria-checked="settings.loadingStyle===option.value" :class="['loading-style-card',option.value,{active:settings.loadingStyle===option.value}]" @click="settings.loadingStyle=option.value"><span class="loading-style-preview" aria-hidden="true"><i/></span><b>{{t(option.title)}}</b><small>{{t(option.desc)}}</small></button></div></div><label class="opacity-setting"><span>{{t('glassOpacity')}} <b>{{settings.glassOpacity}}%</b></span><input v-model.number="settings.glassOpacity" type="range" min="30" max="75" step="1"/></label></section>
<section class="panel form-panel"><h2><Rocket/>{{t('sysIntegration')}}</h2>
<label>{{t('onWindowClose')}}<select v-model="settings.minimizeToTray"><option :value="true">{{t('closeToTray')}}</option><option :value="false">{{t('closeQuit')}}</option></select></label>
<label>{{t('autostartLabel')}}<div class="autostart-row"><button type="button" class="btn secondary" :disabled="autostartBusy||!native" @click="toggleAutostart">{{autostart?t('autostartOnBtn'):t('autostartOffBtn')}}</button><small>{{autostart?t('autostartOnHint'):t('autostartOffHint')}}</small></div></label>
</section>
<section class="panel form-panel"><h2><Timer/>{{t('autoUpdateTitle')}}</h2>
<label>{{t('autoUpdateEnable')}}<select v-model="settings.autoUpdateEnabled"><option :value="false">{{t('optOff')}}</option><option :value="true">{{t('optOn')}}</option></select></label>
<template v-if="settings.autoUpdateEnabled">
<label>{{t('updateFreq')}}<select v-model="settings.autoUpdateMode"><option value="daily">{{t('freqDaily')}}</option><option value="everyNDays">{{t('freqNDays')}}</option><option value="everyNHours">{{t('freqNHours')}}</option></select></label>
<label v-if="settings.autoUpdateMode!=='daily'">{{t('intervalN')}}<input v-model.number="settings.autoUpdateInterval" type="number" min="1" max="720" class="interval-input"/></label>
<label v-if="settings.autoUpdateMode!=='everyNHours'">{{t('triggerTime')}}<input v-model="settings.autoUpdateTime" type="time" class="interval-input"/></label>
<p class="auto-update-hint">{{t('autoUpdateHint')}}</p>
</template>
</section></template>
<template v-else-if="tab==='ai'">
<section class="panel form-panel"><h2><Sparkles/>{{t('aiProviderTitle')}}</h2>
<label>{{t('aiCurrentProvider')}}<select v-model="settings.aiProvider"><option value="spark">{{t('aiSparkOption')}}</option><option value="deepseek">DeepSeek</option></select></label>
<label>{{t('sparkKeyLabel')}}<input v-model="settings.sparkKey" type="password" :placeholder="t('sparkKeyPh')"/></label>
<label>{{t('deepseekKeyLabel')}}<input v-model="settings.deepSeekKey" type="password" :placeholder="t('deepseekKeyPh')"/></label>
<p class="auto-update-hint">{{t('aiKeyHint')}}</p>
<label>{{t('syncApiKeysLabel')}}<select v-model="settings.syncApiKeys"><option :value="false">{{t('syncApiKeysOff')}}</option><option :value="true">{{t('syncApiKeysOn')}}</option></select></label>
<p class="auto-update-hint">{{t('syncApiKeysHint')}}</p>
</section>
</template>
<template v-else-if="tab==='filestorage'">
<section class="panel form-panel">
<h2><CloudUpload/>{{t('fsTitle')}}</h2>
<template v-if="isAdmin">
<label>{{t('fsMode')}}<select v-model="fsCfg.mode"><option value="local">{{t('fsModeLocal')}}</option><option value="server">{{t('fsModeServer')}}</option></select></label>
<template v-if="fsCfg.mode==='server'">
<label>{{t('fsBaseUrl')}}<input v-model.trim="fsCfg.baseUrl" placeholder="http://192.168.1.10:8788"/></label>
<label>{{t('fsApiKey')}}<input v-model.trim="fsCfg.apiKey" type="password" :placeholder="t('fsApiKeyPh')"/></label>
</template>
<p class="auto-update-hint">{{t('fsPageHint')}}</p>
<div class="fs-page-actions">
<button v-if="fsCfg.mode==='server'" class="btn secondary" :disabled="!!fsBusy||!fsLoaded" @click="testFileStorage"><Wifi/>{{fsBusy==='test'?t('fsTesting'):t('fsTestBtn')}}</button>
<button class="btn primary" :disabled="!!fsBusy||!fsLoaded" @click="saveFileStorage"><CheckCircle2/>{{fsBusy==='save'?t('saving'):t('fsSaveBtn')}}</button>
</div>
</template>
<div v-else class="preview-notice"><Info/><div><b>{{t('fsAdminOnlyTitle')}}</b><small>{{t('fsAdminOnlyDesc')}}</small></div></div>
</section>
</template>
<template v-else><section class="panel database-panel"><div class="db-title"><h2><Database/>{{t('dbLocation')}}</h2><span class="db-connected"><CheckCircle2/>{{t('dbConnected')}}</span></div><div v-if="!native" class="preview-notice"><Info/><div><b>{{t('previewModeTitle')}}</b><small>{{t('previewModeDb')}}</small></div></div><div class="db-path"><span>{{t('dbCurrentLoc')}}</span><code :title="settings.databasePath">{{settings.databasePath||t('dbNoPath')}}</code><button :title="t('copyPathTitle')" :disabled="!settings.databasePath" @click="copyPath"><Copy/></button></div><p class="db-relocate-hint">{{t('dbRelocateHint')}}</p><p v-if="dbMessage" class="db-message">{{dbMessage}}</p><button class="btn secondary migrate" :disabled="!native" @click="migrate"><Upload/>{{t('migrateBtn')}}</button></section>
<section class="panel danger-zone"><h2><Trash2/>{{t('dangerZone')}}</h2><p>{{t('dangerDesc')}}</p><div><button @click="clear('stats')"><BarChart3/><span><b>{{t('clearStats')}}</b><small>{{t('clearStatsDesc')}}</small></span></button><button @click="clear('project')"><Folder/><span><b>{{t('clearProject')}}</b><small>{{t('clearProjectDesc')}}</small></span></button><button class="danger" @click="clear('all')"><Trash2/><span><b>{{t('clearAllData')}}</b><small>{{t('clearAllDesc')}}</small></span></button></div></section></template>
<AIScopeDrawer v-if="aiOpen" kind="config" :title="t('settings')" @close="aiOpen=false"/>
</div></template>