233 lines
17 KiB
Vue
233 lines
17 KiB
Vue
<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 深链,其次上次停留的 tab(sync 已迁往个人主页,做合法性回退)
|
||
// 分区切换入口在侧边栏“设置”一级导航的二级菜单,页内不再有 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>
|