Files
llm-in-text/src/components/FileContent.vue
ydy0615 70152c61b1 feat: enhance Milkdown editor and file system functionality
- Normalize line endings in Markdown export for DOCX files.
- Improve selection serialization to Markdown with better handling of empty documents.
- Add a new `updateFile` function to the file system for updating file properties.
- Introduce video transcoding capabilities using FFmpeg, supporting various video formats.
- Update AGENTS.md for clearer plugin structure and responsibilities.
- Add scoped styles for TreeNodeItem component to improve UI consistency.
- Implement cross-origin isolation headers in Vite configuration for enhanced security.
- Remove obsolete test_cross.py file.
2026-05-01 20:55:02 +08:00

1401 lines
39 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.
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import MarkdownIt from 'markdown-it'
import { isOfficeFile, getOfficeFormat } from '../services/officeDetection'
import { canPlayVideoNatively, editVideoBlob, isVideoFile, transcodeVideoToMp4 } from '../utils/ffmpeg'
import ImageEditorComponent from './ImageEditorComponent.vue'
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 },
updateFile: { type: Function, default: null },
showSidebarToggle: { type: Boolean, default: false }
})
const emit = defineEmits(['navigate', 'toggle-sidebar'])
const md = new MarkdownIt({
html: false,
breaks: true,
linkify: true
})
const basePreviewUrl = ref('')
const editedVideoUrl = ref('')
const editedVideoBlob = ref(null)
const imageEditorRef = ref(null)
const imageEditorError = ref('')
const isEditingImage = ref(false)
const isSavingImage = ref(false)
const isTranscodingVideo = ref(false)
const transcodeProgress = ref(0)
const videoPreviewError = ref('')
const isUsingTranscodedVideo = ref(false)
const isApplyingVideoEdit = ref(false)
const videoEditProgress = ref(0)
const videoEditError = ref('')
const videoElement = ref(null)
const videoDurationSeconds = ref(0)
const videoCurrentTime = ref(0)
const clipStartSeconds = ref(0)
const clipEndSeconds = ref(0)
const muteVideoAudio = ref(false)
let previewTaskId = 0
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 isVideo = computed(() => isVideoFile(props.node))
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}` : '二进制文件'
})
const objectUrl = computed(() => editedVideoUrl.value || basePreviewUrl.value)
const canEditImage = computed(() => isImage.value && typeof props.updateFile === 'function')
const hasEditedVideo = computed(() => editedVideoBlob.value instanceof Blob)
const normalizedClipStart = computed(() => clampNumber(clipStartSeconds.value, 0, videoDurationSeconds.value || 0))
const normalizedClipEnd = computed(() => resolveClipEnd(clipEndSeconds.value, normalizedClipStart.value, videoDurationSeconds.value || 0))
const selectedClipDuration = computed(() => Math.max(0, normalizedClipEnd.value - normalizedClipStart.value))
const hasPendingVideoEdit = computed(() => {
if (!isVideo.value || !videoDurationSeconds.value) return false
const duration = videoDurationSeconds.value
return normalizedClipStart.value > 0.05 || Math.abs(normalizedClipEnd.value - duration) > 0.05 || muteVideoAudio.value
})
function clampNumber(value, min, max) {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return min
return Math.min(Math.max(numeric, min), max)
}
function roundSeconds(value = 0) {
return Math.round((Number(value) || 0) * 10) / 10
}
function resolveClipEnd(value, start, duration) {
if (!duration) return 0
const numeric = Number(value)
if (!Number.isFinite(numeric) || numeric <= 0) return duration
return clampNumber(numeric, start, duration)
}
function stripExtension(name = '') {
return String(name).replace(/\.[^.]+$/, '')
}
function buildEditedVideoName(name = 'video.mp4') {
return `${stripExtension(name)}-edited.mp4`
}
function revokeUrl(url = '') {
if (!url) return
URL.revokeObjectURL(url)
}
function clearEditedVideoResult() {
revokeUrl(editedVideoUrl.value)
editedVideoUrl.value = ''
editedVideoBlob.value = null
}
function clearBasePreview() {
revokeUrl(basePreviewUrl.value)
basePreviewUrl.value = ''
}
function resetVideoPreviewState() {
isTranscodingVideo.value = false
transcodeProgress.value = 0
videoPreviewError.value = ''
isUsingTranscodedVideo.value = false
}
function resetVideoEditState() {
isApplyingVideoEdit.value = false
videoEditProgress.value = 0
videoEditError.value = ''
videoDurationSeconds.value = 0
videoCurrentTime.value = 0
clipStartSeconds.value = 0
clipEndSeconds.value = 0
muteVideoAudio.value = false
clearEditedVideoResult()
}
function normalizeVideoEditRange() {
if (!videoDurationSeconds.value) {
clipStartSeconds.value = 0
clipEndSeconds.value = 0
return
}
clipStartSeconds.value = roundSeconds(normalizedClipStart.value)
clipEndSeconds.value = roundSeconds(normalizedClipEnd.value)
}
function assignBasePreviewUrl(url = '') {
clearBasePreview()
basePreviewUrl.value = url
}
function setVideoRangeFromDuration(duration = 0) {
videoDurationSeconds.value = duration
videoCurrentTime.value = 0
clipStartSeconds.value = 0
clipEndSeconds.value = roundSeconds(duration)
}
watch(
[fileBlob, () => props.node?.id],
async ([blob]) => {
const currentTaskId = ++previewTaskId
clearBasePreview()
resetVideoPreviewState()
resetVideoEditState()
if (!(blob instanceof Blob)) return
if (!isVideo.value) {
assignBasePreviewUrl(URL.createObjectURL(blob))
return
}
if (canPlayVideoNatively(props.node)) {
assignBasePreviewUrl(URL.createObjectURL(blob))
return
}
isTranscodingVideo.value = true
try {
const transcodedBlob = await transcodeVideoToMp4(blob, props.node?.name || 'video', {
onProgress(progress) {
if (currentTaskId !== previewTaskId) return
transcodeProgress.value = Math.max(transcodeProgress.value, Math.round(progress * 100))
}
})
if (currentTaskId !== previewTaskId) return
assignBasePreviewUrl(URL.createObjectURL(transcodedBlob))
isUsingTranscodedVideo.value = true
} catch (error) {
if (currentTaskId !== previewTaskId) return
videoPreviewError.value = error instanceof Error && error.message
? error.message
: '当前视频暂时无法在浏览器内转换预览'
} finally {
if (currentTaskId === previewTaskId) {
isTranscodingVideo.value = false
}
}
},
{ immediate: true }
)
watch(
() => props.node?.id,
() => {
isEditingImage.value = false
isSavingImage.value = false
imageEditorError.value = ''
}
)
onBeforeUnmount(() => {
previewTaskId += 1
clearBasePreview()
clearEditedVideoResult()
})
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)
}
function toggleSidebar() {
emit('toggle-sidebar')
}
function getImageExportFormat() {
if (fileExt.value === 'jpg' || fileExt.value === 'jpeg') return 'jpeg'
if (fileExt.value === 'webp') return 'webp'
return 'png'
}
function startImageEditing() {
if (!canEditImage.value || !objectUrl.value || isSavingImage.value) return
imageEditorError.value = ''
isEditingImage.value = true
}
function cancelImageEditing() {
if (isSavingImage.value) return
imageEditorError.value = ''
isEditingImage.value = false
}
function handleImageEditorError(message) {
imageEditorError.value = String(message || '图片编辑器加载失败')
}
async function saveImageEdits() {
if (!props.node?.id || typeof props.updateFile !== 'function' || !imageEditorRef.value) return
isSavingImage.value = true
imageEditorError.value = ''
try {
const editedBlob = await imageEditorRef.value.exportImageBlob({
format: getImageExportFormat(),
quality: 0.92
})
const updated = await props.updateFile(props.node.id, editedBlob, {
mimeType: editedBlob.type || props.node?.mimeType || '',
storageKind: 'blob',
previewText: '',
isTruncatedPreview: false
})
if (updated === false) {
throw new Error('保存图片失败')
}
isEditingImage.value = false
} catch (error) {
imageEditorError.value = error instanceof Error && error.message
? error.message
: '保存图片失败'
} finally {
isSavingImage.value = false
}
}
function formatDuration(seconds = 0) {
const total = Math.max(0, Number(seconds) || 0)
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const secs = total % 60
const paddedSeconds = secs >= 10 ? secs.toFixed(1).padStart(4, '0') : secs.toFixed(1).padStart(3, '0')
if (hours > 0) {
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${paddedSeconds.padStart(4, '0')}`
}
return `${String(minutes).padStart(2, '0')}:${paddedSeconds.padStart(4, '0')}`
}
function downloadBlob(blob, fileName) {
if (!(blob instanceof Blob)) return
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
function handleVideoLoadedMetadata(event) {
const duration = Number(event.target?.duration || 0)
if (!Number.isFinite(duration)) return
setVideoRangeFromDuration(duration)
}
function handleVideoTimeUpdate(event) {
const currentTime = Number(event.target?.currentTime || 0)
if (!Number.isFinite(currentTime)) return
videoCurrentTime.value = currentTime
}
function setClipStartFromCurrentTime() {
if (!videoElement.value) return
clipStartSeconds.value = roundSeconds(videoElement.value.currentTime || 0)
if (clipEndSeconds.value < clipStartSeconds.value) {
clipEndSeconds.value = roundSeconds(videoDurationSeconds.value || clipStartSeconds.value)
}
normalizeVideoEditRange()
}
function setClipEndFromCurrentTime() {
if (!videoElement.value) return
clipEndSeconds.value = roundSeconds(videoElement.value.currentTime || 0)
if (clipEndSeconds.value < clipStartSeconds.value) {
clipStartSeconds.value = roundSeconds(Math.max(0, clipEndSeconds.value - 0.1))
}
normalizeVideoEditRange()
}
async function applyVideoEdits() {
if (!isVideo.value) return
normalizeVideoEditRange()
const sourceBlob = hasEditedVideo.value ? editedVideoBlob.value : fileBlob.value
if (!(sourceBlob instanceof Blob) || !videoDurationSeconds.value || !hasPendingVideoEdit.value) return
isApplyingVideoEdit.value = true
videoEditProgress.value = 0
videoEditError.value = ''
try {
const editedBlob = await editVideoBlob(sourceBlob, hasEditedVideo.value ? buildEditedVideoName(props.node?.name || 'video.mp4') : props.node?.name || 'video.mp4', {
startTime: normalizedClipStart.value,
endTime: normalizedClipEnd.value,
muteAudio: muteVideoAudio.value,
onProgress(progress) {
videoEditProgress.value = Math.max(videoEditProgress.value, Math.round(progress * 100))
}
})
clearEditedVideoResult()
editedVideoBlob.value = editedBlob
editedVideoUrl.value = URL.createObjectURL(editedBlob)
} catch (error) {
videoEditError.value = error instanceof Error && error.message
? error.message
: '视频编辑失败'
} finally {
isApplyingVideoEdit.value = false
}
}
function restoreOriginalVideo() {
clearEditedVideoResult()
videoEditError.value = ''
videoEditProgress.value = 0
muteVideoAudio.value = false
}
function downloadEditedVideo() {
if (!(editedVideoBlob.value instanceof Blob)) return
downloadBlob(editedVideoBlob.value, buildEditedVideoName(props.node?.name || 'video.mp4'))
}
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
downloadBlob(blob, props.node?.name || 'download')
}
</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-header-row">
<button
v-if="showSidebarToggle"
class="sidebar-toggle-btn"
type="button"
title="切换左侧栏"
@click="toggleSidebar"
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M2.75 2A1.75 1.75 0 001 3.75v8.5C1 13.216 1.784 14 2.75 14h10.5A1.75 1.75 0 0015 12.25v-8.5A1.75 1.75 0 0013.25 2H2.75zm0 1.5h2.5v9h-2.5a.25.25 0 01-.25-.25v-8.5a.25.25 0 01.25-.25zm4 9v-9h6.5a.25.25 0 01.25.25v8.5a.25.25 0 01-.25.25h-6.5z"/></svg>
</button>
<div class="file-header-main">
<div class="file-path">
<button type="button" class="breadcrumb-link" @click="navigateTo(null)">workspace</button>
<template v-for="(item, index) in breadcrumb" :key="item.id || item.name">
<span class="path-sep">/</span>
<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>
</template>
</div>
<div class="file-header-meta">
<span class="file-meta">{{ fileSizeLabel }}</span>
<span class="file-meta">{{ locLabel }}</span>
<span v-if="node.isTruncatedPreview" class="truncated-pill">仅预览前 2MB</span>
<span v-else-if="isTranscodingVideo" class="truncated-pill">视频转换中 {{ transcodeProgress }}%</span>
<span v-else-if="isApplyingVideoEdit" class="truncated-pill">编辑处理中 {{ videoEditProgress }}%</span>
<span v-else-if="isUsingTranscodedVideo" class="truncated-pill">已转为 MP4 预览</span>
</div>
</div>
<div class="file-actions">
<button
v-if="canEditImage && !isEditingImage"
class="action-btn"
type="button"
@click="startImageEditing"
:disabled="!objectUrl"
>
编辑图片
</button>
<template v-else-if="canEditImage && isEditingImage">
<button class="action-btn" type="button" @click="cancelImageEditing" :disabled="isSavingImage">取消</button>
<button class="action-btn primary-btn" type="button" @click="saveImageEdits" :disabled="isSavingImage">{{ isSavingImage ? '保存中' : '保存编辑' }}</button>
</template>
<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" :class="{ 'image-editor-active': isEditingImage }">
<div v-if="imageEditorError" class="image-inline-error">{{ imageEditorError }}</div>
<ImageEditorComponent
v-if="isEditingImage"
ref="imageEditorRef"
:image-url="objectUrl"
:image-name="node.name"
@error="handleImageEditorError"
/>
<img :src="objectUrl" :alt="node.name" />
</div>
</div>
<div v-else-if="isVideo" class="content-preview">
<div class="preview-surface video-surface">
<div v-if="isTranscodingVideo" class="video-state-card">
<h3>正在准备视频预览</h3>
<p>当前格式需要先转换为浏览器兼容的 MP4转换完成后会自动开始播放</p>
<div class="video-progress-track" role="progressbar" :aria-valuenow="transcodeProgress" aria-valuemin="0" aria-valuemax="100">
<span class="video-progress-bar" :style="{ width: `${transcodeProgress}%` }"></span>
</div>
<strong>{{ transcodeProgress }}%</strong>
</div>
<div v-else-if="videoPreviewError" class="video-state-card video-state-error">
<h3>视频预览暂时不可用</h3>
<p>{{ videoPreviewError }}</p>
<p>原始文件仍保存在浏览器本地你可以继续下载后使用本地播放器打开</p>
</div>
<video
v-else-if="objectUrl"
ref="videoElement"
class="video-player"
controls
playsinline
:src="objectUrl"
@loadedmetadata="handleVideoLoadedMetadata"
@timeupdate="handleVideoTimeUpdate"
></video>
<section v-if="objectUrl" class="video-edit-panel">
<div class="video-edit-header">
<div>
<h3>基础编辑</h3>
<p>裁剪片段静音导出并把结果另存为新的 MP4 文件</p>
</div>
<div class="video-edit-meta">
<span>总时长 {{ formatDuration(videoDurationSeconds) }}</span>
<span>当前播放 {{ formatDuration(videoCurrentTime) }}</span>
<span>片段时长 {{ formatDuration(selectedClipDuration) }}</span>
</div>
</div>
<div class="video-edit-grid">
<label class="video-field">
<span>开始时间</span>
<input v-model.number="clipStartSeconds" type="number" min="0" :max="videoDurationSeconds || 0" step="0.1" @blur="normalizeVideoEditRange" />
</label>
<label class="video-field">
<span>结束时间</span>
<input v-model.number="clipEndSeconds" type="number" min="0" :max="videoDurationSeconds || 0" step="0.1" @blur="normalizeVideoEditRange" />
</label>
</div>
<div class="video-quick-actions">
<button class="ghost-btn" type="button" @click="setClipStartFromCurrentTime" :disabled="!videoDurationSeconds">用当前时间设为开始</button>
<button class="ghost-btn" type="button" @click="setClipEndFromCurrentTime" :disabled="!videoDurationSeconds">用当前时间设为结束</button>
</div>
<label class="video-checkbox">
<input v-model="muteVideoAudio" type="checkbox" />
<span>静音导出移除音轨</span>
</label>
<div v-if="videoEditError" class="video-inline-error">{{ videoEditError }}</div>
<div v-if="isApplyingVideoEdit" class="video-edit-progress-shell">
<div class="video-progress-track" role="progressbar" :aria-valuenow="videoEditProgress" aria-valuemin="0" aria-valuemax="100">
<span class="video-progress-bar" :style="{ width: `${videoEditProgress}%` }"></span>
</div>
<strong>{{ videoEditProgress }}%</strong>
</div>
<div class="video-edit-actions">
<button class="action-btn primary-btn" type="button" @click="applyVideoEdits" :disabled="isApplyingVideoEdit || !hasPendingVideoEdit">生成编辑预览</button>
<button class="action-btn" type="button" @click="downloadEditedVideo" :disabled="!hasEditedVideo">下载编辑结果</button>
<button class="action-btn" type="button" @click="restoreOriginalVideo" :disabled="!hasEditedVideo">恢复原片</button>
</div>
</section>
</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-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 20px;
flex-wrap: wrap;
}
.sidebar-toggle-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--github-border);
border-radius: 8px;
background: var(--github-bg);
color: var(--github-text-secondary);
cursor: pointer;
}
.sidebar-toggle-btn:hover {
background: var(--github-hover);
}
.file-header-main {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
flex: 1;
flex-wrap: wrap;
}
.file-path {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
flex-wrap: wrap;
}
.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;
}
.file-header-meta {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.file-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-left: auto;
}
.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);
}
.image-surface.image-editor-active {
display: block;
padding: 0;
background: transparent;
border: none;
}
.image-surface.image-editor-active img {
display: none;
}
.image-inline-error {
margin-bottom: 14px;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, #cf222e 35%, var(--github-border) 65%);
border-radius: 10px;
background: color-mix(in srgb, #cf222e 8%, var(--github-bg) 92%);
color: #cf222e;
font-size: 13px;
}
.video-surface {
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
gap: 20px;
min-height: 72vh;
padding: 24px;
background:
radial-gradient(circle at top, rgba(9, 105, 218, 0.08), transparent 38%),
linear-gradient(180deg, rgba(13, 17, 23, 0.04), rgba(13, 17, 23, 0.08));
}
.video-player {
align-self: center;
width: min(1100px, 100%);
max-height: 78vh;
border-radius: 14px;
background: #000;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.22);
}
.video-edit-panel {
width: min(1100px, 100%);
margin: 0 auto;
padding: 20px;
border: 1px solid var(--github-border);
border-radius: 16px;
background: color-mix(in srgb, var(--github-bg) 94%, white 6%);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
}
.video-edit-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.video-edit-header h3 {
margin: 0;
}
.video-edit-header p {
margin: 6px 0 0;
color: var(--github-text-secondary);
line-height: 1.7;
}
.video-edit-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.video-edit-meta span {
display: inline-flex;
align-items: center;
min-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;
font-weight: 600;
}
.video-edit-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.video-field {
display: flex;
flex-direction: column;
gap: 8px;
color: var(--github-text-secondary);
font-size: 13px;
font-weight: 600;
}
.video-field input {
height: 40px;
padding: 0 12px;
border: 1px solid var(--github-border);
border-radius: 10px;
background: var(--github-bg);
color: var(--github-text);
}
.video-quick-actions,
.video-edit-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.video-quick-actions {
margin-top: 14px;
}
.ghost-btn {
height: 34px;
padding: 0 12px;
border: 1px solid var(--github-border);
border-radius: 999px;
background: transparent;
color: var(--github-text-secondary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.ghost-btn:hover:enabled {
background: var(--github-hover);
color: var(--github-text);
}
.ghost-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.video-checkbox {
display: inline-flex;
align-items: center;
gap: 10px;
margin-top: 14px;
color: var(--github-text);
font-size: 13px;
font-weight: 600;
}
.video-inline-error {
margin-top: 14px;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, #cf222e 35%, var(--github-border) 65%);
border-radius: 10px;
background: color-mix(in srgb, #cf222e 8%, var(--github-bg) 92%);
color: #cf222e;
font-size: 13px;
}
.video-edit-progress-shell {
margin-top: 16px;
}
.video-edit-progress-shell strong {
display: inline-block;
margin-top: 8px;
}
.video-edit-actions {
margin-top: 18px;
}
.primary-btn {
border-color: #0969da;
background: #0969da;
color: #fff;
}
.primary-btn:hover:enabled {
background: #0859ba;
}
.video-state-card {
width: min(520px, 100%);
padding: 24px;
border: 1px solid var(--github-border);
border-radius: 16px;
background: color-mix(in srgb, var(--github-bg) 92%, white 8%);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
text-align: center;
}
.video-state-card h3 {
margin: 0 0 10px;
}
.video-state-card p {
margin: 0;
color: var(--github-text-secondary);
line-height: 1.7;
}
.video-state-card strong {
display: block;
margin-top: 12px;
color: var(--github-text);
}
.video-state-error {
border-color: color-mix(in srgb, #cf222e 40%, var(--github-border) 60%);
}
.video-progress-track {
position: relative;
height: 10px;
margin-top: 18px;
overflow: hidden;
border-radius: 999px;
background: var(--github-hover);
}
.video-progress-bar {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #0969da, #2da44e);
transition: width 180ms ease;
}
.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-video::before { content: '▶'; font-size: 11px; font-weight: 700; color: #0969da; }
.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-header-row,
.directory-card-header {
flex-direction: column;
align-items: flex-start;
}
.file-header-main,
.file-actions {
width: 100%;
}
.file-actions {
margin-left: 0;
}
.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>