feat(workspace): 重构工作区组件,优化路径选择和面包屑导航功能

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Wenjie Zhang 2026-04-30 22:40:55 +08:00
parent 9ff925e4df
commit 6fcfb090de
4 changed files with 77 additions and 54 deletions

View File

@ -2,22 +2,34 @@
<section class="workspace-file-list"> <section class="workspace-file-list">
<div class="file-list-header"> <div class="file-list-header">
<div class="path-line"> <div class="path-line">
<button <a-breadcrumb class="path-breadcrumb">
type="button" <a-breadcrumb-item v-for="item in breadcrumbItems" :key="item.path">
class="path-action" <button
:disabled="currentPath === '/'" type="button"
aria-label="返回上级目录" class="breadcrumb-action"
@click="$emit('go-parent')" :class="{ current: item.path === currentPath }"
> :disabled="item.path === currentPath"
<ChevronLeft :size="16" /> :title="item.path"
</button> @click="$emit('select-path', item.path)"
<span class="path-text" :title="currentPath">{{ currentPath }}</span> >
{{ item.name }}
</button>
</a-breadcrumb-item>
</a-breadcrumb>
</div> </div>
<div class="list-actions"> <div class="list-actions">
<span class="entry-count">{{ entries.length }} </span> <span class="entry-count">{{ entries.length }} </span>
<a-button size="small" :type="selectionMode ? 'primary' : 'default'" @click="toggleSelectionMode"> <a-tooltip title="多选">
多选 <a-button
</a-button> size="small"
class="lucide-icon-btn"
:type="selectionMode ? 'primary' : 'default'"
aria-label="多选"
@click="toggleSelectionMode"
>
<ListChecks :size="14" />
</a-button>
</a-tooltip>
<a-button <a-button
v-if="selectionMode" v-if="selectionMode"
size="small" size="small"
@ -122,7 +134,7 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { ChevronLeft, Download, Folder, MoreHorizontal, Trash2 } from 'lucide-vue-next' import { Download, Folder, ListChecks, MoreHorizontal, Trash2 } from 'lucide-vue-next'
import { formatFileSize, formatRelativeTime, getFileIcon, getFileIconColor } from '@/utils/file_utils' import { formatFileSize, formatRelativeTime, getFileIcon, getFileIconColor } from '@/utils/file_utils'
const props = defineProps({ const props = defineProps({
@ -137,7 +149,7 @@ const props = defineProps({
const emit = defineEmits([ const emit = defineEmits([
'select-entry', 'select-entry',
'go-parent', 'select-path',
'update:selectedPaths', 'update:selectedPaths',
'update:selectionMode', 'update:selectionMode',
'delete-selected', 'delete-selected',
@ -148,6 +160,23 @@ const emit = defineEmits([
const selectedPathSet = computed(() => new Set(props.selectedPaths)) const selectedPathSet = computed(() => new Set(props.selectedPaths))
const deletingPathSet = computed(() => new Set(props.deletingPaths)) const deletingPathSet = computed(() => new Set(props.deletingPaths))
const entryPaths = computed(() => props.entries.map((entry) => entry.path)) const entryPaths = computed(() => props.entries.map((entry) => entry.path))
const breadcrumbItems = computed(() => {
const normalizedPath = props.currentPath || '/'
if (normalizedPath === '/') {
return [{ name: '工作区', path: '/' }]
}
const segments = normalizedPath.split('/').filter(Boolean)
return segments.reduce(
(items, segment) => {
const parentPath = items[items.length - 1].path
const path = parentPath === '/' ? `/${segment}` : `${parentPath}/${segment}`
items.push({ name: segment, path })
return items
},
[{ name: '工作区', path: '/' }]
)
})
const allSelected = computed(() => { const allSelected = computed(() => {
return entryPaths.value.length > 0 && entryPaths.value.every((path) => selectedPathSet.value.has(path)) return entryPaths.value.length > 0 && entryPaths.value.every((path) => selectedPathSet.value.has(path))
@ -213,37 +242,34 @@ const toggleEntrySelection = (path, checked) => {
flex: 0 0 auto; flex: 0 0 auto;
} }
.path-action { .path-breadcrumb {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: 1px solid var(--gray-150);
border-radius: 8px;
background: var(--gray-0);
color: var(--gray-600);
cursor: pointer;
&:disabled {
color: var(--gray-300);
cursor: not-allowed;
}
&:hover:not(:disabled) {
background: var(--main-20);
color: var(--main-color);
}
}
.path-text {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
color: var(--gray-900);
font-size: 14px; font-size: 14px;
font-weight: 600; }
.breadcrumb-action {
max-width: 180px;
padding: 0;
overflow: hidden;
border: 0;
background: transparent;
color: var(--main-800);
cursor: pointer;
font: inherit;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-weight: 400;
&:hover:not(:disabled) {
color: var(--main-600);
}
&.current,
&:disabled {
color: var(--gray-900);
cursor: default;
}
} }
.entry-count { .entry-count {

View File

@ -5,10 +5,12 @@
:file="file" :file="file"
:file-path="filePath" :file-path="filePath"
:show-download="false" :show-download="false"
:show-close="true"
:show-fullscreen="true" :show-fullscreen="true"
:full-height="true" :full-height="true"
container-class="workspace-preview-container" container-class="workspace-preview-container"
content-class="workspace-preview-content" content-class="workspace-preview-content"
@close="$emit('close')"
/> />
<div v-else-if="loading" class="preview-state"> <div v-else-if="loading" class="preview-state">
<a-spin /> <a-spin />
@ -31,6 +33,8 @@ defineProps({
filePath: { type: String, default: '' }, filePath: { type: String, default: '' },
loading: { type: Boolean, default: false } loading: { type: Boolean, default: false }
}) })
defineEmits(['close'])
</script> </script>
<style scoped lang="less"> <style scoped lang="less">

View File

@ -104,8 +104,9 @@ defineEmits(['select-personal', 'select-database', 'select-path'])
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 8px; border-radius: 8px;
background: transparent; background: transparent;
color: var(--gray-700); color: var(--gray-600);
font-size: 14px; font-size: 14px;
font-weight: 600;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
transition: transition:
@ -118,13 +119,13 @@ defineEmits(['select-personal', 'select-database', 'select-path'])
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-weight: 500;
} }
&:hover:not(:disabled), &:hover:not(:disabled),
&.active { &.active {
border-color: transparent; border-color: transparent;
background: var(--main-20); color: var(--main-600);
color: var(--main-color);
} }
&.secondary { &.secondary {

View File

@ -59,7 +59,7 @@
:selection-mode="selectionMode" :selection-mode="selectionMode"
:loading="loadingTree" :loading="loadingTree"
@select-entry="handleSelectEntry" @select-entry="handleSelectEntry"
@go-parent="goParentDirectory" @select-path="selectWorkspacePath"
@update:selected-paths="selectedPaths = $event" @update:selected-paths="selectedPaths = $event"
@update:selection-mode="handleSelectionModeChange" @update:selection-mode="handleSelectionModeChange"
@delete-selected="confirmDeleteEntries(selectedEntries)" @delete-selected="confirmDeleteEntries(selectedEntries)"
@ -79,6 +79,7 @@
:file="previewFile" :file="previewFile"
:file-path="selectedEntry?.path || ''" :file-path="selectedEntry?.path || ''"
:loading="loadingPreview" :loading="loadingPreview"
@close="closePreview"
/> />
</template> </template>
@ -338,15 +339,6 @@ const closePreview = () => {
revokePreviewObjectUrl() revokePreviewObjectUrl()
} }
const goParentDirectory = async () => {
if (currentPath.value === '/') return
const trimmed = currentPath.value.replace(/\/$/, '')
const parent = trimmed.slice(0, trimmed.lastIndexOf('/')) || '/'
closePreview()
clearWorkspaceSelection()
await loadWorkspaceEntries(parent)
}
const openCreateDirectoryModal = () => { const openCreateDirectoryModal = () => {
if (activeSourceKey.value !== 'personal') return if (activeSourceKey.value !== 'personal') return
newDirectoryName.value = '' newDirectoryName.value = ''