Files
code-utils/frontend/src/views/ProjectDetail.vue
李琦 7c4109f687 feat: 提交详情能看 diff,也能丢给 AI 审这一次改动。文件检查可单独排除路径,TODO 只认大写,避免把 todo 表和模块当成待办标记。
热力图按容器宽度铺满一年,不再横向滚动。托盘右键换成可换皮肤的弹层;更新下载显示进度,退出后再由脚本拉起安装器,避免还占着 exe。
2026-08-19 15:46:03 +08:00

347 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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, Flame, Sparkles, MessageCircleQuestion, Rocket, ListFilter, Trash2 } 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 ProjectRulesModal from '../components/ProjectRulesModal.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 preview = ref('')
const previewLine = ref(0)
// AI 问答抽屉:详情页内的“二级页面”,不跳转
const aiDrawer = ref(false)
const aiAsk = ref('')
const aiDiffHash = ref('')
const askText = ref('')
const insightExcludes = ref([])
const insightExcludeInput = ref('')
const insightExcludeBusy = ref(false)
// 这些检查项的 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()
}
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 }
loadProjRules()
loadInsightExcludes()
loading.value = false
}
// ---- 项目专属排除规则:顶栏 / 列表右键打开同一弹窗 ----
const projRules = ref([])
const rulesOpen = ref(false)
async function loadProjRules() {
try { projRules.value = (await call('GetProjectRules', +route.params.id)) || [] } catch { projRules.value = [] }
}
function onRulesChanged(list) {
projRules.value = list || []
}
async function refreshInsights(silent = false) {
insightLoading.value = true
try {
insights.value = await call('RefreshProjectInsights', +route.params.id)
if (!silent) 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 }
}
function openChat(text) {
aiDiffHash.value = ''
aiAsk.value = (text || '').trim()
askText.value = ''
aiDrawer.value = true
}
function analyzeDiff(d) {
const hash = d?.hash || detail.value?.hash || ''
if (!hash) return
aiDiffHash.value = hash
aiAsk.value = t('aiPromptDiff')
askText.value = ''
aiDrawer.value = true
}
async function loadInsightExcludes() {
try { insightExcludes.value = (await call('GetInsightExcludes', +route.params.id)) || [] } catch { insightExcludes.value = [] }
}
async function addInsightExclude() {
const v = insightExcludeInput.value.trim()
const id = +route.params.id
if (!v || !id || insightExcludeBusy.value) return
insightExcludeBusy.value = true
try {
await call('AddInsightExclude', id, v)
insightExcludeInput.value = ''
await loadInsightExcludes()
await refreshInsights(true)
} catch (e) {
const code = String(e).split(':')[0].trim()
store.showToast({ type: 'error', key: code ? `errors.${code}` : null, text: String(e) })
} finally {
insightExcludeBusy.value = false
}
}
async function removeInsightExclude(r) {
try {
await call('DeleteRule', r.id)
await loadInsightExcludes()
await refreshInsights(true)
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
}
}
onMounted(load)
</script>
<template>
<div class="page detail-page">
<header class="project-head sticky-head detail-sticky-head">
<div class="detail-head-row">
<button type="button" class="back" @click="router.push('/projects')"><ArrowLeft />{{ t('back') }}</button>
<div class="detail-head-id">
<h1>{{ p.name }}</h1>
<p :title="p.path">{{ p.path }}</p>
</div>
<div class="detail-head-acts">
<button type="button" class="btn secondary" @click="rulesOpen = true">
<ListFilter />{{ t('tabRules') }}
<em v-if="projRules.length" class="detail-rules-badge">{{ projRules.length }}</em>
</button>
<button type="button" class="btn secondary" @click="router.push({ path: '/launchpad', query: { projectId: p.id } })"><Rocket />{{ t('lpToLaunchpad') }}</button>
<button type="button" class="btn secondary" @click="openChat()"><Sparkles />{{ t('aiAskGo') }}</button>
</div>
</div>
<nav class="tabs detail-tabs">
<button type="button" :class="{ active: tab === 'code' }" @click="tab = 'code'"><Code2 />{{ t('code') }}</button>
<button type="button" :class="{ active: tab === 'git' }" @click="tab = 'git'"><GitCommitHorizontal />{{ t('git') }}</button>
<button type="button" :class="{ active: tab === 'structure' }" @click="tab = 'structure'"><FolderTree />{{ t('structure') }}</button>
<button type="button" :class="{ active: tab === 'insights' }" @click="tab = 'insights'"><ClipboardCheck />{{ t('insights') }}</button>
<button type="button" :class="{ active: tab === 'ai' }" @click="tab = 'ai'"><Sparkles />{{ t('aiAnalysis') }}</button>
</nav>
</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 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" @analyze="analyzeDiff" />
</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" :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 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-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">
<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 insight-exclude-panel">
<div class="section-head"><h2><ListFilter />{{ t('insightExcludeTitle') }}</h2></div>
<p class="insight-exclude-hint">{{ t('insightExcludeHint') }}</p>
<div class="proj-rules-add">
<input v-model="insightExcludeInput" :placeholder="t('insightExcludePh')" :disabled="insightExcludeBusy" @keyup.enter="addInsightExclude" />
<button type="button" class="btn secondary" :disabled="insightExcludeBusy || !insightExcludeInput.trim()" @click="addInsightExclude"><Plus />{{ t('add') }}</button>
</div>
<div class="proj-rules-chips">
<button v-for="r in insightExcludes" :key="r.id" type="button" class="proj-rule-chip" :title="t('delete')" @click="removeInsightExclude(r)">{{ r.pattern }}<Trash2 /></button>
<span v-if="!insightExcludes.length" class="proj-rules-empty">{{ t('insightExcludeEmpty') }}</span>
</div>
</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" :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" :diff-hash="aiDiffHash" @close="aiDrawer = false; aiAsk = ''; aiDiffHash = ''" />
<ProjectRulesModal
:open="rulesOpen"
:project-id="p.id"
:project-name="p.name"
@close="rulesOpen = false"
@changed="onRulesChanged"
/>
</div>
</template>