agent-Specialization/static/src/components/chat/ShowFileCard.vue

486 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="show-file-card">
<div class="sfc-header">
<span class="sfc-file-icon" aria-hidden="true"></span>
<span class="sfc-name" :title="displayName">{{ displayName }}</span>
<div class="sfc-actions">
<button
v-if="canCopy"
type="button"
class="sfc-btn"
@click="copyContent"
>
{{ copied ? '已复制' : '复制' }}
</button>
<button
type="button"
class="sfc-btn sfc-btn-download"
@click="handleDownload"
>
下载
</button>
</div>
</div>
<div class="sfc-preview" v-if="canPreview">
<div class="sfc-loading" v-if="loading">
<span class="sfc-spinner"></span>
<span>加载中...</span>
</div>
<div class="sfc-error" v-else-if="error">{{ error }}</div>
<template v-else-if="fileType === 'text' || fileType === 'code' || fileType === 'json'">
<pre class="sfc-code" v-html="highlightedCode"></pre>
</template>
<template v-else-if="fileType === 'csv'">
<div class="sfc-csv-scroll">
<table class="sfc-csv-table">
<thead>
<tr>
<th v-for="(col, i) in csvHeader" :key="i">{{ col }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, ri) in csvRows" :key="ri">
<td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
<div class="sfc-csv-meta" v-if="csvTruncated">
仅显示前 {{ csvRows.length }} 完整内容请下载查看
</div>
</template>
<template v-else-if="fileType === 'markdown'">
<div class="sfc-md" v-html="renderedMarkdown"></div>
</template>
<template v-else-if="fileType === 'image'">
<img
class="sfc-image"
:src="contentUrl"
:alt="displayName"
@click="openFullImage"
@error="onImageError"
/>
</template>
<template v-else-if="fileType === 'pdf'">
<iframe
class="sfc-pdf"
:src="contentUrl"
:title="displayName"
></iframe>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import Prism from 'prismjs';
import { renderMarkdown } from '../../composables/useMarkdownRenderer';
defineOptions({ name: 'ShowFileCard' });
const props = defineProps<{
path: string;
name?: string;
type?: string;
description?: string;
preview?: string;
}>();
// ---- 状态 ----
const loading = ref(false);
const error = ref('');
const rawContent = ref('');
const copied = ref(false);
let objectUrl: string | null = null;
const contentUrl = ref('');
// ---- 计算属性 ----
const fileType = computed(() => props.type || inferShowFileType(props.path));
const displayName = computed(() => props.name || props.path.split('/').pop() || 'file');
const canPreview = computed(() => {
if (props.preview === 'off') return false;
return ['text', 'code', 'json', 'csv', 'markdown', 'image', 'pdf'].includes(fileType.value);
});
const canCopy = computed(() => ['text', 'code', 'json', 'csv', 'markdown'].includes(fileType.value));
const metaText = computed(() => {
if (!rawContent.value) return '';
const sizeKB = (new Blob([rawContent.value]).size / 1024).toFixed(1);
return `${sizeKB} KB`;
});
const highlightedCode = computed(() => {
if (!rawContent.value) return '';
let content = rawContent.value;
if (fileType.value === 'json') {
try { content = JSON.stringify(JSON.parse(content), null, 2); } catch {}
}
try {
const grammar = Prism.languages[displayLang.value];
if (grammar) return Prism.highlight(content, grammar, displayLang.value);
} catch {}
return content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
});
const renderedMarkdown = computed(() => {
if (!rawContent.value) return '';
return renderMarkdown(rawContent.value, false);
});
const csvParsed = computed(() => {
if (fileType.value !== 'csv' || !rawContent.value) return null;
const lines = rawContent.value.split(/\r?\n/).filter(l => l.trim());
const parseLine = (line: string): string[] => {
const result: string[] = [];
let current = '';
let inQuote = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuote) {
if (ch === '"' && line[i + 1] === '"') { current += '"'; i++; }
else if (ch === '"') inQuote = false;
else current += ch;
} else {
if (ch === '"') inQuote = true;
else if (ch === ',') { result.push(current); current = ''; }
else current += ch;
}
}
result.push(current);
return result;
};
const maxRows = 100;
const header = parseLine(lines[0] || '');
const rows = lines.slice(1, maxRows + 1).map(parseLine);
return { header, rows, truncated: lines.length > maxRows + 1 };
});
const csvHeader = computed(() => csvParsed.value?.header || []);
const csvRows = computed(() => csvParsed.value?.rows || []);
const csvTruncated = computed(() => csvParsed.value?.truncated || false);
// ---- 方法 ----
function inferShowFileType(path: string): string {
const extMap: Record<string, string> = {
'.txt': 'text',
'.md': 'markdown', '.markdown': 'markdown',
'.json': 'json', '.json5': 'json',
'.csv': 'csv', '.tsv': 'csv',
'.yaml': 'text', '.yml': 'text', '.toml': 'text',
'.ini': 'text', '.cfg': 'text', '.conf': 'text', '.log': 'text',
'.js': 'code', '.ts': 'code', '.jsx': 'code', '.tsx': 'code',
'.vue': 'code', '.py': 'code', '.rb': 'code', '.go': 'code',
'.rs': 'code', '.java': 'code', '.c': 'code', '.h': 'code',
'.cpp': 'code', '.hpp': 'code', '.cs': 'code', '.php': 'code',
'.swift': 'code', '.kt': 'code', '.sh': 'code', '.bash': 'code',
'.html': 'code', '.htm': 'code', '.css': 'code', '.scss': 'code',
'.less': 'code', '.xml': 'code', '.svg': 'image', '.sql': 'code',
'.png': 'image', '.jpg': 'image', '.jpeg': 'image',
'.gif': 'image', '.webp': 'image', '.bmp': 'image', '.ico': 'image',
'.avif': 'image',
'.pdf': 'pdf'
};
const lowerPath = path.toLowerCase();
const dotIdx = lowerPath.lastIndexOf('.');
if (dotIdx >= 0) {
const ext = lowerPath.slice(dotIdx);
if (extMap[ext]) return extMap[ext];
}
return 'binary';
}
function buildContentUrl() {
return `/api/file/content?path=${encodeURIComponent(props.path)}`;
}
async function loadContent() {
loading.value = true;
error.value = '';
try {
if (['image', 'pdf'].includes(fileType.value)) {
contentUrl.value = buildContentUrl();
} else {
const resp = await fetch(buildContentUrl());
if (!resp.ok) {
let msg = resp.statusText;
try { const j = await resp.json(); msg = j.error || msg; } catch {}
error.value = msg || '加载失败';
return;
}
rawContent.value = await resp.text();
}
} catch (e) {
error.value = (e as Error).message || '网络错误';
} finally {
loading.value = false;
}
}
async function handleDownload() {
const url = `/api/download/file?path=${encodeURIComponent(props.path)}`;
const name = props.path.split('/').pop() || 'file';
// Android App 内通过原生桥接下载WebView 的 a.download 通常无法触发系统下载器
const androidBridge = (window as any).AndroidDownloadBridge;
if (androidBridge && typeof androidBridge.downloadFile === 'function') {
try {
androidBridge.downloadFile(url, name);
return;
} catch (e) {
console.warn('[ShowFileCard] Android 桥接下载失败,回退:', e);
}
}
try {
const resp = await fetch(url);
if (!resp.ok) {
let msg = resp.statusText;
try { const j = await resp.json(); msg = j.error || msg; } catch {}
throw new Error(msg || '下载失败');
}
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
} catch (e) {
console.warn('[ShowFileCard] 下载失败:', e);
window.open(url, '_blank');
}
}
async function copyContent() {
if (!rawContent.value) return;
try {
await navigator.clipboard.writeText(rawContent.value);
copied.value = true;
setTimeout(() => { copied.value = false; }, 2000);
} catch {}
}
function openFullImage() {
if (contentUrl.value) window.open(contentUrl.value, '_blank');
}
function onImageError() {
error.value = '图片加载失败';
}
onBeforeUnmount(() => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
});
onMounted(() => {
if (canPreview.value) {
loadContent();
}
});
</script>
<style lang="scss" scoped>
.show-file-card {
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-base);
overflow: hidden;
margin: 8px 0;
font-size: 13px;
}
.sfc-header {
min-height: 44px;
background: var(--surface-raised);
padding: 0 12px 0 18px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-default);
gap: 12px;
}
.sfc-file-icon {
flex-shrink: 0;
width: 16px;
height: 16px;
background-color: var(--text-secondary);
-webkit-mask: url('/static/icons/file-down.svg') no-repeat center / contain;
mask: url('/static/icons/file-down.svg') no-repeat center / contain;
}
.sfc-name {
flex: 1;
min-width: 0;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sfc-actions {
flex-shrink: 0;
display: flex;
gap: 6px;
}
.sfc-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
padding: 0 10px;
border: 1px solid var(--border-default);
border-radius: 6px;
background: var(--surface-base);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
&:hover {
background: var(--surface-muted);
border-color: var(--border-strong);
}
}
.sfc-btn-download {
color: var(--accent-primary);
}
.sfc-btn-preview {
color: var(--accent-primary);
}
.sfc-preview {
border-top: 1px solid var(--border-default);
max-height: 360px;
overflow: auto;
position: relative;
background: var(--surface-base);
scrollbar-width: thin;
scrollbar-color: var(--text-muted) transparent;
}
.sfc-preview::-webkit-scrollbar {
width: 8px;
}
.sfc-preview::-webkit-scrollbar-track {
background: transparent;
}
.sfc-preview::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 8px;
}
.sfc-loading,
.sfc-error {
display: flex;
align-items: center;
gap: 8px;
padding: 20px;
color: var(--text-tertiary);
justify-content: center;
font-size: 12px;
}
.sfc-error {
color: var(--state-error);
}
.sfc-spinner {
width: 14px;
height: 14px;
border: 2px solid var(--border-default);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: sfc-spin 0.8s linear infinite;
}
@keyframes sfc-spin {
to { transform: rotate(360deg); }
}
.sfc-code {
margin: 0;
padding: 12px;
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
color: var(--text-primary);
background: var(--surface-base);
}
.sfc-csv-scroll {
overflow: auto;
max-height: 350px;
}
.sfc-csv-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.sfc-csv-table th,
.sfc-csv-table td {
padding: 6px 10px;
border: 1px solid var(--border-soft);
text-align: left;
white-space: nowrap;
}
.sfc-csv-table th {
background: var(--surface-soft);
font-weight: 600;
position: sticky;
top: 0;
}
.sfc-csv-meta {
padding: 6px 12px;
font-size: 11px;
color: var(--text-tertiary);
text-align: center;
border-top: 1px solid var(--border-soft);
}
.sfc-md {
padding: 12px;
max-height: 400px;
overflow: auto;
}
.sfc-image {
display: block;
max-width: 100%;
max-height: 400px;
margin: 0 auto;
cursor: zoom-in;
padding: 12px;
}
.sfc-pdf {
width: 100%;
height: 400px;
border: none;
display: block;
}
</style>