初始化
This commit is contained in:
240
frontend/src/views/Dashboard.vue
Normal file
240
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } 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 StatCard from '../components/StatCard.vue'
|
||||
import { useAppStore } from '../store'
|
||||
import { call } from '../api'
|
||||
|
||||
const store = useAppStore()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const search = ref('')
|
||||
const modal = ref(false)
|
||||
const groupModal = ref(false)
|
||||
const editing = ref(0)
|
||||
const groupEditing = ref(0)
|
||||
const saving = ref(false)
|
||||
const groupSaving = ref(false)
|
||||
const error = ref('')
|
||||
const groupError = ref('')
|
||||
const wslDistros = ref([])
|
||||
const wslDistro = ref('')
|
||||
const form = reactive({ name: '', path: '', description: '', groupId: 1 })
|
||||
const groupForm = reactive({ name: '' })
|
||||
const palette = ['#53d6a2', '#5da8ff', '#f7cb4d', '#a78bfa', '#ef6f8f']
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (!q) return store.projects
|
||||
return store.projects.filter(p => (p.name + ' ' + p.path + ' ' + (p.description || '')).toLowerCase().includes(q))
|
||||
})
|
||||
const filteredDashboard = computed(() => filtered.value.reduce((acc, p) => {
|
||||
acc.projects += 1
|
||||
acc.totalLines += Number(p.stats?.totalLines || 0)
|
||||
acc.commits += Number(p.stats?.commitCount || 0)
|
||||
return acc
|
||||
}, { projects: 0, totalLines: 0, commits: 0 }))
|
||||
|
||||
const fmt = n => {
|
||||
n = +n || 0
|
||||
return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : n.toString()
|
||||
}
|
||||
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)
|
||||
const groupLabel = g => g?.id === 1 ? t('myProjectGroup') : (g?.name || t('projectGroup'))
|
||||
const errorText = raw => {
|
||||
const code = ['PROJECT_PATH_NOT_FOUND', 'PROJECT_PATH_UNREADABLE', 'PROJECT_PATH_NOT_DIRECTORY', 'PROJECT_PATH_DUPLICATE', 'PROJECT_GROUP_NOT_FOUND', 'PROJECT_GROUP_NAME_REQUIRED', 'PROJECT_GROUP_DEFAULT_READONLY', 'PROJECT_GROUP_DUPLICATE', 'DATABASE_NOT_READY', 'PROJECT_SAVE_FAILED'].find(x => String(raw).includes(x))
|
||||
return code ? t(`errors.${code}`) : String(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 })
|
||||
error.value = ''
|
||||
modal.value = true
|
||||
}
|
||||
function openGroup(g) {
|
||||
groupEditing.value = g?.id || 0
|
||||
groupForm.name = g?.name || ''
|
||||
groupError.value = ''
|
||||
groupModal.value = true
|
||||
}
|
||||
async function saveGroup() {
|
||||
if (groupSaving.value) return
|
||||
groupSaving.value = true
|
||||
groupError.value = ''
|
||||
try {
|
||||
const saved = await call('SaveProjectGroup', groupEditing.value, groupForm.name)
|
||||
groupModal.value = false
|
||||
await store.refresh()
|
||||
if (!groupEditing.value) await store.changeProjectGroup(saved.id)
|
||||
store.showToast({ type: 'success', text: t('saveProjectGroup') })
|
||||
} catch (e) {
|
||||
groupError.value = errorText(e)
|
||||
store.showToast({ type: 'error', text: groupError.value })
|
||||
} finally {
|
||||
groupSaving.value = false
|
||||
}
|
||||
}
|
||||
async function removeGroup(g) {
|
||||
if (!g) return
|
||||
if (g.id === 1) return
|
||||
if (confirm(`${t('delete')} ${g.name}?`)) {
|
||||
await call('DeleteProjectGroup', g.id)
|
||||
if (store.selectedProjectGroupId === g.id) store.setProjectGroup(0)
|
||||
await store.refresh()
|
||||
}
|
||||
}
|
||||
async function changeGroup(value) {
|
||||
await store.changeProjectGroup(Number(value))
|
||||
}
|
||||
async function browse() {
|
||||
const p = await call('SelectDirectory')
|
||||
if (p) {
|
||||
form.path = p
|
||||
if (!form.name) form.name = p.split(/[\\/]/).pop()
|
||||
}
|
||||
}
|
||||
async function loadWSL() {
|
||||
try {
|
||||
wslDistros.value = await call('ListWSLDistros')
|
||||
if (wslDistros.value.length) wslDistro.value = wslDistros.value[0]
|
||||
else store.showToast({ type: 'error', key: 'noWSL' })
|
||||
} catch {
|
||||
store.showToast({ type: 'error', key: 'noWSL' })
|
||||
}
|
||||
}
|
||||
async function browseWSL() {
|
||||
if (!wslDistro.value) return
|
||||
const p = await call('SelectWSLDirectory', wslDistro.value)
|
||||
if (p) {
|
||||
form.path = p
|
||||
if (!form.name) form.name = p.split(/[\\/]/).pop()
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
if (saving.value) return
|
||||
error.value = ''
|
||||
form.path = form.path.trim()
|
||||
if (!form.path) {
|
||||
error.value = t('pathRequired')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await call('SaveProject', editing.value, { ...form, groupId: Number(form.groupId) || defaultGroupId.value })
|
||||
await store.refresh()
|
||||
modal.value = false
|
||||
store.showToast({ type: 'success', text: t('saveProject') })
|
||||
} catch (e) {
|
||||
error.value = errorText(e)
|
||||
store.showToast({ type: 'error', text: error.value })
|
||||
try { await call('ReportClientError', 'frontend', '项目保存失败', String(e)) } catch {}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
async function remove(p) {
|
||||
if (confirm(`${t('delete')} ${p.name}?`)) {
|
||||
await call('DeleteProject', p.id)
|
||||
await store.refresh()
|
||||
}
|
||||
}
|
||||
async function batch() {
|
||||
await store.batchAnalyze(store.selectedProjectGroupId)
|
||||
}
|
||||
async function refreshProject(p) {
|
||||
try {
|
||||
await store.analyze(p.id, 'all')
|
||||
} catch (e) {
|
||||
store.showToast({ type: 'error', text: errorText(e) })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page dashboard-page">
|
||||
<header class="page-head sticky-head">
|
||||
<div><h1>{{ t('dashboard') }}</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')" />
|
||||
</div>
|
||||
<div class="section-head">
|
||||
<h2>{{ t('projects') }}</h2>
|
||||
<div class="project-tools">
|
||||
<div class="group-filter">
|
||||
<select :value="store.selectedProjectGroupId" @change="changeGroup($event.target.value)">
|
||||
<option :value="0">{{ t('allProjectGroups') }}</option>
|
||||
<option v-for="g in store.projectGroups" :key="g.id" :value="g.id">{{ groupLabel(g) }}</option>
|
||||
</select>
|
||||
<button :title="t('addProjectGroup')" @click="openGroup()"><Plus /></button>
|
||||
<button v-if="store.selectedProjectGroupId && store.selectedProjectGroupId !== 1" :title="t('editProjectGroup')" @click="openGroup(store.projectGroups.find(g => g.id === store.selectedProjectGroupId))"><Pencil /></button>
|
||||
<button v-if="store.selectedProjectGroupId && store.selectedProjectGroupId !== 1" :title="t('deleteProjectGroup')" @click="removeGroup(store.projectGroups.find(g => g.id === store.selectedProjectGroupId))"><Trash2 /></button>
|
||||
</div>
|
||||
<label class="search"><Search /><input v-model="search" :placeholder="t('search')" /></label>
|
||||
</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)">
|
||||
<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 :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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-row project-metrics">
|
||||
<div class="metric-total"><b>{{ fmt(p.stats.totalLines) }}</b><span>{{ t('totalLineCount') }}</span></div>
|
||||
<div class="metric-code"><b>{{ fmt(p.stats.codeLines) }}</b><span>{{ t('codeLines') }}</span></div>
|
||||
<div class="metric-files"><b>{{ fmt(p.stats.fileCount) }}</b><span>{{ t('fileCount') }}</span></div>
|
||||
</div>
|
||||
<div class="language-bar"><i v-for="(x, i) in p.languages.slice(0, 5)" :key="x.name" :style="{ background: palette[i], width: (x.code / Math.max(1, total(p)) * 100) + '%' }" /></div>
|
||||
<div class="legend">
|
||||
<span v-for="(x, i) in p.languages.slice(0, 4)" :key="x.name"><i :style="{ background: palette[i] }" />{{ x.name }} {{ Math.round(x.code / Math.max(1, total(p)) * 100) }}%</span>
|
||||
<span v-if="!p.languages?.length">{{ t('unanalyzed') }}</span>
|
||||
</div>
|
||||
<footer><span>↳ {{ p.stats.commitCount }} {{ t('commitsUnit') }}</span><b class="positive">+{{ fmt(p.stats.addedLines) }}</b><b class="negative">-{{ fmt(p.stats.deletedLines) }}</b></footer>
|
||||
</article>
|
||||
<button class="add-card shine-card" @click="open()"><span><Plus /></span>{{ t('addProject') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
<div v-if="groupModal" class="overlay" @click.self="!groupSaving && (groupModal = false)">
|
||||
<form class="modal compact-modal" @submit.prevent="saveGroup">
|
||||
<header><h2>{{ groupEditing ? t('editProjectGroup') : t('addProjectGroup') }}</h2><button type="button" :disabled="groupSaving" @click="groupModal = false">×</button></header>
|
||||
<label>{{ t('projectGroupName') }}<input v-model="groupForm.name" :disabled="groupSaving" required /></label>
|
||||
<p v-if="groupError" class="form-error">{{ groupError }}</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>
|
||||
</Teleport>
|
||||
</template>
|
||||
47
frontend/src/views/Logs.vue
Normal file
47
frontend/src/views/Logs.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RefreshCw, Trash2, ScrollText, Info, TriangleAlert, CircleX } from 'lucide-vue-next'
|
||||
import { call } from '../api'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const logs = ref([])
|
||||
const filter = ref('all')
|
||||
const auto = ref(true)
|
||||
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') }
|
||||
async function clear() {
|
||||
if (confirm(t('clearLogs') + '?')) {
|
||||
await call('ClearLogs')
|
||||
await load()
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
timer = setInterval(() => auto.value && load(), 5000)
|
||||
})
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="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>
|
||||
</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>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
210
frontend/src/views/ProjectDetail.vue
Normal file
210
frontend/src/views/ProjectDetail.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<script setup>
|
||||
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 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 { call } from '../api'
|
||||
import { useAppStore } from '../store'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const { t } = useI18n()
|
||||
const tab = ref('code')
|
||||
const p = ref({ stats: {}, languages: [] })
|
||||
const git = ref({ commits: [], refs: [], contributors: [], heatmap: [] })
|
||||
const diag = ref(null)
|
||||
const structure = ref({ files: [], folders: [], largeFiles: [], extensions: [] })
|
||||
const insights = ref({ healthScore: 0, summary: {}, issues: [] })
|
||||
const loading = ref(true)
|
||||
const gitLoading = ref(false)
|
||||
const insightLoading = ref(false)
|
||||
const selectedDate = ref('')
|
||||
const issueSeverity = ref('all')
|
||||
const issueType = ref('all')
|
||||
const detail = ref(null)
|
||||
const detailLoading = ref(false)
|
||||
const colors = ['#7b73ff', '#4fd1a1', '#4da5ff', '#f4c84a', '#ef6683', '#23b5d3']
|
||||
|
||||
const fmt = n => {
|
||||
n = +n || 0
|
||||
return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : n.toString()
|
||||
}
|
||||
const bytes = n => n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : (n || 0) + ' B'
|
||||
const pie = computed(() => ({
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { trigger: 'item' },
|
||||
series: [{ type: 'pie', radius: ['52%', '76%'], label: { show: false }, data: p.value.languages.map((x, i) => ({ name: x.name, value: x.code, itemStyle: { color: colors[i % colors.length] } })) }]
|
||||
}))
|
||||
const visibleCommits = computed(() => selectedDate.value ? git.value.commits.filter(c => c.date?.slice(0, 10) === selectedDate.value) : git.value.commits)
|
||||
const gitErrorCode = computed(() => git.value.error || diag.value?.errorCode || '')
|
||||
const gitErrorText = computed(() => gitErrorCode.value ? t(`errors.${gitErrorCode.value}`) : '')
|
||||
const issueTypes = computed(() => [...new Set((insights.value.issues || []).map(x => x.type))])
|
||||
const filteredIssues = computed(() => (insights.value.issues || []).filter(x => (issueSeverity.value === 'all' || x.severity === issueSeverity.value) && (issueType.value === 'all' || x.type === issueType.value)))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
;[p.value, git.value, structure.value, insights.value] = await Promise.all([call('GetProject', +route.params.id), call('GetGitStats', +route.params.id), call('GetStructure', +route.params.id), call('GetProjectInsights', +route.params.id)])
|
||||
try { diag.value = await call('GetGitDiagnostics', +route.params.id) } catch { diag.value = null }
|
||||
loading.value = false
|
||||
}
|
||||
async function refreshInsights() {
|
||||
insightLoading.value = true
|
||||
try {
|
||||
insights.value = await call('RefreshProjectInsights', +route.params.id)
|
||||
store.showToast({ type: 'success', key: 'insightDone' })
|
||||
} catch (e) {
|
||||
store.showToast({ type: 'error', text: String(e) })
|
||||
} finally {
|
||||
insightLoading.value = false
|
||||
}
|
||||
}
|
||||
async function analyze(kind = tab.value === 'git' ? 'git' : 'code') {
|
||||
await store.analyze(+route.params.id, kind)
|
||||
}
|
||||
async function selectRef(r) {
|
||||
gitLoading.value = true
|
||||
try {
|
||||
git.value = await call('GetGitStatsForRef', +route.params.id, r.name)
|
||||
diag.value = await call('GetGitDiagnostics', +route.params.id)
|
||||
selectedDate.value = ''
|
||||
} catch (e) {
|
||||
const code = String(e)
|
||||
store.showToast({ type: 'error', key: code ? `errors.${code}` : null, text: code })
|
||||
git.value = { ...git.value, available: false, error: code }
|
||||
} finally {
|
||||
gitLoading.value = false
|
||||
}
|
||||
}
|
||||
async function checkout(r) {
|
||||
if (!confirm(t('checkoutConfirm', { ref: r.name }))) return
|
||||
gitLoading.value = true
|
||||
try {
|
||||
await call('CheckoutBranch', +route.params.id, r.name)
|
||||
store.showToast({ type: 'success', key: 'branchChanged' })
|
||||
git.value = await call('GetGitStatsForRef', +route.params.id, '')
|
||||
await store.analyze(+route.params.id, 'git')
|
||||
} catch (e) {
|
||||
const raw = String(e)
|
||||
store.showToast({ type: 'error', key: raw.includes('GIT_WORKTREE_DIRTY') ? 'dirty' : `errors.${raw}`, text: raw })
|
||||
} finally {
|
||||
gitLoading.value = false
|
||||
}
|
||||
}
|
||||
async function showCommit(c) {
|
||||
detailLoading.value = true
|
||||
detail.value = { ...c, files: [] }
|
||||
try { detail.value = await call('GetCommitDetails', +route.params.id, c.hash) } finally { detailLoading.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<div><h1>{{ p.name }}</h1><p>{{ p.path }}</p></div>
|
||||
</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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<template v-if="tab === 'code'">
|
||||
<div class="stats-grid five">
|
||||
<StatCard :icon="Rows3" :value="fmt(p.stats.totalLines)" :label="t('totalLineCount')" />
|
||||
<StatCard :icon="Code2" :value="fmt(p.stats.codeLines)" :label="t('codeLines')" />
|
||||
<StatCard :icon="MessageSquareText" :value="fmt(p.stats.commentLines)" :label="t('commentLines')" />
|
||||
<StatCard :icon="Rows3" :value="fmt(p.stats.blankLines)" :label="t('blankLines')" />
|
||||
<StatCard :icon="Files" :value="fmt(p.stats.fileCount)" :label="t('fileCount')" />
|
||||
</div>
|
||||
<div class="split">
|
||||
<section class="panel shine-card"><h2>{{ t('languageDistribution') }}</h2><ChartView v-if="p.languages.length" :option="pie" /><div v-else class="empty">{{ t('empty') }}</div></section>
|
||||
<section class="panel shine-card"><h2>{{ t('languageDetails') }}</h2><div class="language-list"><div v-for="(x, i) in p.languages" :key="x.name"><b><i :style="{ background: colors[i % colors.length] }" />{{ x.name }}</b><span>{{ x.files }} {{ t('files') }}</span><strong>{{ fmt(x.code) }} {{ t('lines') }}</strong></div></div></section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="tab === 'git'">
|
||||
<div class="git-context">
|
||||
<span><GitBranch />{{ t('workspaceBranch') }}: <b>{{ git.workspaceBranch || git.currentBranch || diag?.workspaceBranch || '-' }}</b></span>
|
||||
<span>{{ t('viewRef') }}: <b>{{ git.viewRef || git.currentBranch || diag?.viewRef || '-' }}</b></span>
|
||||
<RefreshCw v-if="gitLoading" class="spin" />
|
||||
</div>
|
||||
<section v-if="gitErrorCode" class="panel git-error-panel">
|
||||
<TriangleAlert />
|
||||
<div><h2>{{ t('gitUnavailable') }}</h2><p>{{ gitErrorText || t('gitUnavailableHint') }}</p><code v-if="diag?.detail">{{ t('gitDetail') }}: {{ diag.detail }}</code></div>
|
||||
<button class="btn secondary" @click="analyze('git')"><RefreshCw />{{ t('analyzeGit') }}</button>
|
||||
</section>
|
||||
<div class="stats-grid four">
|
||||
<StatCard :icon="GitCommitHorizontal" :value="git.commitCount" :label="t('commitCount')" />
|
||||
<StatCard :icon="Plus" tone="green" :value="'+' + fmt(git.added)" :label="t('addedLines')" />
|
||||
<StatCard :icon="Minus" tone="red" :value="'-' + fmt(git.deleted)" :label="t('deletedLines')" />
|
||||
<StatCard :icon="Users" tone="blue" :value="git.contributorCount" :label="t('contributors')" />
|
||||
</div>
|
||||
<section class="panel heat-panel shine-card"><div class="section-head"><h2>{{ t('activityHeatmap') }}</h2><button v-if="selectedDate" class="btn secondary" @click="selectedDate = ''">{{ t('selectedDate', { date: selectedDate }) }} · {{ t('clearFilter') }}</button></div><GitHeatmap :days="git.heatmap" :selected="selectedDate" @select="selectedDate = $event" /></section>
|
||||
<div class="split git-split">
|
||||
<section class="panel structure-scroll-panel"><h2>{{ t('branches') }}</h2><div class="branch interactive" v-for="r in git.refs" :key="r.name" :class="{ selected: r.name === git.viewRef }" @click="selectRef(r)"><span :class="{ current: r.current }"><GitCommitHorizontal />{{ r.name }}</span><code>{{ r.hash }}</code><small>{{ r.kind }}</small><button class="checkout-btn" :title="t('checkout')" @click.stop="checkout(r)"><ExternalLink /></button></div><div v-if="!git.refs?.length" class="empty">{{ t('empty') }}</div></section>
|
||||
<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 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>
|
||||
|
||||
<template v-else-if="tab === 'structure'">
|
||||
<div class="stats-grid four">
|
||||
<StatCard :icon="Files" :value="structure.totalFiles" :label="t('totalFiles')" />
|
||||
<StatCard :icon="Folder" :value="structure.totalDirs" :label="t('folderCount')" />
|
||||
<StatCard :icon="HardDrive" :value="bytes(structure.totalSize || 0)" :label="t('totalSize')" />
|
||||
<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('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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<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">
|
||||
<h2>{{ t('deepInsights') }}</h2>
|
||||
<p>{{ t('deepInsightsHint') }}</p>
|
||||
<div class="insight-counts">
|
||||
<span class="high">{{ insights.summary?.high || 0 }} {{ t('highRisk') }}</span>
|
||||
<span class="medium">{{ insights.summary?.medium || 0 }} {{ t('mediumRisk') }}</span>
|
||||
<span class="low">{{ insights.summary?.low || 0 }} {{ t('lowRisk') }}</span>
|
||||
<span>{{ insights.summary?.todoCount || 0 }} TODO</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn secondary" :disabled="insightLoading" @click="refreshInsights"><RefreshCw :class="{ spin: insightLoading }" />{{ t('refresh') }}</button>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="section-head insight-filter">
|
||||
<h2>{{ t('issueList') }}</h2>
|
||||
<div class="actions">
|
||||
<select v-model="issueSeverity"><option value="all">{{ t('allSeverity') }}</option><option value="high">{{ t('highRisk') }}</option><option value="medium">{{ t('mediumRisk') }}</option><option value="low">{{ t('lowRisk') }}</option></select>
|
||||
<select v-model="issueType"><option value="all">{{ t('allTypes') }}</option><option v-for="type in issueTypes" :key="type" :value="type">{{ type }}</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</article>
|
||||
<div v-if="!filteredIssues.length" class="empty"><ClipboardCheck />{{ t('noIssues') }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<div class="center-action"><button class="btn secondary" @click="analyze()"><RefreshCw />{{ t('analyze') }}</button></div>
|
||||
</div>
|
||||
</template>
|
||||
54
frontend/src/views/Settings.vue
Normal file
54
frontend/src/views/Settings.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { FileSliders, Database, Plus, Trash2, Upload, BarChart3, Folder, Languages, SunMoon, CheckCircle2, Info, Copy } from 'lucide-vue-next'
|
||||
import { call, isNative } from '../api'
|
||||
import { useAppStore } from '../store'
|
||||
|
||||
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 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:'保留当前页面,只显示底部进度'}
|
||||
]
|
||||
|
||||
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||''
|
||||
}
|
||||
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='数据库已迁移并切换到新位置'
|
||||
store.showToast({type:'success',text:dbMessage.value})
|
||||
}catch(e){dbMessage.value=String(e);store.showToast({type:'error',text:'数据库迁移失败'})}
|
||||
}
|
||||
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()})
|
||||
</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>
|
||||
</div></template>
|
||||
Reference in New Issue
Block a user