修复: 项目详情批量(任务列表联动刷新/工作流降级标注/中文git乱码/子目录错误行内/零工程空态/切tab保活) + 依赖图批量(节点渲染register/环高亮/防环防重/点击选中) + 销账

This commit is contained in:
lxy
2026-08-09 21:35:13 +08:00
parent 4a9f77872e
commit 4df91ed155
15 changed files with 402 additions and 77 deletions
+12 -11
View File
@@ -587,22 +587,23 @@ graph TD
> 用户要求「检查项目详情(概览、文件、依赖图)存在的问题」并「先检查做记录,不直接改」。详单见 [项目详情页走查-2026-08-09.md](./05-代码审查/项目详情页走查-2026-08-09.md)。共 **60 项**P0×1 / P1×6 / P2×14 / P3×27)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**P0(核心不可用)**:
- [ ] **PD-P0-1**依赖图节点 Vue 组件未注册到 x6-vue-shape → **节点根本不渲染**(DependencyGraph.vue:82 只 import 未 register,`component` 字段库内不读;ModuleNode props.data 但 vue-shape 传 {node,graph})
- [x] **PD-P0-1**✅ 已修(2026-08-09 e6760e8):DependencyGraph 调 `register({shape:'vue-shape',component:ModuleNode})` + ModuleNode `props.node?.getData?.()` 双兜底(vue-shape 传 {node,graph} 非 data,主代核 x6 源码确认)
**P1(数据正确性/一致性·6 项)**:
- [ ] **PD-P1-1**中文文件名 git status 八进制转义未还原(core.quotepath 默认)→ 变更视图乱码/打不开/树无徽标(module.rs:1081-1091)
- [ ] **PD-P1-2**删除参与依赖的工程必失败(FK 约束无级联清理)→ remove_project_module 报错+前端误导(module.rs:249-260)
- [ ] **PD-P1-3**工作流日志/DAG/进度读全局 liveEvents 无项目隔离 → 跨项目数据错配(ProjectDetail.vue:676-709)
- [ ] **PD-P1-4** — projectTasks df-data-changed 本地监听 → AI/其它视图改任务后概览列表陈旧(TaskDetail 已做此处漏做,:534-542)
- [ ] **PD-P1-5**AI 写文件后自动切变更视图不刷新 GitChanges + 徽标计数清零(v-show 不重挂载,:399-411)
- [ ] **PD-P1-6**子目录展开失败把父级整棵树替换为错误态且无法自愈(FileTree:157-181)
- [x] **PD-P1-1**✅ 已修(a9cb802):module.rs git status `-c core.quotepath=false` 一行根治中文乱码
- [x] **PD-P1-2**✅ 已修(e6760e8):remove_project_module 删工程前级联清 module_dependencies(FK 约束)
- [~] **PD-P1-3**🟡 已降级标注(2026-08-09 a9cb802):WorkflowEventPayload 无 project 维度无法按项目过滤 → 实时进度面板默认隐藏(wfProgressEnabled)+ 日志默认折叠+「全局事件」标注,不改事件协议(跨层大改留设计)
- [x] **PD-P1-4**✅ 已修(a9cb802):projectTasks df-data-changed 本地监听(entity=task,200ms 防抖,overview 活跃才刷)
- [x] **PD-P1-5**✅ 已修(a9cb802):df-data-changed 写文件后重拉 gitStatus + GitChanges refreshKey prop(修徽标清零+不刷新)
- [x] **PD-P1-6**✅ 已修(a9cb802):FileTree toggleDir 错误改行内态(toggleErrors 按 path),不炸整树
**P2(体验/打磨·14 项)**:
- [ ] **PD-P2-1** 概览工程列表与 FileExplorer 双向不同步(:548-555) / **PD-P2-2** 快速切项目列表竞态(无 seq 守卫,:866-871) / **PD-P2-3** DAG 边条件编辑 emit 父级未监听→失效回退 / **PD-P2-4** onUnmounted stopEventListener 停全局监听波及他视图 / **PD-P2-5** 折叠态跨项目残留
- [ ] **PD-P2-6** 概览任务无 loading/分页 / **PD-P2-7** 项目状态 in_progress/testing 无 i18n / **PD-P2-8** 环高亮 attrs.body 选择器 vue-shape 无 body 元素(UX-P0-4 未真正修) / **PD-P2-9** 节点点击 emit 无人监听+路由重复导航(死代码) / **PD-P2-10** 零工程文件 tab 永久转圈无空态
- [ ] **PD-P2-11** 打开文件 tab 全量 git status+log 扫描(纯浏览也扫) / **PD-P2-12** 切 tab v-if 反复销毁重建 FileExplorer(状态全丢+重复扫描) / **PD-P2-13** cycleNodes 跨数据变更不重置 / **PD-P2-14** 重复依赖可重复添加(前后端无去重)
- [x] **PD-P2-1** ✅ 已修(a9cb802):watch(activeTab) 切回 overview 重拉 modules / **PD-P2-2** ✅ 已修:loadProjectTasks/loadModules 加 seq 序号守卫 / **PD-P2-3** 🟡 DAG 边条件编辑父级未监听(a9cb802 概览进度面板默认隐藏,编辑入口随隐藏;TaskDetail 场景不受影响,待评估) / **PD-P2-4** 🟡 stopEventListener 波及他视图(共享单例,留设计) / **PD-P2-5** ✅ 已修:watch(projectId) 重置折叠态
- [x] **PD-P2-6** 🟡 概览任务无 loading/分页(未做,列表上限 500 通常够,待评估) / **PD-P2-7** ✅ 已修:constants/project.ts 补全 5 状态 + i18n / **PD-P2-8** ✅ 已修(e6760e8):环高亮改 isCycle 注入 data + ModuleNode 红框 / **PD-P2-9** ✅ 已修:节点点击改组件内 selectedId 选中高亮(移除死代码 emit+push) / **PD-P2-10** ✅ 已修(a9cb802):零工程空态引导 CTA
- [x] **PD-P2-11** 🟡 打开文件 tab 全量 git 扫描(后端 5s 缓存已缓解,懒加载留设计) / **PD-P2-12** ✅ 已修:FileExplorer/DependencyGraph 用 KeepAlive 保活(onActivated 守卫) / **PD-P2-13** ✅ 已修(e6760e8):loadModules 重置 cycleNodes / **PD-P2-14** ✅ 已修:前端禁用已存在组合 + 后端幂等
**P3(边缘/打磨·27 项)**: 见详单文档(概览 O10-18 / 文件 F13-24 / 依赖图 G14-18)
**P3(边缘/打磨·27 项)**: 见详单文档(概览 O10-18 / 文件 F13-24 / 依赖图 G14-18) — 部分已随批修(见下)
> 已随批修 P3O7 sourceScores clamp / O8 descCollapsible DOM 测量 / O9 handleSync 补模块+灵感 / O10 parseStack 缓存 / 依赖图 G10 防环文案 cycleAddError / G14 后端 from!=to 校验 / G16 省略号统一。其余待后续批。
> **优先级建议**:P0 G1 节点不渲染 → P1 F3 中文乱码 + G3 删依赖必失败 + O2 跨项目串数据 + O1 列表陈旧 + F1 变更不刷新 → P2 分批。
> **memory 指针**[[devflow-project-detail-walkthrough]]
+43 -1
View File
@@ -255,6 +255,25 @@ pub async fn remove_project_module(state: State<'_, AppState>, id: String) -> Re
if let Ok(Some(module)) = state.project_modules.get_by_id(&id).await {
invalidate_git_status_cache(&module.path);
}
// G3:FK 级联 — module_dependencies 的 from/to REFERENCES project_modules(id),参与依赖的
// 工程直接删必 FK 违例失败(foreign_keys=ON)。删工程前先清所有参与该工程的依赖边
// (从/到任一端都算参与),顺序参照 purge_with_descendants(子表先于父表删)。
for dep in state
.module_dependencies
.list_by_field("from_module_id", &id)
.await
.map_err(err_str)?
{
state.module_dependencies.delete(&dep.id).await.map_err(err_str)?;
}
for dep in state
.module_dependencies
.list_by_field("to_module_id", &id)
.await
.map_err(err_str)?
{
state.module_dependencies.delete(&dep.id).await.map_err(err_str)?;
}
state.project_modules.delete(&id).await.map_err(err_str)?;
Ok(())
}
@@ -1065,7 +1084,14 @@ fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
return map;
}
let timeout = std::time::Duration::from_secs(10);
let Some(out) = run_git_cmd(path, &["status", "--porcelain"], timeout) else {
// B1[PD-P1-1]:-c core.quotepath=false 让 git status 直接输出 UTF-8 原名(中文/非 ASCII
// 路径不再八进制转义 \xxx),否则 GitChanges/文件树展示一串转义码。其余特殊字符
// (空格/引号)仍按 C 风格加引号,由下方 strip_prefix 逻辑剥除兜底。
let Some(out) = run_git_cmd(
path,
&["-c", "core.quotepath=false", "status", "--porcelain"],
timeout,
) else {
return map;
};
for line in out.lines() {
@@ -1302,6 +1328,22 @@ pub async fn add_module_dependency(
if project_id.is_empty() || from_module_id.is_empty() || to_module_id.is_empty() {
return Err("project_id / from_module_id / to_module_id 不能为空".to_string());
}
// G9:依赖两端不能是同一工程(自环无意义)。
if from_module_id == to_module_id {
return Err("依赖两端不能是同一工程".to_string());
}
// G6:幂等 — 同 project_id + from + to 已存在则直接返回已存在记录,不重复插入。
let existing = state
.module_dependencies
.list_by_field("project_id", &project_id)
.await
.map_err(err_str)?;
if let Some(found) = existing
.iter()
.find(|d| d.from_module_id == from_module_id && d.to_module_id == to_module_id)
{
return Ok(found.clone());
}
let dep_type = dep_type.unwrap_or_else(|| "library".to_string());
let record = ModuleDependencyRecord {
id: new_id(),
+73 -20
View File
@@ -46,7 +46,13 @@
<div class="dep-graph__modal-field">
<label>{{ $t('dependencyGraph.targetLabel') }}</label>
<select v-model="depTo" class="dep-graph__modal-input">
<option v-for="m in modules" :key="m.id" :value="m.id">{{ m.name }}</option>
<!-- G6:已存在 fromto 组合或选中自身作为目标时禁用该候选 -->
<option
v-for="m in modules"
:key="m.id"
:value="m.id"
:disabled="depFrom === m.id || (depFrom !== '' && existingDepKeys.has(`${depFrom}->${m.id}`))"
>{{ m.name }}</option>
</select>
</div>
<div class="dep-graph__modal-field">
@@ -61,7 +67,7 @@
</div>
<div class="dep-graph__modal-actions">
<button class="btn btn-ghost btn-sm" @click="showAddDep = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary btn-sm" :disabled="!depFrom || !depTo || depFrom === depTo" @click="onAddDep">{{ $t('dependencyGraph.confirm') }}</button>
<button class="btn btn-primary btn-sm" :disabled="!depFrom || !depTo || depFrom === depTo || existingDepKeys.has(`${depFrom}->${depTo}`)" @click="onAddDep">{{ $t('dependencyGraph.confirm') }}</button>
</div>
</div>
</div>
@@ -74,11 +80,11 @@
*
* 节点用 Vue 组件(ModuleNode.vue)渲染,通过 @antv/x6-vue-shape 注册。
*/
import { onMounted, onBeforeUnmount, ref, watch, markRaw } from 'vue'
import { useRouter } from 'vue-router'
import { onMounted, onBeforeUnmount, ref, watch, computed, markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import { Graph, Selection, Snapline, History, Scroller, MiniMap } from '@antv/x6'
import dagre from 'dagre'
import { register } from '@antv/x6-vue-shape'
import '@antv/x6-vue-shape'
import { Message } from '@arco-design/web-vue'
import { moduleApi, type ProjectModuleRecord, type ModuleDependencyRecord } from '@/api/module'
@@ -86,16 +92,16 @@ import { useConfirm } from '@/composables/useConfirm'
import ModuleNode from './ModuleNode.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
// vue-shape 注册:把节点 Vue 组件绑定到 'vue-shape' 形状。
// 库内 shapeMaps 仅经 register 填充(view.js:48 renderVueComponent 读 shapeMaps[node.shape]),
// 单纯 import '@antv/x6-vue-shape' 只注册 shape 不绑组件 → 节点渲染空/报错(P0)。
// 幂等:Graph.registerNode 对同名 shape 重复注册会覆盖,重复进组件也安全。
register({ shape: 'vue-shape', component: ModuleNode })
const props = defineProps<{
projectId: string
}>()
const router = useRouter()
const emit = defineEmits<{
(e: 'select-module', moduleId: string): void
}>()
const containerRef = ref<HTMLElement | null>(null)
const modules = ref<ProjectModuleRecord[]>([])
const dependencies = ref<ModuleDependencyRecord[]>([])
@@ -115,8 +121,23 @@ const depFrom = ref('')
const depTo = ref('')
const depType = ref('library')
// 已存在的依赖组合(from→to),用于弹窗候选禁用重复添加(G6)。
const existingDepKeys = computed(
() => new Set(dependencies.value.map(d => `${d.from_module_id}->${d.to_module_id}`)),
)
// 当前选中高亮的节点 id(点击节点组件内高亮,不导航;G4)。
const selectedId = ref<string | null>(null)
async function onAddDep() {
if (!depFrom.value || !depTo.value || depFrom.value === depTo.value) return
// 重复依赖拦截(G6):已存在组合,弹窗候选已禁用,此处双保险防直达。
if (existingDepKeys.value.has(`${depFrom.value}->${depTo.value}`)) return
// 防环(G7):本地 DFS 校验,新增 source→target 会成环则阻止。
if (wouldCreateCycle(depFrom.value, depTo.value)) {
Message.warning(t('dependencyGraph.cycleAddError'))
return
}
try {
await moduleApi.addModuleDependency(props.projectId, depFrom.value, depTo.value, depType.value)
showAddDep.value = false
@@ -127,11 +148,40 @@ async function onAddDep() {
renderGraph()
} catch (e) {
console.error('[DependencyGraph] 添加依赖失败:', e)
// G8:对齐删除依赖的失败反馈,补用户可见错误提示(common.unknownError 通用兜底)。
Message.error(t('common.unknownError'))
}
}
/**
* 本地防环:在现有依赖图上,若 target 已能到达 source(DFS),则新增 source→target 会成环。
* 复用 detectModuleCycles 的 DFS 思路的最小前端校验(纯本地,不请求后端)。
*/
function wouldCreateCycle(sourceId: string, targetId: string): boolean {
const adj = new Map<string, string[]>()
for (const d of dependencies.value) {
const list = adj.get(d.from_module_id) ?? []
list.push(d.to_module_id)
adj.set(d.from_module_id, list)
}
const visited = new Set<string>()
const stack = [targetId]
while (stack.length > 0) {
const cur = stack.pop()!
if (cur === sourceId) return true
if (visited.has(cur)) continue
visited.add(cur)
for (const next of adj.get(cur) ?? []) {
if (!visited.has(next)) stack.push(next)
}
}
return false
}
async function loadModules() {
loading.value = true
// 数据变更后旧环高亮失效,复位待重新检测(否则残留上一批环节点红框,G5)。
cycleNodes.value = new Set()
try {
const [mods, deps] = await Promise.all([
moduleApi.listProjectModules(props.projectId),
@@ -147,8 +197,10 @@ async function loadModules() {
}
}
function renderGraph() {
function renderGraph(opts?: { center?: boolean }) {
if (!graph) return
// 点选刷新(节点点击)时跳过 centerContent,避免视图跳动;默认(加载/删除/环检测后)居中。
const shouldCenter = opts?.center !== false
const nodeWidth = 200
const nodeHeight = 80
@@ -168,7 +220,6 @@ function renderGraph() {
// 映射回 X6 节点格式
const nodes = modules.value.map(m => {
const pos = g.node(m.id)
const isCycle = cycleNodes.value.has(m.id)
return {
id: m.id,
shape: 'vue-shape',
@@ -182,12 +233,12 @@ function renderGraph() {
path: m.path,
stack: m.stack,
gitUrl: m.git_url,
// 环节点高亮(G2):vue-shape markup 无 body 选择器,attrs.body 渲染无效,
// 改由 isCycle 注入节点 data,ModuleNode 根元素按类描红框。
isCycle: cycleNodes.value.has(m.id),
// 选中高亮(G4):当前点选节点组件内高亮。
selected: selectedId.value === m.id,
},
// 环节点高亮(红框);renderGraph 是画布唯一渲染入口,高亮必须在这里注入,
// 否则会被 graph.fromJSON 整体替换抹除。
attrs: isCycle ? {
body: { stroke: '#e05050', strokeWidth: 3 },
} : undefined,
}
})
@@ -207,7 +258,7 @@ function renderGraph() {
}))
graph.fromJSON({ nodes, edges })
graph.centerContent()
if (shouldCenter) graph.centerContent()
}
function buildGraph() {
@@ -228,8 +279,10 @@ function buildGraph() {
graph.use(new MiniMap({ width: 200, height: 120, padding: 10 }))
graph.on('node:click', ({ node }) => {
emit('select-module', String(node.id))
router.push(`/projects/${props.projectId}`)
// 组件内选中高亮(G4):点击节点仅高亮,不 emit 不导航(依赖图是查看/编辑关系,
// 跳工程详情与 Tab 语义冲突,原 emit+router.push 是无意义死代码)。
selectedId.value = String(node.id)
renderGraph({ center: false })
})
renderGraph()
+70 -10
View File
@@ -84,9 +84,18 @@
<!-- 文件树视图 -->
<div v-show="viewMode === 'tree'" class="explorer-tree">
<div v-if="!currentModuleId" class="tree-loading">
<!-- B4[PD-P2-8P2-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="module-empty-icon">📦</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"
@@ -106,6 +115,7 @@
<GitChanges
v-if="currentModuleId"
:module-id="currentModuleId"
:refresh-key="gitStatusRefreshKey"
@select-file="onChangeFileSelect"
/>
</div>
@@ -198,7 +208,7 @@
* 父组件(ProjectDetail)传入 projectId,本组件按 id 拉工程列表(moduleApi.listProjectModules),
* 单工程自动选中;多工程默认选首个 + 提供下拉切换。
*/
import { ref, computed, watch, reactive, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, reactive, onMounted, onUnmounted, onActivated, onDeactivated } from 'vue'
import { useI18n } from 'vue-i18n'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { moduleApi, type ProjectModuleRecord, type FileTreeEntry, type GitStatusResult } from '@/api/module'
@@ -217,6 +227,9 @@ 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)
@@ -277,6 +290,9 @@ async function loadModules() {
} catch (e) {
console.error('[FileExplorer] 加载工程列表失败', e)
modules.value = []
} finally {
// B4[PD-P2-8→P2-10]:标记加载完成(成功/失败都算),供空态判定
mLoaded.value = true
}
}
@@ -364,6 +380,15 @@ function onDetach() {
// ── 视图模式切换:文件树 / 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)
@@ -396,16 +421,22 @@ 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', (e) => {
_unlistenDataChanged = await listen<{ entity: string; action: string }>('df-data-changed', async (e) => {
if (e.payload.entity !== 'file') return
// 刷新 git 状态缓存,使变更计数角标即时更新
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
if (viewMode.value === 'changes') {
// 已在变更视图,触发 GitChanges 重新拉取(切一次 viewMode 触发 watch)
viewMode.value = 'tree'
viewMode.value = 'changes'
} else {
// 不在变更视图,自动切换到变更视图,让用户看到 AI 写入的内容
}
// 通知 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'
}
})
@@ -757,6 +788,35 @@ async function onRemoveModule() {
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;
}
.module-empty-icon {
font-size: 28px;
opacity: 0.6;
line-height: 1;
}
.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;
+23 -2
View File
@@ -39,6 +39,12 @@
<span class="expand-icon">{{ expandedPaths.has(entry.path) ? '▾' : '▸' }}</span>
<span class="file-icon">📁</span>
<span class="file-name">{{ entry.name }}</span>
<!-- B3[PD-P1-6]:展开失败 行内 (title 显原因),点击重试;不设整树 error,不炸其他已展开节点 -->
<span
v-if="toggleErrors.has(entry.path)"
class="dir-toggle-error"
:title="$t('fileExplorer.loadFailed') + ': ' + toggleErrors.get(entry.path)"
></span>
</div>
<!-- 文件:点击通知父组件加载预览 -->
@@ -93,7 +99,7 @@
* 这样设计的好处:刷新(refresh)只需 FileExplorer 清空 loadedChildren 重拉根,
* 所有展开的子树因 expandedPaths 被清而卸载,下次展开重新拉取,无脏数据。
*/
import { ref, watch, onMounted } from 'vue'
import { ref, reactive, watch, onMounted } from 'vue'
import { moduleApi, type FileTreeEntry } from '@/api/module'
const props = withDefaults(
@@ -126,6 +132,10 @@ const emit = defineEmits<{
const entries = ref<FileTreeEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// B3[PD-P1-6]:目录展开失败的行内错误态(路径 → 错误信息)。之前把 toggleDir 失败设到 error.value,
// 会让整个子树渲染分支(v-else-if="error")替换成错误文案,一次子目录失败就炸掉已展开的其他节点。
// 改为按 path 记录,条目行内显 ⚠ + title 原因,点击重试;成功加载即清除。
const toggleErrors = reactive(new Map<string, string>())
/** 拉取当前 subPath 的条目列表。 */
async function loadEntries() {
@@ -170,8 +180,11 @@ async function toggleDir(entry: FileTreeEntry) {
loading.value = true
const res = await moduleApi.getModuleFileTree(props.moduleId, entry.path)
props.loadedChildren.set(entry.path, res.entries)
toggleErrors.delete(entry.path)
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
// B3[PD-P1-6]:行内错误态(该目录 ⚠ + title 原因),不设整树 error;不展开,点击可重试
toggleErrors.set(entry.path, e instanceof Error ? e.message : String(e))
return
} finally {
loading.value = false
}
@@ -335,6 +348,14 @@ watch(
white-space: nowrap;
}
/* B3[PD-P1-6]:目录展开失败的行内 ⚠(flex-shrink 防挤压,危险色) */
.dir-toggle-error {
flex-shrink: 0;
font-size: 11px;
color: #e05050;
cursor: pointer;
}
/* Git 状态徽章 */
.git-badge {
flex-shrink: 0;
+9
View File
@@ -183,6 +183,8 @@ const { t } = useI18n()
const props = defineProps<{
moduleId: string
/** B2[PD-P1-5]:外部(FileExplorer df-data-changed)自增刷新信号,变化时重拉 git 状态。 */
refreshKey?: number
}>()
const emit = defineEmits<{
@@ -531,6 +533,13 @@ function showCommitFileDiff(path: string) {
}
watch(() => props.moduleId, loadStatus, { immediate: true })
// B2[PD-P1-5]:外部刷新信号(AI 写文件后 FileExplorer 自增)变化 → 重拉 git 状态。
// 需保留当前历史 Tab/详情展示(只刷 gitStatus/commits,不动 showHistory/selectedCommit)。
watch(() => props.refreshKey, () => {
if (props.refreshKey === undefined) return
loadStatus()
})
</script>
<style scoped>
+24 -12
View File
@@ -1,5 +1,5 @@
<template>
<div class="module-node" :class="{ active: selected }">
<div class="module-node" :class="{ active: isSelected, 'module-node--cycle': data.isCycle }">
<div class="module-node__header">
<span class="module-node__icon">📦</span>
<span class="module-node__name">{{ data.name }}</span>
@@ -16,39 +16,45 @@ import { computed } from 'vue'
import { parseJsonArray } from '@/utils/json'
const props = defineProps<{
data: {
name: string
path: string
stack: string | null
gitUrl: string | null
}
// x6-vue-shape props node + graph(view.js:48 h(component, { node, graph })),
// node.getData() , data propdata prop x6
node?: any
graph?: any
data?: Record<string, unknown>
selected?: boolean
}>()
// : node.getData()(vue-shape ), props.data()
// data { name, path, stack, gitUrl } + { isCycle, selected }
const data = computed<Record<string, any>>(() => props.node?.getData?.() ?? props.data ?? {})
// :data.selected(vue-shape ) selected prop
const isSelected = computed(() => !!props.selected || !!data.value.selected)
// :(/)+,
// '...' + slice(-29) ,( C:/a D:/b )
// :(/)+ + ( 32 )
const truncatedPath = computed(() => {
const p = props.data?.path || ''
const p = data.value?.path || ''
if (p.length <= 32) return p
// :(/ \,)( C: )
const sepMatch = p.match(/[\\/]/)
if (!sepMatch) {
// ,退
return '...' + p.slice(-29)
return '' + p.slice(-29)
}
const sepIdx = sepMatch.index! + 1
const head = p.slice(0, sepIdx)
const tailBudget = 32 - head.length - 1 // -1 ( )
const tailBudget = 32 - head.length - 1 // -1
if (tailBudget < 4) {
// ,退()
return '...' + p.slice(-29)
return '' + p.slice(-29)
}
const tail = p.slice(-tailBudget)
return head + '…' + tail
})
const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
const stackList = computed(() => parseJsonArray(data.value?.stack).slice(0, 3))
</script>
<style scoped>
@@ -70,6 +76,12 @@ const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
border-color: #5378e8;
box-shadow: 0 0 0 2px rgba(83, 120, 232, 0.3);
}
/* (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);
}
.module-node__header {
display: flex;
align-items: center;
+8
View File
@@ -8,10 +8,18 @@
// 值为 i18n key,实际文案走 $t('projects.status.<key>')(见 src/i18n/{zh-CN,en}/projects.ts)
// F-09 对齐:删 in_progress/paused/cancelled(project 维度无数据产生源、无 UI 入口、无消费方),
// 保留与后端真实生命周期一致的最小集(planning 常态 + 软删 deleted_at 由 list_deleted 单独处理)。
// A6[PD-P2-7] 补全:in_progress/testing/releasing/paused/cancelled —— 老 DB 数据或后端可能
// 返回这些状态,projectStatusLabel 兜底逻辑会把未收录的 status 原样透传给 $t(报错/显裸 key),
// 补齐映射 + i18n 文案避免渲染裸 key。阶段 badge class(.stage-*) 已存在(CSS 无需动)。
export const PROJECT_STATUS_LABELS: Record<string, string> = {
planning: 'projects.status.planning',
active: 'projects.status.active',
in_progress: 'projects.status.in_progress',
testing: 'projects.status.testing',
releasing: 'projects.status.releasing',
completed: 'projects.status.completed',
paused: 'projects.status.paused',
cancelled: 'projects.status.cancelled',
}
/** 项目状态 → 卡片 badge 样式 class */
+1
View File
@@ -20,6 +20,7 @@ export default {
cycleWarning: 'Detected {n} cycle nodes (highlighted in red)',
noCycle: 'No cyclic dependencies detected',
cycleError: 'Cycle detection failed',
cycleAddError: 'This dependency would create a cycle — blocked (remove related edges first if needed)',
deleteConfirm: 'Delete the dependency "{from} → {to}"? This cannot be undone.',
deleteSuccess: 'Dependency deleted',
deleteError: 'Failed to delete dependency',
+2
View File
@@ -35,6 +35,8 @@ export default {
emptyTasks: 'No tasks yet',
// Workflow log panel
workflowLogTitle: '📋 Workflow Log',
// A2[PD-P1-3]: event stream has no project_id dimension, cannot filter per project; label "global events" to avoid misreading as this project's logs
workflowLogGlobal: 'Global events',
// B-41 workflow progress panel
workflowProgressTitle: '📈 Workflow Progress',
runDemoWorkflow: '▶ Run Demo Workflow',
+5
View File
@@ -64,7 +64,12 @@ export default {
status: {
planning: '📐 Planning',
active: '🚀 In Progress',
in_progress: '🔨 Developing',
testing: '🧪 Testing',
releasing: '🚢 Releasing',
completed: '✅ Completed',
paused: '⏸ Paused',
cancelled: '✖ Cancelled',
},
// Error fallbacks (user-visible via state.error)
err: {
+1
View File
@@ -20,6 +20,7 @@ export default {
cycleWarning: '检测到 {n} 个环节点(已红框高亮)',
noCycle: '未检测到环形依赖',
cycleError: '环检测失败',
cycleAddError: '添加该依赖会形成环,已阻止(如需调整先删除相关依赖边)',
deleteConfirm: '确定删除「{from} → {to}」的依赖吗?此操作不可撤销。',
deleteSuccess: '依赖已删除',
deleteError: '删除依赖失败',
+2
View File
@@ -35,6 +35,8 @@ export default {
emptyTasks: '暂无任务',
// 工作流日志面板
workflowLogTitle: '📋 工作流日志',
// A2[PD-P1-3]:事件流无 project_id 维度,无法按项目过滤,标注「全局事件」避免误读为本项目
workflowLogGlobal: '全局事件',
// B-41 工作流实时进度面板
workflowProgressTitle: '📈 工作流实时进度',
runDemoWorkflow: '▶ 运行测试工作流',
+5
View File
@@ -63,7 +63,12 @@ export default {
status: {
planning: '📐 规划中',
active: '🚀 进行中',
in_progress: '🔨 开发中',
testing: '🧪 测试中',
releasing: '🚢 发布中',
completed: '✅ 已完成',
paused: '⏸ 已暂停',
cancelled: '✖ 已取消',
},
// 错误回退(state.error 用户可见)
err: {
+121 -18
View File
@@ -77,14 +77,19 @@
</button>
</nav>
<!-- 文件浏览器 Tab -->
<!-- 文件浏览器 Tab(B5[PD-P2-12]:KeepAlive 保活 overview 不销毁文件 tab
展开目录/选中文件/视图模式状态;首次激活才跑 onMounted,后续激活走 onActivated 复用状态) -->
<section v-if="activeTab === 'files'" class="file-explorer-wrap">
<KeepAlive>
<FileExplorer :project-id="projectId" />
</KeepAlive>
</section>
<!-- 依赖图 Tab -->
<!-- 依赖图 Tab(B5:同样 KeepAlive 保活,避免每次切 tab 重挂载重拉) -->
<section v-else-if="activeTab === 'graph'" class="file-explorer-wrap">
<KeepAlive>
<DependencyGraph :project-id="projectId" />
</KeepAlive>
</section>
<!-- 概览 Tab:两栏(项目信息 + 任务列表),工作流日志移至下方(P1-g+ 问题10 三栏拥挤) -->
@@ -174,8 +179,8 @@
</span>
</div>
<p v-if="m.description" class="module-desc-overview">{{ m.description }}</p>
<div v-if="parseStack(m.stack).length" class="module-stack-overview">
<span class="tech-tag" v-for="tag in parseStack(m.stack)" :key="tag">{{ tag }}</span>
<div v-if="(moduleStack.get(m.id) ?? []).length" class="module-stack-overview">
<span class="tech-tag" v-for="tag in moduleStack.get(m.id)" :key="tag">{{ tag }}</span>
</div>
</div>
</div>
@@ -183,10 +188,10 @@
</div>
<!-- 技术栈 -->
<div class="info-item" v-if="parseStack(currentProject.stack).length">
<div class="info-item" v-if="projectStack.length">
<span class="label">{{ $t('projectDetail.techStackLabel') }}</span>
<div class="info-tags">
<span class="tech-tag" v-for="t in parseStack(currentProject.stack)" :key="t">{{ t }}</span>
<span class="tech-tag" v-for="t in projectStack" :key="t">{{ t }}</span>
</div>
</div>
<div class="info-item">
@@ -203,6 +208,7 @@
P1-g+ 问题10③:长描述折叠(对齐 TaskDetail 长内容),默认折叠截 4 ,展开看全文 -->
<div v-if="currentProject.description" class="description-wrap" :class="{ 'is-collapsed': !descExpanded }">
<span
ref="descEl"
class="value description ai-md"
v-html="renderedDesc"
></span>
@@ -265,7 +271,7 @@
<div
class="score-bar-fill"
:class="'fill-' + scoreTier(dim.score)"
:style="{ width: dim.score + '%' }"
:style="{ width: Math.min(100, Math.max(0, dim.score)) + '%' }"
></div>
</div>
<span class="score-bar-value">{{ dim.score }}</span>
@@ -326,10 +332,13 @@
<WorkflowDagDisplay v-if="wfDagJson" :dag-json="wfDagJson" :node-statuses="wfProgress.nodeStatuses" />
</section>
<!-- 工作流日志:无记录时折叠为摘要(标题 + 计数),有记录才展开列表 -->
<section v-if="formattedEvents.length > 0 || !workflowLogCollapsed" class="panel workflow-log-panel">
<!-- 工作流日志:A2[PD-P1-3]默认折叠为摘要(标题 + 全局标注 + 计数),有记录才展开列表
不设 v-if 条件(否则空事件 + 默认折叠时整面板消失,而非折叠) -->
<section class="panel workflow-log-panel">
<div class="panel-header">
<h2>{{ $t('projectDetail.workflowLogTitle') }}</h2>
<!-- A2[PD-P1-3]:事件流无项目维度,标注全局事件避免误读为本项目日志 -->
<span class="global-event-tag">{{ $t('projectDetail.workflowLogGlobal') }}</span>
<span v-if="formattedEvents.length === 0" class="task-count">{{ $t('projectDetail.emptyWorkflowLog') }}</span>
<button
v-else
@@ -406,11 +415,12 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import { open } from '@tauri-apps/plugin-dialog'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { useProjectStore } from '@/stores/project'
import { projectApi, taskApi, workflowApi } from '@/api'
import { moduleApi, type ProjectModuleRecord } from '@/api/module'
@@ -425,7 +435,7 @@ import DependencyGraph from '@/components/project/DependencyGraph.vue'
import WorkflowDagDisplay from '@/components/workflow/WorkflowDagDisplay.vue'
import { useConfirm } from '@/composables/useConfirm'
import { useRendered } from '@/composables/useMarkdown'
import type { ProjectId, TaskRecord } from '@/api/types'
import type { ProjectId, TaskRecord, DfDataChangedPayload } from '@/api/types'
const route = useRoute()
const router = useRouter()
@@ -434,6 +444,12 @@ const { t, locale } = useI18n()
const logListRef = ref<HTMLElement | null>(null)
const showApprovalDialog = ref(false)
// A1[PD-P1-4]: AI df-data-changed (entity=task) loadProjectTasks
// TaskDetail B-260616-18 :onMounted onBeforeUnmount ;,
// tab ()
let _unlistenDataChanged: UnlistenFn | null = null
let _taskReloadTimer: ReturnType<typeof setTimeout> | null = null
// Tab :(//) vs (Batch 10)
// P1-g+ 10:activeTab localStorage(,)
const TAB_STORAGE_PREFIX = 'df-project-tab-'
@@ -452,14 +468,30 @@ function setActiveTab(tab: DetailTab) {
}
// (P1-g+ 10): 4 ,
// descCollapsible (, 120 )
const DESC_COLLAPSE_THRESHOLD = 120
// A8[PD-P3-16]:( Markdown /, px 线),
// TaskDetail descEl scrollHeight + renderedDesc nextTick
// descCollapsible true()
const DESC_COLLAPSE_PX = 96 // max-height 6em(4 ×14px×1.6)
const descEl = ref<HTMLElement | null>(null)
const descExpanded = ref(false)
const descCollapsible = computed(() => (currentProject.value?.description?.length ?? 0) >= DESC_COLLAPSE_THRESHOLD)
const descOverflow = ref(false)
const descCollapsible = computed(() => descOverflow.value)
async function measureDescHeight() {
// descEl <span>(), max-height scrollHeight ,
// .description-wrap max-height
await nextTick()
const el = descEl.value
if (!el) { descOverflow.value = false; return }
descOverflow.value = el.scrollHeight > DESC_COLLAPSE_PX
}
// (P1-g+ 10):( + emptyWorkflowLog ),
// collapse ,
const workflowLogCollapsed = ref(false)
// A2[PD-P1-3]: project_id ,(workflow-event execution_id+event,
// event task_id/project_id ), + ,
const workflowLogCollapsed = ref(true)
// composables/useConfirm( 4 :Projects/ProjectDetail/Ideas/Settings)
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
@@ -532,10 +564,18 @@ const assessmentLabel = (recommendation: string): string =>
// project_id list_tasks {project_id} , ref
// TaskQuery.project_id idx_tasks_project_id( api/types.ts:191)
const projectTasks = ref<TaskRecord[]>([])
// A4[PD-P2-2]: ( projectId,
// resolve projectId )
let taskReqSeq = 0
async function loadProjectTasks() {
const seq = ++taskReqSeq
const pid = projectId.value
try {
projectTasks.value = await taskApi.list({ project_id: projectId.value })
const list = await taskApi.list({ project_id: pid })
if (seq !== taskReqSeq || pid !== projectId.value) return
projectTasks.value = list
} catch (e) {
if (seq !== taskReqSeq || pid !== projectId.value) return
console.error('加载项目任务失败:', e)
projectTasks.value = []
}
@@ -546,13 +586,24 @@ async function loadProjectTasks() {
// (monorepo/) + ; moduleApi + ConfirmDialog()
// + arco Message() Tab FileExplorer v-if ,
const modules = ref<ProjectModuleRecord[]>([])
// A4[PD-P2-2]: loadProjectTasks ()
let moduleReqSeq = 0
async function loadModules() {
const seq = ++moduleReqSeq
const pid = projectId.value
try {
modules.value = await moduleApi.listProjectModules(projectId.value)
const list = await moduleApi.listProjectModules(pid)
if (seq !== moduleReqSeq || pid !== projectId.value) return
modules.value = list
} catch {
if (seq !== moduleReqSeq || pid !== projectId.value) return
modules.value = []
}
}
// A10[PD-P3-13]:parseStack computed m.stack JSON.parse ,
// moduleStack.get(m.id) ;(/)
const moduleStack = computed(() => new Map(modules.value.map((m) => [m.id, parseStack(m.stack)])))
const projectStack = computed(() => parseStack(currentProject.value?.stack))
/**
* 工程状态归一:老工程 status null 视作 active(默认活跃)
@@ -697,11 +748,19 @@ const latestWfExecId = computed(() => {
const evs = store.liveEvents
return evs.length > 0 ? evs[evs.length - 1].execution_id : null
})
// A2[PD-P1-3]:workflow-event { execution_id, event },event project_id/task_id
//
// ( true), DAG/(,)
const wfProgressEnabled = ref(false)
const wfProgress = computed(() => store.workflowProgress(latestWfExecId.value))
const hasWfProgress = computed(() => Object.keys(wfProgress.value.nodeStatuses).length > 0)
const hasWfProgress = computed(() =>
wfProgressEnabled.value && Object.keys(wfProgress.value.nodeStatuses).length > 0
)
watch(latestWfExecId, async (id) => {
wfDagJson.value = ''
if (!id) return
// A2[PD-P1-3]:() DAG, IPC
if (!wfProgressEnabled.value) return
try {
const record = await workflowApi.getExecution(id)
if (record?.dag_json) wfDagJson.value = record.dag_json
@@ -743,6 +802,9 @@ async function submitNewTask() {
async function handleSync() {
await store.loadProjects()
await loadProjectTasks() // , store.loadTasks
// A9[PD-P3-18]: + ,/
await loadModules()
await store.loadIdeas()
await checkPath()
}
@@ -860,19 +922,50 @@ onMounted(async () => {
await store.loadIdeas()
await store.startEventListener()
await checkPath()
// A8[PD-P3-16]:(,renderedDesc watch )
await measureDescHeight()
// A1[PD-P1-4]:(entity=task )
try {
_unlistenDataChanged = await listen<DfDataChangedPayload>('df-data-changed', (event) => {
if (event.payload.entity !== 'task') return
// tab (;/)
if (activeTab.value !== 'overview') return
if (_taskReloadTimer) clearTimeout(_taskReloadTimer)
_taskReloadTimer = setTimeout(() => { void loadProjectTasks() }, 200)
})
} catch (e) {
console.error('[ProjectDetail] 启动 df-data-changed 监听失败:', e)
}
})
// (URL /projects/:id ), + Tab +
watch(projectId, async (newId) => {
if (!newId) return
activeTab.value = readStoredTab(newId)
// A5[PD-P2-5]: +
descExpanded.value = false
workflowLogCollapsed.value = true
await loadProjectTasks()
await loadModules()
})
// A3[PD-P2-1]: FileExplorer tab ,
watch(activeTab, (tab) => {
if (tab === 'overview') void loadModules()
})
// A8[PD-P3-16]:Markdown /
watch(renderedDesc, () => { void measureDescHeight() })
// ()
watch(() => currentProject.value?.path, () => { checkPath() })
onBeforeUnmount(() => {
// A1[PD-P1-4]: df-data-changed + ()
if (_taskReloadTimer) { clearTimeout(_taskReloadTimer); _taskReloadTimer = null }
if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null }
})
onUnmounted(() => {
// store.stopEventListener ( unlisten()
// _eventUnlisten, startEventListener ,)
@@ -1259,6 +1352,16 @@ onUnmounted(() => {
此处仅保留本组件特有 .task-count */
.task-count { font-size: 12px; color: var(--df-text-dim); }
/* A2[PD-P1-3]:工作流日志「全局事件」标注(次要小字标签) */
.global-event-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 8px;
color: var(--df-text-dim);
background: rgba(136, 136, 136, 0.12);
flex-shrink: 0;
}
/* ===== 任务卡片 ===== */
.task-list { display: flex; flex-direction: column; }
.task-card {