Files
code-utils/frontend/src/views/ProjectDetail.vue

347 lines
21 KiB
Vue
Raw Normal View History

2026-08-11 19:07:05 +08:00
<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'
2026-08-11 19:07:05 +08:00
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'
2026-08-14 07:51:46 +08:00
import FilePreviewModal from '../components/FilePreviewModal.vue'
import AIBrief from '../components/AIBrief.vue'
import ProjectAIDrawer from '../components/ProjectAIDrawer.vue'
import ProjectRulesModal from '../components/ProjectRulesModal.vue'
2026-08-11 19:07:05 +08:00
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)
2026-08-14 07:51:46 +08:00
const preview = ref('')
const previewLine = ref(0)
// AI 问答抽屉:详情页内的“二级页面”,不跳转
const aiDrawer = ref(false)
const aiAsk = ref('')
const aiDiffHash = ref('')
2026-08-14 07:51:46 +08:00
const askText = ref('')
const insightExcludes = ref([])
const insightExcludeInput = ref('')
const insightExcludeBusy = ref(false)
2026-08-14 07:51:46 +08:00
// 这些检查项的 path 是真实源码文件folder_concentration 是目录、large_commit 是提交哈希,不可预览)
const fileIssueTypes = ['large_file', 'long_file', 'todo_marker']
2026-08-11 19:07:05 +08:00
const colors = ['#7b73ff', '#4fd1a1', '#4da5ff', '#f4c84a', '#ef6683', '#23b5d3']
2026-08-14 07:51:46 +08:00
function openPreview(path, line = 0) {
previewLine.value = line
preview.value = path
}
2026-08-11 19:07:05 +08:00
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 }
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
loadProjRules()
loadInsightExcludes()
2026-08-11 19:07:05 +08:00
loading.value = false
}
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
// ---- 项目专属排除规则:顶栏 / 列表右键打开同一弹窗 ----
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
const projRules = ref([])
const rulesOpen = ref(false)
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
async function loadProjRules() {
try { projRules.value = (await call('GetProjectRules', +route.params.id)) || [] } catch { projRules.value = [] }
}
function onRulesChanged(list) {
projRules.value = list || []
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
}
async function refreshInsights(silent = false) {
2026-08-11 19:07:05 +08:00
insightLoading.value = true
try {
insights.value = await call('RefreshProjectInsights', +route.params.id)
if (!silent) store.showToast({ type: 'success', key: 'insightDone' })
2026-08-11 19:07:05 +08:00
} 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 }
}
2026-08-14 07:51:46 +08:00
function openChat(text) {
aiDiffHash.value = ''
2026-08-14 07:51:46 +08:00
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) })
}
}
2026-08-11 19:07:05 +08:00
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>
2026-08-11 19:07:05 +08:00
</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>
2026-08-11 19:07:05 +08:00
</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>
2026-08-14 07:51:46 +08:00
<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>
2026-08-11 19:07:05 +08:00
<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" />
2026-08-11 19:07:05 +08:00
</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">
2026-08-14 07:51:46 +08:00
<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>
2026-08-11 19:07:05 +08:00
<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>
2026-08-14 07:51:46 +08:00
<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>
2026-08-11 19:07:05 +08:00
</template>
2026-08-14 07:51:46 +08:00
<template v-else-if="tab === 'insights'">
2026-08-11 19:07:05 +08:00
<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>
2026-08-11 19:07:05 +08:00
</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>
2026-08-14 07:51:46 +08:00
<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>
2026-08-11 19:07:05 +08:00
</article>
<div v-if="!filteredIssues.length" class="empty"><ClipboardCheck />{{ t('noIssues') }}</div>
</div>
</section>
</template>
2026-08-14 07:51:46 +08:00
<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>
2026-08-11 19:07:05 +08:00
<div class="center-action"><button class="btn secondary" @click="analyze()"><RefreshCw />{{ t('analyze') }}</button></div>
2026-08-14 07:51:46 +08:00
<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"
/>
2026-08-11 19:07:05 +08:00
</div>
</template>