Files
lingxi-ai/packages/client/src/components/hermes/files/FileList.vue
T
yiandCursor 7d10320a82
Build / build (push) Has been cancelled
NPM Lockfile Check / npm ci --ignore-scripts (push) Has been cancelled
Playwright / e2e (push) Has been cancelled
feat: 灵犀 Studio Web UI 定制版
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 11:29:11 +08:00

213 lines
5.6 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { NButton, NSpin, NEmpty, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore, isImageFile, isMarkdownFile, isTextFile } from '@/stores/hermes/files'
import { downloadFile } from '@/api/hermes/download'
import type { FileEntry } from '@/api/hermes/files'
const { t } = useI18n()
const message = useMessage()
const filesStore = useFilesStore()
const emit = defineEmits<{
(e: 'contextmenu-entry', event: MouseEvent, entry: FileEntry): void
}>()
function formatSize(bytes: number): string {
if (bytes === 0) return '—'
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
let size = bytes
while (size >= 1024 && i < units.length - 1) {
size /= 1024
i++
}
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
function formatDate(iso: string): string {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleString()
}
function getFileIcon(entry: FileEntry): string {
if (entry.isDir) return '📁'
const ext = entry.name.split('.').pop()?.toLowerCase() || ''
const iconMap: Record<string, string> = {
yaml: '⚙️', yml: '⚙️', json: '📋', toml: '⚙️',
md: '📝', txt: '📄', log: '📄',
py: '🐍', js: '📜', ts: '📜', vue: '💚',
png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', svg: '🖼️', webp: '🖼️',
zip: '📦', gz: '📦', tar: '📦',
sh: '⚡', bash: '⚡',
}
return iconMap[ext] || '📄'
}
function handleDoubleClick(entry: FileEntry) {
if (entry.isDir) {
filesStore.navigateTo(entry.path)
} else if (isTextFile(entry.name)) {
filesStore.openEditor(entry.path)
} else if (isImageFile(entry.name) || isMarkdownFile(entry.name)) {
filesStore.openPreview(entry)
}
}
function handleContextMenu(e: MouseEvent, entry: FileEntry) {
e.preventDefault()
emit('contextmenu-entry', e, entry)
}
async function handleDownload(entry: FileEntry) {
try {
await downloadFile(entry.path, entry.name)
} catch (err: any) {
message.error(err.message || t('files.backendError'))
}
}
</script>
<template>
<div class="file-list">
<NSpin :show="filesStore.loading">
<NEmpty v-if="!filesStore.loading && filesStore.sortedEntries.length === 0" :description="t('files.emptyDir')" />
<div v-else class="file-list-items">
<div class="file-list-header">
<div class="file-name sort-header" @click="filesStore.setSort('name')">
{{ t('files.name') }}
<span v-if="filesStore.sortBy === 'name'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '↑' : '↓' }}</span>
</div>
<div class="file-size sort-header" @click="filesStore.setSort('size')">
{{ t('files.size') }}
<span v-if="filesStore.sortBy === 'size'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '↑' : '↓' }}</span>
</div>
<div class="file-date sort-header" @click="filesStore.setSort('modTime')">
{{ t('files.modified') }}
<span v-if="filesStore.sortBy === 'modTime'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '↑' : '↓' }}</span>
</div>
<div class="file-actions-placeholder" />
</div>
<div
v-for="entry in filesStore.sortedEntries"
:key="entry.path"
class="file-list-row"
@dblclick="handleDoubleClick(entry)"
@contextmenu="handleContextMenu($event, entry)"
>
<div class="file-name">
<span class="file-icon">{{ getFileIcon(entry) }}</span>
<span>{{ entry.name }}</span>
</div>
<div class="file-size">{{ entry.isDir ? '—' : formatSize(entry.size) }}</div>
<div class="file-date">{{ formatDate(entry.modTime) }}</div>
<div class="file-actions">
<NButton v-if="isTextFile(entry.name) && !entry.isDir" size="tiny" quaternary @click.stop="filesStore.openEditor(entry.path)" :title="t('files.edit')"></NButton>
<NButton v-if="!entry.isDir" size="tiny" quaternary @click.stop="handleDownload(entry)" :title="t('files.download')"></NButton>
</div>
</div>
</div>
</NSpin>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.file-list {
padding: 8px 16px;
}
.file-list-header {
display: flex;
align-items: center;
padding: 6px 12px;
gap: 16px;
font-size: 12px;
font-weight: 500;
color: $text-muted;
border-bottom: 1px solid $border-light;
margin-bottom: 4px;
user-select: none;
}
.sort-header {
cursor: pointer;
&:hover {
color: $text-primary;
}
}
.sort-indicator {
margin-left: 2px;
font-size: 11px;
}
.file-actions-placeholder {
width: 60px;
flex-shrink: 0;
}
.file-list-row {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: $radius-sm;
cursor: pointer;
gap: 16px;
font-size: 13px;
&:hover {
background-color: rgba(var(--accent-primary-rgb), 0.06);
.file-actions {
opacity: 1;
}
}
}
.file-name {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-icon {
flex-shrink: 0;
}
.file-size {
width: 80px;
text-align: right;
color: $text-secondary;
flex-shrink: 0;
}
.file-date {
width: 160px;
color: $text-secondary;
flex-shrink: 0;
}
.file-actions {
opacity: 0;
transition: opacity $transition-fast;
display: flex;
gap: 4px;
flex-shrink: 0;
}
@media (max-width: $breakpoint-mobile) {
.file-size, .file-date {
display: none;
}
}
</style>