更新若干功能

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,11 +1,12 @@
<script setup>
import { computed, reactive, ref } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Folder, Code2, GitCommitHorizontal, Plus, RefreshCw, Search, Trash2, Pencil, FolderOpen } from 'lucide-vue-next'
import { Folder, Code2, GitCommitHorizontal, Plus, RefreshCw, Search, Trash2, Pencil, FolderOpen, PieChart, X, CheckCircle2, CircleX, Ban, Star, CloudDownload, ChevronUp, ChevronDown } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import ChartView from '../components/ChartView.vue'
import { useAppStore } from '../store'
import { call } from '../api'
import { call, on } from '../api'
const store = useAppStore()
const router = useRouter()
@@ -23,6 +24,8 @@ const wslDistros = ref([])
const wslDistro = ref('')
const form = reactive({ name: '', path: '', description: '', groupId: 1 })
const groupForm = reactive({ name: '' })
const srcMode = ref('local') // 新建项目来源local 本地目录 / git 克隆
const gitForm = reactive({ url: '', parentDir: '' })
const palette = ['#53d6a2', '#5da8ff', '#f7cb4d', '#a78bfa', '#ef6f8f']
const filtered = computed(() => {
@@ -36,11 +39,25 @@ const filteredDashboard = computed(() => filtered.value.reduce((acc, p) => {
acc.commits += Number(p.stats?.commitCount || 0)
return acc
}, { projects: 0, totalLines: 0, commits: 0 }))
const langColors = ['#7b73ff', '#4fd1a1', '#4da5ff', '#f4c84a', '#ef6683', '#23b5d3', '#a78bfa', '#fb923c']
const languages = computed(() => store.dashboard.languages || [])
const langTotal = computed(() => languages.value.reduce((s, x) => s + x.code, 0))
const langPie = computed(() => ({
backgroundColor: 'transparent',
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [{ type: 'pie', radius: ['50%', '74%'], label: { show: false }, data: languages.value.slice(0, 12).map((x, i) => ({ name: x.name, value: x.code, itemStyle: { color: langColors[i % langColors.length] } })) }]
}))
const fmt = n => {
n = +n || 0
return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : n.toString()
}
// 顶部统计(大卡片 + 语言分布)可折叠为一行小卡片,状态跨会话记忆
const statsCollapsed = ref(localStorage.getItem('cc-dash-stats-collapsed') === '1')
function toggleStats() {
statsCollapsed.value = !statsCollapsed.value
localStorage.setItem('cc-dash-stats-collapsed', statsCollapsed.value ? '1' : '0')
}
const total = p => p.languages?.reduce((s, x) => s + x.code, 0) || 0
const projectRunning = id => Object.values(store.tasks).some(x => x.projectId === id && !['completed', 'error', 'cancelled'].includes(x.stage))
const defaultGroupId = computed(() => store.projectGroups[0]?.id || 1)
@@ -53,9 +70,21 @@ const errorText = raw => {
function open(p) {
editing.value = p?.id || 0
Object.assign(form, p ? { name: p.name, path: p.path, description: p.description, groupId: p.groupId || defaultGroupId.value } : { name: '', path: '', description: '', groupId: store.selectedProjectGroupId || defaultGroupId.value })
srcMode.value = 'local'
Object.assign(gitForm, { url: '', parentDir: gitForm.parentDir })
error.value = ''
modal.value = true
}
// Git 地址变化时自动推导项目名(用户未手填的情况下)
function onGitUrl() {
const u = gitForm.url.trim().replace(/[/\\]+$/, '').replace(/\.git$/, '')
const seg = u.split(/[/:\\]/).pop() || ''
if (seg && !form.name) form.name = seg
}
async function browseParent() {
const p = await call('SelectDirectory')
if (p) gitForm.parentDir = p
}
function openGroup(g) {
groupEditing.value = g?.id || 0
groupForm.name = g?.name || ''
@@ -117,6 +146,7 @@ async function browseWSL() {
}
async function save() {
if (saving.value) return
if (!editing.value && srcMode.value === 'git') return cloneSave()
error.value = ''
form.path = form.path.trim()
if (!form.path) {
@@ -137,6 +167,29 @@ async function save() {
saving.value = false
}
}
// 从 Git 克隆到指定目录后直接入库;进度经任务事件展示
async function cloneSave() {
error.value = ''
if (!gitForm.url.trim()) { error.value = t('cloneUrlRequired'); return }
if (!gitForm.parentDir.trim()) { error.value = t('cloneDirRequired'); return }
saving.value = true
try {
await call('CloneProject', {
url: gitForm.url.trim(), parentDir: gitForm.parentDir.trim(), name: form.name.trim(),
groupId: Number(form.groupId) || defaultGroupId.value, description: form.description
})
await store.refresh()
modal.value = false
store.showToast({ type: 'success', text: t('cloneDone') })
} catch (e) {
const raw = String(e?.message || e)
const code = ['URL_REQUIRED', 'DIR_NOT_FOUND', 'NAME_REQUIRED', 'TARGET_NOT_EMPTY'].find(x => raw.includes(x))
error.value = code ? t(`errors.${code}`) : raw
store.showToast({ type: 'error', text: error.value })
} finally {
saving.value = false
}
}
async function remove(p) {
if (confirm(`${t('delete')} ${p.name}?`)) {
await call('DeleteProject', p.id)
@@ -153,21 +206,136 @@ async function refreshProject(p) {
store.showToast({ type: 'error', text: errorText(e) })
}
}
function consumePendingAction() {
if (store.pendingAction === 'addProject') {
store.pendingAction = ''
open()
}
}
watch(() => store.pendingAction, consumePendingAction)
// ---- 云端项目待绑定:同账号在其它机器添加的项目,本机需要选目录落地 ----
const cloudPending = ref([])
const bindTarget = ref(null) // { name, description, group } 待绑定项
const bindDir = ref('')
const bindBusy = ref(false)
const bindErr = ref('')
let offSync = null
async function loadPending() {
try { cloudPending.value = (await call('ListCloudPendingProjects')) || [] } catch { cloudPending.value = [] }
}
function openBind(it) {
bindTarget.value = it
bindDir.value = ''
bindErr.value = ''
}
async function browseBind() {
const p = await call('SelectDirectory')
if (p) bindDir.value = p
}
async function bindNow() {
if (bindBusy.value || !bindTarget.value) return
bindErr.value = ''
if (!bindDir.value.trim()) { bindErr.value = t('pathRequired'); return }
bindBusy.value = true
try {
await call('BindCloudProject', bindTarget.value.name, bindDir.value.trim())
bindTarget.value = null
await Promise.all([store.refresh(), loadPending()])
store.showToast({ type: 'success', key: 'cloudBindDone' })
} catch (e) {
bindErr.value = errorText(e)
} finally {
bindBusy.value = false
}
}
onMounted(() => {
consumePendingAction()
loadPending()
offSync = on('sync:done', loadPending)
})
onUnmounted(() => offSync?.())
</script>
<template>
<div class="page dashboard-page">
<header class="page-head sticky-head">
<div><h1>{{ t('dashboard') }}</h1><p>{{ t('dashboardSubtitle') }}</p></div>
<div><h1>{{ t('projects') }}</h1><p>{{ t('dashboardSubtitle') }}</p></div>
<div class="actions">
<button class="btn secondary" @click="batch"><RefreshCw />{{ t('batch') }}</button>
<button class="btn primary" @click="open()"><Plus />{{ t('addProject') }}</button>
</div>
</header>
<div class="stats-grid three">
<StatCard :icon="Folder" :value="filteredDashboard.projects" :label="t('totalProjects')" />
<StatCard :icon="Code2" tone="green" :value="fmt(filteredDashboard.totalLines)" :label="t('totalLines')" />
<StatCard :icon="GitCommitHorizontal" tone="blue" :value="fmt(filteredDashboard.commits)" :label="t('commits')" />
<section v-if="cloudPending.length" class="panel cloud-pending">
<header>
<b><CloudDownload />{{ t('cloudPendingTitle') }}<em>{{ cloudPending.length }}</em></b>
<small>{{ t('cloudPendingDesc') }}</small>
</header>
<div class="cloud-pending-list">
<div v-for="it in cloudPending" :key="it.name" class="cloud-pending-item">
<div class="cp-main">
<b>{{ it.name }}</b>
<small>{{ [it.group, it.description].filter(Boolean).join(' · ') }}</small>
</div>
<button class="btn secondary" @click="openBind(it)"><FolderOpen />{{ t('cloudBindBtn') }}</button>
</div>
</div>
</section>
<!-- 展开三张统计大卡折叠一行小卡片语言分布区一并收起 -->
<template v-if="!statsCollapsed">
<div class="stats-head-bar">
<small>{{ t('statsOverview') }}</small>
<button type="button" class="stats-toggle" @click="toggleStats"><ChevronUp />{{ t('statsCollapseBtn') }}</button>
</div>
<div class="stats-grid three">
<StatCard :icon="Folder" :value="filteredDashboard.projects" :label="t('totalProjects')" />
<StatCard :icon="Code2" tone="green" :value="fmt(filteredDashboard.totalLines)" :label="t('totalLines')" />
<StatCard :icon="GitCommitHorizontal" tone="blue" :value="fmt(filteredDashboard.commits)" :label="t('commits')" />
</div>
</template>
<div v-else class="stats-mini">
<span class="mini-stat"><i class="tone"><Folder /></i><b>{{ filteredDashboard.projects }}</b><small>{{ t('totalProjects') }}</small></span>
<span class="mini-stat"><i class="tone green"><Code2 /></i><b>{{ fmt(filteredDashboard.totalLines) }}</b><small>{{ t('totalLines') }}</small></span>
<span class="mini-stat"><i class="tone blue"><GitCommitHorizontal /></i><b>{{ fmt(filteredDashboard.commits) }}</b><small>{{ t('commits') }}</small></span>
<span v-if="languages.length" class="mini-stat langs"><i class="tone purple"><PieChart /></i>
<em v-for="(x, i) in languages.slice(0, 3)" :key="x.name"><s :style="{ background: langColors[i % langColors.length] }" />{{ x.name }} {{ langTotal ? Math.round(x.code / langTotal * 100) : 0 }}%</em>
</span>
<button type="button" class="stats-toggle" :title="t('statsExpandBtn')" @click="toggleStats"><ChevronDown />{{ t('statsExpandBtn') }}</button>
</div>
<section v-if="store.batchSummary" class="panel batch-summary" :class="{ 'has-failures': store.batchSummary.failed }">
<header>
<h2><CheckCircle2 v-if="!store.batchSummary.failed" class="ok" /><CircleX v-else class="bad" />{{ t('batchSummaryTitle') }}</h2>
<button class="batch-close" :title="t('cancel')" @click="store.batchSummary = null"><X /></button>
</header>
<div class="batch-counts">
<span>{{ t('batchTotal', { n: store.batchSummary.total }) }}</span>
<span class="ok"><CheckCircle2 />{{ store.batchSummary.completed }} {{ t('batchOk') }}</span>
<span class="bad" v-if="store.batchSummary.failed"><CircleX />{{ store.batchSummary.failed }} {{ t('batchFail') }}</span>
<span class="skip" v-if="store.batchSummary.cancelled"><Ban />{{ store.batchSummary.cancelled }} {{ t('batchCancelled') }}</span>
</div>
<ul v-if="store.batchSummary.failures?.length" class="batch-failures">
<li v-for="f in store.batchSummary.failures" :key="f">{{ f }}</li>
</ul>
</section>
<div v-if="!statsCollapsed && languages.length" class="split lang-overview">
<section class="panel shine-card">
<h2><PieChart class="panel-icon" />{{ t('languageDistribution') }}<small>{{ store.selectedProjectGroupId ? groupLabel(store.projectGroups.find(g => g.id === store.selectedProjectGroupId)) : t('allProjectGroups') }}</small></h2>
<ChartView :option="langPie" />
</section>
<section class="panel shine-card">
<h2>{{ t('languageDetails') }}<small>{{ fmt(langTotal) }} {{ t('lines') }}</small></h2>
<div class="lang-rank">
<div v-for="(x, i) in languages.slice(0, 8)" :key="x.name" class="lang-rank-row">
<i :style="{ background: langColors[i % langColors.length] }" />
<b>{{ x.name }}</b>
<span>{{ fmt(x.files) }} {{ t('files') }}</span>
<em><s :style="{ width: (x.code / Math.max(1, languages[0]?.code) * 100) + '%', background: langColors[i % langColors.length] }" /></em>
<strong>{{ fmt(x.code) }}</strong>
<small>{{ langTotal ? Math.round(x.code / langTotal * 100) : 0 }}%</small>
</div>
</div>
</section>
</div>
<div class="section-head">
<h2>{{ t('projects') }}</h2>
@@ -185,10 +353,11 @@ async function refreshProject(p) {
</div>
</div>
<div class="project-grid">
<article v-for="p in filtered" :key="p.id" class="project-card shine-card" @click="router.push('/project/' + p.id)">
<article v-for="p in filtered" :key="p.id" class="project-card shine-card" :class="{ favorited: store.favorites.includes(p.id) }" @click="router.push('/project/' + p.id)">
<div class="project-title">
<div><h3>{{ p.name }}</h3><span class="group-chip">{{ p.groupId === 1 ? t('myProjectGroup') : (p.groupName || t('projectGroup')) }}</span><p :title="p.path">{{ p.path }}</p></div>
<div class="icon-actions">
<button class="wb-star" :class="{ active: store.favorites.includes(p.id) }" :title="store.favorites.includes(p.id) ? t('unfavorite') : t('favorite')" @click.stop="store.toggleFavorite(p.id)"><Star /></button>
<button :title="t('refreshProject')" :disabled="projectRunning(p.id)" @click.stop="refreshProject(p)"><RefreshCw :class="{ spin: projectRunning(p.id) }" /></button>
<button :title="t('edit')" @click.stop="open(p)"><Pencil /></button>
<button :title="t('delete')" @click.stop="remove(p)"><Trash2 /></button>
@@ -213,19 +382,27 @@ async function refreshProject(p) {
<div v-if="modal" class="overlay" @click.self="!saving && (modal = false)">
<form class="modal" @submit.prevent="save">
<header><h2>{{ editing ? t('editProjectTitle') : t('addProjectTitle') }}</h2><button type="button" :disabled="saving" @click="modal = false">×</button></header>
<div v-if="!editing" class="src-switch">
<button type="button" :class="{ active: srcMode === 'local' }" :disabled="saving" @click="srcMode = 'local'"><FolderOpen />{{ t('srcLocal') }}</button>
<button type="button" :class="{ active: srcMode === 'git' }" :disabled="saving" @click="srcMode = 'git'"><GitCommitHorizontal />{{ t('srcGit') }}</button>
</div>
<label v-if="!editing && srcMode === 'git'">{{ t('cloneUrl') }}<input v-model="gitForm.url" placeholder="https://github.com/user/repo.git" :disabled="saving" @change="onGitUrl" /></label>
<label>{{ t('projectName') }}<input v-model="form.name" :disabled="saving" /></label>
<label>{{ t('projectGroup') }}<select v-model.number="form.groupId" :disabled="saving"><option v-for="g in store.projectGroups" :key="g.id" :value="g.id">{{ groupLabel(g) }}</option></select></label>
<label>{{ t('projectPath') }}<div class="browse"><input v-model="form.path" :disabled="saving" required /><button type="button" class="btn secondary" :disabled="saving" @click="browse"><FolderOpen />{{ t('browse') }}</button></div></label>
<div class="wsl-picker hidden-wsl-picker">
<button v-if="!wslDistros.length" type="button" class="btn secondary" @click="loadWSL">{{ t('selectWSL') }}</button>
<template v-else>
<select v-model="wslDistro"><option v-for="d in wslDistros" :key="d">{{ d }}</option></select>
<button type="button" class="btn secondary" @click="browseWSL"><FolderOpen />{{ t('selectWSL') }}</button>
</template>
</div>
<template v-if="editing || srcMode === 'local'">
<label>{{ t('projectPath') }}<div class="browse"><input v-model="form.path" :disabled="saving" required /><button type="button" class="btn secondary" :disabled="saving" @click="browse"><FolderOpen />{{ t('browse') }}</button></div></label>
<div class="wsl-picker hidden-wsl-picker">
<button v-if="!wslDistros.length" type="button" class="btn secondary" @click="loadWSL">{{ t('selectWSL') }}</button>
<template v-else>
<select v-model="wslDistro"><option v-for="d in wslDistros" :key="d">{{ d }}</option></select>
<button type="button" class="btn secondary" @click="browseWSL"><FolderOpen />{{ t('selectWSL') }}</button>
</template>
</div>
</template>
<label v-else>{{ t('cloneDir') }}<div class="browse"><input v-model="gitForm.parentDir" :placeholder="t('cloneDirHint')" :disabled="saving" /><button type="button" class="btn secondary" :disabled="saving" @click="browseParent"><FolderOpen />{{ t('browse') }}</button></div></label>
<label>{{ t('description') }}<textarea v-model="form.description" :disabled="saving" /></label>
<p v-if="error" class="form-error">{{ error }}</p>
<footer><button type="button" class="btn secondary" :disabled="saving" @click="modal = false">{{ t('cancel') }}</button><button class="btn primary" :disabled="saving"><RefreshCw v-if="saving" class="spin" />{{ saving ? t('saving') : t('saveProject') }}</button></footer>
<footer><button type="button" class="btn secondary" :disabled="saving" @click="modal = false">{{ t('cancel') }}</button><button class="btn primary" :disabled="saving"><RefreshCw v-if="saving" class="spin" />{{ saving ? (srcMode === 'git' && !editing ? t('cloning') : t('saving')) : (srcMode === 'git' && !editing ? t('cloneAndAdd') : t('saveProject')) }}</button></footer>
</form>
</div>
<div v-if="groupModal" class="overlay" @click.self="!groupSaving && (groupModal = false)">
@@ -236,5 +413,14 @@ async function refreshProject(p) {
<footer><button type="button" class="btn secondary" :disabled="groupSaving" @click="groupModal = false">{{ t('cancel') }}</button><button class="btn primary" :disabled="groupSaving"><RefreshCw v-if="groupSaving" class="spin" />{{ groupSaving ? t('saving') : t('saveProjectGroup') }}</button></footer>
</form>
</div>
<div v-if="bindTarget" class="overlay" @click.self="!bindBusy && (bindTarget = null)">
<form class="modal compact-modal" @submit.prevent="bindNow">
<header><h2>{{ t('cloudBindTitle') }}</h2><button type="button" :disabled="bindBusy" @click="bindTarget = null">&times;</button></header>
<label>{{ t('projectName') }}<input :value="bindTarget.name" disabled /></label>
<label>{{ t('projectPath') }}<div class="browse"><input v-model="bindDir" :disabled="bindBusy" required /><button type="button" class="btn secondary" :disabled="bindBusy" @click="browseBind"><FolderOpen />{{ t('browse') }}</button></div></label>
<p v-if="bindErr" class="form-error">{{ bindErr }}</p>
<footer><button type="button" class="btn secondary" :disabled="bindBusy" @click="bindTarget = null">{{ t('cancel') }}</button><button class="btn primary" :disabled="bindBusy"><RefreshCw v-if="bindBusy" class="spin" />{{ bindBusy ? t('saving') : t('cloudBindBtn') }}</button></footer>
</form>
</div>
</Teleport>
</template>

View File

@@ -1,47 +1,176 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RefreshCw, Trash2, ScrollText, Info, TriangleAlert, CircleX } from 'lucide-vue-next'
import { RefreshCw, Trash2, ScrollText, Info, TriangleAlert, CircleX, Search, ChevronLeft, ChevronRight, ChevronDown, Copy, Check, Sparkles } from 'lucide-vue-next'
import { call } from '../api'
import StatCard from '../components/StatCard.vue'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
const { t } = useI18n()
const logs = ref([])
const filter = ref('all')
const aiOpen = ref(false)
const page = ref({ items: [], total: 0, info: 0, warning: 0, error: 0 })
const query = ref('')
const level = ref('all')
const category = ref('all')
const categories = ref([])
const pageIndex = ref(0)
const pageSize = 50
const auto = ref(true)
const expanded = ref(new Set())
const loading = ref(false)
const copiedId = ref(0)
let timer
const shown = computed(() => filter.value === 'all' ? logs.value : logs.value.filter(x => filter.value === 'run' ? x.level !== 'error' : x.level === 'error'))
const counts = computed(() => ({
all: logs.value.length,
info: logs.value.filter(x => x.level === 'info').length,
warning: logs.value.filter(x => x.level === 'warning').length,
error: logs.value.filter(x => x.level === 'error').length
}))
async function load() { logs.value = await call('GetLogs', 'all') }
let debounce
let copyTimer
const pageCount = () => Math.max(1, Math.ceil(page.value.total / pageSize))
// 按本地日期分组,头部显示 今天/昨天/日期。
const localDate = s => {
const d = new Date(s)
return isNaN(d) ? String(s).slice(0, 10) : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const groups = computed(() => {
const out = []
let cur = null
for (const x of page.value.items) {
const d = localDate(x.createdAt)
if (!cur || cur.date !== d) { cur = { date: d, items: [] }; out.push(cur) }
cur.items.push(x)
}
return out
})
function dayLabel(d) {
const now = new Date()
const today = localDate(now)
now.setDate(now.getDate() - 1)
if (d === today) return t('today')
if (d === localDate(now)) return t('yesterday')
return d
}
const fullTime = s => String(s || '').replace('T', ' ').slice(0, 19)
function relTime(s) {
const ms = Date.now() - new Date(s).getTime()
if (!isFinite(ms) || ms < 0) return fullTime(s).slice(11, 16)
const m = Math.floor(ms / 60000)
if (m < 1) return t('justNow')
if (m < 60) return t('minAgo', { n: m })
const h = Math.floor(m / 60)
if (h < 24) return t('hourAgo', { n: h })
const d = Math.floor(h / 24)
if (d < 7) return t('dayAgo', { n: d })
return fullTime(s).slice(5, 16)
}
async function load() {
loading.value = true
try {
page.value = await call('SearchLogs', query.value, level.value, category.value, pageIndex.value * pageSize, pageSize)
categories.value = await call('GetLogCategories')
} finally {
loading.value = false
}
}
function setLevel(v) {
level.value = level.value === v ? 'all' : v
pageIndex.value = 0
load()
}
function go(delta) {
const next = pageIndex.value + delta
if (next < 0 || next >= pageCount()) return
pageIndex.value = next
load()
}
function toggle(id) {
const s = new Set(expanded.value)
s.has(id) ? s.delete(id) : s.add(id)
expanded.value = s
}
async function copyDetail(x) {
try {
await navigator.clipboard.writeText(`[${x.level}] ${x.category} ${fullTime(x.createdAt)}\n${x.message}\n${x.detail}`)
copiedId.value = x.id
clearTimeout(copyTimer)
copyTimer = setTimeout(() => { copiedId.value = 0 }, 1500)
} catch {}
}
async function clear() {
if (confirm(t('clearLogs') + '?')) {
await call('ClearLogs')
pageIndex.value = 0
await load()
}
}
watch(query, () => {
clearTimeout(debounce)
debounce = setTimeout(() => { pageIndex.value = 0; load() }, 300)
})
watch(category, () => { pageIndex.value = 0; load() })
onMounted(async () => {
await load()
timer = setInterval(() => auto.value && load(), 5000)
timer = setInterval(() => auto.value && !query.value && pageIndex.value === 0 && load(), 5000)
})
onUnmounted(() => clearInterval(timer))
onUnmounted(() => { clearInterval(timer); clearTimeout(debounce); clearTimeout(copyTimer) })
</script>
<template>
<div class="page">
<div class="page logs-page">
<header class="page-head sticky-head">
<div><h1>{{ t('logs') }}</h1><p>{{ t('logSubtitle') }}</p></div>
<div class="actions"><label class="toggle"><input type="checkbox" v-model="auto" />{{ t('autoRefresh') }}</label><button class="btn secondary" @click="load"><RefreshCw />{{ t('refresh') }}</button><button class="btn danger" @click="clear"><Trash2 />{{ t('clearLogs') }}</button></div>
<div class="actions">
<label class="toggle"><input type="checkbox" v-model="auto" />{{ t('autoRefresh') }}</label>
<button class="btn secondary" @click="aiOpen = true"><Sparkles />{{ t('aiScopeBtn') }}</button>
<button class="btn secondary" @click="load"><RefreshCw :class="{ spin: loading }" />{{ t('refresh') }}</button>
<button class="btn danger" @click="clear"><Trash2 />{{ t('clearLogs') }}</button>
</div>
</header>
<div class="stats-grid four"><StatCard :icon="ScrollText" :value="counts.all" :label="t('totalLogs')" /><StatCard :icon="Info" :value="counts.info" :label="t('info')" /><StatCard :icon="TriangleAlert" :value="counts.warning" :label="t('warning')" /><StatCard :icon="CircleX" :value="counts.error" :label="t('error')" /></div>
<div class="tabs compact"><button :class="{ active: filter === 'all' }" @click="filter = 'all'">{{ t('allLogs') }}</button><button :class="{ active: filter === 'run' }" @click="filter = 'run'">{{ t('runLogs') }}</button><button :class="{ active: filter === 'error' }" @click="filter = 'error'">{{ t('errorLogs') }}</button></div>
<div class="stats-grid four">
<StatCard :icon="ScrollText" :value="page.total" :label="t('totalLogs')" />
<StatCard :icon="Info" tone="blue" :value="page.info" :label="t('info')" />
<StatCard :icon="TriangleAlert" :value="page.warning" :label="t('warning')" />
<StatCard :icon="CircleX" tone="red" :value="page.error" :label="t('error')" />
</div>
<div class="log-toolbar">
<label class="search log-search"><Search /><input v-model="query" :placeholder="t('searchLogs')" /></label>
<div class="log-chips" role="group">
<button class="log-chip info" :class="{ active: level === 'info' }" @click="setLevel('info')"><Info />{{ t('info') }}<b>{{ page.info }}</b></button>
<button class="log-chip warning" :class="{ active: level === 'warning' }" @click="setLevel('warning')"><TriangleAlert />{{ t('warning') }}<b>{{ page.warning }}</b></button>
<button class="log-chip error" :class="{ active: level === 'error' }" @click="setLevel('error')"><CircleX />{{ t('error') }}<b>{{ page.error }}</b></button>
</div>
<select v-model="category" class="log-category">
<option value="all">{{ t('allCategories') }}</option>
<option v-for="c in categories" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<section class="panel log-panel">
<article class="log" v-for="x in shown" :key="x.id" :class="x.level"><span><Info v-if="x.level === 'info'" /><TriangleAlert v-else-if="x.level === 'warning'" /><CircleX v-else /></span><div><small>{{ x.category }}</small><b>{{ x.message }}</b><code v-if="x.detail">{{ x.detail }}</code></div><time>{{ x.createdAt?.replace('T', ' ').slice(0, 19) }}</time></article>
<div v-if="!shown.length" class="empty"><ScrollText />{{ t('noLogs') }}</div>
<div class="log-rows">
<template v-for="g in groups" :key="g.date">
<div class="log-day"><span>{{ dayLabel(g.date) }}</span><i /></div>
<article v-for="x in g.items" :key="x.id" class="log-row" :class="[x.level, { open: expanded.has(x.id), clickable: !!x.detail }]">
<button class="log-row-main" @click="x.detail && toggle(x.id)">
<span class="log-badge"><Info v-if="x.level === 'info'" /><TriangleAlert v-else-if="x.level === 'warning'" /><CircleX v-else /></span>
<b class="log-msg">{{ x.message }}</b>
<small class="log-cat">{{ x.category }}</small>
<time :title="fullTime(x.createdAt)">{{ relTime(x.createdAt) }}</time>
<ChevronDown v-if="x.detail" class="log-caret" />
</button>
<div v-if="x.detail && expanded.has(x.id)" class="log-detail-wrap">
<code class="log-detail">{{ x.detail }}</code>
<button class="log-copy" :class="{ ok: copiedId === x.id }" @click.stop="copyDetail(x)">
<Check v-if="copiedId === x.id" /><Copy v-else />{{ copiedId === x.id ? t('copiedShort') : t('copyBtn') }}
</button>
</div>
</article>
</template>
</div>
<div v-if="!page.items.length" class="empty log-empty"><ScrollText /><b>{{ t('noLogs') }}</b><small>{{ t('noLogsHint') }}</small></div>
<footer v-if="page.total > pageSize" class="log-pager">
<button class="btn secondary" :disabled="pageIndex === 0" @click="go(-1)"><ChevronLeft /></button>
<span>{{ pageIndex + 1 }} / {{ pageCount() }}</span>
<button class="btn secondary" :disabled="pageIndex + 1 >= pageCount()" @click="go(1)"><ChevronRight /></button>
</footer>
</section>
<AIScopeDrawer v-if="aiOpen" kind="logs" :title="t('logs')" @close="aiOpen = false" />
</div>
</template>

View File

@@ -2,12 +2,15 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ArrowLeft, Code2, GitCommitHorizontal, FolderTree, RefreshCw, Files, MessageSquareText, Rows3, Users, Plus, Minus, HardDrive, Folder, FileWarning, GitBranch, ExternalLink, TriangleAlert, ClipboardCheck } from 'lucide-vue-next'
import { ArrowLeft, Code2, GitCommitHorizontal, FolderTree, RefreshCw, Files, MessageSquareText, Rows3, Users, Plus, Minus, HardDrive, Folder, FileWarning, GitBranch, ExternalLink, TriangleAlert, ClipboardCheck, Flame, Sparkles, MessageCircleQuestion } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import ChartView from '../components/ChartView.vue'
import GitHeatmap from '../components/GitHeatmap.vue'
import GitTrend from '../components/GitTrend.vue'
import CommitDrawer from '../components/CommitDrawer.vue'
import FilePreviewModal from '../components/FilePreviewModal.vue'
import AIBrief from '../components/AIBrief.vue'
import ProjectAIDrawer from '../components/ProjectAIDrawer.vue'
import { call } from '../api'
import { useAppStore } from '../store'
@@ -29,8 +32,21 @@ const issueSeverity = ref('all')
const issueType = ref('all')
const detail = ref(null)
const detailLoading = ref(false)
const preview = ref('')
const previewLine = ref(0)
// AI 问答抽屉:详情页内的“二级页面”,不跳转
const aiDrawer = ref(false)
const aiAsk = ref('')
const askText = ref('')
// 这些检查项的 path 是真实源码文件folder_concentration 是目录、large_commit 是提交哈希,不可预览)
const fileIssueTypes = ['large_file', 'long_file', 'todo_marker']
const colors = ['#7b73ff', '#4fd1a1', '#4da5ff', '#f4c84a', '#ef6683', '#23b5d3']
function openPreview(path, line = 0) {
previewLine.value = line
preview.value = path
}
const fmt = n => {
n = +n || 0
return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : n.toString()
@@ -101,6 +117,11 @@ async function showCommit(c) {
detail.value = { ...c, files: [] }
try { detail.value = await call('GetCommitDetails', +route.params.id, c.hash) } finally { detailLoading.value = false }
}
function openChat(text) {
aiAsk.value = (text || '').trim()
askText.value = ''
aiDrawer.value = true
}
onMounted(load)
</script>
@@ -108,14 +129,16 @@ onMounted(load)
<div class="page detail-page">
<header class="project-head sticky-head detail-sticky-head">
<div class="detail-head-row">
<button class="back" @click="router.push('/')"><ArrowLeft />{{ t('back') }}</button>
<button class="back" @click="router.push('/projects')"><ArrowLeft />{{ t('back') }}</button>
<div><h1>{{ p.name }}</h1><p>{{ p.path }}</p></div>
<button class="btn secondary ai-entry" @click="openChat()"><Sparkles />{{ t('aiAskGo') }}</button>
</div>
<div class="tabs detail-tabs">
<button :class="{ active: tab === 'code' }" @click="tab = 'code'"><Code2 />{{ t('code') }}</button>
<button :class="{ active: tab === 'git' }" @click="tab = 'git'"><GitCommitHorizontal />{{ t('git') }}</button>
<button :class="{ active: tab === 'structure' }" @click="tab = 'structure'"><FolderTree />{{ t('structure') }}</button>
<button :class="{ active: tab === 'insights' }" @click="tab = 'insights'"><ClipboardCheck />{{ t('insights') }}</button>
<button :class="{ active: tab === 'ai' }" @click="tab = 'ai'"><Sparkles />{{ t('aiAnalysis') }}</button>
</div>
</header>
@@ -156,6 +179,19 @@ onMounted(load)
<section class="panel structure-scroll-panel"><h2>{{ t('recentCommits') }}</h2><button class="commit" v-for="c in visibleCommits.slice(0, 30)" :key="c.hash" @click="showCommit(c)"><span class="avatar">{{ c.author?.[0] }}</span><div><b>{{ c.message }}</b><small>{{ c.author }} · {{ c.hash.slice(0, 7) }} · {{ c.date?.slice(0, 10) }}</small></div><span class="positive">+{{ c.added }}</span><span class="negative">-{{ c.deleted }}</span></button><div v-if="!visibleCommits.length" class="empty">{{ t('empty') }}</div></section>
</div>
<section class="panel trend shine-card"><h2>{{ t('commitTrend') }}</h2><GitTrend :commits="git.commits" @select="selectedDate = $event" /></section>
<section v-if="git.hotspots?.length" class="panel shine-card hotspot-panel">
<h2><Flame class="hotspot-icon" />{{ t('fileHotspots') }}<small>{{ t('fileHotspotsHint') }}</small></h2>
<div class="hotspot-list">
<button v-for="(h, i) in git.hotspots.slice(0, 20)" :key="h.path" class="hotspot-row" :title="h.path" @click="openPreview(h.path)">
<b>#{{ i + 1 }}</b>
<span class="hotspot-path">{{ h.path }}</span>
<i class="hotspot-bar"><em :style="{ width: (h.changes / Math.max(1, git.hotspots[0]?.changes) * 100) + '%' }" /></i>
<strong>{{ h.changes }} {{ t('changesUnit') }}</strong>
<span class="positive">+{{ fmt(h.added) }}</span>
<span class="negative">-{{ fmt(h.deleted) }}</span>
</button>
</div>
</section>
<section class="panel shine-card"><h2>{{ t('contributorRanking') }}</h2><div class="contributor" v-for="(c, i) in git.contributors" :key="c.email"><b>#{{ i + 1 }}</b><span class="avatar">{{ c.name?.[0] }}</span><div><strong>{{ c.name }}</strong><small>{{ c.email }}</small></div><span>{{ c.commits }} {{ t('commitsUnit') }}</span><span class="positive">+{{ fmt(c.added) }}</span><span class="negative">-{{ fmt(c.deleted) }}</span></div><div v-if="!git.contributors?.length" class="empty">{{ t('empty') }}</div></section>
<CommitDrawer v-if="detail" :detail="detail" :loading="detailLoading" @close="detail = null" />
</template>
@@ -168,12 +204,12 @@ onMounted(load)
<StatCard :icon="FileWarning" tone="red" :value="structure.largeFiles?.length || 0" :label="t('largeFiles')" />
</div>
<div class="split structure-split">
<section class="panel structure-panel"><h2>{{ t('directoryStructure') }}</h2><div class="file-tree"><div v-for="f in structure.files?.slice(0, 300)" :key="f.path" :style="{ paddingLeft: Math.min((f.path.split('/').length - 1) * 16, 160) + 'px' }"><Folder v-if="f.isDir" /><Files v-else /><span :title="f.path">{{ f.name }}</span><small>{{ f.isDir ? '' : bytes(f.size) }}</small></div></div></section>
<section class="panel structure-panel"><h2>{{ t('directoryStructure') }}</h2><div class="file-tree"><div v-for="f in structure.files?.slice(0, 300)" :key="f.path" :class="{ 'file-clickable': !f.isDir }" :style="{ paddingLeft: Math.min((f.path.split('/').length - 1) * 16, 160) + 'px' }" @click="!f.isDir && openPreview(f.path)"><Folder v-if="f.isDir" /><Files v-else /><span :title="f.path">{{ f.name }}</span><small>{{ f.isDir ? '' : bytes(f.size) }}</small></div></div></section>
<section class="panel structure-panel"><h2>{{ t('folderSize') }}</h2><div class="folder-size" v-for="f in structure.folders" :key="f.name"><b :title="f.name">{{ f.name }}</b><span>{{ f.files }} {{ t('files') }}</span><i><em :style="{ width: (f.size / Math.max(1, structure.folders[0]?.size) * 100) + '%' }" /></i><strong>{{ bytes(f.size) }}</strong></div></section>
</div>
<section class="panel large-file-panel"><h2>{{ t('largeFileDetection') }}</h2><div class="large-file" v-for="f in structure.largeFiles" :key="f.path"><div><b>{{ f.name }}</b><small>{{ f.path }}</small></div><strong>{{ bytes(f.size) }}</strong></div><div v-if="!structure.largeFiles?.length" class="empty">{{ t('noLargeFiles') }}</div></section>
<section class="panel large-file-panel"><h2>{{ t('largeFileDetection') }}</h2><div class="large-file file-clickable" v-for="f in structure.largeFiles" :key="f.path" @click="openPreview(f.path)"><div><b>{{ f.name }}</b><small>{{ f.path }}</small></div><strong>{{ bytes(f.size) }}</strong></div><div v-if="!structure.largeFiles?.length" class="empty">{{ t('noLargeFiles') }}</div></section>
</template>
<template v-else>
<template v-else-if="tab === 'insights'">
<section class="panel insights-hero shine-card">
<div class="score-ring" :style="{ '--score': insights.healthScore || 0 }"><strong>{{ insights.healthScore || 0 }}</strong><span>{{ t('healthScore') }}</span></div>
<div class="insight-summary">
@@ -199,12 +235,27 @@ onMounted(load)
<div class="issue-list">
<article v-for="issue in filteredIssues" :key="issue.type + issue.path + issue.line + issue.title" class="issue-card" :class="issue.severity">
<span class="issue-severity">{{ t('severity.' + issue.severity) }}</span>
<div><h3>{{ issue.title }}</h3><p>{{ issue.detail }}</p><small v-if="issue.path">{{ issue.path }}<template v-if="issue.line">:{{ issue.line }}</template></small><code v-if="issue.evidence">{{ issue.evidence }}</code><b>{{ issue.suggestion }}</b></div>
<div><h3>{{ issue.title }}</h3><p>{{ issue.detail }}</p><small v-if="issue.path" :class="{ 'issue-file': fileIssueTypes.includes(issue.type) }" :title="fileIssueTypes.includes(issue.type) ? t('clickPreview') : ''" @click="fileIssueTypes.includes(issue.type) && openPreview(issue.path, issue.line || 0)">{{ issue.path }}<template v-if="issue.line">:{{ issue.line }}</template></small><code v-if="issue.evidence">{{ issue.evidence }}</code><b>{{ issue.suggestion }}</b></div>
</article>
<div v-if="!filteredIssues.length" class="empty"><ClipboardCheck />{{ t('noIssues') }}</div>
</div>
</section>
</template>
<template v-else>
<section class="panel ai-ask-bar">
<Sparkles />
<input v-model="askText" :placeholder="t('aiAskThisProject')" @keyup.enter="openChat(askText)" />
<button class="btn primary" @click="openChat(askText)"><MessageCircleQuestion />{{ t('aiAskGo') }}</button>
</section>
<div class="ai-brief-grid">
<AIBrief v-if="p.id" :project-id="p.id" kind="project" />
<AIBrief v-if="p.id" :project-id="p.id" kind="git" />
<AIBrief v-if="p.id" :project-id="p.id" kind="structure" />
<AIBrief v-if="p.id" :project-id="p.id" kind="insights" />
</div>
</template>
<div class="center-action"><button class="btn secondary" @click="analyze()"><RefreshCw />{{ t('analyze') }}</button></div>
<FilePreviewModal v-if="preview" :project-id="+route.params.id" :path="preview" :line="previewLine" @close="preview = ''; previewLine = 0" />
<ProjectAIDrawer v-if="aiDrawer" :project-id="p.id" :project-name="p.name" :ask="aiAsk" @close="aiDrawer = false; aiAsk = ''" />
</div>
</template>

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>