优化: 项目文件树/依赖图组件 + 灵感对抗校验 + 小程序脚本 + 模块管理
This commit is contained in:
+4
-1
@@ -36,6 +36,9 @@ export interface FileTreeEntry {
|
||||
size: number
|
||||
/** Git 状态码(`git status --porcelain` 的 XY 两字符,如 " M"/"M "/"??");文件夹恒无 */
|
||||
git_status?: string
|
||||
/** 目录是否含可见子项(过滤隐藏文件/噪音目录后,与展开实际展示一致);文件恒为 false。
|
||||
* 后端列目录时即时统计,前端未展开即可据此行内标注空目录。 */
|
||||
has_children: boolean
|
||||
}
|
||||
|
||||
/** getModuleFileTree 返回结构。 */
|
||||
@@ -177,7 +180,7 @@ export const moduleApi = {
|
||||
},
|
||||
|
||||
/** 分页查询工程 Git 提交历史。返回 { commits, has_more }。 */
|
||||
/** 列出工程本地分支(只读)。返回 { current, branches: [{ name, is_current }] }。 */
|
||||
/** 列出工程分支(本地 + 远程跟踪,只读)。返回 { current, branches: [{ name, is_current }] }。 */
|
||||
listBranches(moduleId: string): Promise<{ current: string; branches: { name: string; is_current: boolean }[] }> {
|
||||
return invoke('list_branches', { moduleId })
|
||||
},
|
||||
|
||||
@@ -80,9 +80,9 @@
|
||||
*
|
||||
* 节点用 Vue 组件(ModuleNode.vue)渲染,通过 @antv/x6-vue-shape 注册。
|
||||
*/
|
||||
import { onMounted, onBeforeUnmount, ref, watch, computed, markRaw } from 'vue'
|
||||
import { onMounted, onBeforeUnmount, onActivated, ref, watch, computed, markRaw } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Graph, Selection, Snapline, History, Scroller, MiniMap } from '@antv/x6'
|
||||
import { Graph, Selection, Snapline, History, Scroller, MiniMap, Export } from '@antv/x6'
|
||||
import dagre from 'dagre'
|
||||
import { register } from '@antv/x6-vue-shape'
|
||||
import '@antv/x6-vue-shape'
|
||||
@@ -261,13 +261,38 @@ function renderGraph(opts?: { center?: boolean }) {
|
||||
if (shouldCenter) graph.centerContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* F4:局部更新节点选中态(替代整图 fromJSON 重建,保留平移/缩放态)。
|
||||
* 只刷新被点节点 + 上次选中节点两个 cell:更新其 data.selected,并手动触发该 node view 的
|
||||
* 'vue' action 重挂载 Vue 组件。原因:vue-shape 下 node.setData 不触发组件重渲染
|
||||
* (NodeView/VueShapeView 的 actions 映射无 data→render,'component' 变化才触发 'vue' action)。
|
||||
*/
|
||||
function updateNodeSelection(nextId: string) {
|
||||
if (!graph) return
|
||||
const prevId = selectedId.value
|
||||
selectedId.value = nextId
|
||||
const ids = new Set<string>()
|
||||
if (nextId) ids.add(nextId)
|
||||
if (prevId) ids.add(prevId)
|
||||
for (const id of ids) {
|
||||
const cell = graph.getCellById(id)
|
||||
if (!cell) continue
|
||||
const d = cell.getData() ?? {}
|
||||
cell.setData({ ...d, selected: id === nextId })
|
||||
// 'vue' 不在库导出 FlagManagerAction 联合类型内(vue-shape 扩展),故以 any 访问。
|
||||
const view = graph.findViewByCell(cell) as any
|
||||
if (view?.confirmUpdate) view.confirmUpdate(view.getFlag('vue'))
|
||||
}
|
||||
}
|
||||
|
||||
function buildGraph() {
|
||||
if (!containerRef.value) return
|
||||
|
||||
graph = new Graph({
|
||||
container: containerRef.value,
|
||||
background: { color: '#1a1a2e' },
|
||||
grid: { visible: true, size: 10, type: 'dot', args: { color: '#2a2a4e' } },
|
||||
// U1:画布/grid 用主题 token 实际色值(X6 background/grid 需具体色值,非 CSS 变量),深色硬编码移除。
|
||||
background: { color: themeColor('--df-bg', '#0c0e1a') },
|
||||
grid: { visible: true, size: 10, type: 'dot', args: { color: themeColor('--df-border-strong', '#2a2a4e') } },
|
||||
mousewheel: { enabled: true, modifiers: ['ctrl'], minScale: 0.3, maxScale: 3 },
|
||||
interacting: { nodeMovable: true },
|
||||
})
|
||||
@@ -277,18 +302,31 @@ function buildGraph() {
|
||||
graph.use(new History({ enabled: true }))
|
||||
graph.use(new Scroller({ enabled: true, pannable: true }))
|
||||
graph.use(new MiniMap({ width: 200, height: 120, padding: 10 }))
|
||||
// F3:注册 Export 插件。X6 的 toPNG 依赖 graph.use(new Export()) 挂载 'export' 插件,
|
||||
// 缺注册时 Graph.prototype.toPNG 静默 no-op(回调永不触发、无报错),导出按钮失效。
|
||||
graph.use(new Export())
|
||||
|
||||
graph.on('node:click', ({ node }) => {
|
||||
// 组件内选中高亮(G4):点击节点仅高亮,不 emit 不导航(依赖图是查看/编辑关系,
|
||||
// 跳工程详情与 Tab 语义冲突,原 emit+router.push 是无意义死代码)。
|
||||
selectedId.value = String(node.id)
|
||||
renderGraph({ center: false })
|
||||
// F4:局部刷新选中态(仅被点节点+上次选中节点),不再整图 fromJSON 重建,保留平移/缩放态。
|
||||
updateNodeSelection(String(node.id))
|
||||
})
|
||||
|
||||
renderGraph()
|
||||
bindEdgeDeleteButtons()
|
||||
}
|
||||
|
||||
/**
|
||||
* U1:读取 CSS 主题 token 的实际色值(X6 画布 background/grid 需要具体颜色字符串,不支持 CSS 变量)。
|
||||
* 取不到时回退传入的默认色值。buildGraph 在挂载后执行,此时 data-theme 已生效,读到当前主题色。
|
||||
*/
|
||||
function themeColor(varName: string, fallback: string): string {
|
||||
if (typeof window === 'undefined') return fallback
|
||||
const v = getComputedStyle(document.documentElement).getPropertyValue(varName).trim()
|
||||
return v || fallback
|
||||
}
|
||||
|
||||
function fitContent() {
|
||||
graph?.zoomToFit({ padding: 20, maxScale: 1.5 })
|
||||
}
|
||||
@@ -320,19 +358,25 @@ async function checkCycles() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出 PNG。 */
|
||||
async function exportPNG() {
|
||||
/**
|
||||
* 导出 PNG。
|
||||
* F3:toPNG 回调是异步触发,外层 try/catch 捕不到回调内错误;且 X6 的 toPNGAsync 在导出失败时
|
||||
* 吞错永不 resolve(会挂死),故沿用回调风格,错误处理放入回调体(try/catch 包下载逻辑),
|
||||
* 失败 console.error + Message.error 用户可见提示。
|
||||
*/
|
||||
function exportPNG() {
|
||||
if (!graph) return
|
||||
try {
|
||||
graph.toPNG((dataUri: string) => {
|
||||
graph.toPNG((dataUri: string) => {
|
||||
try {
|
||||
const a = document.createElement('a')
|
||||
a.href = dataUri
|
||||
a.download = `dependency-graph-${props.projectId}.png`
|
||||
a.click()
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[DependencyGraph] 导出 PNG 失败:', e)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[DependencyGraph] 导出 PNG 失败:', e)
|
||||
Message.error(t('common.unknownError'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除依赖(二次确认 + 重绘)。 */
|
||||
@@ -384,12 +428,15 @@ function hideEdgeDeleteBtn() {
|
||||
deleteDepTarget = null
|
||||
}
|
||||
|
||||
// F2:edge hover 处理器提升为 setup 作用域稳定函数引用(非每次 bind 新建对象),
|
||||
// 使 graph.off 能删掉旧 handler,避免 edge:mouseenter/leave 监听累积导致 showEdgeDeleteBtn 重复触发。
|
||||
function onEdgeEnter(e: any) { showEdgeDeleteBtn(e.edge) }
|
||||
function onEdgeLeave() { hideEdgeDeleteBtn() }
|
||||
|
||||
/** 给所有 edge 绑 hover 显示删除按钮的监听。 */
|
||||
function bindEdgeDeleteButtons() {
|
||||
if (!graph) return
|
||||
try { graph.off('edge:mouseenter', onEdgeEnter); graph.off('edge:mouseleave', onEdgeLeave) } catch { /* ignore */ }
|
||||
function onEdgeEnter(e: any) { showEdgeDeleteBtn(e.edge) }
|
||||
function onEdgeLeave() { hideEdgeDeleteBtn() }
|
||||
graph.on('edge:mouseenter', onEdgeEnter)
|
||||
graph.on('edge:mouseleave', onEdgeLeave)
|
||||
}
|
||||
@@ -410,6 +457,18 @@ onMounted(async () => {
|
||||
buildGraph()
|
||||
})
|
||||
|
||||
// KeepAlive 保活:本组件被 KeepAlive 包裹不重挂载,缺 onActivated 时切回依赖图 tab 不刷新(F1)。
|
||||
// 切回 tab 时重新拉模块+依赖并重绘(覆盖概览页增删模块后切回的场景)。
|
||||
// 首次挂载 KeepAlive 激活也会触发,此时 onMounted 已 loadModules+renderGraph,守卫跳过避免重复拉。
|
||||
let activatedOnce = false
|
||||
onActivated(() => {
|
||||
if (!activatedOnce) {
|
||||
activatedOnce = true
|
||||
return
|
||||
}
|
||||
void loadModules().then(() => renderGraph())
|
||||
})
|
||||
|
||||
watch(() => props.projectId, async () => {
|
||||
await loadModules()
|
||||
renderGraph()
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
:file-path="selectedFilePath"
|
||||
:module-root-path="currentModule?.path ?? ''"
|
||||
:git-status="selectedFileGitStatus"
|
||||
:external-diff="selectedCommitDiff"
|
||||
/>
|
||||
<div v-else class="preview-placeholder-inline">{{ $t('fileExplorer.selectFileHint') }}</div>
|
||||
</div>
|
||||
@@ -252,6 +253,8 @@ const expandedPaths = reactive(new Set<string>())
|
||||
const loadedChildren = reactive(new Map<string, FileTreeEntry[]>())
|
||||
const selectedFilePath = ref<string | null>(null)
|
||||
const selectedFileGitStatus = ref<string | undefined>(undefined)
|
||||
/** GitChanges(变更/提交历史)点文件注入的 diff:传给右侧 FilePreview 在宽区渲染(历史提交的 diff 优先)。 */
|
||||
const selectedCommitDiff = ref('')
|
||||
|
||||
const currentModule = computed(
|
||||
() => modules.value.find((m) => m.id === currentModuleId.value) ?? null,
|
||||
@@ -302,6 +305,7 @@ function resetTreeState() {
|
||||
loadedChildren.clear()
|
||||
selectedFilePath.value = null
|
||||
selectedFileGitStatus.value = undefined
|
||||
selectedCommitDiff.value = ''
|
||||
}
|
||||
|
||||
/** 自定义下拉选择工程。 */
|
||||
@@ -317,6 +321,8 @@ function onFileSelect(path: string) {
|
||||
selectedFilePath.value = path
|
||||
// 从 loadedChildren 反查 git_status(任意层目录的条目都可能含此文件)。
|
||||
selectedFileGitStatus.value = findEntryGitStatus(path)
|
||||
// 树视图选中 → 回到内容预览,不残留 GitChanges 注入的 diff
|
||||
selectedCommitDiff.value = ''
|
||||
}
|
||||
|
||||
/** 递归在 loadedChildren 缓存中查指定路径条目的 git_status。 */
|
||||
@@ -351,6 +357,7 @@ async function refresh() {
|
||||
loadedChildren.clear()
|
||||
selectedFilePath.value = null
|
||||
selectedFileGitStatus.value = undefined
|
||||
selectedCommitDiff.value = ''
|
||||
// 等一个 tick 让 FileTree watch 触发重拉;实际拉取在子组件,这里只做状态清空。
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
refreshing.value = false
|
||||
@@ -402,10 +409,11 @@ async function switchToChanges() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Git 变更视图中选择文件 → 在右侧预览显示 diff。 */
|
||||
function onChangeFileSelect(path: string) {
|
||||
/** Git 变更视图中选择文件 → 在右侧预览显示 diff(变化/提交历史点文件都走此路,宽区渲染)。 */
|
||||
function onChangeFileSelect(path: string, diff?: string) {
|
||||
selectedFilePath.value = path
|
||||
selectedFileGitStatus.value = gitStatusData.value?.changed_files.find(f => f.path === path)?.status
|
||||
selectedCommitDiff.value = diff ?? ''
|
||||
}
|
||||
|
||||
watch(() => props.projectId, loadModules, { immediate: true })
|
||||
@@ -761,6 +769,7 @@ async function onRemoveModule() {
|
||||
|
||||
.explorer-tree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 8px 4px 8px 0;
|
||||
|
||||
@@ -39,28 +39,28 @@
|
||||
<div class="preview-placeholder-text">{{ $t('fileExplorer.selectFileHint') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-else-if="loading" class="preview-loading">
|
||||
<!-- 加载中(外部 diff 存在时让位于 diff 视图,历史提交的文件可能不在当前工作树) -->
|
||||
<div v-else-if="loading && !isExternalDiff" class="preview-loading">
|
||||
<span class="spinner"></span>
|
||||
{{ $t('fileExplorer.loadingFile') }}
|
||||
</div>
|
||||
|
||||
<!-- 错误 -->
|
||||
<div v-else-if="error" class="preview-error">⚠ {{ error }}</div>
|
||||
<!-- 错误(外部 diff 存在时同样让位:历史文件当前树不存在不应遮住 diff) -->
|
||||
<div v-else-if="error && !isExternalDiff" class="preview-error">⚠ {{ error }}</div>
|
||||
|
||||
<!-- 二进制 -->
|
||||
<div v-else-if="isBinary" class="preview-binary">
|
||||
<!-- 二进制(外部 diff 存在时让位) -->
|
||||
<div v-else-if="isBinary && !isExternalDiff" class="preview-binary">
|
||||
<span class="binary-icon">📄</span>
|
||||
<p>{{ $t('fileExplorer.binaryNotSupported') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 图片 -->
|
||||
<div v-else-if="isImage" class="preview-image">
|
||||
<!-- 图片(外部 diff 存在时让位) -->
|
||||
<div v-else-if="isImage && !isExternalDiff" class="preview-image">
|
||||
<img :src="imageUrl ?? ''" :alt="filePath ?? ''" />
|
||||
</div>
|
||||
|
||||
<!-- Diff 视图(切到 diff 模式时显示;置于 Markdown 之前,使 .md 文件也支持 Diff) -->
|
||||
<div v-else-if="showDiff && diffContent" class="preview-diff">
|
||||
<!-- Diff 视图(外部注入的 diff 优先展示;置于 Markdown 之前,使 .md 文件也支持 Diff) -->
|
||||
<div v-else-if="showDiff && diffViewContent" class="preview-diff">
|
||||
<div v-for="(ln, idx) in diffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type">
|
||||
<span class="diff-line-num">{{ ln.oldNum || '' }}</span>
|
||||
<span class="diff-line-num">{{ ln.newNum || '' }}</span>
|
||||
@@ -98,6 +98,9 @@ const props = defineProps<{
|
||||
filePath: string | null
|
||||
moduleRootPath: string
|
||||
gitStatus?: string
|
||||
/** 外部注入的 diff(如 GitChanges 变更/提交历史点文件得到的该文件 diff,含历史提交的 diff)。
|
||||
* 非空时优先渲染此 diff(宽预览区),不发起重复的 git diff 请求。 */
|
||||
externalDiff?: string
|
||||
}>()
|
||||
|
||||
const { } = useMarkdown()
|
||||
@@ -127,6 +130,17 @@ const showDiff = ref(false)
|
||||
const diffContent = ref('')
|
||||
const diffLoading = ref(false)
|
||||
|
||||
/** 是否存在外部注入的 diff(非空即视为外部 diff 模式:默认展示 diff,且 diff 优先级最高)。 */
|
||||
const isExternalDiff = computed(() => !!props.externalDiff && props.externalDiff.trim().length > 0)
|
||||
|
||||
/** 实际展示的 diff 内容:外部注入优先,否则用本组件拉取的 git diff。 */
|
||||
const diffViewContent = computed(() => (isExternalDiff.value ? props.externalDiff! : diffContent.value))
|
||||
|
||||
/** 外部 diff 注入时默认切到 diff 视图(点文件即看变化,无需再点 📝);清空则回到内容视图。 */
|
||||
watch(() => props.externalDiff, (val) => {
|
||||
showDiff.value = !!(val && val.trim().length > 0)
|
||||
})
|
||||
|
||||
/** 请求序号守卫:loadFile/loadDiff 各自单调递增,await 返回后校验仍是"最新 seq"才写状态,
|
||||
* 否则丢弃(快速切文件/切视图时旧响应晚到不覆盖新内容)。
|
||||
* 注:loadFile 与 loadDiff 分用两个计数器 —— 若共用一个,loadDiff 自增会让在途的 loadFile
|
||||
@@ -152,11 +166,11 @@ function parseHunkHeader(line: string): { oldStart: number; newStart: number } {
|
||||
}
|
||||
|
||||
const diffLines = computed<DiffLine[]>(() => {
|
||||
if (!diffContent.value) return []
|
||||
if (!diffViewContent.value) return []
|
||||
const lines: DiffLine[] = []
|
||||
let oldNum = 0
|
||||
let newNum = 0
|
||||
for (const raw of diffContent.value.split('\n')) {
|
||||
for (const raw of diffViewContent.value.split('\n')) {
|
||||
if (raw.startsWith('@@')) {
|
||||
const h = parseHunkHeader(raw)
|
||||
oldNum = h.oldStart
|
||||
@@ -180,7 +194,8 @@ const diffLines = computed<DiffLine[]>(() => {
|
||||
|
||||
function toggleDiff() {
|
||||
showDiff.value = !showDiff.value
|
||||
if (showDiff.value && !diffContent.value) {
|
||||
// 外部 diff 已由调用方提供,无需再发 git diff;仅内部模式在首次切到 diff 时才拉取
|
||||
if (showDiff.value && !diffContent.value && !isExternalDiff.value) {
|
||||
loadDiff()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,11 @@
|
||||
<!-- 错误 -->
|
||||
<div v-else-if="error" class="tree-error">⚠ {{ error }}</div>
|
||||
|
||||
<!-- 空目录 -->
|
||||
<div v-else-if="entries.length === 0" class="tree-empty">{{ $t('fileExplorer.emptyDir') }}</div>
|
||||
<!-- 空目录(展开后子条目为空) -->
|
||||
<div v-else-if="entries.length === 0" class="tree-empty">
|
||||
<span class="tree-empty-icon">📭</span>
|
||||
<span>{{ $t('fileExplorer.emptyDir') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 条目列表 -->
|
||||
<ul v-else class="tree-list">
|
||||
@@ -39,6 +42,13 @@
|
||||
<span class="expand-icon">{{ expandedPaths.has(entry.path) ? '▾' : '▸' }}</span>
|
||||
<span class="file-icon">📁</span>
|
||||
<span class="file-name">{{ entry.name }}</span>
|
||||
<!-- 空目录标:has_children 由后端列目录时即时统计,未展开即可见「空」,
|
||||
避免用户误以为目录没加载出来(展开后才显空态) -->
|
||||
<span
|
||||
v-if="entry.has_children === false"
|
||||
class="tree-empty-badge"
|
||||
:title="$t('fileExplorer.emptyDir')"
|
||||
>{{ $t('fileExplorer.emptyDirMark') }}</span>
|
||||
<!-- B3[PD-P1-6]:展开失败 → 行内 ⚠(title 显原因),点击重试;不设整树 error,不炸其他已展开节点 -->
|
||||
<span
|
||||
v-if="toggleErrors.has(entry.path)"
|
||||
@@ -356,6 +366,25 @@ watch(
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 空目录小标(目录行右侧,未展开即可见;flex-shrink 防挤压) */
|
||||
.tree-empty-badge {
|
||||
flex-shrink: 0;
|
||||
/* 文件名后稍靠右一点(不贴名、也不推到最右):固定小间隔即可 */
|
||||
margin-left: 8px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
background: rgba(150, 150, 150, 0.14);
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
|
||||
/* 展开后空态图标(📭,与文字同行) */
|
||||
.tree-empty-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Git 状态徽章 */
|
||||
.git-badge {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
</div>
|
||||
<div v-if="commitDetailLoading" class="gc-loading"><span class="spinner"></span></div>
|
||||
<template v-else-if="commitDetail">
|
||||
<!-- 变更文件列表 -->
|
||||
<!-- 变更文件列表(点击文件 → diff 渲染到右侧宽预览区,见 emit select-file) -->
|
||||
<div class="gc-commit-files">
|
||||
<div
|
||||
v-for="f in commitDetail.files"
|
||||
@@ -143,25 +143,6 @@
|
||||
<span class="gc-file-path">{{ f.path }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Diff 预览(可收起,占更宽空间) -->
|
||||
<div v-if="commitSelectedFile" class="gc-commit-diff">
|
||||
<div class="gc-commit-diff-header">
|
||||
<span class="gc-commit-diff-path">{{ commitSelectedFile }}</span>
|
||||
<button class="gc-commit-diff-toggle" @click="commitDiffCollapsed = !commitDiffCollapsed">
|
||||
{{ commitDiffCollapsed ? $t('gitChanges.expand') : $t('gitChanges.collapse') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="!commitDiffCollapsed" class="gc-diff-content">
|
||||
<div v-if="!commitDiff" class="gc-diff-empty">{{ $t('gitChanges.noDiff') }}</div>
|
||||
<div v-for="(ln, idx) in commitDiffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type">
|
||||
<span class="diff-hdr-text" v-if="ln.type === 'hdr'">{{ ln.text }}</span>
|
||||
<template v-else>
|
||||
<span class="diff-line-prefix">{{ ln.prefix }}</span>
|
||||
<span class="diff-line-text">{{ ln.text }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
@@ -172,8 +153,9 @@
|
||||
/**
|
||||
* Git 变更查看器 — 变更文件列表(按目录树分组) + 提交历史(分页/搜索) + 文件 diff 预览。
|
||||
*
|
||||
* 历史 Tab 布局:列表始终可见,选中提交的详情从底部滑出(可收起),diff 在更宽容器渲染,
|
||||
* 避免几百行 diff 把列表挤走、或在 30% 窄栏内挤压。
|
||||
* 布局:变更 Tab 点文件 / 提交历史点文件 → emit select-file,把该文件 diff 注入右侧 FilePreview
|
||||
* 宽预览区渲染;本组件窄栏只保留文件列表与提交详情(文件清单),不再内嵌 diff —— 几百行 diff
|
||||
* 在 30% 窄栏内渲染会被挤压/截断,故 diff 展示统一走右侧宽区。
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { moduleApi, type GitStatusResult } from '@/api/module'
|
||||
@@ -188,7 +170,8 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-file', path: string): void
|
||||
/** 选择文件 → 在右侧宽预览区展示其 diff(第二个参数为可选注入的 diff 内容)。 */
|
||||
(e: 'select-file', path: string, diff?: string): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -231,8 +214,6 @@ const selectedCommit = ref<CommitItem | null>(null)
|
||||
const commitDetail = ref<{ files: { status: string; path: string }[]; diff: string } | null>(null)
|
||||
const commitDetailLoading = ref(false)
|
||||
const commitSelectedFile = ref('')
|
||||
const commitDiff = ref('')
|
||||
const commitDiffCollapsed = ref(false)
|
||||
|
||||
const branchName = computed(() => gitStatus.value?.branch || '')
|
||||
|
||||
@@ -271,33 +252,6 @@ const groupedChanges = computed<FileGroup[]>(() => {
|
||||
return groups.filter(g => g.files.length > 0)
|
||||
})
|
||||
|
||||
interface DiffLine {
|
||||
type: 'add' | 'del' | 'ctx' | 'hdr'
|
||||
prefix: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const commitDiffLines = computed<DiffLine[]>(() => parseDiff(commitDiff.value))
|
||||
|
||||
function parseDiff(text: string): DiffLine[] {
|
||||
if (!text) return []
|
||||
const lines: DiffLine[] = []
|
||||
for (const raw of text.split('\n')) {
|
||||
if (raw.startsWith('@@')) {
|
||||
lines.push({ type: 'hdr', prefix: '', text: raw })
|
||||
} else if (raw.startsWith('+')) {
|
||||
lines.push({ type: 'add', prefix: '+', text: raw.slice(1) })
|
||||
} else if (raw.startsWith('-')) {
|
||||
lines.push({ type: 'del', prefix: '-', text: raw.slice(1) })
|
||||
} else if (raw.startsWith('\\')) {
|
||||
continue
|
||||
} else {
|
||||
lines.push({ type: 'ctx', prefix: ' ', text: raw })
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function statusLabel(s: string): string {
|
||||
const x = s.trim()
|
||||
if (x === '??') return 'U'
|
||||
@@ -478,6 +432,7 @@ async function switchToHistory() {
|
||||
|
||||
async function selectFile(path: string) {
|
||||
selectedFile.value = path
|
||||
// 先切预览到该文件(内容视图),diff 拉到后回填,点文件即展示变化
|
||||
emit('select-file', path)
|
||||
diffContent.value = ''
|
||||
diffLoading.value = true
|
||||
@@ -489,6 +444,10 @@ async function selectFile(path: string) {
|
||||
} finally {
|
||||
diffLoading.value = false
|
||||
}
|
||||
// 仅当仍是当前选中文件时才回填 diff,防快速切换时旧响应覆盖新文件
|
||||
if (selectedFile.value === path) {
|
||||
emit('select-file', path, diffContent.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击提交行 → 加载该提交的变更文件列表 */
|
||||
@@ -501,8 +460,8 @@ async function selectCommit(c: CommitItem) {
|
||||
selectedCommit.value = c
|
||||
commitDetail.value = null
|
||||
commitSelectedFile.value = ''
|
||||
commitDiff.value = ''
|
||||
commitDiffCollapsed.value = false
|
||||
// 切换提交 → 清空右侧宽预览,避免残留上一个提交文件的 diff 与当前提交错位
|
||||
emit('select-file', '', '')
|
||||
commitDetailLoading.value = true
|
||||
try {
|
||||
const res = await moduleApi.getCommitDetail(props.moduleId, c.hash)
|
||||
@@ -518,18 +477,17 @@ function closeCommitDetail() {
|
||||
selectedCommit.value = null
|
||||
commitDetail.value = null
|
||||
commitSelectedFile.value = ''
|
||||
commitDiff.value = ''
|
||||
emit('select-file', '', '')
|
||||
}
|
||||
|
||||
/** 在提交详情中点击文件 → 精确提取该文件的 diff 块(按 diff --git 头切块) */
|
||||
/** 在提交详情中点击文件 → 精确提取该文件的 diff 块(按 diff --git 头切块),路由到右侧宽预览区渲染 */
|
||||
function showCommitFileDiff(path: string) {
|
||||
commitSelectedFile.value = path
|
||||
commitDiffCollapsed.value = false
|
||||
if (!commitDetail.value) {
|
||||
commitDiff.value = ''
|
||||
emit('select-file', path, '')
|
||||
return
|
||||
}
|
||||
commitDiff.value = extractFileDiff(commitDetail.value.diff, path)
|
||||
emit('select-file', path, extractFileDiff(commitDetail.value.diff, path))
|
||||
}
|
||||
|
||||
watch(() => props.moduleId, loadStatus, { immediate: true })
|
||||
@@ -599,23 +557,7 @@ watch(() => props.refreshKey, () => {
|
||||
position: sticky; top: 0; z-index: 1;
|
||||
}
|
||||
|
||||
/* Diff 预览 */
|
||||
.gc-diff-content {
|
||||
font-family: var(--df-font-mono, Consolas, monospace);
|
||||
font-size: 12px; line-height: 1.5; overflow: auto; scrollbar-width: thin;
|
||||
}
|
||||
.diff-line { display: flex; padding: 0 14px; }
|
||||
.diff-line-prefix { width: 16px; flex-shrink: 0; text-align: center; user-select: none; }
|
||||
.diff-line-text { flex: 1; white-space: pre; overflow: hidden; }
|
||||
.diff-hdr-text {
|
||||
padding: 4px 14px; background: rgba(60,140,220,0.08);
|
||||
color: var(--df-text-dim); font-weight: 500; font-size: 11px; display: block;
|
||||
}
|
||||
.diff-add { background: rgba(40,160,70,0.12); }
|
||||
.diff-add .diff-line-prefix { color: #4caf50; }
|
||||
.diff-del { background: rgba(220,60,60,0.12); }
|
||||
.diff-del .diff-line-prefix { color: #e05050; }
|
||||
.diff-ctx { color: var(--df-text); }
|
||||
/* Diff 预览样式已随窄栏内 diff 面板移除(diff 统一在右侧 FilePreview 宽区渲染) */
|
||||
|
||||
/* ====== 提交历史 ====== */
|
||||
.gc-history { display: flex; flex-direction: column; min-height: 0; }
|
||||
@@ -714,31 +656,6 @@ watch(() => props.refreshKey, () => {
|
||||
.gc-commit-file-row:hover { background: rgba(255,255,255,0.04); }
|
||||
.gc-commit-file-row.active { background: rgba(255,255,255,0.06); }
|
||||
|
||||
/* 提交 diff(可收起,占更宽空间) */
|
||||
.gc-commit-diff {
|
||||
flex: 1; min-height: 0;
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gc-commit-diff-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 5px 14px; background: rgba(255,255,255,0.03);
|
||||
border-bottom: 0.5px solid var(--df-border); flex-shrink: 0;
|
||||
}
|
||||
.gc-commit-diff-path {
|
||||
flex: 1; min-width: 0;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-family: var(--df-font-mono, Consolas, monospace); font-size: 11px;
|
||||
color: var(--df-text);
|
||||
}
|
||||
.gc-commit-diff-toggle {
|
||||
background: none; border: 0.5px solid var(--df-border);
|
||||
color: var(--df-text-dim); cursor: pointer; font-size: 11px;
|
||||
padding: 2px 8px; border-radius: 4px;
|
||||
}
|
||||
.gc-commit-diff-toggle:hover { color: var(--df-text); border-color: var(--df-accent); }
|
||||
.gc-commit-diff .gc-diff-content { flex: 1; min-height: 0; }
|
||||
|
||||
/* 滑出动画 */
|
||||
.gc-detail-slide-enter-active, .gc-detail-slide-leave-active {
|
||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||
@@ -851,8 +768,6 @@ watch(() => props.refreshKey, () => {
|
||||
padding: 40px 0; color: var(--df-text-dim); font-size: 13px;
|
||||
}
|
||||
.gc-empty-inline { padding: 24px 0; }
|
||||
.gc-diff-loading { display: flex; justify-content: center; padding: 20px; }
|
||||
.gc-diff-empty { padding: 20px 14px; color: var(--df-text-dim); font-size: 12px; text-align: center; }
|
||||
.spinner {
|
||||
width: 16px; height: 16px;
|
||||
border: 2px solid var(--df-border); border-top-color: var(--df-accent);
|
||||
|
||||
@@ -66,21 +66,21 @@ const stackList = computed(() => parseJsonArray(data.value?.stack).slice(0, 3))
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #16213e;
|
||||
border: 1px solid #0f3460;
|
||||
background: var(--df-bg-card);
|
||||
border: 1px solid var(--df-border-strong);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.module-node.active {
|
||||
border-color: #5378e8;
|
||||
box-shadow: 0 0 0 2px rgba(83, 120, 232, 0.3);
|
||||
border-color: var(--df-accent);
|
||||
box-shadow: 0 0 0 2px var(--df-accent-soft);
|
||||
}
|
||||
/* 环节点红框高亮(G2):vue-shape markup 无 body 选择器,原 attrs.body 渲染无效,
|
||||
改由依赖图把 isCycle 注入节点 data,组件根元素按类描边。 */
|
||||
.module-node--cycle {
|
||||
border-color: #e05050;
|
||||
box-shadow: 0 0 0 2px rgba(224, 80, 80, 0.3);
|
||||
border-color: var(--df-danger);
|
||||
box-shadow: 0 0 0 2px var(--df-danger-bg);
|
||||
}
|
||||
.module-node__header {
|
||||
display: flex;
|
||||
@@ -93,14 +93,14 @@ const stackList = computed(() => parseJsonArray(data.value?.stack).slice(0, 3))
|
||||
.module-node__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
color: var(--df-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.module-node__path {
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
color: var(--df-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -114,7 +114,7 @@ const stackList = computed(() => parseJsonArray(data.value?.stack).slice(0, 3))
|
||||
font-size: 9px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(83, 120, 232, 0.15);
|
||||
color: #7c9aff;
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,7 @@ export default {
|
||||
// File tree
|
||||
loading: 'Loading…',
|
||||
emptyDir: 'Empty directory',
|
||||
emptyDirMark: 'empty',
|
||||
// File preview
|
||||
selectFileHint: '← Select a file on the left to preview',
|
||||
loadingFile: 'Loading file…',
|
||||
|
||||
@@ -10,6 +10,7 @@ export default {
|
||||
// 文件树
|
||||
loading: '加载中…',
|
||||
emptyDir: '空目录',
|
||||
emptyDirMark: '空',
|
||||
// 文件预览
|
||||
selectFileHint: '← 选择左侧文件查看预览',
|
||||
loadingFile: '加载文件中…',
|
||||
|
||||
@@ -1256,7 +1256,7 @@ onUnmounted(() => {
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.project-detail { padding: 16px 20px 20px; display: flex; flex-direction: column; min-height: 0; flex: 1; max-height: 100%; overflow: hidden; }
|
||||
.project-detail { padding: 16px 20px 20px; display: flex; flex-direction: column; height: 100%; min-height: 0; max-height: 100%; overflow: hidden; }
|
||||
|
||||
/* ===== Tab 导航(Batch 10 文件浏览器) ===== */
|
||||
.detail-tabs {
|
||||
|
||||
Reference in New Issue
Block a user