FilePreviewModal.vue 8.58 KB
<!--
  产物文件内容预览(R-19 输入输出 tab 的配套)

  为什么需要它:「输入输出」tab 把产物的**绝对路径**摆出来了,但那是服务器本地路径 ——
  在浏览器里既点不开也拷不进 IDE 就能用,走查时只能靠肉眼确认「路径长得对」,
  而真正要确认的是「这份 brd.md 里到底写了什么、下游读到的是不是它」。

  后端复用现成的 `GET /api/company/:cid/files/content?path=<相对公司根>`
  (没有新造端点;`graph_files.rel_path` 本来就是相对公司根的,正好对上)。
  该端点有 512KB 上限,超了会回 `truncated: true`,这里如实显示而不是装作读全了。

  渲染分三档,刻意不做语法高亮之外的加工:
    .md    → marked(已是既有依赖,不新增包)
    .json  → JSON.parse 后重新缩进;**解析失败时退回原文**并说明 ——
             产物 json 写坏了本身就是要看的信息,替它藏起来等于删掉线索
    其它   → 原文 <pre>
-->
<template>
  <div v-if="open" class="mask" @click.self="close">
    <div class="modal" role="dialog" aria-modal="true" :aria-label="`预览 ${fileName}`">
      <header class="head">
        <div class="titles">
          <strong class="name">{{ fileName }}</strong>
          <span v-if="kind" class="kind">{{ kind }}</span>
        </div>
        <div class="acts">
          <button class="mini" :disabled="!content" @click="copyPath">
            {{ copied === 'path' ? '已复制' : '复制路径' }}
          </button>
          <button class="mini" :disabled="!content" @click="copyBody">
            {{ copied === 'body' ? '已复制' : '复制内容' }}
          </button>
          <button class="mini close" aria-label="关闭" @click="close">✕</button>
        </div>
      </header>

      <p class="fullpath mono">{{ absPath }}</p>

      <div class="body">
        <p v-if="loading" class="muted">读取中…</p>

        <div v-else-if="error" class="err">
          <p><b>读不出来:</b>{{ error }}</p>
          <p class="muted small">
            这个端点的根目录是 <code>data/companies/{{ companyId }}</code>,只认相对它的路径。
            索引里登记的 <code>rel_path</code> 正是这种形态;若这里报 404,
            说明文件已被移动或删除,而索引行还在(<code>graph_files</code> 不跟随文件系统变化)。
          </p>
        </div>

        <template v-else>
          <p v-if="truncated" class="warn small">
            ⚠️ 文件超过 512KB,后端未返回全文——下面显示的不是完整内容。
          </p>
          <div v-if="mode === 'md'" class="md" v-html="rendered"></div>
          <pre v-else class="code">{{ rendered }}</pre>
          <p v-if="mode === 'json-raw'" class="warn small">
            ⚠️ 这份 <code>.json</code> 解析失败,上面是**原文**(没有替它格式化)。
            产物 json 写坏了本身就是要看的信息。
          </p>
        </template>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue';
import { marked } from 'marked';

const props = defineProps({
  open: { type: Boolean, default: false },
  companyId: { type: String, default: '' },
  relPath: { type: String, default: '' },
  absPath: { type: String, default: '' },
  kind: { type: String, default: '' },
});
const emit = defineEmits(['close']);

const loading = ref(false);
const error = ref('');
const content = ref('');
const truncated = ref(false);
const copied = ref('');

const fileName = computed(() => String(props.relPath || props.absPath).split('/').pop() || '文件');

const mode = computed(() => {
  const n = fileName.value.toLowerCase();
  if (n.endsWith('.md') || n.endsWith('.markdown')) return 'md';
  if (n.endsWith('.json')) return jsonOk.value ? 'json' : 'json-raw';
  return 'text';
});

const jsonOk = ref(true);

const rendered = computed(() => {
  if (!content.value) return '';
  if (mode.value === 'md') return marked.parse(content.value);
  if (mode.value === 'json') {
    try {
      return JSON.stringify(JSON.parse(content.value), null, 2);
    } catch {
      return content.value;
    }
  }
  return content.value;
});

function close() {
  emit('close');
}

async function load() {
  loading.value = true;
  error.value = '';
  content.value = '';
  truncated.value = false;
  jsonOk.value = true;
  copied.value = '';
  try {
    const q = encodeURIComponent(props.relPath);
    const r = await fetch(`/api/company/${props.companyId}/files/content?path=${q}`);
    const d = await r.json().catch(() => ({}));
    if (!r.ok) {
      error.value = d.error || `HTTP ${r.status}`;
      return;
    }
    content.value = d.content ?? '';
    truncated.value = Boolean(d.truncated);
    if (fileName.value.toLowerCase().endsWith('.json')) {
      try { JSON.parse(content.value); } catch { jsonOk.value = false; }
    }
  } catch (e) {
    error.value = e.message;
  } finally {
    loading.value = false;
  }
}

async function copy(text, tag) {
  try {
    await navigator.clipboard.writeText(text);
    copied.value = tag;
    setTimeout(() => { if (copied.value === tag) copied.value = ''; }, 1500);
  } catch {
    // 剪贴板被拒(非 https / 无权限)时不假装成功
    copied.value = '';
  }
}
const copyPath = () => copy(props.absPath || props.relPath, 'path');
const copyBody = () => copy(content.value, 'body');

// 打开时才拉;换文件也要重拉
watch(
  () => [props.open, props.relPath],
  ([isOpen, rel]) => { if (isOpen && rel) load(); },
  { immediate: true }
);

// Esc 关闭:弹框存在期间才挂监听
watch(() => props.open, (isOpen) => {
  const onKey = (e) => { if (e.key === 'Escape') close(); };
  if (isOpen) window.addEventListener('keydown', onKey, { once: true });
});
</script>

<style scoped>
.mask {
  position: fixed; inset: 0; z-index: 60;
  background: rgba(0, 0, 0, .42);
  display: flex; align-items: center; justify-content: center; padding: 24px;
}
/* 底色/文字色**硬编码浅色**,与整个控制台一致(GraphConsole.vue 的
   `.console { background:#fff; color:#1a1a1a }`,NodeDetailPanel 里 CSS 变量用了 0 次)。
   第一版写的是 var(--bg-surface, #fff) —— 那个变量不是「不透明表面」,
   它是 rgba(...,0.02) 的**叠加色调**(App.vue 里两套主题都是 2%),
   于是弹框背景几乎全透、把身后 42% 的遮罩透出来,文字还跟着主题走成近白色。
   fallback #fff 永远轮不到(变量确实存在,只是语义不是我以为的那个)。 */
.modal {
  background: #fff; color: #1a1a1a;
  border: 1px solid #e2e2e6; border-radius: 10px;
  width: min(900px, 100%); max-height: 84vh; display: flex; flex-direction: column;
  box-shadow: 0 12px 40px rgba(0, 0, 0, .28);
}
.head {
  display: flex; align-items: center; gap: 12px; padding: 12px 14px 10px;
  border-bottom: 1px solid #eee;
}
.titles { display: flex; align-items: center; gap: 8px; min-width: 0; }
.name { font-size: 14px; word-break: break-all; }
.kind {
  font-family: ui-monospace, Menlo, monospace; font-size: 11px; font-weight: 700;
  background: #eaf0ff; color: #1d4ed8; border-radius: 4px; padding: 1px 6px;
}
.acts { margin-left: auto; display: flex; gap: 6px; flex-shrink: 0; }
.mini {
  font-size: 12px; padding: 3px 9px; cursor: pointer;
  border: 1px solid #ddd; border-radius: 5px;
  background: #fafafa; color: #1a1a1a;
}
.mini:hover:not(:disabled) { background: #f0f0f2; }
.mini:disabled { opacity: .5; cursor: not-allowed; }
.mini.close { font-size: 13px; padding: 3px 8px; }
.fullpath {
  margin: 8px 14px 0; padding: 5px 8px; font-size: 11.5px;
  background: #f7f7f9; border-radius: 4px; word-break: break-all; color: #555;
}
.body { padding: 12px 14px 16px; overflow: auto; }
.code {
  font-family: ui-monospace, Menlo, monospace; font-size: 12.5px; line-height: 1.6;
  background: #f7f7f9; border-radius: 6px; padding: 12px; margin: 0;
  white-space: pre-wrap; word-break: break-word;
}
.md { font-size: 14px; line-height: 1.7; }
.md :deep(h1) { font-size: 20px; }
.md :deep(h2) { font-size: 17px; }
.md :deep(h3) { font-size: 15px; }
.md :deep(pre) {
  background: #f7f7f9; border-radius: 6px; padding: 10px; overflow-x: auto;
  font-size: 12.5px;
}
.md :deep(code) { font-family: ui-monospace, Menlo, monospace; font-size: .9em; }
.md :deep(table) { border-collapse: collapse; }
.md :deep(th), .md :deep(td) { border: 1px solid #e2e2e6; padding: 4px 8px; }
.mono { font-family: ui-monospace, Menlo, monospace; }
.muted { color: #888; }
.small { font-size: 12px; }
.warn { color: #b3480c; }
.err { color: #c0392b; font-size: 13px; }
.err .muted { color: #888; }
</style>