更新若干功能

This commit is contained in:
李琦
2026-08-14 07:51:46 +08:00
parent d2aeb13a09
commit 153c7ed448
48 changed files with 5567 additions and 2673 deletions

View File

@@ -1,19 +1,31 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { FileSliders, Database, Plus, Trash2, Upload, BarChart3, Folder, Languages, SunMoon, CheckCircle2, Info, Copy } from 'lucide-vue-next'
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 tab=ref(new URLSearchParams(location.search).get('settingsTab')||'rules'),rules=ref([]),form=reactive({pattern:'',category:'custom'})
const settings=reactive({theme:'dark',locale:'zh-CN',gitScope:'current',databasePath:'',autoRefresh:true,glassOpacity:55,loadingStyle:'fullscreen-orbit'})
const store=useAppStore(),{locale}=useI18n(),native=isNative(),dbMessage=ref('')
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:'能量轨道',desc:'环形粒子、扫描光束和能量核心'},
{value:'fullscreen-grid',title:'数据矩阵',desc:'流动数据网格和聚合节点'},
{value:'fullscreen-warp',title:'光速跃迁',desc:'深空隧道、放射光束和跃迁环'},
{value:'bar',title:'底部进度条',desc:'保留当前页面,只显示底部进度'}
{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(){
@@ -21,7 +33,50 @@ async function load(){
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))}
@@ -34,21 +89,68 @@ async function migrate(){
await call('MigrateDatabase',p)
const status=await call('GetBootstrapStatus')
settings.databasePath=status.databasePath
dbMessage.value='数据库已迁移并切换到新位置'
dbMessage.value=t('dbMigratedMsg')
store.showToast({type:'success',text:dbMessage.value})
}catch(e){dbMessage.value=String(e);store.showToast({type:'error',text:'数据库迁移失败'})}
}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',text:'数据库路径已复制'})}catch{store.showToast({type:'error',text:'无法复制路径'})}}
async function clear(mode){const id=mode==='project'?Number(prompt('项目 ID')):0;if(mode==='project'&&!id)return;if(confirm('此操作不可恢复,确认继续?')){await call('ClearData',mode,id);await store.refresh()}}
watch(()=>[settings.theme,settings.locale,settings.glassOpacity,settings.loadingStyle],save)
onMounted(async()=>{await load();apply()})
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"><div><h1>设置</h1><p>管理排除规则界面和数据库配置</p></div></header>
<div class="tabs settings-tabs"><button :class="{active:tab==='rules'}" @click="tab='rules'"><FileSliders/>排除规则</button><button :class="{active:tab==='appearance'}" @click="tab='appearance'"><SunMoon/>界面设置</button><button :class="{active:tab==='database'}" @click="tab='database'"><Database/>数据管理</button></div>
<template v-if="tab==='rules'"><section class="panel rule-add"><h2><Plus/>添加排除规则</h2><div><input v-model="form.pattern" placeholder="例如 *.log, cache, temp" @keyup.enter="add"/><select v-model="form.category"><option value="general">通用</option><option value="php">PHP</option><option value="go">Go</option><option value="vue">Vue/JS</option><option value="custom">自定义</option></select><button class="btn primary" @click="add"><Plus/>添加</button></div><small>支持 * 通配符;目录名会在任意层级匹配</small></section><section v-for="(items,name) in groups" :key="name" class="panel rule-group"><h2>{{name}} <small>{{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">默认</small><Trash2 v-else/></button></div></section></template>
<template v-else-if="tab==='appearance'"><section class="panel form-panel"><h2><Languages/>语言与主题</h2><label>界面语言<select v-model="settings.locale"><option value="zh-CN">简体中文</option><option value="en">English</option></select></label><label>主题<select v-model="settings.theme"><option value="dark">暗色</option><option value="light">浅色</option><option value="system">跟随系统</option></select></label><label>Git 默认范围<select v-model="settings.gitScope"><option value="current">当前分支</option><option value="all">所有分支</option></select></label><div class="loading-style-setting"><span>统计 Loading 样式</span><div class="loading-style-grid" role="radiogroup" aria-label="统计 Loading 样式"><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>{{option.title}}</b><small>{{option.desc}}</small></button></div></div><label class="opacity-setting"><span>卡片透明度 <b>{{settings.glassOpacity}}%</b></span><input v-model.number="settings.glassOpacity" type="range" min="30" max="75" step="1"/></label></section></template>
<template v-else><section class="panel database-panel"><div class="db-title"><h2><Database/>数据库位置</h2><span class="db-connected"><CheckCircle2/>已连接</span></div><div v-if="!native" class="preview-notice"><Info/><div><b>当前是浏览器预览模式</b><small>浏览器无法访问本地数据库和文件选择器,请运行 code-count.exe 使用数据库功能。</small></div></div><div class="db-path"><span>当前位置</span><code :title="settings.databasePath">{{settings.databasePath||'未获取到数据库路径'}}</code><button title="复制路径" :disabled="!settings.databasePath" @click="copyPath"><Copy/></button></div><p v-if="dbMessage" class="db-message">{{dbMessage}}</p><button class="btn secondary migrate" :disabled="!native" @click="migrate"><Upload/>选择新位置并迁移</button></section>
<section class="panel danger-zone"><h2><Trash2/>清空数据</h2><p>选择要清空的数据类型此操作不可恢复</p><div><button @click="clear('stats')"><BarChart3/><span><b>清空统计数据</b><small>删除分析记录保留项目配置</small></span></button><button @click="clear('project')"><Folder/><span><b>清空单个项目</b><small>删除指定项目的统计数据</small></span></button><button class="danger" @click="clear('all')"><Trash2/><span><b>清空所有数据</b><small>删除所有项目和分析记录</small></span></button></div></section></template>
<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 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>