TaskFilesPanel.vue 10.4 KB
<!--
  任务级产物汇总(用户反馈:逐个节点点「输入输出」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>