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