- 前四批:暗色 UA 重置+color-scheme、知识详情窄屏双闸、启动链 6 点加固、原生 tooltip 换 CSS - 统一治理:新建 --df-pad-page/--df-fs-item/--df-on-accent token 与 .df-seg/.modal-box--sm--md/.df-badge--solid/.empty-icon--sm--xs 公共类 - 收编:status-tag 双规格、AuditLog 容器、模态自建块、空态图标四档、加载更多三实现、圆角/字号/mono 栈字面量约 60 处 - 根修:.page-header h1 加压缩保护(治标题竖排);KnowledgeDetail hover token 笔误
912 lines
32 KiB
Vue
912 lines
32 KiB
Vue
<template>
|
|
<!--
|
|
文件浏览器主容器(Batch 10)。
|
|
布局:顶部工具栏(路径面包屑 + 刷新 + 多工程切换) + 左侧文件树 + 右侧文件预览。
|
|
单工程不显工程选择器,多工程显示下拉切换。
|
|
-->
|
|
<div class="file-explorer">
|
|
<!-- 顶部工具栏 -->
|
|
<div class="explorer-toolbar">
|
|
<div class="toolbar-left">
|
|
<!-- 多工程下拉(单工程隐藏) -->
|
|
<div v-if="modules.length > 1" class="module-dropdown">
|
|
<button class="module-select" @click="moduleDropdownOpen = !moduleDropdownOpen">
|
|
<span class="module-select-name">{{ currentModule?.name ?? '...' }}</span>
|
|
<span class="module-select-arrow" :class="{ open: moduleDropdownOpen }">▾</span>
|
|
</button>
|
|
<div v-if="moduleDropdownOpen" class="module-dropdown-menu" @click.stop>
|
|
<div
|
|
v-for="m in modules"
|
|
:key="m.id"
|
|
class="module-dropdown-item"
|
|
:class="{ active: m.id === currentModuleId }"
|
|
role="button"
|
|
tabindex="0"
|
|
@click="onModuleSelect(m.id)"
|
|
@keydown.enter.prevent="onModuleSelect(m.id)"
|
|
@keydown.space.prevent="onModuleSelect(m.id)"
|
|
>
|
|
<span class="module-dropdown-name">{{ m.name }}</span>
|
|
<span v-if="m.path" class="module-dropdown-path" :title="m.path">{{ m.path }}</span>
|
|
</div>
|
|
<div class="module-dropdown-divider"></div>
|
|
<div
|
|
class="module-dropdown-item module-dropdown-action"
|
|
role="button"
|
|
tabindex="0"
|
|
@click="onScanSubmodules"
|
|
@keydown.enter.prevent="onScanSubmodules"
|
|
@keydown.space.prevent="onScanSubmodules"
|
|
>
|
|
<span class="module-dropdown-name">🔍 {{ $t('fileExplorer.scanSubmodules') }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 路径面包屑 -->
|
|
<nav class="breadcrumb">
|
|
<span
|
|
class="crumb crumb-root"
|
|
:title="currentModule?.path ?? ''"
|
|
role="button"
|
|
tabindex="0"
|
|
@click="goRoot"
|
|
@keydown.enter.prevent="goRoot"
|
|
@keydown.space.prevent="goRoot"
|
|
>
|
|
{{ currentModule?.name ?? '...' }}
|
|
</span>
|
|
<template v-for="(seg, idx) in breadcrumbSegments" :key="idx">
|
|
<span class="crumb-sep">/</span>
|
|
<span
|
|
class="crumb"
|
|
role="button"
|
|
tabindex="0"
|
|
@click="goBreadcrumb(seg.path)"
|
|
@keydown.enter.prevent="goBreadcrumb(seg.path)"
|
|
@keydown.space.prevent="goBreadcrumb(seg.path)"
|
|
>{{ seg.name }}</span>
|
|
</template>
|
|
</nav>
|
|
</div>
|
|
|
|
<div class="toolbar-right">
|
|
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.editCurrentModule')" :aria-label="$t('fileExplorer.editCurrentModule')" @click="openEditModule">
|
|
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.deleteCurrentModule')" :aria-label="$t('fileExplorer.deleteCurrentModule')" @click="confirmRemoveModule">
|
|
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.addModuleTitle')" :aria-label="$t('fileExplorer.addModuleTitle')" @click="openAddModule">
|
|
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" type="button" :disabled="refreshing" @click="refresh">
|
|
<span v-if="refreshing" class="spinner"></span>
|
|
{{ refreshing ? $t('fileExplorer.refreshing') : $t('fileExplorer.refresh') }}
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" type="button" :title="$t('fileExplorer.detachToWindow')" :aria-label="$t('fileExplorer.detachToWindow')" @click="onDetach">
|
|
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M15 3h6v6"/>
|
|
<path d="M10 14L21 3"/>
|
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 主体:文件树/变更列表 + 预览 -->
|
|
<div class="explorer-body">
|
|
<!-- 左:视图切换按钮(文件树 / 变更) -->
|
|
<div class="explorer-sidebar">
|
|
<div class="explorer-view-tabs">
|
|
<button class="view-tab" :class="{ active: viewMode === 'tree' }" type="button" :title="$t('fileExplorer.viewTree')" :aria-label="$t('fileExplorer.viewTree')" @click="viewMode = 'tree'">
|
|
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
|
</button>
|
|
<button class="view-tab" :class="{ active: viewMode === 'changes' }" type="button" :title="$t('fileExplorer.viewChanges')" :aria-label="$t('fileExplorer.viewChanges')" @click="switchToChanges">
|
|
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
|
<span v-if="changedCount > 0" class="changed-badge">{{ changedCount }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 文件树视图 -->
|
|
<div v-show="viewMode === 'tree'" class="explorer-tree">
|
|
<!-- B4[PD-P2-8→P2-10]:区分加载中 vs 零工程——currentModuleId 为空且已加载完工程列表
|
|
说明无工程,显示空态引导(添加工程 CTA,对齐概览模块空态),而非无限 spinner -->
|
|
<div v-if="!currentModuleId && !mLoaded" class="tree-loading">
|
|
<span class="spinner"></span>
|
|
</div>
|
|
<div v-else-if="!currentModuleId" class="module-empty">
|
|
<div class="empty-icon empty-icon--xs">📦</div>
|
|
<p class="module-empty-hint">{{ $t('projectDetail.modulesEmptyHint') }}</p>
|
|
<div class="module-empty-actions">
|
|
<button class="btn btn-primary btn-sm" type="button" @click="openAddModule">{{ $t('fileExplorer.addModule') }}</button>
|
|
</div>
|
|
</div>
|
|
<FileTree
|
|
v-else
|
|
:module-id="currentModuleId"
|
|
sub-path=""
|
|
:indent="12"
|
|
:expanded-paths="expandedPaths"
|
|
:loaded-children="loadedChildren"
|
|
:selected-path="selectedFilePath"
|
|
@toggle-dir="onToggleDir"
|
|
@file-select="onFileSelect"
|
|
@load-children="onLoadChildren"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Git 变更视图 -->
|
|
<div v-show="viewMode === 'changes'" class="explorer-changes">
|
|
<GitChanges
|
|
v-if="currentModuleId"
|
|
:module-id="currentModuleId"
|
|
:refresh-key="gitStatusRefreshKey"
|
|
@select-file="onChangeFileSelect"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 右:文件预览(moduleId 为空时不渲染,避免空 moduleId IPC 调用报错) -->
|
|
<div class="explorer-preview">
|
|
<FilePreview
|
|
v-if="currentModuleId"
|
|
:module-id="currentModuleId"
|
|
: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>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 新增/编辑工程弹窗 -->
|
|
<div v-if="showModuleModal" class="modal-overlay" @click.self="showModuleModal = false">
|
|
<div class="modal-box modal-box--sm">
|
|
<h3 class="modal-title">{{ editingModule ? $t('fileExplorer.editModule') : $t('fileExplorer.addModule') }}</h3>
|
|
<div class="modal-field">
|
|
<label>{{ $t('fileExplorer.moduleName') }}</label>
|
|
<input v-model="moduleFormName" :placeholder="$t('fileExplorer.moduleNamePlaceholder')" />
|
|
</div>
|
|
<div class="modal-field">
|
|
<label>{{ $t('fileExplorer.modulePath') }}</label>
|
|
<input v-model="moduleFormPath" :placeholder="$t('fileExplorer.modulePathPlaceholder')" />
|
|
</div>
|
|
<div class="modal-field">
|
|
<label>{{ $t('fileExplorer.moduleGitUrl') }} <span class="opt-label">{{ $t('fileExplorer.optSuffix') }}</span></label>
|
|
<input v-model="moduleFormGitUrl" :placeholder="$t('fileExplorer.moduleGitUrlPlaceholder')" />
|
|
</div>
|
|
<div class="modal-field">
|
|
<label>{{ $t('fileExplorer.moduleDescription') }} <span class="opt-label">{{ $t('fileExplorer.optSuffix') }}</span></label>
|
|
<textarea
|
|
v-model="moduleFormDescription"
|
|
:placeholder="$t('fileExplorer.moduleDescriptionPlaceholder')"
|
|
rows="2"
|
|
></textarea>
|
|
</div>
|
|
<div class="modal-field">
|
|
<label>{{ $t('fileExplorer.moduleStack') }} <span class="opt-label">{{ $t('fileExplorer.optSuffix') }}</span></label>
|
|
<input v-model="moduleFormStack" :placeholder="$t('fileExplorer.moduleStackPlaceholder')" />
|
|
</div>
|
|
<div class="modal-field">
|
|
<label>{{ $t('fileExplorer.moduleStatus') }}</label>
|
|
<select v-model="moduleFormStatus">
|
|
<option value="active">{{ $t('fileExplorer.moduleStatusActive') }}</option>
|
|
<option value="archived">{{ $t('fileExplorer.moduleStatusArchived') }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-ghost" @click="showModuleModal = false">{{ $t('common.cancel') }}</button>
|
|
<button class="btn btn-primary" :disabled="savingModule || !moduleFormName.trim() || !moduleFormPath.trim()" @click="onSaveModule">
|
|
{{ savingModule ? $t('common.loading') : (editingModule ? $t('common.save') : $t('fileExplorer.addModule')) }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 删除工程确认弹窗 -->
|
|
<div v-if="showRemoveConfirm" class="modal-overlay" @click.self="showRemoveConfirm = false">
|
|
<div class="modal-box modal-box--sm">
|
|
<h3 class="modal-title">{{ $t('fileExplorer.removeModule') }}</h3>
|
|
<p style="margin: 12px 0; color: var(--df-text-secondary);">
|
|
{{ $t('fileExplorer.removeConfirm', { name: currentModule?.name ?? '' }) }}
|
|
</p>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-ghost" @click="showRemoveConfirm = false">{{ $t('common.cancel') }}</button>
|
|
<button class="btn btn-danger" :disabled="removingModule" @click="onRemoveModule">
|
|
{{ removingModule ? $t('common.loading') : $t('common.delete') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
/**
|
|
* FileExplorer — 文件浏览器主容器。
|
|
*
|
|
* 状态持有(子组件 FileTree 是递归的,状态集中在此避免分散):
|
|
* - expandedPaths:Set<string>,展开的目录相对路径集合
|
|
* - loadedChildren:Map<path, entries>,目录条目缓存(展开秒开,避免重复请求)
|
|
* - selectedFilePath / selectedFileGitStatus:当前选中文件(传给 FilePreview)
|
|
*
|
|
* 父组件(ProjectDetail)传入 projectId,本组件按 id 拉工程列表(moduleApi.listProjectModules),
|
|
* 单工程自动选中;多工程默认选首个 + 提供下拉切换。
|
|
*/
|
|
import { ref, computed, watch, reactive, onMounted, onUnmounted, onActivated, onDeactivated } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { Message } from '@arco-design/web-vue'
|
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
|
import { moduleApi, type ProjectModuleRecord, type FileTreeEntry, type GitStatusResult } from '@/api/module'
|
|
import FileTree from './FileTree.vue'
|
|
import FilePreview from './FilePreview.vue'
|
|
import GitChanges from './GitChanges.vue'
|
|
import { detachFileExplorer } from '@/composables/project/useFileExplorerWindow'
|
|
|
|
const props = defineProps<{
|
|
projectId: string
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
|
|
const modules = ref<ProjectModuleRecord[]>([])
|
|
const currentModuleId = ref<string>('')
|
|
const refreshing = ref(false)
|
|
const moduleDropdownOpen = ref(false)
|
|
// B4[PD-P2-8→P2-10]:工程列表是否已加载完(首次拉取 resolve)。currentModuleId 为空时用它
|
|
// 区分「还在加载中(转圈)」vs「确实无工程(空态引导)」,避免无工程时无限 spinner。
|
|
const mLoaded = ref(false)
|
|
|
|
// 工程 CRUD 弹窗
|
|
const showModuleModal = ref(false)
|
|
const editingModule = ref<ProjectModuleRecord | null>(null)
|
|
const savingModule = ref(false)
|
|
const moduleFormName = ref('')
|
|
const moduleFormPath = ref('')
|
|
const moduleFormGitUrl = ref('')
|
|
// V40 工程实体化:职责描述 / 技术栈(逗号分隔) / 状态(active/archived)
|
|
const moduleFormDescription = ref('')
|
|
const moduleFormStack = ref('')
|
|
const moduleFormStatus = ref<'active' | 'archived'>('active')
|
|
|
|
// 删除确认
|
|
const showRemoveConfirm = ref(false)
|
|
const removingModule = ref(false)
|
|
|
|
// 树状态(顶层持有,递归子树共享 props)。
|
|
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,
|
|
)
|
|
|
|
/** 面包屑分段(由当前选中文件路径或最后展开目录派生)。 */
|
|
const breadcrumbSegments = computed(() => {
|
|
// 面包屑跟随选中文件所在目录(更贴合"我在看哪里");未选文件时显示根。
|
|
const ref = selectedFilePath.value ?? ''
|
|
if (!ref) return []
|
|
const parts = ref.split('/').filter(Boolean)
|
|
const segs: { name: string; path: string }[] = []
|
|
let acc = ''
|
|
for (const p of parts) {
|
|
acc = acc ? `${acc}/${p}` : p
|
|
segs.push({ name: p, path: acc })
|
|
}
|
|
return segs
|
|
})
|
|
|
|
/** 拉工程列表并初始化选中(记忆上次选中)。 */
|
|
async function loadModules() {
|
|
try {
|
|
const list = await moduleApi.listProjectModules(props.projectId)
|
|
modules.value = list
|
|
if (list.length > 0) {
|
|
const lastSelected = localStorage.getItem('df-module-' + props.projectId)
|
|
const target = list.find(m => m.id === lastSelected)
|
|
if (target) {
|
|
currentModuleId.value = target.id
|
|
} else if (!list.some((m) => m.id === currentModuleId.value)) {
|
|
currentModuleId.value = list[0].id
|
|
}
|
|
}
|
|
resetTreeState()
|
|
} catch (e) {
|
|
console.error('[FileExplorer] 加载工程列表失败', e)
|
|
modules.value = []
|
|
} finally {
|
|
// B4[PD-P2-8→P2-10]:标记加载完成(成功/失败都算),供空态判定
|
|
mLoaded.value = true
|
|
}
|
|
}
|
|
|
|
/** 重置树状态(切工程时调用,清展开/缓存/选中文件;refresh 也归零选中)。 */
|
|
function resetTreeState() {
|
|
expandedPaths.clear()
|
|
loadedChildren.clear()
|
|
selectedFilePath.value = null
|
|
selectedFileGitStatus.value = undefined
|
|
selectedCommitDiff.value = ''
|
|
}
|
|
|
|
/** 自定义下拉选择工程。 */
|
|
function onModuleSelect(id: string) {
|
|
currentModuleId.value = id
|
|
localStorage.setItem('df-module-' + props.projectId, id)
|
|
moduleDropdownOpen.value = false
|
|
resetTreeState()
|
|
}
|
|
|
|
/** 点击文件:记录选中 + git 状态(从已加载条目查)。 */
|
|
function onFileSelect(path: string) {
|
|
selectedFilePath.value = path
|
|
// 从 loadedChildren 反查 git_status(任意层目录的条目都可能含此文件)。
|
|
selectedFileGitStatus.value = findEntryGitStatus(path)
|
|
// 树视图选中 → 回到内容预览,不残留 GitChanges 注入的 diff
|
|
selectedCommitDiff.value = ''
|
|
}
|
|
|
|
/** 递归在 loadedChildren 缓存中查指定路径条目的 git_status。 */
|
|
function findEntryGitStatus(path: string): string | undefined {
|
|
for (const entries of loadedChildren.values()) {
|
|
const hit = entries.find((e) => e.path === path)
|
|
if (hit) return hit.git_status
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/** 文件夹展开/收起(子组件 emit;同步顶层 expandedPaths)。 */
|
|
function onToggleDir(path: string, _entries: FileTreeEntry[]) {
|
|
if (expandedPaths.has(path)) {
|
|
expandedPaths.delete(path)
|
|
} else {
|
|
expandedPaths.add(path)
|
|
}
|
|
}
|
|
|
|
/** 子目录懒加载触发(子组件 emit;顶层仅记录已请求,实际拉取由 FileTree 自身完成)。 */
|
|
function onLoadChildren(_path: string) {
|
|
// 占位:FileTree 内部已自拉并缓存到 loadedChildren;此处保留事件入口便于未来扩展(如统一节流)。
|
|
}
|
|
|
|
/** 刷新根目录(清展开/缓存,并把选中文件归位)。
|
|
* 切工程走 resetTreeState(同样清选中);此处独立清是为了让刷新后树回到根、预览回到占位,
|
|
* 避免选中文件指向已不存在的路径(刷新后 git 状态/文件内容可能已变)。 */
|
|
async function refresh() {
|
|
refreshing.value = true
|
|
expandedPaths.clear()
|
|
loadedChildren.clear()
|
|
selectedFilePath.value = null
|
|
selectedFileGitStatus.value = undefined
|
|
selectedCommitDiff.value = ''
|
|
// 等一个 tick 让 FileTree watch 触发重拉;实际拉取在子组件,这里只做状态清空。
|
|
await new Promise((r) => setTimeout(r, 50))
|
|
refreshing.value = false
|
|
}
|
|
|
|
/** 面包屑 → 根(仅清展开状态,保留当前打开的预览文件)。 */
|
|
function goRoot() {
|
|
expandedPaths.clear()
|
|
}
|
|
|
|
/** 面包屑 → 某段(仅展开父链使该目录可见,不改变当前预览文件)。 */
|
|
function goBreadcrumb(path: string) {
|
|
// 确保父链展开(否则面包屑跳转后看不到该目录)。
|
|
const parts = path.split('/').filter(Boolean)
|
|
let acc = ''
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
acc = acc ? `${acc}/${parts[i]}` : parts[i]
|
|
expandedPaths.add(acc)
|
|
}
|
|
}
|
|
|
|
/** 弹出文件浏览器到独立窗口。 */
|
|
function onDetach() {
|
|
detachFileExplorer(props.projectId, currentModule.value?.name || t('fileExplorer.detachTitle'))
|
|
}
|
|
|
|
// ── 视图模式切换:文件树 / Git 变更 ──
|
|
const viewMode = ref<'tree' | 'changes'>('tree')
|
|
const gitStatusData = ref<GitStatusResult | null>(null)
|
|
// B2[PD-P1-5]:git 状态刷新信号(自增)。AI 写文件后(下)传 GitChanges 触发重拉,
|
|
// 替代旧的「tree→changes 切换触发 watch」hack;同时自身重拉 gitStatusData 保徽标计数。
|
|
const gitStatusRefreshKey = ref(0)
|
|
// 本组件是否可见(KeepAlive 保活下 onDeactivated 置 false,onActivated 置 true)。
|
|
// 保活后组件在切走 overview 时仍存活,其 df-data-changed 监听会在后台触发;
|
|
// 用此守卫避免「用户切到 overview 时文件 tab 后台偷偷切到 changes 视图」。
|
|
const viewVisible = ref(true)
|
|
onActivated(() => { viewVisible.value = true })
|
|
onDeactivated(() => { viewVisible.value = false })
|
|
|
|
const changedCount = computed(() => gitStatusData.value?.changed_files.length ?? 0)
|
|
|
|
// F5:切工程清 git 状态缓存 —— 否则 changedCount 徽标沿用上一工程的计数(张冠李戴),
|
|
// 且 switchToChanges 因 gitStatusData 非空短路,不再为新工程重拉。进入变更视图时自然重拉。
|
|
watch(currentModuleId, () => {
|
|
gitStatusData.value = null
|
|
})
|
|
|
|
async function switchToChanges() {
|
|
viewMode.value = 'changes'
|
|
if (!currentModuleId.value || gitStatusData.value) return
|
|
try {
|
|
gitStatusData.value = await moduleApi.getModuleGitStatus(currentModuleId.value)
|
|
} catch {
|
|
gitStatusData.value = null
|
|
}
|
|
}
|
|
|
|
/** 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 })
|
|
|
|
// 点击外部关闭工程下拉(点在 .module-dropdown 内不关,否则按钮 toggle 会被冒泡回调立即关闭)
|
|
function closeDropdown(e: Event) {
|
|
if ((e.target as HTMLElement)?.closest('.module-dropdown')) return
|
|
moduleDropdownOpen.value = false
|
|
}
|
|
onMounted(() => document.addEventListener('click', closeDropdown))
|
|
onUnmounted(() => document.removeEventListener('click', closeDropdown))
|
|
|
|
// AI 工具写入文件后(df-data-changed entity=file)自动跳转变更视图 + 刷新
|
|
let _unlistenDataChanged: UnlistenFn | null = null
|
|
onMounted(async () => {
|
|
_unlistenDataChanged = await listen<{ entity: string; action: string }>('df-data-changed', async (e) => {
|
|
if (e.payload.entity !== 'file') return
|
|
if (!currentModuleId.value) return
|
|
// B2[PD-P1-5]:重拉 git 状态而非置 null —— 置 null 会让 changedCount 徽标瞬闪为 0,
|
|
// 且 switchToChanges 短路 !gitStatusData,事件后依赖旧缓存不再重新拉取。
|
|
try {
|
|
gitStatusData.value = await moduleApi.getModuleGitStatus(currentModuleId.value)
|
|
} catch {
|
|
gitStatusData.value = null
|
|
}
|
|
// 通知 GitChanges 重拉(已在 changes 视图立即生效;未挂载时挂载即拉最新)。
|
|
// 后端 git status 有 5s TTL 缓存(module.rs GIT_STATUS_CACHE_TTL),写后立即重拉可能拿到
|
|
// 缓存旧值,5s 内随用户操作/再次刷新自然更新;跨层绕过缓存留待设计(不强行改事件协议)。
|
|
gitStatusRefreshKey.value++
|
|
if (viewMode.value !== 'changes' && viewVisible.value) {
|
|
// 不在变更视图且本组件可见:自动切换到变更视图,让用户看到 AI 写入的内容
|
|
viewMode.value = 'changes'
|
|
}
|
|
})
|
|
})
|
|
onUnmounted(() => { _unlistenDataChanged?.() })
|
|
|
|
// 自动扫描子仓库
|
|
async function onScanSubmodules() {
|
|
moduleDropdownOpen.value = false
|
|
try {
|
|
await moduleApi.scanProjectModules(props.projectId)
|
|
await loadModules()
|
|
} catch (e) {
|
|
Message.error(t('common.unknownError'))
|
|
console.error('[FileExplorer] 扫描子仓库失败:', e)
|
|
}
|
|
}
|
|
|
|
/** 打开新增工程弹窗。 */
|
|
function openAddModule() {
|
|
editingModule.value = null
|
|
moduleFormName.value = ''
|
|
moduleFormPath.value = ''
|
|
moduleFormGitUrl.value = ''
|
|
moduleFormDescription.value = ''
|
|
moduleFormStack.value = ''
|
|
moduleFormStatus.value = 'active'
|
|
showModuleModal.value = true
|
|
}
|
|
|
|
/** 打开编辑当前工程弹窗。 */
|
|
function openEditModule() {
|
|
if (!currentModule.value) return
|
|
editingModule.value = currentModule.value
|
|
moduleFormName.value = currentModule.value.name
|
|
moduleFormPath.value = currentModule.value.path
|
|
moduleFormGitUrl.value = currentModule.value.git_url ?? ''
|
|
moduleFormDescription.value = currentModule.value.description ?? ''
|
|
moduleFormStack.value = currentModule.value.stack ?? ''
|
|
// 老工程 status 为 null 时视作 active
|
|
moduleFormStatus.value = (currentModule.value.status === 'archived' ? 'archived' : 'active')
|
|
showModuleModal.value = true
|
|
}
|
|
|
|
/** 弹出删除当前工程确认。 */
|
|
function confirmRemoveModule() {
|
|
if (!currentModule.value) return
|
|
showRemoveConfirm.value = true
|
|
}
|
|
|
|
/** 保存工程(新增/编辑)。 */
|
|
async function onSaveModule() {
|
|
if (savingModule.value) return
|
|
savingModule.value = true
|
|
try {
|
|
if (editingModule.value) {
|
|
await moduleApi.updateProjectModule({
|
|
id: editingModule.value.id,
|
|
name: moduleFormName.value.trim(),
|
|
path: moduleFormPath.value.trim(),
|
|
gitUrl: moduleFormGitUrl.value.trim() || null,
|
|
description: moduleFormDescription.value.trim() || null,
|
|
stack: moduleFormStack.value.trim() || null,
|
|
status: moduleFormStatus.value,
|
|
})
|
|
} else {
|
|
await moduleApi.addProjectModule({
|
|
projectId: props.projectId,
|
|
name: moduleFormName.value.trim(),
|
|
path: moduleFormPath.value.trim(),
|
|
gitUrl: moduleFormGitUrl.value.trim() || null,
|
|
description: moduleFormDescription.value.trim() || null,
|
|
stack: moduleFormStack.value.trim() || null,
|
|
status: moduleFormStatus.value,
|
|
})
|
|
}
|
|
showModuleModal.value = false
|
|
await loadModules()
|
|
} catch (e) {
|
|
Message.error(t('common.unknownError'))
|
|
console.error('[FileExplorer] 保存工程失败:', e)
|
|
} finally {
|
|
savingModule.value = false
|
|
}
|
|
}
|
|
|
|
/** 删除当前工程。 */
|
|
async function onRemoveModule() {
|
|
if (removingModule.value || !currentModule.value) return
|
|
removingModule.value = true
|
|
try {
|
|
await moduleApi.removeProjectModule(currentModule.value.id)
|
|
showRemoveConfirm.value = false
|
|
currentModuleId.value = ''
|
|
await loadModules()
|
|
} catch (e) {
|
|
Message.error(t('common.unknownError'))
|
|
console.error('[FileExplorer] 删除工程失败:', e)
|
|
} finally {
|
|
removingModule.value = false
|
|
}
|
|
}
|
|
|
|
</script>
|
|
|
|
<style scoped>
|
|
.file-explorer {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
max-height: 100%;
|
|
background: var(--df-bg);
|
|
border: 0.5px solid var(--df-border);
|
|
border-radius: var(--df-radius-lg);
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* 工具栏 */
|
|
.explorer-toolbar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 8px 12px;
|
|
border-bottom: 0.5px solid var(--df-border);
|
|
background: var(--df-bg-card);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.toolbar-left,
|
|
.toolbar-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.module-select {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 5px 10px;
|
|
background: var(--df-bg);
|
|
border: 0.5px solid var(--df-border);
|
|
border-radius: var(--df-radius-sm);
|
|
color: var(--df-text);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
max-width: 180px;
|
|
}
|
|
|
|
.module-select:hover { border-color: var(--df-accent); }
|
|
.module-select-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.module-select-arrow {
|
|
font-size: 10px;
|
|
color: var(--df-text-dim);
|
|
transition: transform 0.15s;
|
|
}
|
|
.module-select-arrow.open { transform: rotate(180deg); }
|
|
|
|
.module-dropdown { position: relative; flex-shrink: 0; }
|
|
|
|
.module-dropdown-menu {
|
|
position: absolute;
|
|
top: calc(100% + 4px);
|
|
left: 0;
|
|
min-width: 220px;
|
|
max-width: 320px;
|
|
background: var(--df-bg-card);
|
|
border: 0.5px solid var(--df-border);
|
|
border-radius: var(--df-radius-sm);
|
|
box-shadow: var(--df-shadow-sm);
|
|
z-index: 20;
|
|
max-height: 280px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.module-dropdown-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
padding: 8px 12px;
|
|
cursor: pointer;
|
|
border-bottom: 0.5px solid var(--df-hover);
|
|
}
|
|
.module-dropdown-item:last-child { border-bottom: none; }
|
|
.module-dropdown-item:hover { background: var(--df-hover); }
|
|
.module-dropdown-item.active {
|
|
background: rgba(255,255,255,0.06);
|
|
border-left: 2px solid var(--df-accent);
|
|
}
|
|
.module-dropdown-divider {
|
|
height: 0.5px;
|
|
background: var(--df-border);
|
|
margin: 4px 0;
|
|
}
|
|
.module-dropdown-action .module-dropdown-name {
|
|
color: var(--df-accent);
|
|
font-size: 11px;
|
|
}
|
|
.module-dropdown-name { font-size: 12px; color: var(--df-text); }
|
|
.module-dropdown-path {
|
|
font-size: 10px;
|
|
color: var(--df-text-dim);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* 面包屑 */
|
|
.breadcrumb {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 12px;
|
|
color: var(--df-text-dim);
|
|
overflow: hidden;
|
|
min-width: 0;
|
|
}
|
|
|
|
.crumb {
|
|
cursor: pointer;
|
|
padding: 2px 6px;
|
|
border-radius: var(--df-radius-sm);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 200px;
|
|
transition: background 0.12s;
|
|
}
|
|
|
|
.crumb:hover {
|
|
background: var(--df-bg);
|
|
color: var(--df-text);
|
|
}
|
|
|
|
.crumb-root {
|
|
font-weight: 500;
|
|
color: var(--df-text);
|
|
}
|
|
|
|
.crumb-sep {
|
|
color: var(--df-text-dim);
|
|
opacity: 0.6;
|
|
}
|
|
|
|
/* 按钮基础样式 (.btn / .btn-ghost / .btn-sm) 已收敛至 global.css */
|
|
/* FileExplorer 工具栏为紧凑布局,覆 padding/font-size 避免按钮变大 */
|
|
.btn { padding: 6px 12px; font-size: 12px; }
|
|
|
|
/* 主体两栏 — 自适应比例 30% / 70% */
|
|
.explorer-body {
|
|
flex: 1;
|
|
display: flex;
|
|
overflow: hidden;
|
|
min-height: 0;
|
|
}
|
|
|
|
/* 左侧边栏(视图切换 + 内容) */
|
|
.explorer-sidebar {
|
|
width: 30%;
|
|
min-width: 200px;
|
|
max-width: 420px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
border-right: 0.5px solid var(--df-border);
|
|
flex-shrink: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* 视图切换 Tab */
|
|
.explorer-view-tabs {
|
|
display: flex;
|
|
border-bottom: 0.5px solid var(--df-border);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.view-tab {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
padding: 6px 0;
|
|
background: none;
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
color: var(--df-text-dim);
|
|
cursor: pointer;
|
|
font-size: 11px;
|
|
transition: color 0.15s, border-color 0.15s;
|
|
position: relative;
|
|
}
|
|
|
|
.view-tab:hover {
|
|
color: var(--df-text);
|
|
background: var(--df-hover);
|
|
}
|
|
|
|
.view-tab.active {
|
|
color: var(--df-text);
|
|
border-bottom-color: var(--df-accent);
|
|
}
|
|
|
|
.changed-badge {
|
|
position: absolute;
|
|
top: 3px;
|
|
right: 8px;
|
|
min-width: 14px;
|
|
height: 14px;
|
|
padding: 0 4px;
|
|
border-radius: var(--df-radius);
|
|
background: var(--df-accent);
|
|
color: #fff;
|
|
font-size: 9px;
|
|
font-weight: 500;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.explorer-tree {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
padding: 4px 4px 4px 0;
|
|
/* 滚动条样式走全局 ::-webkit-scrollbar(5px + var(--df-scrollbar) 主题色):
|
|
此处不得设 scrollbar-width —— Chromium 121+ 一旦设了标准 scrollbar-* 属性,
|
|
该元素 ::-webkit-scrollbar 定制即失效,文件列表会回退成原生样式滚动条(不随主题) */
|
|
}
|
|
|
|
.explorer-changes {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
min-height: 0;
|
|
}
|
|
|
|
.tree-loading {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
height: 100%;
|
|
}
|
|
|
|
/* B4[PD-P2-8→P2-10]:零工程空态引导(对齐概览模块空态视觉) */
|
|
.module-empty {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 32px 16px;
|
|
text-align: center;
|
|
height: 100%;
|
|
justify-content: center;
|
|
}
|
|
/* 空态图标走 global .empty-icon.empty-icon--xs(28px 档,scoped 28px 副本已删) */
|
|
.module-empty-hint {
|
|
margin: 0;
|
|
font-size: 12px;
|
|
color: var(--df-text-dim);
|
|
line-height: 1.6;
|
|
max-width: 300px;
|
|
}
|
|
.module-empty-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.tree-loading .spinner {
|
|
width: 20px;
|
|
height: 20px;
|
|
border: 2px solid var(--df-border);
|
|
border-top-color: var(--df-accent);
|
|
border-radius: 50%;
|
|
animation: df-spin 0.8s linear infinite;
|
|
}
|
|
|
|
.explorer-preview {
|
|
flex: 1;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.preview-placeholder-inline {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
color: var(--df-text-dim);
|
|
font-size: 13px;
|
|
min-height: 200px;
|
|
}
|
|
|
|
/* 滚动条统一样式(styles/global.css 全局 ::-webkit-scrollbar:5px + var(--df-scrollbar))已覆盖,
|
|
此处不重复声明 —— 原局部副本硬编码白色系 rgba,浅色主题下 thumb 不可见 */
|
|
|
|
/* 窄窗口自适应 */
|
|
@media (max-width: 900px) {
|
|
.explorer-sidebar {
|
|
width: 240px;
|
|
min-width: 160px;
|
|
max-width: 280px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.explorer-toolbar {
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}
|
|
.explorer-sidebar {
|
|
width: 180px;
|
|
min-width: 120px;
|
|
}
|
|
}
|
|
|
|
/* spinner(刷新按钮内);df-spin keyframes 消费 global.css 全局定义(原本地重复定义已删) */
|
|
.spinner {
|
|
width: 10px;
|
|
height: 10px;
|
|
border: 1.5px solid var(--df-border);
|
|
border-top-color: var(--df-accent);
|
|
border-radius: 50%;
|
|
animation: df-spin 0.8s linear infinite;
|
|
display: inline-block;
|
|
}
|
|
</style>
|