- 集成 AndroidPdfViewer (mhiew fork) 实现不依赖浏览器的 PDF 预览 - 新增 PdfPreviewActivity 与 JS Bridge (AndroidPdfBridge) - ShowFileCard 在 Android App 内对 PDF 显示「预览」按钮,不内嵌 iframe - 新增 /api/file/content 后端接口用于文件内容 inline 预览 - 前端支持 <show_file> 标签与 download:// 链接渲染 - 更新 Android 版本号至 1.0.35
498 lines
13 KiB
Vue
498 lines
13 KiB
Vue
<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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
});
|
||
|
||
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>
|