agent-Specialization/static/src/components/chat/ShowFileCard.vue
JOJO e4a663594d feat: Android App 原生 PDF 预览与前端 show_file 卡片
- 集成 AndroidPdfViewer (mhiew fork) 实现不依赖浏览器的 PDF 预览
- 新增 PdfPreviewActivity 与 JS Bridge (AndroidPdfBridge)
- ShowFileCard 在 Android App 内对 PDF 显示「预览」按钮,不内嵌 iframe
- 新增 /api/file/content 后端接口用于文件内容 inline 预览
- 前端支持 <show_file> 标签与 download:// 链接渲染
- 更新 Android 版本号至 1.0.35
2026-06-24 00:06:10 +08:00

498 lines
13 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="canAndroidPdfPreview"
type="button"
class="sfc-btn sfc-btn-preview"
@click="previewPdf"
>
预览
</button>
<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 isAndroidApp = typeof window !== 'undefined' && Boolean((window as any).AndroidPdfBridge);
// ---- 状态 ----
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;
// Android App 内 PDF 不走 iframe 内嵌预览,由原生 PdfPreviewActivity 处理
if (isAndroidApp && fileType.value === 'pdf') return false;
return ['text', 'code', 'json', 'csv', 'markdown', 'image', 'pdf'].includes(fileType.value);
});
const canAndroidPdfPreview = computed(() => {
return isAndroidApp && fileType.value === 'pdf';
});
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';
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 previewPdf() {
const bridge = (window as any).AndroidPdfBridge;
if (bridge && typeof bridge.previewPdf === 'function') {
bridge.previewPdf(contentUrl.value);
}
}
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>