FilePreviewModal.vue
8.08 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
<!--
产物文件内容预览(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;
}
.modal {
background: var(--bg-surface, #fff); color: var(--text-primary, #1a1a1a);
border: 1px solid var(--border-primary, #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, .18);
}
.head {
display: flex; align-items: center; gap: 12px; padding: 12px 14px 10px;
border-bottom: 1px solid var(--border-primary, #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 var(--border-primary, #ddd); border-radius: 5px;
background: var(--bg-overlay, #fafafa); color: inherit;
}
.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>