feat: 提交详情能看 diff,也能丢给 AI 审这一次改动。文件检查可单独排除路径,TODO 只认大写,避免把 todo 表和模块当成待办标记。

热力图按容器宽度铺满一年,不再横向滚动。托盘右键换成可换皮肤的弹层;更新下载显示进度,退出后再由脚本拉起安装器,避免还占着 exe。
This commit is contained in:
李琦
2026-08-19 15:46:03 +08:00
parent 1694397245
commit 7c4109f687
38 changed files with 2618 additions and 129 deletions

View File

@@ -2,7 +2,7 @@
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 } 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, 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'
@@ -38,7 +38,11 @@ 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']
@@ -69,6 +73,7 @@ async function load() {
;[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
}
@@ -82,11 +87,11 @@ async function loadProjRules() {
function onRulesChanged(list) {
projRules.value = list || []
}
async function refreshInsights() {
async function refreshInsights(silent = false) {
insightLoading.value = true
try {
insights.value = await call('RefreshProjectInsights', +route.params.id)
store.showToast({ type: 'success', key: 'insightDone' })
if (!silent) store.showToast({ type: 'success', key: 'insightDone' })
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally {
@@ -131,10 +136,48 @@ async function showCommit(c) {
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>
@@ -216,7 +259,7 @@ onMounted(load)
</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" />
<CommitDrawer v-if="detail" :detail="detail" :loading="detailLoading" @close="detail = null" @analyze="analyzeDiff" />
</template>
<template v-else-if="tab === 'structure'">
@@ -245,7 +288,19 @@ onMounted(load)
<span>{{ insights.summary?.todoCount || 0 }} TODO</span>
</div>
</div>
<button class="btn secondary" :disabled="insightLoading" @click="refreshInsights"><RefreshCw :class="{ spin: insightLoading }" />{{ t('refresh') }}</button>
<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">
@@ -279,7 +334,7 @@ onMounted(load)
</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 = ''" />
<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"