Files
JARVIS/frontend/src/pages/knowledge/index.vue

1657 lines
38 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import {
ArrowLeft,
Database,
FileText,
Folder,
FolderPlus,
Loader,
Trash2,
Upload,
X,
} from 'lucide-vue-next'
import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView'
const {
documents,
currentFolderId,
uploadError,
uploadSuccess,
highlightedDocumentId,
uploadInput,
showNewFolderDialog,
newFolderName,
newFolderParentId,
showRenameDialog,
renameFolderName,
showDeleteDialog,
deletingFolder,
showDocumentDialog,
activeDocument,
activeDocumentContent,
isLoadingDocumentContent,
activeDocumentChunks,
isLoadingDocumentChunks,
chunkDrafts,
chunkEditing,
chunkSaving,
chunkEditError,
isRoot,
visibleFolders,
breadcrumbs,
explorerTitle,
enterFolder,
goToFolder,
goBack,
triggerUpload,
handleUpload,
handleDeleteDocument,
openNewFolderDialog,
createFolder,
openRenameDialog,
renameFolder,
openDeleteDialog,
deleteFolder,
openDocument,
closeDocumentDialog,
startChunkEdit,
cancelChunkEdit,
saveChunkEdit,
getFileTypeColor,
formatFileSize,
formatDate,
getStatusLabel,
} = useKnowledgeView()
</script>
<template>
<div class="knowledge-view grid-bg scanlines">
<div class="page-header">
<div class="header-left">
<div class="header-icon"><Database :size="20" /></div>
<div class="header-text">
<h1>KNOWLEDGE BASE</h1>
<span class="header-sub">{{ explorerTitle }}</span>
</div>
</div>
<div class="header-actions">
<button class="btn" @click="openNewFolderDialog(currentFolderId)">
<FolderPlus :size="14" />
{{ isRoot ? '新建文件夹' : '新建子文件夹' }}
</button>
<button v-if="!isRoot" class="btn primary" @click="triggerUpload">
<Upload :size="14" /> 上传文件
</button>
</div>
</div>
<input
ref="uploadInput"
type="file"
class="hidden-upload"
accept=".pdf,.md,.txt,.doc,.docx,.csv,.xlsx"
@change="handleUpload"
/>
<div class="explorer-shell holo-card">
<div class="toolbar">
<div class="toolbar-left">
<button class="nav-btn" :disabled="isRoot" @click="goBack">
<ArrowLeft :size="14" />
</button>
<div class="breadcrumbs">
<button
v-for="(crumb, index) in breadcrumbs"
:key="`${crumb.id ?? 'root'}-${index}`"
class="breadcrumb-item"
:class="{ active: crumb.id === currentFolderId || (crumb.id === null && isRoot) }"
@click="goToFolder(crumb.id)"
>
{{ crumb.name }}
</button>
</div>
</div>
<div class="toolbar-right">
<span class="location-tag">{{ isRoot ? 'ROOT' : 'FOLDER' }}</span>
</div>
</div>
<div v-if="uploadError" class="upload-error">
{{ uploadError }}
</div>
<div v-if="uploadSuccess" class="upload-success">
{{ uploadSuccess }}
</div>
<div class="explorer-grid">
<template v-if="visibleFolders.length || documents.length">
<button
v-for="folder in visibleFolders"
:key="folder.id"
class="explorer-tile folder-tile"
@click="enterFolder(folder)"
>
<div class="folder-activate-flash"></div>
<div class="tile-frame"></div>
<div class="folder-tech-corners">
<span class="corner corner-tl"></span>
<span class="corner corner-tr"></span>
<span class="corner corner-bl"></span>
<span class="corner corner-br"></span>
</div>
<div class="folder-scan"></div>
<div class="folder-grid-lines"></div>
<div class="tile-actions" @click.stop>
<button class="icon-btn" title="重命名文件夹" @click="openRenameDialog(folder)">
<FileText :size="12" />
</button>
<button class="icon-btn danger" title="删除文件夹" @click="openDeleteDialog(folder)">
<Trash2 :size="12" />
</button>
</div>
<div class="folder-glyph">
<div class="folder-pulse-ring"></div>
<div class="folder-core">
<Folder :size="42" />
</div>
<span class="folder-beam beam-a"></span>
<span class="folder-beam beam-b"></span>
</div>
<div class="folder-label-bar">
<div class="tile-name folder-title-name">{{ folder.name }}</div>
</div>
<div class="tile-meta folder-meta">{{ folder.children?.length ?? 0 }} 个子文件夹</div>
</button>
<button
v-for="doc in documents"
:key="doc.id"
class="explorer-tile file-tile"
:class="{ 'upload-highlight': highlightedDocumentId === doc.id }"
@click="openDocument(doc)"
>
<div class="tile-frame"></div>
<div class="tile-actions" @click.stop>
<button class="icon-btn danger" title="删除文件" @click="handleDeleteDocument(doc.id)">
<Trash2 :size="12" />
</button>
</div>
<div class="file-badge" :style="{ color: getFileTypeColor(doc.file_type), borderColor: `${getFileTypeColor(doc.file_type)}55` }">
{{ doc.file_type.toUpperCase() }}
</div>
<div class="file-icon" :style="{ color: getFileTypeColor(doc.file_type) }">
<FileText :size="38" />
</div>
<div class="tile-name">{{ doc.title }}</div>
<div class="tile-meta">{{ formatFileSize(doc.file_size) }} · {{ formatDate(doc.created_at) }}</div>
<div class="tile-status-row">
<div class="tile-status" :class="(doc.ingestion_status ?? (doc.is_indexed ? 'ready' : 'uploaded')).toLowerCase()">
{{ getStatusLabel(doc.ingestion_status, doc.is_indexed) }}
</div>
<Loader
v-if="['uploaded', 'parsing', 'indexing'].includes(doc.ingestion_status ?? (doc.is_indexed ? 'ready' : 'uploaded'))"
:size="12"
class="spin tile-inline-loader"
/>
</div>
<div v-if="doc.ingestion_error" class="tile-warning">{{ doc.ingestion_error }}</div>
</button>
</template>
<div v-else class="empty-state">
<div class="empty-core-shell">
<div class="empty-orbit orbit-outer"></div>
<div class="empty-orbit orbit-inner"></div>
<div class="empty-scanline"></div>
<div class="empty-core-glow"></div>
<div class="empty-folder-chamber">
<div class="empty-folder-plate"></div>
<div class="empty-folder-pulse"></div>
<div class="empty-folder-icon-wrap">
<Folder :size="54" />
</div>
<span class="empty-beam beam-left"></span>
<span class="empty-beam beam-right"></span>
</div>
</div>
<div class="empty-copy">
<span class="empty-kicker">{{ isRoot ? 'ARCHIVE CORE ONLINE' : 'FOLDER CHAMBER STANDBY' }}</span>
<span class="empty-title">{{ isRoot ? 'ROOT DIRECTORY EMPTY' : 'FOLDER IS READY FOR DEPLOYMENT' }}</span>
<span class="empty-sub">
{{ isRoot ? '当前知识库尚未初始化目录结构,请先创建第一个文件夹。' : '当前目录为空,可在此新建子文件夹或上传文件。' }}
</span>
</div>
<div class="empty-actions">
<button class="btn primary empty-cta" @click="openNewFolderDialog(currentFolderId)">
<FolderPlus :size="14" />
{{ isRoot ? '初始化知识仓库' : '新建子文件夹' }}
</button>
<button v-if="!isRoot" class="btn" @click="triggerUpload">
<Upload :size="14" /> 上传文件
</button>
</div>
</div>
</div>
</div>
<div v-if="showNewFolderDialog" class="dialog-overlay" @click.self="showNewFolderDialog = false">
<div class="dialog">
<div class="dialog-header">
<h3>{{ isRoot && newFolderParentId === null ? '新建文件夹' : '新建子文件夹' }}</h3>
<button class="close-btn" @click="showNewFolderDialog = false">
<X :size="16" />
</button>
</div>
<input
v-model="newFolderName"
class="dialog-input"
placeholder="文件夹名称"
@keyup.enter="createFolder"
autofocus
/>
<div class="dialog-actions">
<button class="btn" @click="showNewFolderDialog = false">取消</button>
<button class="btn primary" @click="createFolder">创建</button>
</div>
</div>
</div>
<div v-if="showRenameDialog" class="dialog-overlay" @click.self="showRenameDialog = false">
<div class="dialog">
<div class="dialog-header">
<h3>重命名文件夹</h3>
<button class="close-btn" @click="showRenameDialog = false">
<X :size="16" />
</button>
</div>
<input
v-model="renameFolderName"
class="dialog-input"
placeholder="文件夹名称"
@keyup.enter="renameFolder"
autofocus
/>
<div class="dialog-actions">
<button class="btn" @click="showRenameDialog = false">取消</button>
<button class="btn primary" @click="renameFolder">重命名</button>
</div>
</div>
</div>
<div v-if="showDeleteDialog" class="dialog-overlay" @click.self="showDeleteDialog = false">
<div class="dialog">
<div class="dialog-header">
<h3>删除文件夹</h3>
<button class="close-btn" @click="showDeleteDialog = false">
<X :size="16" />
</button>
</div>
<p class="dialog-body">
确定要删除文件夹{{ deletingFolder?.name }}该操作不可恢复
</p>
<div class="dialog-actions">
<button class="btn" @click="showDeleteDialog = false">取消</button>
<button class="btn danger" @click="deleteFolder">删除</button>
</div>
</div>
</div>
<div v-if="showDocumentDialog && activeDocument" class="dialog-overlay" @click.self="closeDocumentDialog()">
<div class="dialog document-dialog">
<div class="dialog-header">
<h3>{{ activeDocument.title }}</h3>
<button class="close-btn" @click="closeDocumentDialog()">
<X :size="16" />
</button>
</div>
<div class="document-info-row">
<span class="file-badge" :style="{ color: getFileTypeColor(activeDocument.file_type), borderColor: `${getFileTypeColor(activeDocument.file_type)}55` }">
{{ activeDocument.file_type.toUpperCase() }}
</span>
<span>{{ formatFileSize(activeDocument.file_size) }}</span>
<span class="doc-chunk-count">{{ activeDocument.chunk_count }} 个知识切片</span>
<span class="doc-status-pill">{{ getStatusLabel(activeDocument.ingestion_status, activeDocument.is_indexed) }}</span>
</div>
<div v-if="activeDocument.ingestion_error" class="upload-error">
{{ activeDocument.ingestion_error }}
</div>
<div class="document-content-grid">
<div class="document-preview">
<div v-if="isLoadingDocumentContent" class="preview-loading">
<Loader :size="16" class="spin" />
<span>加载文档内容中...</span>
</div>
<pre v-else>{{ activeDocumentContent || '暂无可预览内容' }}</pre>
</div>
<div class="chunk-panel">
<div class="chunk-panel-header">
<div>
<div class="chunk-panel-title">知识切片</div>
<div class="chunk-panel-subtitle">当前已加载 {{ activeDocumentChunks.length }} / {{ activeDocument.chunk_count }} 个切片</div>
</div>
<span class="chunk-count-badge">{{ activeDocumentChunks.length }}</span>
</div>
<div v-if="isLoadingDocumentChunks" class="chunk-loading-state">
<div class="chunk-loading-bar"></div>
<span>正在读取切片内容...</span>
</div>
<div v-else-if="activeDocumentChunks.length" class="chunk-list">
<div
v-for="chunk in activeDocumentChunks"
:key="chunk.id"
class="chunk-card"
:class="{ editing: chunkEditing[chunk.id] }"
>
<div class="chunk-card-header">
<div class="chunk-card-meta">
<span class="chunk-index">切片 #{{ chunk.chunk_index + 1 }}</span>
<span class="chunk-size">{{ chunk.content.length }} 字符</span>
<span v-if="chunk.metadata_" class="chunk-meta-raw">{{ chunk.metadata_ }}</span>
</div>
<button
v-if="!chunkEditing[chunk.id]"
class="btn"
@click="startChunkEdit(chunk)"
>
编辑
</button>
</div>
<pre v-if="!chunkEditing[chunk.id]" class="chunk-content">{{ chunk.content }}</pre>
<div v-else class="chunk-edit-form">
<textarea
v-model="chunkDrafts[chunk.id]"
class="chunk-textarea"
rows="7"
></textarea>
<div v-if="chunkEditError[chunk.id]" class="upload-error chunk-error">
{{ chunkEditError[chunk.id] }}
</div>
<div class="chunk-actions">
<button class="btn" :disabled="chunkSaving[chunk.id]" @click="cancelChunkEdit(chunk.id)">
取消
</button>
<button class="btn primary" :disabled="chunkSaving[chunk.id]" @click="saveChunkEdit(chunk.id)">
<Loader v-if="chunkSaving[chunk.id]" :size="12" class="spin" />
{{ chunkSaving[chunk.id] ? '保存中' : '保存' }}
</button>
</div>
</div>
</div>
</div>
<div v-else class="chunk-empty">暂无切片数据</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.knowledge-view {
height: 100%;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.header-left {
display: flex;
align-items: center;
gap: 14px;
}
.header-icon {
color: var(--accent-cyan);
filter: drop-shadow(0 0 8px var(--accent-cyan));
}
h1 {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
letter-spacing: 0.15em;
color: var(--text-primary);
margin: 0;
}
.header-sub {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
letter-spacing: 0.1em;
}
.header-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.hidden-upload {
display: none;
}
.explorer-shell {
min-height: 620px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 18px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
background: linear-gradient(180deg, rgba(0, 245, 212, 0.06), rgba(0, 245, 212, 0.02));
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.nav-btn {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(0, 245, 212, 0.08);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
color: var(--accent-cyan);
}
.breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.breadcrumb-item {
padding: 6px 10px;
border-radius: 999px;
background: transparent;
border: 1px solid transparent;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
}
.breadcrumb-item:hover,
.breadcrumb-item.active {
color: var(--accent-cyan);
border-color: var(--border-mid);
background: rgba(0, 245, 212, 0.08);
}
.location-tag {
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border-mid);
color: var(--accent-cyan);
background: rgba(0, 245, 212, 0.08);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
}
.upload-error,
.upload-success {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 11px;
}
.upload-error {
color: var(--accent-red);
background: rgba(255, 71, 87, 0.08);
border: 1px solid rgba(255, 71, 87, 0.2);
}
.upload-success {
color: var(--accent-green);
background: rgba(52, 211, 153, 0.08);
border: 1px solid rgba(52, 211, 153, 0.24);
}
.explorer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(196px, 1fr));
gap: 18px;
align-content: start;
}
.explorer-tile {
position: relative;
min-height: 220px;
padding: 18px 16px 16px;
border-radius: 20px;
border: 1px solid var(--border-dim);
background:
radial-gradient(circle at top, rgba(0, 245, 212, 0.08), transparent 45%),
linear-gradient(180deg, rgba(10, 15, 26, 0.92), rgba(13, 21, 37, 0.98));
color: var(--text-primary);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
overflow: hidden;
}
.explorer-tile:hover {
border-color: var(--border-bright);
box-shadow: var(--glow-cyan);
transform: translateY(-2px);
}
.upload-highlight {
border-color: rgba(52, 211, 153, 0.8);
box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.35), 0 0 28px rgba(52, 211, 153, 0.25);
}
.tile-frame {
position: absolute;
inset: 10px;
border: 1px solid rgba(0, 245, 212, 0.08);
border-radius: 16px;
pointer-events: none;
}
.tile-actions {
position: absolute;
top: 12px;
right: 12px;
display: flex;
gap: 6px;
opacity: 0;
transition: opacity var(--transition-fast);
}
.explorer-tile:hover .tile-actions {
opacity: 1;
}
.icon-btn {
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: rgba(0, 9, 19, 0.78);
border: 1px solid var(--border-dim);
color: var(--text-secondary);
}
.icon-btn:hover {
color: var(--accent-cyan);
border-color: var(--border-mid);
background: rgba(0, 245, 212, 0.1);
}
.icon-btn.danger:hover {
color: var(--accent-red);
border-color: rgba(255, 71, 87, 0.3);
background: rgba(255, 71, 87, 0.12);
}
.folder-tile {
text-align: center;
}
.folder-activate-flash,
.folder-tech-corners,
.folder-scan,
.folder-grid-lines {
position: absolute;
inset: 0;
pointer-events: none;
}
.folder-activate-flash {
opacity: 0;
background: radial-gradient(circle at center, rgba(124, 230, 255, 0.34), rgba(0, 245, 212, 0.14) 35%, transparent 68%);
}
.folder-tech-corners {
z-index: 1;
}
.folder-tech-corners .corner {
position: absolute;
width: 26px;
height: 26px;
border-color: rgba(0, 245, 212, 0.22);
border-style: solid;
opacity: 0.75;
transition: all var(--transition-mid);
}
.folder-tech-corners .corner-tl {
top: 9px;
left: 9px;
border-width: 2px 0 0 2px;
border-top-left-radius: 10px;
}
.folder-tech-corners .corner-tr {
top: 9px;
right: 9px;
border-width: 2px 2px 0 0;
border-top-right-radius: 10px;
}
.folder-tech-corners .corner-bl {
bottom: 9px;
left: 9px;
border-width: 0 0 2px 2px;
border-bottom-left-radius: 10px;
}
.folder-tech-corners .corner-br {
bottom: 9px;
right: 9px;
border-width: 0 2px 2px 0;
border-bottom-right-radius: 10px;
}
.folder-scan,
.folder-grid-lines {
opacity: 0;
transition: opacity var(--transition-mid);
}
.folder-scan {
background: linear-gradient(
115deg,
transparent 24%,
rgba(0, 245, 212, 0.04) 40%,
rgba(124, 230, 255, 0.18) 50%,
rgba(0, 245, 212, 0.04) 60%,
transparent 76%
);
transform: translateX(-130%);
}
.folder-grid-lines {
background:
linear-gradient(rgba(0, 245, 212, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 245, 212, 0.05) 1px, transparent 1px);
background-size: 18px 18px;
mix-blend-mode: screen;
}
.folder-tile:hover .folder-scan,
.folder-tile:hover .folder-grid-lines {
opacity: 1;
}
.folder-tile:hover .folder-tech-corners .corner {
border-color: rgba(124, 230, 255, 0.7);
box-shadow: 0 0 10px rgba(0, 245, 212, 0.18);
}
.folder-tile:hover .corner-tl,
.folder-tile:hover .corner-br {
transform: translate(1px, 1px);
}
.folder-tile:hover .corner-tr,
.folder-tile:hover .corner-bl {
transform: translate(-1px, -1px);
}
.folder-tile:active .folder-activate-flash {
animation: activate-flash 320ms ease-out;
}
.folder-tile:hover .folder-scan {
animation: hologram-scan 1.4s ease-out forwards;
}
.folder-glyph {
position: relative;
margin-top: 22px;
width: 100px;
height: 94px;
display: flex;
align-items: center;
justify-content: center;
animation: folder-float 4.8s ease-in-out infinite;
}
.folder-pulse-ring {
position: absolute;
width: 108px;
height: 108px;
border-radius: 50%;
border: 1px solid rgba(0, 245, 212, 0.16);
box-shadow: 0 0 18px rgba(0, 245, 212, 0.08);
animation: pulse-ring 3.2s ease-in-out infinite;
}
.folder-core {
width: 92px;
height: 78px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 22px;
color: #79d4ff;
background: linear-gradient(180deg, rgba(0, 245, 212, 0.16), rgba(123, 44, 191, 0.12));
border: 1px solid rgba(0, 245, 212, 0.22);
box-shadow:
inset 0 0 18px rgba(0, 245, 212, 0.08),
0 0 18px rgba(0, 245, 212, 0.12);
animation: core-breathe 3.4s ease-in-out infinite;
}
.folder-core svg {
filter: drop-shadow(0 0 6px rgba(124, 230, 255, 0.35));
}
.folder-beam {
position: absolute;
display: block;
width: 70px;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(0, 245, 212, 0.85), transparent);
animation: beam-flicker 2.6s ease-in-out infinite;
}
.beam-a {
top: 18px;
left: -8px;
}
.beam-b {
right: -8px;
bottom: 20px;
}
.file-tile {
text-align: center;
}
.folder-label-bar {
width: 100%;
display: flex;
align-items: center;
min-height: 34px;
padding: 8px 2px 0;
border-top: 1px solid rgba(0, 245, 212, 0.1);
position: relative;
}
.folder-label-bar::before {
content: '';
position: absolute;
top: -1px;
left: 2px;
width: 42px;
height: 1px;
background: linear-gradient(90deg, rgba(0, 245, 212, 0.85), transparent);
}
.folder-title-name {
text-align: left;
}
.folder-meta {
color: #84c7d6;
}
.folder-tile:hover .folder-label-bar {
border-top-color: rgba(124, 230, 255, 0.24);
}
.folder-tile:hover .folder-label-bar::before {
width: 64px;
background: linear-gradient(90deg, rgba(124, 230, 255, 0.95), transparent);
}
.folder-tile:hover .folder-title-name {
color: #f2fbff;
}
.file-badge {
margin-top: 8px;
padding: 3px 8px;
border: 1px solid;
border-radius: 999px;
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.12em;
}
.file-icon {
margin-top: 22px;
min-height: 88px;
display: flex;
align-items: center;
justify-content: center;
filter: drop-shadow(0 0 10px currentColor);
}
.tile-name {
width: 100%;
font-size: 13px;
line-height: 1.45;
color: var(--text-primary);
word-break: break-word;
}
.tile-meta {
width: 100%;
min-height: 30px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
line-height: 1.5;
}
.tile-status-row {
margin-top: auto;
display: inline-flex;
align-items: center;
gap: 8px;
}
.tile-status {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.12em;
}
.tile-inline-loader {
color: var(--accent-cyan);
}
.ready {
color: var(--accent-green);
}
.uploaded,
.parsing,
.indexing {
color: var(--accent-amber);
}
.warning {
color: var(--accent-purple);
}
.failed {
color: var(--accent-red);
}
.tile-warning {
width: 100%;
color: var(--accent-red);
font-family: var(--font-mono);
font-size: 10px;
line-height: 1.4;
word-break: break-word;
}
.doc-chunk-count {
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.08em;
}
.doc-status-pill {
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border-mid);
color: var(--accent-cyan);
background: rgba(0, 245, 212, 0.08);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
}
.empty-state {
grid-column: 1 / -1;
min-height: 440px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 40px 24px;
border: 1px solid rgba(0, 245, 212, 0.12);
border-radius: 24px;
background:
radial-gradient(circle at center, rgba(0, 245, 212, 0.08), transparent 42%),
linear-gradient(180deg, rgba(8, 13, 24, 0.96), rgba(4, 8, 16, 0.98));
position: relative;
overflow: hidden;
}
.empty-state::before {
content: '';
position: absolute;
inset: 0;
background:
linear-gradient(90deg, transparent, rgba(0, 245, 212, 0.05), transparent),
radial-gradient(circle at top, rgba(123, 44, 191, 0.14), transparent 32%);
pointer-events: none;
}
.empty-core-shell {
position: relative;
width: 250px;
height: 250px;
display: flex;
align-items: center;
justify-content: center;
}
.empty-orbit {
position: absolute;
border-radius: 50%;
border: 1px solid rgba(0, 245, 212, 0.12);
box-shadow: inset 0 0 18px rgba(0, 245, 212, 0.04);
}
.orbit-outer {
width: 250px;
height: 250px;
}
.orbit-inner {
width: 184px;
height: 184px;
border-color: rgba(0, 245, 212, 0.18);
}
.empty-scanline {
position: absolute;
width: 180px;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(0, 245, 212, 0.9), transparent);
box-shadow: 0 0 12px rgba(0, 245, 212, 0.5);
animation: float 3s ease-in-out infinite;
}
.empty-core-glow {
position: absolute;
width: 126px;
height: 126px;
border-radius: 50%;
background: radial-gradient(circle, rgba(0, 245, 212, 0.16), rgba(123, 44, 191, 0.08), transparent 70%);
filter: blur(6px);
}
.empty-folder-chamber {
position: relative;
width: 124px;
height: 124px;
display: flex;
align-items: center;
justify-content: center;
animation: folder-float 5.4s ease-in-out infinite;
}
.empty-folder-plate {
position: absolute;
inset: 14px;
border-radius: 28px;
border: 1px solid rgba(0, 245, 212, 0.22);
background: linear-gradient(180deg, rgba(0, 245, 212, 0.14), rgba(123, 44, 191, 0.14));
box-shadow:
inset 0 0 24px rgba(0, 245, 212, 0.08),
0 0 32px rgba(0, 245, 212, 0.12);
}
.empty-folder-pulse {
position: absolute;
width: 138px;
height: 138px;
border-radius: 50%;
border: 1px solid rgba(0, 245, 212, 0.14);
box-shadow: 0 0 22px rgba(0, 245, 212, 0.08);
animation: pulse-ring 3.6s ease-in-out infinite;
}
.empty-folder-icon-wrap {
position: relative;
z-index: 1;
width: 92px;
height: 92px;
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
color: #7ce6ff;
background: rgba(3, 10, 18, 0.72);
border: 1px solid rgba(0, 245, 212, 0.16);
box-shadow: 0 0 24px rgba(0, 245, 212, 0.16);
animation: core-breathe 3.8s ease-in-out infinite;
}
.empty-folder-icon-wrap svg {
filter: drop-shadow(0 0 8px rgba(124, 230, 255, 0.45));
}
.empty-beam {
position: absolute;
width: 78px;
height: 1px;
top: 50%;
transform: translateY(-50%);
background: linear-gradient(90deg, transparent, rgba(0, 245, 212, 0.95), transparent);
animation: beam-flicker 2.4s ease-in-out infinite;
}
.beam-left {
left: -34px;
}
.beam-right {
right: -34px;
}
.empty-copy {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
max-width: 560px;
}
.empty-kicker {
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.24em;
text-transform: uppercase;
}
.empty-title {
color: var(--text-primary);
font-family: var(--font-display);
font-size: 22px;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.empty-sub {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.8;
}
.empty-actions {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.empty-cta {
min-width: 188px;
justify-content: center;
}
@keyframes folder-float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-5px); }
}
@keyframes core-breathe {
0%, 100% {
transform: scale(1);
opacity: 0.92;
}
50% {
transform: scale(1.04);
opacity: 1;
}
}
@keyframes pulse-ring {
0%, 100% {
transform: scale(0.92);
opacity: 0.35;
}
50% {
transform: scale(1.06);
opacity: 0.78;
}
}
@keyframes beam-flicker {
0%, 100% {
opacity: 0.35;
transform: translateY(-50%) scaleX(0.92);
}
50% {
opacity: 1;
transform: translateY(-50%) scaleX(1.06);
}
}
@keyframes hologram-scan {
0% {
transform: translateX(-130%);
opacity: 0;
}
20% {
opacity: 0.55;
}
100% {
transform: translateX(130%);
opacity: 0;
}
}
@keyframes activate-flash {
0% {
opacity: 0;
transform: scale(0.92);
}
35% {
opacity: 0.95;
transform: scale(1.03);
}
100% {
opacity: 0;
transform: scale(1.08);
}
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.folder-tile:hover .folder-glyph,
.folder-tile:hover .empty-folder-chamber {
animation-duration: 2.3s;
}
.folder-tile:hover .folder-core,
.folder-tile:hover .folder-pulse-ring,
.folder-tile:hover .empty-folder-icon-wrap,
.folder-tile:hover .empty-folder-pulse {
animation-duration: 2s;
}
.folder-tile:hover .folder-beam,
.folder-tile:hover .empty-beam {
animation-duration: 1.2s;
}
@media (prefers-reduced-motion: reduce) {
.folder-glyph,
.folder-pulse-ring,
.folder-core,
.folder-beam,
.folder-scan,
.folder-tech-corners .corner,
.empty-folder-chamber,
.empty-folder-pulse,
.empty-folder-icon-wrap,
.empty-beam {
animation: none !important;
}
}
@media (max-width: 700px) {
.explorer-grid {
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 14px;
}
.explorer-tile {
min-height: 204px;
padding: 16px 14px 14px;
}
.folder-glyph {
width: 90px;
height: 84px;
margin-top: 18px;
}
.folder-pulse-ring {
width: 96px;
height: 96px;
}
.folder-core {
width: 84px;
height: 72px;
}
.empty-core-shell {
width: 210px;
height: 210px;
}
.orbit-outer {
width: 210px;
height: 210px;
}
.orbit-inner {
width: 156px;
height: 156px;
}
.empty-title {
font-size: 18px;
letter-spacing: 0.12em;
}
}
.spin {
animation: spin 1s linear infinite;
}
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-lg);
padding: 24px;
width: 400px;
max-width: 90vw;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.document-dialog {
width: min(860px, 92vw);
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
gap: 12px;
}
.dialog-header h3 {
font-family: var(--font-display);
font-size: 14px;
letter-spacing: 0.1em;
color: var(--text-primary);
margin: 0;
}
.close-btn {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 4px;
border-radius: 3px;
display: flex;
align-items: center;
transition: all var(--transition-fast);
}
.close-btn:hover {
color: var(--text-primary);
background: var(--bg-panel);
}
.dialog-input {
width: 100%;
padding: 10px 14px;
background: var(--bg-panel);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 13px;
margin-bottom: 16px;
box-sizing: border-box;
}
.dialog-input:focus {
border-color: var(--accent-cyan);
outline: none;
}
.dialog-body {
font-size: 13px;
color: var(--text-secondary);
margin: 0 0 16px 0;
line-height: 1.5;
}
.dialog-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.document-info-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
margin-bottom: 14px;
}
.document-content-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
gap: 16px;
}
.document-preview,
.chunk-panel {
min-height: 360px;
max-height: 62vh;
overflow: auto;
background: rgba(3, 5, 10, 0.7);
border: 1px solid var(--border-dim);
border-radius: 14px;
padding: 16px;
}
.document-preview pre {
white-space: pre-wrap;
word-break: break-word;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.7;
}
.chunk-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.chunk-panel-title {
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.14em;
}
.chunk-panel-subtitle {
margin-top: 4px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
}
.chunk-count-badge {
min-width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid rgba(0, 245, 212, 0.2);
background: rgba(0, 245, 212, 0.08);
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 12px;
}
.chunk-loading-state {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 0;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
}
.chunk-loading-bar {
width: 100%;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(0, 245, 212, 0.15), rgba(124, 230, 255, 0.45), rgba(0, 245, 212, 0.15));
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
}
.chunk-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.chunk-card {
border: 1px solid var(--border-dim);
border-radius: 12px;
background: rgba(8, 15, 24, 0.82);
padding: 12px;
}
.chunk-card.editing {
border-color: rgba(0, 245, 212, 0.4);
box-shadow: inset 0 0 0 1px rgba(0, 245, 212, 0.08);
}
.chunk-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.chunk-card-meta {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.chunk-index {
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
}
.chunk-size {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
}
.chunk-meta-raw {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
line-height: 1.5;
word-break: break-word;
}
.chunk-content {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
}
.chunk-edit-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.chunk-textarea {
width: 100%;
min-height: 150px;
resize: vertical;
background: rgba(0, 9, 19, 0.9);
border: 1px solid var(--border-dim);
border-radius: 10px;
color: var(--text-primary);
padding: 12px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
box-sizing: border-box;
}
.chunk-textarea:focus {
border-color: var(--accent-cyan);
outline: none;
}
.chunk-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.chunk-error {
width: 100%;
}
.chunk-empty {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
}
.preview-loading {
display: flex;
align-items: center;
gap: 8px;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 11px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--bg-panel);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.1em;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn:hover {
border-color: var(--border-mid);
background: var(--bg-card-hover);
}
.btn.primary {
background: var(--accent-cyan-dim);
border-color: rgba(0, 245, 212, 0.3);
color: var(--accent-cyan);
}
.btn.primary:hover {
background: rgba(0, 245, 212, 0.2);
box-shadow: var(--glow-cyan);
}
.btn.danger {
background: rgba(255, 71, 87, 0.1);
border-color: rgba(255, 71, 87, 0.3);
color: var(--accent-red);
}
.btn.danger:hover {
background: rgba(255, 71, 87, 0.2);
box-shadow: 0 0 12px rgba(255, 71, 87, 0.2);
}
@media (max-width: 900px) {
.toolbar {
flex-direction: column;
align-items: stretch;
}
.toolbar-left,
.toolbar-right {
width: 100%;
}
.toolbar-right {
display: flex;
justify-content: flex-start;
}
.document-content-grid {
grid-template-columns: 1fr;
}
}
</style>