更新若干功能

This commit is contained in:
李琦
2026-08-15 17:18:00 +08:00
parent 4954295961
commit 6a81e479ef
77 changed files with 8165 additions and 2372 deletions

View File

@@ -2,7 +2,7 @@
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 { 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'
@@ -10,8 +10,8 @@ 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 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)
@@ -47,12 +47,12 @@ async function toggleAutostart(){
}
// ---- 全局文件存储(仅管理员 id=1权威配置存远端 MySQL保存即全员生效 ----
const isAdmin=computed(()=>store.syncStatus.userId===1)
const fsCfg=reactive({mode:'local',baseUrl:'',apiKey:''})
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||'',apiKey:c.apiKey||''})
Object.assign(fsCfg,{mode:c.mode||'local',baseUrl:c.baseUrl||''})
}catch{}
fsLoaded.value=true
}
@@ -60,9 +60,28 @@ 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){store.showToast({type:'error',text:errText(e)})}
}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(){
@@ -75,7 +94,42 @@ async function testFileStorage(){
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 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()}}
@@ -99,7 +153,7 @@ watch(()=>[settings.theme,settings.locale,settings.glassOpacity,settings.loading
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()})
onMounted(async()=>{await load();apply();if(tab.value==='filestorage')loadFileStorage();if(tab.value==='kindicons')loadKindIcons()})
onUnmounted(()=>{clearTimeout(aiKeyTimer)})
</script>
@@ -121,6 +175,7 @@ onUnmounted(()=>{clearTimeout(aiKeyTimer)})
<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>
@@ -138,8 +193,7 @@ onUnmounted(()=>{clearTimeout(aiKeyTimer)})
<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>
<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">
@@ -150,6 +204,28 @@ onUnmounted(()=>{clearTimeout(aiKeyTimer)})
<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"/>