Files
code-utils/frontend/src/views/Settings.vue
2026-08-15 17:18:00 +08:00

233 lines
17 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, Image, ImageUp, X } 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','kindicons','database']
const TAB_TITLE={rules:'tabRules',appearance:'tabAppearance',ai:'aiAnalysis',filestorage:'tabFileStorage',kindicons:'tabKindIcons',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:''})
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||''})
}catch{}
fsLoaded.value=true
}
async function saveFileStorage(){
if(fsBusy.value)return
fsBusy.value='save'
try{
const has=await call('AdminHasStepUp')
if(!has){
const code=window.prompt(t('adminStepupHint'),'')
if(!code){fsBusy.value='';return}
await call('AdminStepUp',String(code).trim())
}
await call('SaveFileStorageConfig',{...fsCfg})
store.showToast({type:'success',key:'fsSavedToast'})
}catch(e){
const code=String(e).split(':')[0].trim()
if(code==='ADMIN_STEPUP_REQUIRED'||code==='ADMIN_IP_CHANGED'){
const tip=code==='ADMIN_IP_CHANGED'?t('adminIpChangedRisk'):t('adminStepupHint')
const c=window.prompt(tip,'')
if(c){
try{
await call('AdminStepUp',String(c).trim())
await call('SaveFileStorageConfig',{...fsCfg})
store.showToast({type:'success',key:'fsSavedToast'})
}catch(e2){store.showToast({type:'error',text:errText(e2)})}
}
}else 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)}
async function checkSoftwareUpdate(){
try{
const r=await call('CheckAppUpdate',true)
if(r?.upToDate) store.showToast({type:'success',text:t('aboutUpToDate',{version:r.current||r.latest||'—'})})
else store.showToast({type:'info',text:t('aboutUpdateAvailable',{latest:r.latest,current:r.current})})
}catch(e){store.showToast({type:'error',text:errText(e)})}
}
watch(tab,v=>{if(v==='filestorage')loadFileStorage();if(v==='kindicons')loadKindIcons()})
const knownKinds=ref([])
const kindIcons=ref({})
const kindBusy=ref('')
async function loadKindIcons(){
try{
knownKinds.value=await call('ListKnownLaunchKinds')||[]
kindIcons.value=await call('ListKindIcons')||{}
}catch{knownKinds.value=[];kindIcons.value={}}
}
async function pickKindIcon(kind){
if(kindBusy.value)return
kindBusy.value=kind
try{
const u=await call('PickKindIcon',kind)
if(u)kindIcons.value={...kindIcons.value,[kind]:u}
}catch(e){store.showToast({type:'error',text:errText(e)})}
finally{kindBusy.value=''}
}
async function clearKindIcon(kind){
if(kindBusy.value)return
kindBusy.value=kind
try{
await call('ClearKindIcon',kind)
const next={...kindIcons.value};delete next[kind];kindIcons.value=next
}catch(e){store.showToast({type:'error',text:errText(e)})}
finally{kindBusy.value=''}
}
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();if(tab.value==='kindicons')loadKindIcons()})
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>
<button type="button" class="btn secondary" style="margin-top:.5rem" @click="checkSoftwareUpdate">{{t('checkAppUpdate')}}</button>
</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="https://o-api.nailaoyun.cn/pms-api"/></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-if="tab==='kindicons'">
<section class="panel form-panel">
<h2><Image/>{{t('kindIconsTitle')}}</h2>
<p class="auto-update-hint">{{t('kindIconsHint')}}</p>
<template v-if="isAdmin">
<div class="kind-icons-grid">
<div v-for="k in knownKinds" :key="k" class="kind-icon-card">
<span class="kind-icon-preview">
<img v-if="kindIcons[k]" :src="kindIcons[k]" alt="" />
<Image v-else />
</span>
<b>{{k}}</b>
<div class="kind-icon-ops">
<button type="button" class="btn secondary" :disabled="!!kindBusy" @click="pickKindIcon(k)"><ImageUp/>{{t('avatarPick')}}</button>
<button v-if="kindIcons[k]" type="button" class="btn secondary" :disabled="!!kindBusy" @click="clearKindIcon(k)"><X/>{{t('lpClearIcon')}}</button>
</div>
</div>
</div>
</template>
<div v-else class="preview-notice"><Info/><div><b>{{t('fsAdminOnlyTitle')}}</b><small>{{t('kindIconsAdminOnly')}}</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>