Files
llm-in-text/src/components/FileContent.vue

727 lines
19 KiB
Vue
Raw Normal View History

<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import MarkdownIt from 'markdown-it'
import { isOfficeFile, getOfficeFormat } from '../services/officeDetection'
import UniverPreview from './UniverPreview.vue'
const props = defineProps({
node: { type: Object, default: null },
breadcrumb: { type: Array, default: () => [] },
rootNodes: { type: Array, default: () => [] },
getFileIcon: { type: Function, default: () => 'file' },
getFileBlob: { type: Function, default: () => null }
})
const emit = defineEmits(['navigate'])
const md = new MarkdownIt({
html: false,
breaks: true,
linkify: true
})
const objectUrl = ref('')
const isRoot = computed(() => !props.node)
const isFolder = computed(() => props.node?.type === 'folder')
const fileExt = computed(() => {
if (!props.node || props.node.type !== 'file') return ''
const parts = props.node.name.split('.')
return parts.length > 1 ? parts.pop().toLowerCase() : ''
})
const isMarkdown = computed(() => ['md', 'markdown'].includes(fileExt.value))
const isImage = computed(() => {
const mime = String(props.node?.mimeType || '')
return mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(fileExt.value)
})
const isPdf = computed(() => {
const mime = String(props.node?.mimeType || '')
return mime === 'application/pdf' || fileExt.value === 'pdf'
})
const isText = computed(() => {
const mime = String(props.node?.mimeType || '')
return Boolean(props.node?.content || props.node?.previewText) || mime.startsWith('text/') || mime.includes('json') || ['txt', 'json', 'js', 'jsx', 'ts', 'tsx', 'css', 'html', 'htm', 'py', 'vue', 'xml', 'yaml', 'yml', 'csv', 'log', 'sql', 'toml', 'ini', 'cfg', 'conf', 'sh', 'bat', 'ps1', 'java', 'c', 'cpp', 'h', 'hpp', 'go', 'rs'].includes(fileExt.value)
})
const isOffice = computed(() => {
if (!props.node || props.node.type !== 'file') return false
if (!props.node.name) return false
return isOfficeFile({ name: props.node.name, type: props.node.mimeType })
})
const officeFormat = computed(() => {
if (!isOffice.value) return null
if (!props.node?.name) return null
return getOfficeFormat({ name: props.node.name, type: props.node.mimeType })
})
const folderItems = computed(() => {
if (!isRoot.value && !isFolder.value) return []
return isRoot.value ? props.rootNodes : props.node.children || []
})
const fileBlob = computed(() => props.getFileBlob(props.node))
const previewText = computed(() => props.node?.content || props.node?.previewText || '')
const lineCount = computed(() => {
if (!previewText.value) return 0
return previewText.value.split('\n').length
})
const renderedMarkdown = computed(() => md.render(previewText.value || ''))
const fileSizeLabel = computed(() => formatBytes(props.node?.size || fileBlob.value?.size || 0))
const locLabel = computed(() => {
const count = lineCount.value
return count ? `${count}` : '二进制文件'
})
watch(
fileBlob,
(blob) => {
if (objectUrl.value) URL.revokeObjectURL(objectUrl.value)
objectUrl.value = blob instanceof Blob ? URL.createObjectURL(blob) : ''
},
{ immediate: true }
)
onBeforeUnmount(() => {
if (objectUrl.value) URL.revokeObjectURL(objectUrl.value)
})
function formatBytes(bytes = 0) {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let index = 0
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index += 1
}
return `${value >= 100 || index === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`
}
function formatDate(timestamp) {
if (!timestamp) return '-'
return new Date(timestamp).toLocaleString('zh-CN')
}
function navigateTo(id) {
emit('navigate', id)
}
async function copyText() {
if (!previewText.value) return
try {
await navigator.clipboard.writeText(previewText.value)
} catch {
// ignore
}
}
function openRaw() {
if (!objectUrl.value) return
window.open(objectUrl.value, '_blank', 'noopener,noreferrer')
}
function downloadFile() {
const blob = fileBlob.value
if (!blob) return
const url = objectUrl.value || URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = props.node?.name || 'download'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
}
</script>
<template>
<div class="file-content">
<div v-if="isRoot || isFolder" class="directory-shell">
<div class="directory-card">
<div class="directory-card-header">
<div>
<h3>{{ isRoot ? '本地文件空间' : node.name }}</h3>
<p>{{ isRoot ? '所有文件都保存在当前浏览器本地,不会上传到服务器。' : '像 GitHub 一样浏览当前目录内容。' }}</p>
</div>
<div class="directory-header-meta">
<span>{{ folderItems.length }} </span>
<span>{{ isRoot ? '根目录' : '目录' }}</span>
</div>
</div>
<div class="directory-table">
<div class="directory-row directory-row-head">
<span class="col-name">Name</span>
<span class="col-size">Size</span>
<span class="col-date">Updated</span>
</div>
<div v-if="!isRoot" class="directory-row directory-parent" @click="navigateTo(node.parentId || null)">
<span class="col-name">..</span>
<span class="col-size">-</span>
<span class="col-date">返回上级</span>
</div>
<button
v-for="item in folderItems"
:key="item.id"
class="directory-row directory-button"
type="button"
@click="navigateTo(item.id)"
>
<span class="col-name">
<span :class="item.type === 'folder' ? 'icon-folder' : ['icon-file', `icon-${getFileIcon(item.name)}`]"></span>
<span class="name-text">{{ item.name }}</span>
</span>
<span class="col-size">{{ item.type === 'folder' ? '-' : formatBytes(item.size) }}</span>
<span class="col-date">{{ formatDate(item.updatedAt) }}</span>
</button>
<div v-if="folderItems.length === 0" class="directory-empty">
这里还没有文件你可以从左侧直接上传文件或者新建一个目录开始整理
</div>
</div>
</div>
</div>
<template v-else-if="node">
<div class="github-file-header">
<div class="file-path-bar">
<div class="file-path">
<span v-for="(item, index) in breadcrumb" :key="item.id || item.name" class="file-path-item">
<button
v-if="index < breadcrumb.length - 1"
type="button"
class="breadcrumb-link"
@click="navigateTo(item.id)"
>
{{ item.name }}
</button>
<span v-else class="breadcrumb-current">{{ item.name }}</span>
<span v-if="index < breadcrumb.length - 1" class="path-sep">/</span>
</span>
</div>
<button class="top-link" type="button">Top</button>
</div>
<div class="file-meta-row">
<div class="meta-tabs">
<button class="meta-tab active" type="button">{{ isImage || isPdf ? 'Preview' : 'Code' }}</button>
<button class="meta-tab" type="button" disabled>Blame</button>
<span class="file-meta">{{ locLabel }}</span>
<span class="file-meta">{{ fileSizeLabel }}</span>
<span v-if="node.isTruncatedPreview" class="truncated-pill">仅预览前 2MB</span>
</div>
<div class="file-actions">
<button class="action-btn" type="button" @click="openRaw" :disabled="!objectUrl">Raw</button>
<button class="action-btn" type="button" @click="copyText" :disabled="!previewText">复制</button>
<button class="action-btn" type="button" @click="downloadFile" :disabled="!fileBlob">下载</button>
</div>
</div>
</div>
<div v-if="isMarkdown" class="content-markdown">
<article class="markdown-body" v-html="renderedMarkdown"></article>
</div>
<div v-else-if="isText" class="content-text">
<div class="code-container">
<div class="line-numbers">
<div v-for="n in Math.max(lineCount, 1)" :key="n" class="line-number">{{ n }}</div>
</div>
<pre class="text-content">{{ previewText }}</pre>
</div>
</div>
<div v-else-if="isImage && objectUrl" class="content-preview">
<div class="preview-surface image-surface">
<img :src="objectUrl" :alt="node.name" />
</div>
</div>
<div v-else-if="isPdf && objectUrl" class="content-preview">
<iframe class="pdf-frame" :src="objectUrl" :title="node.name"></iframe>
</div>
<div v-else-if="isOffice && fileBlob && officeFormat" class="content-preview">
<UniverPreview
:fileBlob="fileBlob"
:fileName="node.name"
:format="officeFormat"
/>
</div>
<div v-else class="content-unsupported">
<div class="unsupported-card">
<h3>暂不支持在线预览此文件</h3>
<p>文件已保存在浏览器本地你仍然可以点击右上角下载获取原文件</p>
<div class="unsupported-meta">
<span>{{ node.name }}</span>
<span>{{ fileSizeLabel }}</span>
<span>{{ node.mimeType || '未知类型' }}</span>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.file-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: #f6f8fa;
}
[data-theme='dark'] .file-content {
background: #0d1117;
}
.directory-shell,
.content-markdown,
.content-text,
.content-preview,
.content-unsupported {
flex: 1;
min-height: 0;
overflow: auto;
}
.directory-shell {
padding: 24px;
}
.directory-card {
overflow: hidden;
border: 1px solid var(--github-border);
border-radius: 12px;
background: var(--github-bg);
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.06);
}
.directory-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px 24px;
border-bottom: 1px solid var(--github-border);
}
.directory-card-header h3 {
margin: 0;
font-size: 1.25rem;
}
.directory-card-header p {
margin: 6px 0 0;
color: var(--github-text-secondary);
font-size: 14px;
}
.directory-header-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.directory-header-meta span,
.truncated-pill {
display: inline-flex;
align-items: center;
height: 26px;
padding: 0 10px;
border: 1px solid var(--github-border);
border-radius: 999px;
background: var(--github-hover);
color: var(--github-text-secondary);
font-size: 12px;
font-weight: 600;
}
.directory-table {
display: flex;
flex-direction: column;
}
.directory-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px 180px;
align-items: center;
gap: 16px;
min-height: 48px;
padding: 0 20px;
border-bottom: 1px solid var(--github-border);
}
.directory-row-head {
min-height: 40px;
background: var(--github-hover);
color: var(--github-text-secondary);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.directory-button {
width: 100%;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
text-align: left;
}
.directory-button:hover,
.directory-parent:hover {
background: var(--github-hover);
}
.directory-parent {
cursor: pointer;
color: var(--github-text-secondary);
}
.col-name {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.name-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-size,
.col-date {
color: var(--github-text-secondary);
font-size: 13px;
}
.directory-empty {
padding: 40px 24px;
color: var(--github-text-secondary);
}
.github-file-header {
border-bottom: 1px solid var(--github-border);
background: var(--github-bg);
}
.file-path-bar,
.file-meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 20px;
}
.file-path-bar {
border-bottom: 1px solid var(--github-border);
}
.file-path {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
flex-wrap: wrap;
}
.file-path-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.breadcrumb-link {
border: none;
background: transparent;
color: #0969da;
cursor: pointer;
font-size: 14px;
}
.breadcrumb-current {
color: var(--github-text);
font-size: 14px;
font-weight: 600;
}
.path-sep,
.file-meta {
color: var(--github-text-secondary);
font-size: 13px;
}
.top-link {
border: none;
background: transparent;
color: var(--github-text-secondary);
font-weight: 600;
}
.meta-tabs {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.meta-tab {
height: 32px;
padding: 0 12px;
border: 1px solid var(--github-border);
border-radius: 8px;
background: var(--github-hover);
color: var(--github-text);
font-weight: 600;
}
.meta-tab[disabled] {
opacity: 0.56;
}
.meta-tab.active {
background: var(--github-bg);
}
.file-actions {
display: flex;
gap: 8px;
}
.action-btn {
height: 32px;
padding: 0 12px;
border: 1px solid var(--github-border);
border-radius: 8px;
background: var(--github-bg);
color: var(--github-text);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.action-btn:hover:enabled {
background: var(--github-hover);
}
.action-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.content-markdown {
padding: 24px;
}
.markdown-body {
max-width: 920px;
margin: 0 auto;
padding: 28px 32px;
border: 1px solid var(--github-border);
border-radius: 12px;
background: var(--github-bg);
line-height: 1.8;
color: var(--github-text);
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {
margin-top: 1.4em;
margin-bottom: 0.6em;
}
.markdown-body :deep(pre) {
overflow: auto;
padding: 16px;
border-radius: 10px;
background: var(--github-code-bg);
}
.content-text {
padding: 16px;
}
.code-container {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
min-height: 100%;
border: 1px solid var(--github-border);
border-radius: 12px;
overflow: hidden;
background: var(--github-bg);
}
.line-numbers {
padding: 18px 0;
background: var(--github-hover);
color: var(--github-text-secondary);
text-align: right;
user-select: none;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 12px;
}
.line-number {
padding: 0 14px 0 0;
line-height: 1.65;
}
.text-content {
margin: 0;
padding: 18px 20px;
overflow: auto;
white-space: pre;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 13px;
line-height: 1.65;
color: var(--github-text);
}
.content-preview {
padding: 20px;
}
.preview-surface {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
border: 1px solid var(--github-border);
border-radius: 12px;
background:
linear-gradient(45deg, rgba(208, 215, 222, 0.35) 25%, transparent 25%, transparent 75%, rgba(208, 215, 222, 0.35) 75%),
linear-gradient(45deg, rgba(208, 215, 222, 0.35) 25%, transparent 25%, transparent 75%, rgba(208, 215, 222, 0.35) 75%);
background-position: 0 0, 12px 12px;
background-size: 24px 24px;
}
.image-surface img {
max-width: 100%;
max-height: 76vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
}
.pdf-frame {
width: 100%;
min-height: 78vh;
border: 1px solid var(--github-border);
border-radius: 12px;
background: var(--github-bg);
}
.content-unsupported {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.unsupported-card {
width: min(640px, 100%);
padding: 28px;
border: 1px solid var(--github-border);
border-radius: 16px;
background: var(--github-bg);
text-align: center;
}
.unsupported-card h3 {
margin: 0 0 8px;
}
.unsupported-card p {
margin: 0;
color: var(--github-text-secondary);
}
.unsupported-meta {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin-top: 18px;
}
.unsupported-meta span,
.icon-folder,
.icon-file {
display: inline-flex;
align-items: center;
}
.unsupported-meta span {
height: 28px;
padding: 0 10px;
border: 1px solid var(--github-border);
border-radius: 999px;
background: var(--github-hover);
color: var(--github-text-secondary);
font-size: 12px;
}
.icon-folder::before {
content: '📁';
font-size: 15px;
}
.icon-file::before {
content: '📄';
font-size: 14px;
}
.icon-markdown::before { content: 'Ⓜ'; font-size: 12px; font-weight: 700; color: #0969da; }
.icon-json::before { content: '{ }'; font-size: 8px; font-weight: 700; color: #8250df; }
.icon-javascript::before { content: 'JS'; font-size: 9px; font-weight: 700; color: #9a6700; }
.icon-typescript::before { content: 'TS'; font-size: 9px; font-weight: 700; color: #0969da; }
.icon-css::before { content: 'CSS'; font-size: 7px; font-weight: 700; color: #1f883d; }
.icon-html::before { content: 'HTML'; font-size: 6px; font-weight: 700; color: #bc4c00; }
.icon-python::before { content: 'PY'; font-size: 9px; font-weight: 700; color: #0969da; }
.icon-vue::before { content: 'Vue'; font-size: 8px; font-weight: 700; color: #1f883d; }
.icon-yaml::before { content: 'YML'; font-size: 8px; font-weight: 700; color: #0969da; }
.icon-xml::before { content: '</>'; font-size: 8px; font-weight: 700; color: #bc4c00; }
.icon-csv::before { content: 'CSV'; font-size: 8px; font-weight: 700; color: #1f883d; }
.icon-log::before { content: 'LOG'; font-size: 7px; font-weight: 700; color: #6e7781; }
.icon-sql::before { content: 'SQL'; font-size: 8px; font-weight: 700; color: #8250df; }
.icon-image::before { content: '🖼'; font-size: 13px; }
.icon-pdf::before { content: 'PDF'; font-size: 8px; font-weight: 700; color: #cf222e; }
.icon-word::before { content: 'DOC'; font-size: 7px; font-weight: 700; color: #0969da; }
.icon-ppt::before { content: 'PPT'; font-size: 7px; font-weight: 700; color: #bc4c00; }
.icon-excel::before { content: 'XLS'; font-size: 7px; font-weight: 700; color: #1f883d; }
.icon-zip::before { content: 'ZIP'; font-size: 7px; font-weight: 700; color: #6f42c1; }
.icon-text::before { content: 'TXT'; font-size: 8px; font-weight: 700; color: #6e7781; }
@media (max-width: 960px) {
.file-path-bar,
.file-meta-row,
.directory-card-header {
flex-direction: column;
align-items: flex-start;
}
.directory-row {
grid-template-columns: minmax(0, 1fr);
padding-top: 10px;
padding-bottom: 10px;
}
.col-size,
.col-date {
font-size: 12px;
}
.code-container {
grid-template-columns: 56px minmax(0, 1fr);
}
}
</style>