Feature(custom): add selected inhint and optimize UI of manage file explorer page

This commit is contained in:
Kuingsmile
2026-01-03 15:12:42 +08:00
parent 0db59266a2
commit 94fe4f4972
8 changed files with 3865 additions and 4026 deletions

View File

@@ -1,6 +1,6 @@
module.exports = { module.exports = {
extends: ['stylelint-config-standard', 'stylelint-config-html/vue', 'stylelint-config-standard-vue'], extends: ['stylelint-config-standard', 'stylelint-config-html/vue', 'stylelint-config-standard-vue'],
plugins: ['stylelint-order'], plugins: [],
rules: { rules: {
// 这里是允许了空的style标签 // 这里是允许了空的style标签
'no-empty-source': null, 'no-empty-source': null,
@@ -36,83 +36,5 @@ module.exports = {
// property-no-vendor-prefix // property-no-vendor-prefix
'property-no-vendor-prefix': true, 'property-no-vendor-prefix': true,
// 属性的排序 // 属性的排序
'order/properties-order': [
'position',
'top',
'right',
'bottom',
'left',
'z-index',
'display',
'justify-content',
'align-items',
'float',
'clear',
'overflow',
'overflow-x',
'overflow-y',
'margin',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'border',
'border-style',
'border-width',
'border-color',
'border-top',
'border-top-style',
'border-top-width',
'border-top-color',
'border-right',
'border-right-style',
'border-right-width',
'border-right-color',
'border-bottom',
'border-bottom-style',
'border-bottom-width',
'border-bottom-color',
'border-left',
'border-left-style',
'border-left-width',
'border-left-color',
'border-radius',
'padding',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'width',
'min-width',
'max-width',
'height',
'min-height',
'max-height',
'font-size',
'font-family',
'font-weight',
'text-align',
'text-justify',
'text-indent',
'text-overflow',
'text-decoration',
'white-space',
'color',
'background',
'background-position',
'background-repeat',
'background-size',
'background-color',
'background-clip',
'opacity',
'filter',
'list-style',
'outline',
'visibility',
'box-shadow',
'text-shadow',
'resize',
'transition',
],
}, },
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<div ref="containerRef" class="virtual-scroller" :style="{ height: `${containerHeight}px` }" @scroll="handleScroll"> <div ref="containerRef" class="virtual-scroller" @scroll="handleScroll">
<div class="virtual-scroller-content" :style="contentStyles"> <div class="virtual-scroller-content" :style="contentStyles">
<div <div
class="virtual-scroller-viewport" class="virtual-scroller-viewport"
@@ -8,15 +8,11 @@
> >
<div <div
v-for="realIndex in visibleIndexes" v-for="realIndex in visibleIndexes"
:key=" :key="items[realIndex] && items[realIndex][keyField || 'id'] ? items[realIndex][keyField || 'id'] : realIndex"
itemsRef[realIndex] && itemsRef[realIndex][props.keyField || 'id']
? itemsRef[realIndex][props.keyField || 'id']
: realIndex
"
class="virtual-scroller-item" class="virtual-scroller-item"
:style="itemStyle" :style="itemStyle"
> >
<slot :item="itemsRef[realIndex]" :index="realIndex" /> <slot :item="items[realIndex]" :index="realIndex" />
</div> </div>
</div> </div>
</div> </div>
@@ -24,73 +20,43 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue'
import { useVirtualGrid } from '@/hooks/useVirtualGrid' import { useVirtualGrid } from '@/hooks/useVirtualGrid'
type Item = any
interface Breakpoint { interface Breakpoint {
min: number min: number
cols: number cols: number
} }
const props = withDefaults( const {
defineProps<{ items,
items: Item[] itemHeight,
itemHeight: number gridBreakpoints = [],
height?: number bufferFactor = 0.5,
gridItems?: number keyField = 'id',
gridBreakpoints?: Breakpoint[] itemPadding = 8,
bufferFactor?: number viewMode = 'grid',
pageMode?: boolean } = defineProps<{
keyField?: string items: any[]
itemPadding?: number itemHeight: number
viewMode?: 'list' | 'grid' gridBreakpoints?: Breakpoint[]
}>(), bufferFactor?: number
{ keyField?: string
height: 400, itemPadding?: number
gridItems: 1, viewMode?: 'list' | 'grid'
gridBreakpoints: () => [], }>()
bufferFactor: 0.5,
pageMode: false,
keyField: 'id',
itemPadding: 0,
viewMode: 'grid',
},
)
const containerRef = useTemplateRef('containerRef') const containerRef = useTemplateRef('containerRef')
const containerHeight = ref<number>(props.pageMode ? 0 : props.height) const containerHeight = ref(0)
const containerWidth = ref<number>(0) const containerWidth = ref<number>(0)
const parentScrollListeners = ref<HTMLElement[]>([]) const parentScrollListeners = ref<HTMLElement[]>([])
const sortedBreakpoints = computed<Breakpoint[]>(() => [...gridBreakpoints].sort((a, b) => a.min - b.min))
const itemsRef = ref<Item[]>(props.items)
watch(
() => props.items,
v => {
itemsRef.value = v
},
)
const localViewMode = ref<'list' | 'grid'>(props.viewMode)
watch(
() => props.viewMode,
v => {
localViewMode.value = v
},
)
const sortedBreakpoints = computed<Breakpoint[]>(() => [...props.gridBreakpoints].sort((a, b) => a.min - b.min))
const isForcedList = computed(() => localViewMode.value === 'list')
const effectiveCols = computed<number>(() => { const effectiveCols = computed<number>(() => {
if (isForcedList.value) return 1 if (viewMode === 'list') return 1
const base = Math.max(1, props.gridItems || 1)
const w = containerWidth.value || 0 const w = containerWidth.value || 0
let cols = base let cols = 1
for (const bp of sortedBreakpoints.value) { for (const bp of sortedBreakpoints.value) {
if (w >= bp.min) cols = Math.max(1, bp.cols) if (w >= bp.min) cols = Math.max(1, bp.cols)
} }
@@ -101,11 +67,11 @@ const isGridMode = computed(() => effectiveCols.value > 1)
const { gridCalculations, visibleIndexes, viewportOffset, updateScrollTop, scrollToItem, scrollToTop, scrollToBottom } = const { gridCalculations, visibleIndexes, viewportOffset, updateScrollTop, scrollToItem, scrollToTop, scrollToBottom } =
useVirtualGrid({ useVirtualGrid({
items: itemsRef, items: () => items,
itemHeight: props.itemHeight, itemHeight,
containerHeight, containerHeight,
gridItems: effectiveCols, gridItems: effectiveCols,
bufferFactor: props.bufferFactor, bufferFactor,
}) })
const contentStyles = computed(() => ({ const contentStyles = computed(() => ({
@@ -118,13 +84,13 @@ const viewportStyle = computed(() => {
} }
if (isGridMode.value) { if (isGridMode.value) {
base['--items-per-row'] = String(effectiveCols.value) base['--items-per-row'] = String(effectiveCols.value)
base['--row-height'] = `${props.itemHeight}px` base['--row-height'] = `${itemHeight}px`
base['--item-gap'] = `${props.itemPadding}px` base['--item-gap'] = `${itemPadding}px`
} }
return base return base
}) })
const itemStyle = computed(() => (isGridMode.value ? {} : { height: `${props.itemHeight}px` })) const itemStyle = computed(() => (isGridMode.value ? {} : { height: `${itemHeight}px` }))
function handleScroll() { function handleScroll() {
const c = containerRef.value const c = containerRef.value
@@ -133,7 +99,6 @@ function handleScroll() {
} }
function handlePageScroll() { function handlePageScroll() {
if (!props.pageMode) return
const now = Date.now() const now = Date.now()
if (now - lastScrollTime.value < 16) return if (now - lastScrollTime.value < 16) return
lastScrollTime.value = now lastScrollTime.value = now
@@ -158,74 +123,56 @@ let ro: ResizeObserver | null = null
const lastScrollTime = ref(0) const lastScrollTime = ref(0)
function updateContainerMetrics() { function updateContainerMetrics() {
const el = containerRef.value if (!containerRef.value) return
if (!el) return const rect = containerRef.value.getBoundingClientRect()
const rect = el.getBoundingClientRect()
containerWidth.value = rect.width containerWidth.value = rect.width
if (props.pageMode) { containerHeight.value = Math.max(200, window.innerHeight - rect.top - 12)
containerHeight.value = Math.max(200, window.innerHeight - rect.top - 12)
} else {
containerHeight.value = props.height
}
} }
onMounted(() => {
const el = containerRef.value
if (!el) return
ro = new ResizeObserver(updateContainerMetrics)
ro.observe(el as unknown as Element)
if (props.pageMode) {
ro.observe(document.documentElement)
window.addEventListener('scroll', handlePageScroll, { passive: true })
let parent = el.parentElement
while (parent) {
if (parent.scrollHeight > parent.clientHeight) {
parent.addEventListener('scroll', handlePageScroll, { passive: true })
parentScrollListeners.value.push(parent)
}
parent = parent.parentElement
}
}
updateContainerMetrics()
if (props.pageMode) {
window.addEventListener('resize', updateContainerMetrics, { passive: true })
}
})
onBeforeUnmount(() => {
if (ro) ro.disconnect()
window.removeEventListener('resize', updateContainerMetrics)
if (props.pageMode) {
window.removeEventListener('scroll', handlePageScroll)
parentScrollListeners.value.forEach(parent => {
parent.removeEventListener('scroll', handlePageScroll)
})
parentScrollListeners.value = []
}
})
function scrollTo(index: number) { function scrollTo(index: number) {
scrollToItem(index) scrollToItem(index)
} }
function setViewMode(mode: 'list' | 'grid') {
localViewMode.value = mode
}
function toggleViewMode() {
setViewMode(isGridMode.value ? 'list' : 'grid')
}
function refresh() { function refresh() {
updateContainerMetrics() updateContainerMetrics()
if (containerRef.value) { if (containerRef.value) {
updateScrollTop(containerRef.value.scrollTop) updateScrollTop(containerRef.value.scrollTop)
} }
if (props.pageMode) { handlePageScroll()
handlePageScroll()
}
} }
defineExpose({ scrollTo, scrollToTop, scrollToBottom, setViewMode, toggleViewMode, refresh }) onMounted(() => {
if (!containerRef.value) return
ro = new ResizeObserver(updateContainerMetrics)
ro.observe(containerRef.value)
ro.observe(document.documentElement)
window.addEventListener('scroll', handlePageScroll, { passive: true })
let parent = containerRef.value.parentElement
while (parent) {
if (parent.scrollHeight > parent.clientHeight) {
parent.addEventListener('scroll', handlePageScroll, { passive: true })
parentScrollListeners.value.push(parent)
}
parent = parent.parentElement
}
updateContainerMetrics()
window.addEventListener('resize', updateContainerMetrics, { passive: true })
})
onBeforeUnmount(() => {
if (ro) ro.disconnect()
window.removeEventListener('resize', updateContainerMetrics)
window.removeEventListener('scroll', handlePageScroll)
parentScrollListeners.value.forEach(parent => {
parent.removeEventListener('scroll', handlePageScroll)
})
parentScrollListeners.value = []
})
defineExpose({ scrollTo, scrollToTop, scrollToBottom, refresh })
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,47 +1,44 @@
import type { Ref } from 'vue' import type { MaybeRefOrGetter } from 'vue'
import { computed, isRef, ref, watch } from 'vue' import { computed, ref, toValue, watch } from 'vue'
export interface UseVirtualGridOptions { export interface UseVirtualGridOptions {
items: Ref<any[]> items: MaybeRefOrGetter<any[]>
itemHeight: number itemHeight: number
containerHeight: Ref<number> containerHeight: MaybeRefOrGetter<number>
gridItems?: number | Ref<number> gridItems?: number | MaybeRefOrGetter<number>
bufferFactor?: number bufferFactor?: number
} }
export function useVirtualGrid(options: UseVirtualGridOptions) { export function useVirtualGrid(options: UseVirtualGridOptions) {
const { items, itemHeight, containerHeight, gridItems = 1, bufferFactor = 0.5 } = options const { items, itemHeight, containerHeight, gridItems = 1, bufferFactor = 0.5 } = options
const gridItemsRef = isRef(gridItems) ? gridItems : ref(gridItems)
const scrollTop = ref(0) const scrollTop = ref(0)
const gridCalculations = computed(() => { const gridCalculations = computed(() => {
const itemsPerRow = Math.max(1, gridItemsRef.value || 1) const currentItems = toValue(items)
const totalRows = Math.ceil(items.value.length / itemsPerRow) const itemsPerRow = Math.max(1, toValue(gridItems) || 1)
const rowHeight = itemHeight const totalRows = Math.ceil(currentItems.length / itemsPerRow)
const totalHeight = totalRows * rowHeight const totalHeight = totalRows * itemHeight
return { return {
itemsPerRow, itemsPerRow,
totalRows, totalRows,
rowHeight, itemHeight,
totalHeight, totalHeight,
} }
}) })
const visibleRange = computed(() => { const visibleRange = computed(() => {
const { rowHeight, totalRows } = gridCalculations.value const { itemHeight, totalRows } = gridCalculations.value
const height = containerHeight.value const height = toValue(containerHeight)
if (!height || !rowHeight || totalRows === 0) { if (!height || !itemHeight || totalRows === 0) {
return { startRow: 0, endRow: 0, visibleRows: 0 } return { startRow: 0, endRow: 0, visibleRows: 0 }
} }
const buffer = Math.ceil((height / itemHeight) * bufferFactor)
const buffer = Math.ceil((height / rowHeight) * bufferFactor) const startRow = Math.max(0, Math.floor(scrollTop.value / itemHeight) - buffer)
const startRow = Math.max(0, Math.floor(scrollTop.value / rowHeight) - buffer) const visibleRows = Math.ceil(height / itemHeight) + buffer * 2
const visibleRows = Math.ceil(height / rowHeight) + buffer * 2
const endRow = Math.min(totalRows, startRow + visibleRows) const endRow = Math.min(totalRows, startRow + visibleRows)
return { startRow, endRow, visibleRows } return { startRow, endRow, visibleRows }
}) })
@@ -53,7 +50,7 @@ export function useVirtualGrid(options: UseVirtualGridOptions) {
for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) { for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) {
for (let col = 0; col < itemsPerRow; col++) { for (let col = 0; col < itemsPerRow; col++) {
const itemIndex = rowIndex * itemsPerRow + col const itemIndex = rowIndex * itemsPerRow + col
if (itemIndex < items.value.length) { if (itemIndex < toValue(items).length) {
indexes.push(itemIndex) indexes.push(itemIndex)
} }
} }
@@ -63,9 +60,9 @@ export function useVirtualGrid(options: UseVirtualGridOptions) {
}) })
const viewportOffset = computed(() => { const viewportOffset = computed(() => {
const { rowHeight } = gridCalculations.value const { itemHeight } = gridCalculations.value
const { startRow } = visibleRange.value const { startRow } = visibleRange.value
return startRow * rowHeight return startRow * itemHeight
}) })
function updateScrollTop(newScrollTop: number) { function updateScrollTop(newScrollTop: number) {
@@ -73,9 +70,9 @@ export function useVirtualGrid(options: UseVirtualGridOptions) {
} }
function scrollToItem(index: number) { function scrollToItem(index: number) {
const { itemsPerRow, rowHeight } = gridCalculations.value const { itemsPerRow, itemHeight } = gridCalculations.value
const rowIndex = Math.floor(index / itemsPerRow) const rowIndex = Math.floor(index / itemsPerRow)
scrollTop.value = rowIndex * rowHeight scrollTop.value = rowIndex * itemHeight
} }
function scrollToTop() { function scrollToTop() {
@@ -84,24 +81,20 @@ export function useVirtualGrid(options: UseVirtualGridOptions) {
function scrollToBottom() { function scrollToBottom() {
const { totalHeight } = gridCalculations.value const { totalHeight } = gridCalculations.value
scrollTop.value = Math.max(0, totalHeight - containerHeight.value) scrollTop.value = Math.max(0, totalHeight - toValue(containerHeight))
} }
watch(containerHeight, () => {
const { totalHeight } = gridCalculations.value
if (scrollTop.value > totalHeight - containerHeight.value) {
scrollTop.value = Math.max(0, totalHeight - containerHeight.value)
}
})
watch( watch(
() => items.value.length, () => [toValue(containerHeight), toValue(items).length],
() => { ([newHeight]) => {
const { totalHeight } = gridCalculations.value const { totalHeight } = gridCalculations.value
if (scrollTop.value > totalHeight - containerHeight.value) { const maxScroll = Math.max(0, totalHeight - (newHeight as number))
scrollTop.value = Math.max(0, totalHeight - containerHeight.value)
if (scrollTop.value > maxScroll) {
scrollTop.value = maxScroll
} }
}, },
{ flush: 'post' },
) )
return { return {

View File

@@ -266,246 +266,138 @@
</div> </div>
<!-- Content Card --> <!-- Content Card -->
<div class="bucket-card content-card"> <!-- Fullscreen Header (only visible in fullscreen mode) -->
<!-- Fullscreen Header (only visible in fullscreen mode) --> <div v-if="isContentFullscreen" class="bucket-card fullscreen-header">
<div v-if="isContentFullscreen" class="fullscreen-header"> <div class="fullscreen-header-left">
<div class="fullscreen-header-left"> <div class="fullscreen-breadcrumb">
<div class="fullscreen-breadcrumb"> <HomeIcon class="action-icon" />
<HomeIcon class="action-icon" /> <template v-if="configMap.prefix !== '/'">
<template v-if="configMap.prefix !== '/'"> <template v-for="(item, index) in configMap.prefix.replace(/\/$/g, '').split('/')" :key="index">
<template v-for="(item, index) in configMap.prefix.replace(/\/$/g, '').split('/')" :key="index"> <ChevronRightIcon class="breadcrumb-separator" />
<ChevronRightIcon class="breadcrumb-separator" /> <button class="breadcrumb-item" @click="handleBreadcrumbClick(Number(index))">
<button class="breadcrumb-item" @click="handleBreadcrumbClick(Number(index))"> {{ item === '' ? t('pages.manage.bucket.rootFolder') : item }}
{{ item === '' ? t('pages.manage.bucket.rootFolder') : item }} </button>
</button>
</template>
</template> </template>
<template v-else> </template>
<span class="breadcrumb-item current"> <template v-else>
{{ t('pages.manage.bucket.rootFolder') }} <span class="breadcrumb-item current">
</span> {{ t('pages.manage.bucket.rootFolder') }}
</template> </span>
</div> </template>
</div> </div>
</div>
<div class="fullscreen-header-center"> <div class="fullscreen-header-center">
<div class="file-info"> <div class="file-info">
<div class="file-info-item"> <div class="file-info-item">
<FileIcon class="action-icon" /> <FileIcon class="action-icon" />
<span>{{ `${t('pages.manage.bucket.fileNum', { num: currentPageFilesInfo.length })}` }}</span> <span>{{ `${t('pages.manage.bucket.fileNum', { num: currentPageFilesInfo.length })}` }}</span>
</div>
<div class="file-info-item">
<span>{{ `${t('pages.manage.bucket.pageFileSize', { size: calculateAllFileSize })}` }}</span>
</div>
</div> </div>
</div> <div class="file-info-item">
<span>{{ `${t('pages.manage.bucket.pageFileSize', { size: calculateAllFileSize })}` }}</span>
<div class="fullscreen-header-right">
<!-- Search -->
<input
v-model="searchText"
type="text"
class="search-input"
:placeholder="t('pages.manage.bucket.searchPlaceholder')"
/>
<!-- Exit Fullscreen -->
<div class="tooltip">
<button class="action-button secondary" @click="toggleContentFullscreen">
<ShrinkIcon class="action-icon" />
<span class="tooltip-text">{{ t('pages.manage.bucket.exitFullScreen') }}</span>
</button>
</div> </div>
</div> </div>
</div> </div>
<div class="content-area"> <div class="fullscreen-header-right">
<!-- Virtual Scroller --> <!-- Search -->
<div class="virtual-scroller-container"> <input
<VirtualScroller v-model="searchText"
ref="virtualScrollerRef" type="text"
:items="filterList" class="search-input"
:item-height="layoutStyle === 'grid' ? 240 : 70" :placeholder="t('pages.manage.bucket.searchPlaceholder')"
:view-mode="layoutStyle" />
:grid-breakpoints="gridBreakpoints"
:page-mode="true" <!-- Exit Fullscreen -->
:buffer-factor="0.5" <div class="tooltip">
key-field="key" <button class="action-button secondary" @click="toggleContentFullscreen">
:item-padding="8" <ShrinkIcon class="action-icon" />
<span class="tooltip-text">{{ t('pages.manage.bucket.exitFullScreen') }}</span>
</button>
</div>
</div>
</div>
<div class="bucket-card content-area">
<!-- Virtual Scroller -->
<div v-if="filterList.length === 0" class="empty-state">
<ImageIcon :size="64" class="empty-icon" />
<h3>{{ t('pages.gallery.noImagesFound') }}</h3>
<p>{{ t('pages.gallery.tryAdjustingFilters') }}</p>
</div>
<VirtualScroller
v-else
ref="virtualScrollerRef"
:items="filterList"
class="virtual-gallery-scroller"
:item-height="layoutStyle === 'grid' ? 240 : 70"
:view-mode="layoutStyle"
:grid-breakpoints="gridBreakpoints"
key-field="key"
>
<template #default="{ item, index }">
<!-- Grid View -->
<div
v-if="layoutStyle === 'grid'"
class="file-grid-item"
:class="{ selected: item.checked }"
@click="handleClickFile(item)"
> >
<template #default="{ item, index }"> <div class="file-preview">
<!-- Grid View --> <!-- Image Preview -->
<div <template v-if="!item.isDir && !['webdavplist', 'sftp', 'local', 's3plist'].includes(currentPicBedName)">
v-if="layoutStyle === 'grid'" <img v-if="isShowThumbnail && item.isImage" :src="item.url" class="file-image" @error="() => {}" />
class="file-grid-item" <img v-else :src="`./assets/icons/${getFileIconPath(item.fileName ?? '')}`" class="file-image" />
:class="{ selected: item.checked }" </template>
@click="handleClickFile(item)"
>
<div class="file-preview">
<!-- Image Preview -->
<template
v-if="!item.isDir && !['webdavplist', 'sftp', 'local', 's3plist'].includes(currentPicBedName)"
>
<img v-if="isShowThumbnail && item.isImage" :src="item.url" class="file-image" @error="() => {}" />
<img v-else :src="`./assets/icons/${getFileIconPath(item.fileName ?? '')}`" class="file-image" />
</template>
<!-- S3 PreSign Image --> <!-- S3 PreSign Image -->
<ImagePreSign <ImagePreSign
v-else-if="!item.isDir && currentPicBedName === 's3plist' && isUsePreSignedUrl" v-else-if="!item.isDir && currentPicBedName === 's3plist' && isUsePreSignedUrl"
:is-show-thumbnail="isShowThumbnail" :is-show-thumbnail="isShowThumbnail"
:item="item" :item="item"
:alias="configMap.alias" :alias="configMap.alias"
:url="item.url" :url="item.url"
:config="handleGetS3Config(item)" :config="handleGetS3Config(item)"
/> />
<!-- WebDAV Image --> <!-- WebDAV Image -->
<ImageWebdav <ImageWebdav
v-else-if="!item.isDir && currentPicBedName === 'webdavplist' && item.isImage" v-else-if="!item.isDir && currentPicBedName === 'webdavplist' && item.isImage"
:is-show-thumbnail="isShowThumbnail" :is-show-thumbnail="isShowThumbnail"
:item="item" :item="item"
:config="handleGetWebdavConfig()" :config="handleGetWebdavConfig()"
:url="item.url" :url="item.url"
/> />
<!-- Local Image --> <!-- Local Image -->
<ImageLocal <ImageLocal
v-else-if="!item.isDir && currentPicBedName === 'local' && item.isImage" v-else-if="!item.isDir && currentPicBedName === 'local' && item.isImage"
:is-show-thumbnail="isShowThumbnail" :is-show-thumbnail="isShowThumbnail"
:item="item" :item="item"
:local-path="item.key" :local-path="item.key"
/> />
<!-- Default File Icon --> <!-- Default File Icon -->
<template v-else-if="!item.isDir"> <template v-else-if="!item.isDir">
<img :src="`./assets/icons/${getFileIconPath(item.fileName ?? '')}`" class="file-image" /> <img :src="`./assets/icons/${getFileIconPath(item.fileName ?? '')}`" class="file-image" />
</template> </template>
<!-- Folder Icon --> <!-- Folder Icon -->
<template v-else> <template v-else>
<FolderIcon class="file-icon" /> <FolderIcon class="file-icon" />
</template> </template>
</div> </div>
<div class="file-info-section"> <div class="file-info-section">
<div class="file-name" :title="item.fileName" @click.stop="copyToClipboard(item.fileName ?? '')"> <div class="file-name" :title="item.fileName" @click.stop="copyToClipboard(item.fileName ?? '')">
{{ formatFileName(item.fileName ?? '', 25) }} {{ formatFileName(item.fileName ?? '', 25) }}
</div>
<div class="file-meta">
<span>{{ formatFileSize(item.fileSize) }}</span>
<span>{{ item.formatedTime }}</span>
</div>
<div class="file-actions">
<div class="file-action-group">
<!-- Rename -->
<button
v-if="!item.isDir && isShowRenameFileIcon"
class="file-action-button"
@click.stop="handleRenameFile(item)"
>
<EditIcon class="action-icon" />
</button>
<!-- Download Folder -->
<button
v-if="item.isDir"
class="file-action-button"
@click.stop="handleFolderBatchDownload(item)"
>
<DownloadIcon class="action-icon" />
</button>
<!-- Copy Link Dropdown -->
<div class="file-actions-dropdown" :data-dropdown-index="index">
<button class="file-action-button" @click.stop="toggleCopyDropdown(index, $event)">
<CopyIcon class="action-icon" />
</button>
<teleport to="body">
<div
v-if="copyDropdownIndex === index"
class="file-actions-dropdown-content floating"
:style="getDropdownStyle(index)"
data-floating-dropdown
>
<div
v-for="format in linkFormatList"
:key="format"
class="file-actions-dropdown-item"
@click.stop="copyLink(item, format)"
>
{{ t(`pages.manage.bucket.linkFormat.${format}`) }}
</div>
<div
v-if="isShowPresignedUrl"
class="file-actions-dropdown-item"
@click.stop="async () => copyToClipboard(await getPreSignedUrl(item))"
>
{{ t('pages.manage.bucket.linkFormat.presign') }}
</div>
</div>
</teleport>
</div>
<!-- File Info -->
<button class="file-action-button" @click.stop="handleShowFileInfo(item)">
<InfoIcon class="action-icon" />
</button>
<!-- Delete -->
<button class="file-action-button danger" @click.stop="handleDeleteFile(item)">
<Trash2Icon class="action-icon" />
</button>
</div>
<!-- Checkbox -->
<input v-model="item.checked" type="checkbox" class="file-checkbox" @click.stop />
</div>
</div>
</div> </div>
<div class="file-meta">
<!-- List View --> <span>{{ formatFileSize(item.fileSize) }}</span>
<div <span>{{ item.formatedTime }}</span>
v-else </div>
class="file-list-item" <div class="file-actions">
:class="{ selected: item.checked }" <div class="file-action-group">
@click="handleCheckChangeOther(item)"
>
<!-- Checkbox -->
<input v-model="item.checked" type="checkbox" class="file-list-checkbox file-checkbox" @click.stop />
<!-- Icon -->
<div class="file-list-icon">
<template v-if="!item.isDir">
<img
v-if="isShowThumbnail && item.isImage"
:src="item.url"
class="file-image"
style="border-radius: 4px; width: 32px; height: 32px; object-fit: cover"
@error="() => {}"
/>
<img
v-else
:src="`./assets/icons/${getFileIconPath(item.fileName ?? '')}`"
style="width: 32px; height: 32px; object-fit: contain"
/>
</template>
<FolderIcon v-else class="file-icon" style="width: 32px; height: 32px" />
</div>
<!-- File Info -->
<div class="file-list-info" @click.stop="handleClickFile(item)">
<div class="file-list-name">
{{ formatFileName(item.fileName ?? '', 40) }}
</div>
<div class="file-list-meta">
<span>{{ formatFileSize(item.fileSize) }}</span>
<span>{{ item.formatedTime }}</span>
</div>
</div>
<!-- Actions -->
<div class="file-list-actions">
<!-- Rename --> <!-- Rename -->
<button <button
v-if="!item.isDir && isShowRenameFileIcon" v-if="!item.isDir && isShowRenameFileIcon"
@@ -520,23 +412,36 @@
<DownloadIcon class="action-icon" /> <DownloadIcon class="action-icon" />
</button> </button>
<!-- Copy Link --> <!-- Copy Link Dropdown -->
<button <div class="file-actions-dropdown" :data-dropdown-index="index">
class="file-action-button" <button class="file-action-button" @click.stop="toggleCopyDropdown(index, $event)">
@click.stop=" <CopyIcon class="action-icon" />
async () => </button>
copyToClipboard( <teleport to="body">
await formatLink( <div
item.url, v-if="copyDropdownIndex === index"
item.fileName, class="file-actions-dropdown-content floating"
manageStore.config.settings.pasteFormat ?? '$markdown', :style="getDropdownStyle(index)"
manageStore.config.settings.customPasteFormat ?? '$url', data-floating-dropdown
), >
) <div
" v-for="format in linkFormatList"
> :key="format"
<CopyIcon class="action-icon" /> class="file-actions-dropdown-item"
</button> @click.stop="copyLink(item, format)"
>
{{ t(`pages.manage.bucket.linkFormat.${format}`) }}
</div>
<div
v-if="isShowPresignedUrl"
class="file-actions-dropdown-item"
@click.stop="async () => copyToClipboard(await getPreSignedUrl(item))"
>
{{ t('pages.manage.bucket.linkFormat.presign') }}
</div>
</div>
</teleport>
</div>
<!-- File Info --> <!-- File Info -->
<button class="file-action-button" @click.stop="handleShowFileInfo(item)"> <button class="file-action-button" @click.stop="handleShowFileInfo(item)">
@@ -548,11 +453,95 @@
<Trash2Icon class="action-icon" /> <Trash2Icon class="action-icon" />
</button> </button>
</div> </div>
<!-- Checkbox -->
<input v-model="item.checked" type="checkbox" class="file-checkbox" @click.stop />
</div> </div>
</template> </div>
</VirtualScroller> </div>
</div>
</div> <!-- List View -->
<div v-else class="file-list-item" :class="{ selected: item.checked }" @click="handleCheckChangeOther(item)">
<!-- Checkbox -->
<input v-model="item.checked" type="checkbox" class="file-list-checkbox file-checkbox" @click.stop />
<!-- Icon -->
<div class="file-list-icon">
<template v-if="!item.isDir">
<img
v-if="isShowThumbnail && item.isImage"
:src="item.url"
class="file-image"
style="border-radius: 4px; width: 32px; height: 32px; object-fit: cover"
@error="() => {}"
/>
<img
v-else
:src="`./assets/icons/${getFileIconPath(item.fileName ?? '')}`"
style="width: 32px; height: 32px; object-fit: contain"
/>
</template>
<FolderIcon v-else class="file-icon" style="width: 32px; height: 32px" />
</div>
<!-- File Info -->
<div class="file-list-info" @click.stop="handleClickFile(item)">
<div class="file-list-name">
{{ formatFileName(item.fileName ?? '', 40) }}
</div>
<div class="file-list-meta">
<span>{{ formatFileSize(item.fileSize) }}</span>
<span>{{ item.formatedTime }}</span>
</div>
</div>
<!-- Actions -->
<div class="file-list-actions">
<!-- Rename -->
<button
v-if="!item.isDir && isShowRenameFileIcon"
class="file-action-button"
@click.stop="handleRenameFile(item)"
>
<EditIcon class="action-icon" />
</button>
<!-- Download Folder -->
<button v-if="item.isDir" class="file-action-button" @click.stop="handleFolderBatchDownload(item)">
<DownloadIcon class="action-icon" />
</button>
<!-- Copy Link -->
<button
class="file-action-button"
@click.stop="
async () =>
copyToClipboard(
await formatLink(
item.url,
item.fileName,
manageStore.config.settings.pasteFormat ?? '$markdown',
manageStore.config.settings.customPasteFormat ?? '$url',
),
)
"
>
<CopyIcon class="action-icon" />
</button>
<!-- File Info -->
<button class="file-action-button" @click.stop="handleShowFileInfo(item)">
<InfoIcon class="action-icon" />
</button>
<!-- Delete -->
<button class="file-action-button danger" @click.stop="handleDeleteFile(item)">
<Trash2Icon class="action-icon" />
</button>
</div>
</div>
</template>
</VirtualScroller>
</div> </div>
<!-- URL Upload Dialog --> <!-- URL Upload Dialog -->
@@ -779,7 +768,6 @@
) )
" "
:item-height="60" :item-height="60"
:height="300"
view-mode="list" view-mode="list"
> >
<template #default="{ item }"> <template #default="{ item }">
@@ -869,7 +857,7 @@
{{ t('pages.manage.bucket.clearAll') }} {{ t('pages.manage.bucket.clearAll') }}
</button> </button>
</div> </div>
<VirtualScroller :items="uploadingTaskList" :item-height="60" :height="400" view-mode="list"> <VirtualScroller :items="uploadingTaskList" :item-height="60" view-mode="list">
<template #default="{ item }"> <template #default="{ item }">
<div class="file-list-item"> <div class="file-list-item">
<div class="file-list-info"> <div class="file-list-info">
@@ -904,7 +892,6 @@
<VirtualScroller <VirtualScroller
:items="uploadedTaskList.filter(item => item.status === 'uploaded')" :items="uploadedTaskList.filter(item => item.status === 'uploaded')"
:item-height="60" :item-height="60"
:height="400"
view-mode="list" view-mode="list"
> >
<template #default="{ item }"> <template #default="{ item }">
@@ -944,7 +931,6 @@
<VirtualScroller <VirtualScroller
:items="uploadedTaskList.filter(item => item.status !== 'uploaded')" :items="uploadedTaskList.filter(item => item.status !== 'uploaded')"
:item-height="60" :item-height="60"
:height="400"
view-mode="list" view-mode="list"
> >
<template #default="{ item }"> <template #default="{ item }">
@@ -1045,7 +1031,7 @@
{{ t('pages.manage.bucket.openDownloadFolder') }} {{ t('pages.manage.bucket.openDownloadFolder') }}
</button> </button>
</div> </div>
<VirtualScroller :items="downloadingTaskList" :item-height="60" :height="500" view-mode="list"> <VirtualScroller :items="downloadingTaskList" :item-height="60" view-mode="list">
<template #default="{ item }"> <template #default="{ item }">
<div class="file-list-item"> <div class="file-list-item">
<div class="file-list-info"> <div class="file-list-info">
@@ -1084,7 +1070,6 @@
<VirtualScroller <VirtualScroller
:items="downloadedTaskList.filter(item => item.status === 'downloaded')" :items="downloadedTaskList.filter(item => item.status === 'downloaded')"
:item-height="60" :item-height="60"
:height="500"
view-mode="list" view-mode="list"
> >
<template #default="{ item }"> <template #default="{ item }">
@@ -1128,7 +1113,6 @@
<VirtualScroller <VirtualScroller
:items="downloadedTaskList.filter(item => item.status !== 'downloaded')" :items="downloadedTaskList.filter(item => item.status !== 'downloaded')"
:item-height="60" :item-height="60"
:height="500"
view-mode="list" view-mode="list"
> >
<template #default="{ item }"> <template #default="{ item }">

View File

@@ -12,6 +12,7 @@
/* Header Card */ /* Header Card */
.bucket-card { .bucket-card {
overflow: hidden;
border: 1px solid var(--color-border-secondary); border: 1px solid var(--color-border-secondary);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
background: var(--color-surface); background: var(--color-surface);
@@ -401,64 +402,76 @@
} }
/* Content Area */ /* Content Area */
.content-card { .content-area {
flex: 1;
display: flex; display: flex;
flex-direction: column; overflow: hidden;
min-height: 0; min-height: 0;
flex: 1;
flex-direction: column;
} }
.content-area { .content-area::-webkit-scrollbar {
position: relative; width: 8px;
overflow: visible hidden; }
padding: 0.5rem;
background: var(--color-background-secondary); .content-area::-webkit-scrollbar-track {
flex: 1; border-radius: var(--radius-md);
background: var(--color-surface-elevated);
}
.content-area::-webkit-scrollbar-thumb {
border-radius: var(--radius-md);
background: var(--color-border);
transition: var(--transition-fast);
}
.content-area::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary);
} }
/* Virtual Scroller Container */ /* Virtual Scroller Container */
.virtual-scroller-container { .virtual-gallery-scroller {
overflow: visible;
width: 100%; width: 100%;
height: 100%; flex: 1;
min-height: 0;
} }
/* File Grid Item */ /* File Grid Item */
.file-grid-item { .virtual-gallery-scroller .file-grid-item {
position: relative;
z-index: 1;
display: flex; display: flex;
overflow: visible; overflow: visible;
border: 1px solid var(--color-border-secondary); border: 1px solid var(--color-border-secondary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
height: 220px; margin: 0;
background: var(--color-surface); background: var(--color-surface);
transition: var(--transition-medium);
flex-direction: column;
cursor: pointer; cursor: pointer;
transition: var(--transition-medium);
width: 100%;
height: calc(100% - 8px);
box-sizing: border-box;
flex-direction: column;
} }
.file-grid-item:hover { .virtual-gallery-scroller .file-grid-item:hover {
z-index: 10;
border-color: var(--color-border); border-color: var(--color-border);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
transform: translateY(-2px); transform: translateY(-2px);
} }
.file-grid-item.selected { .virtual-gallery-scroller .file-grid-item.selected {
z-index: 10; border-color: var(--color-blue-common);
border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgb(59 130 246 / 20%); box-shadow: 0 0 0 2px rgb(59 130 246 / 20%);
} }
.file-preview { .virtual-gallery-scroller .file-preview {
position: relative; position: relative;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
background: var(--color-surface-elevated); background: var(--color-surface-elevated);
aspect-ratio: 16/9; flex: 1;
aspect-ratio: auto;
} }
.file-image { .file-image {
@@ -475,18 +488,17 @@
} }
.file-info-section { .file-info-section {
position: relative;
z-index: 1;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 0.75rem; padding: 0.75rem;
flex: 1; min-height: 80px;
flex-shrink: 0;
flex-direction: column; flex-direction: column;
} }
.file-name { .file-name {
overflow: hidden; overflow: hidden;
margin-bottom: 0.5rem; margin-bottom: 0.75rem;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -509,8 +521,6 @@
} }
.file-actions { .file-actions {
position: relative;
z-index: 10;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -528,8 +538,8 @@
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 0.375rem; padding: 0.375rem;
width: 28px; width: 32px;
height: 28px; height: 32px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
background: var(--color-surface-elevated); background: var(--color-surface-elevated);
transition: var(--transition-fast); transition: var(--transition-fast);
@@ -1151,35 +1161,6 @@ input:checked + .switch-slider::before {
} }
} }
/* Empty State */
.empty-state {
display: flex;
justify-content: center;
align-items: center;
padding: 4rem 2rem;
text-align: center;
flex-direction: column;
}
.empty-state-icon {
margin-bottom: 1rem;
width: 64px;
height: 64px;
color: var(--color-text-tertiary);
}
.empty-state-title {
margin-bottom: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-primary);
}
.empty-state-description {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
/* Responsive Design */ /* Responsive Design */
@media (width <= 768px) { @media (width <= 768px) {
.bucket-container { .bucket-container {
@@ -1317,3 +1298,30 @@ input:checked + .switch-slider::before {
justify-content: space-between; justify-content: space-between;
} }
} }
.empty-state {
display: flex;
justify-content: center;
align-items: center;
padding: 4rem 2rem;
text-align: center;
flex-direction: column;
}
.empty-icon {
margin-bottom: 1rem;
color: var(--color-text-secondary);
}
.empty-state h3 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
}
.empty-state p {
margin: 0;
color: var(--color-text-secondary);
}

View File

@@ -1,5 +1,9 @@
/* ManageMain Page Styles */ /* ManageMain Page Styles */
html, body {
overflow-x: hidden;
}
.manage-container { .manage-container {
display: flex; display: flex;
padding: 0.75rem; padding: 0.75rem;
@@ -260,10 +264,13 @@
} }
.content-area { .content-area {
overflow-y: auto; display: flex;
padding: 1.5rem; overflow: hidden;
min-height: 0; flex-direction: column;
flex: 1; margin: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
} }
.action-button { .action-button {

View File

@@ -202,16 +202,12 @@
v-else v-else
:key="componentKey" :key="componentKey"
ref="virtualScrollerRef" ref="virtualScrollerRef"
v-model:view-mode="viewMode" :view-mode="viewMode"
class="virtual-gallery-scroller" class="virtual-gallery-scroller"
:items="filterList" :items="filterList"
:item-height="itemHeight" :item-height="300"
:grid-items="4"
:grid-breakpoints="effectiveGridBreakpoints" :grid-breakpoints="effectiveGridBreakpoints"
key-field="key" key-field="key"
:page-mode="true"
:buffer-factor="0.5"
:item-padding="8"
> >
<template #default="{ item, index }"> <template #default="{ item, index }">
<div class="gallery-item" :class="{ selected: choosedList[item.id || ''] }"> <div class="gallery-item" :class="{ selected: choosedList[item.id || ''] }">
@@ -590,7 +586,6 @@ const viewMode = useStorage<'list' | 'grid'>('galleryViewMode', 'grid')
const componentKey = ref(0) const componentKey = ref(0)
const currentSortField = ref<'name' | 'time' | 'ext' | 'check'>('name') const currentSortField = ref<'name' | 'time' | 'ext' | 'check'>('name')
const userGridColumns = useStorage<number>('galleryGridColumns', 4) const userGridColumns = useStorage<number>('galleryGridColumns', 4)
const itemHeight = 300
const effectiveGridBreakpoints = computed(() => { const effectiveGridBreakpoints = computed(() => {
return [{ min: 0, cols: userGridColumns.value }] return [{ min: 0, cols: userGridColumns.value }]

View File

@@ -663,41 +663,9 @@ input:checked + .switch-slider::before {
padding: 1.5rem; padding: 1.5rem;
} }
.gallery-item {
overflow: hidden;
border: 1px solid var(--color-border-secondary);
border-radius: var(--radius-lg);
background: var(--color-surface);
transition: var(--transition-medium);
cursor: pointer;
}
.gallery-item:hover {
border-color: var(--color-border);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.gallery-item.selected {
border-color: var(--color-blue-common);
box-shadow: 0 0 0 2px rgb(59 130 246 / 20%);
}
/* Image Container */ /* Image Container */
.image-container {
position: relative;
aspect-ratio: 16/9;
overflow: hidden;
background: var(--color-background-secondary);
}
.gallery-image {
transition: var(--transition-medium);
}
.gallery-image.loading {
opacity: 0;
}
.image-placeholder { .image-placeholder {
display: flex; display: flex;
@@ -709,15 +677,6 @@ input:checked + .switch-slider::before {
background: var(--color-surface-elevated); background: var(--color-surface-elevated);
} }
.image-loader {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
background: var(--color-surface-elevated);
}
.loader-spinner { .loader-spinner {
border: 2px solid var(--color-border); border: 2px solid var(--color-border);
border-top: 2px solid var(--color-blue-common); border-top: 2px solid var(--color-blue-common);
@@ -733,9 +692,6 @@ input:checked + .switch-slider::before {
} }
/* Image Info */ /* Image Info */
.image-info {
padding: 1rem;
}
.image-name { .image-name {
overflow: hidden; overflow: hidden;
@@ -1348,11 +1304,28 @@ input:checked + .switch-slider::before {
/* Ensure gallery items work well with virtual scroller */ /* Ensure gallery items work well with virtual scroller */
.virtual-gallery-scroller .gallery-item { .virtual-gallery-scroller .gallery-item {
display: flex; display: flex;
overflow: hidden;
border: 1px solid var(--color-border-secondary);
border-radius: var(--radius-lg);
margin: 0; margin: 0;
background: var(--color-surface);
cursor: pointer;
width: 100%; width: 100%;
height: calc(100% - 8px); height: calc(100% - 8px);
box-sizing: border-box; box-sizing: border-box;
flex-direction: column; flex-direction: column;
transition: var(--transition-medium);
}
.virtual-gallery-scroller .gallery-item:hover {
border-color: var(--color-border);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.virtual-gallery-scroller .gallery-item.selected {
border-color: var(--color-blue-common);
box-shadow: 0 0 0 2px rgb(59 130 246 / 20%);
} }
.virtual-gallery-scroller .image-container { .virtual-gallery-scroller .image-container {
@@ -1367,6 +1340,11 @@ input:checked + .switch-slider::before {
aspect-ratio: auto; aspect-ratio: auto;
} }
.virtual-gallery-scroller .image-container.selected {
outline: 2px solid var(--color-blue-common);
box-shadow: 0 0 0 2px rgb(59 130 246 / 20%);
}
.virtual-gallery-scroller .image-info { .virtual-gallery-scroller .image-info {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1380,6 +1358,11 @@ input:checked + .switch-slider::before {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
transition: var(--transition-medium);
}
.virtual-gallery-scroller .gallery-image.loading {
opacity: 0;
} }
.virtual-gallery-scroller .image-loader { .virtual-gallery-scroller .image-loader {