TaskFilesPanel.vue
10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
<!--
任务级产物汇总(用户反馈:逐个节点点「输入输出」tab 才能看产物,控制台缺一个全局入口)。
两类数据源,**故意分成两节,不合并**(挖过一次才知道混了会看不见真东西):
① 「输入输出」tab 的「输出」小节同一份数据(graph_files,按 company+task 检索,
只回当前有效行)——这是各节点私有目录下的规划笔记/日志/契约快照,不是最终交付物。
② 项目根(W2「每个 root run 一个项目根」,graph-engine.js ensureProjectRoot)——
这个任务的代码真正写的地方,跨节点共享,图① 完全不覆盖它(indexNodeOutputs 只扫
节点自己的 output_ref 目录)。第一版做完这个面板后才在真实回归任务里发现:
没有它,用户问「最终结果在哪」时这个面板会一直说「没有」,而真实网站文件其实
好好地躺在 `projects/{pid}/` 下,只是从没被 graph_files 索引过。
预览统一走 FilePreviewModal,不新造渲染逻辑;项目根的文件树复用已有的
`GET /api/company/:cid/files`(公司级统一文件树,此前零调用方),不新开列目录端点。
-->
<template>
<button class="trigger" :disabled="!files && !projectRoot" @click="onOpen">
📄 产物{{ files ? `(${files.length})` : '' }}
</button>
<div v-if="open" class="mask" @click.self="close">
<div class="modal" role="dialog" aria-modal="true" aria-label="任务产物汇总">
<header class="head">
<strong>任务产物汇总</strong>
<button class="mini close" aria-label="关闭" @click="close">✕</button>
</header>
<div class="scroll">
<!-- 项目根:真实交付物落点,放最上面——这通常才是用户问「结果在哪」真正要看的东西 -->
<template v-if="projectRoot">
<h4>最终交付物 · 项目根</h4>
<p class="muted small">
这个任务的代码写在这里(跨节点共享,与下面「节点产出索引」不是同一批文件)。
</p>
<p class="mono path root">{{ projectRoot.root_path }}</p>
<p v-if="projLoading" class="muted small">读取文件树中…</p>
<p v-else-if="projErr" class="muted small">读不出来:{{ projErr }}</p>
<ul v-else-if="projFiles.length" class="list">
<li v-for="f in projFiles" :key="f.path">
<div class="row">
<span class="from" :title="f.path">{{ f.path }}</span>
<span class="ts">{{ fmtSize(f.size) }}</span>
</div>
<button class="mono path as-link" :title="`预览 ${f.path}`" @click="previewProjectFile(f)">
{{ projectRoot.root_path }}/{{ f.path }}
</button>
</li>
</ul>
<p v-else class="muted small">项目根已分配,但目录里还没有文件。</p>
</template>
<h4>节点产出索引</h4>
<p class="muted small">跨这个任务下的全部 run/节点,只列当前有效版本;规划笔记/日志/契约快照这类,不是最终交付物。</p>
<div v-if="!files" class="empty">
<p>产物索引未随响应下发(后端还没返回 <code>taskFiles</code>),这一块整体降级——不猜。</p>
</div>
<div v-else-if="!files.length" class="empty">
<p>这个任务下还没有任何节点产出并登记进索引的文件。</p>
</div>
<ul v-else class="list">
<li v-for="f in sorted" :key="f.id || f.relPath">
<div class="row">
<span class="kind">{{ f.kind }}</span>
<span class="from" :title="f.relPath">{{ shortLabel(f.relPath) }}</span>
<span class="ts">{{ f.createdAt }}</span>
</div>
<button class="mono path as-link" :title="`预览 ${f.relPath}`" @click="preview(f)">
{{ f.relPath }}
</button>
</li>
</ul>
</div>
</div>
</div>
<FilePreviewModal
:open="pv.open"
:company-id="companyId"
:rel-path="pv.relPath"
:abs-path="pv.absPath"
:kind="pv.kind"
@close="pv.open = false"
/>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue';
import FilePreviewModal from './FilePreviewModal.vue';
import { normFile } from './io-model';
const props = defineProps({
// 与 NodeDetailPanel 的 `files` prop 同一份数据(GraphConsole 从 rootState/midState 转发)。
// null = 后端没吐这个字段(整块降级);[] = 吐了,但这个任务确实还没有产物。
files: { type: Array, default: null },
companyRoot: { type: String, default: null },
companyId: { type: String, default: '' },
// { pid, root_path, mode } | null —— graph-engine.readProjectRootRecord 的原样透传
projectRoot: { type: Object, default: null },
});
const open = ref(false);
function close() { open.value = false; }
function onOpen() {
open.value = true;
if (props.projectRoot) loadProjectTree();
}
// 与 NodeDetailPanel 的口径一致:snake_case/camelCase 都接得住
const norm = computed(() => (props.files || []).map(normFile));
// 最新产出的排前面——「最终结果」通常就是这个任务里最后落盘的那几份
const sorted = computed(() => [...norm.value].sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))));
// rel_path 形如 `runs/{runId}/nodes/{nodeRunId}/xxx` 或 `tasks/{taskId}/xxx`——
// 截掉后端目录结构里跟用户无关的那一段,只留「谁产的 / 文件名」这一句话
function shortLabel(relPath) {
const parts = String(relPath || '').split('/');
const file = parts.pop();
const nodeIdx = parts.indexOf('nodes');
if (nodeIdx >= 0 && parts[nodeIdx + 1]) return `${parts[nodeIdx + 1]} / ${file}`;
if (parts[0] === 'tasks') return `任务级共享 / ${file}`;
return file;
}
const pv = reactive({ open: false, relPath: '', absPath: '', kind: '' });
function preview(f) {
if (!f.relPath) return;
pv.relPath = f.relPath;
pv.absPath = props.companyRoot ? `${props.companyRoot}/${f.relPath}` : f.relPath;
pv.kind = f.kind || '';
pv.open = true;
}
// ── 项目根:文件树是懒加载的(只在面板打开且确实有 projectRoot 时才拉,
// 公司文件树可能很大,不该跟着 taskFiles 一起在后台轮询里拉)────────────
const projFiles = ref([]);
const projLoading = ref(false);
const projErr = ref('');
/** 把 `GET /:companyId/files` 的树拍平成「path 以 projects/{pid}/ 开头」的文件列表 */
function flattenUnderProject(tree, pid) {
const prefix = `projects/${pid}/`;
const out = [];
function walk(nodes) {
for (const n of nodes || []) {
if (n.type === 'file' && n.path.startsWith(prefix)) {
out.push({ path: n.path.slice(prefix.length), size: n.size, modifiedAt: n.modifiedAt });
} else if (n.type === 'directory') {
walk(n.children);
}
}
}
walk(tree);
return out.sort((a, b) => a.path.localeCompare(b.path));
}
async function loadProjectTree() {
if (!props.projectRoot || !props.companyId) return;
projLoading.value = true;
projErr.value = '';
try {
const r = await fetch(`/api/company/${props.companyId}/files`);
const j = await r.json().catch(() => ({}));
if (!r.ok) { projErr.value = j.error || `HTTP ${r.status}`; return; }
projFiles.value = flattenUnderProject(j.tree || [], props.projectRoot.pid);
} catch (e) {
projErr.value = e.message;
} finally {
projLoading.value = false;
}
}
// 换了任务(projectRoot.pid 变了)且面板还开着时,跟着重新拉一次
watch(() => props.projectRoot && props.projectRoot.pid, () => { if (open.value) loadProjectTree(); });
function previewProjectFile(f) {
pv.relPath = `projects/${props.projectRoot.pid}/${f.path}`;
pv.absPath = `${props.projectRoot.root_path}/${f.path}`;
pv.kind = 'artifact';
pv.open = true;
}
function fmtSize(n) {
if (n == null) return '';
if (n < 1024) return `${n}B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
return `${(n / 1024 / 1024).toFixed(1)}MB`;
}
</script>
<style scoped>
.trigger {
border: 1px solid #ddd; background: #fff; border-radius: 5px; cursor: pointer;
font-size: 12px; padding: 4px 10px; color: #333;
}
.trigger:hover:not(:disabled) { border-color: #999; }
.trigger:disabled { opacity: .4; cursor: not-allowed; }
.mask {
position: fixed; inset: 0; z-index: 55;
background: rgba(0, 0, 0, .42);
display: flex; align-items: center; justify-content: center; padding: 24px;
}
.modal {
background: #fff; color: #1a1a1a;
border: 1px solid #e2e2e6; border-radius: 10px;
width: min(640px, 100%); max-height: 80vh; display: flex; flex-direction: column;
box-shadow: 0 12px 40px rgba(0, 0, 0, .28);
}
.head { display: flex; align-items: center; gap: 10px; padding: 12px 14px 10px; border-bottom: 1px solid #eee; }
.mini.close {
margin-left: auto; font-size: 13px; padding: 3px 8px; cursor: pointer;
border: 1px solid #ddd; border-radius: 5px; background: #fafafa; color: #1a1a1a;
}
.mini.close:hover { background: #f0f0f2; }
.empty { padding: 6px 0; color: #888; font-size: 13px; }
.empty code { background: #f2f2f4; padding: 1px 5px; border-radius: 3px; }
.scroll { overflow: auto; padding: 10px 14px 14px; flex: 1; min-height: 0; }
h4 { font-size: 12.5px; margin: 14px 0 4px; color: #1a1a1a; }
h4:first-child { margin-top: 0; }
.path.root { background: #f7f7f9; border-radius: 4px; padding: 5px 8px; margin: 4px 0 10px; color: #444; word-break: break-all; cursor: default; }
.list { list-style: none; margin: 0; padding: 0; }
.list li { padding: 8px 0; border-bottom: 1px solid #f2f2f4; }
.list li:last-child { border-bottom: none; }
.row { display: flex; align-items: center; gap: 8px; font-size: 12px; margin-bottom: 3px; }
.kind {
font-family: ui-monospace, Menlo, monospace; font-size: 11px; font-weight: 700;
background: #eaf0ff; color: #1d4ed8; border-radius: 4px; padding: 1px 6px; flex-shrink: 0;
}
.from { color: #555; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ts { margin-left: auto; color: #999; font-size: 10.5px; flex-shrink: 0; }
.path {
display: block; width: 100%; text-align: left; background: none; border: none;
font-size: 11px; color: #2b6cb0; cursor: pointer; padding: 0; word-break: break-all;
}
.path:hover { text-decoration: underline; }
.mono { font-family: ui-monospace, Menlo, monospace; }
.muted { color: #888; }
.small { font-size: 11px; }
</style>