mirror of
https://github.com/Kuingsmile/PicList.git
synced 2026-05-07 05:32:52 +08:00
✨ Feature(custom): add selected inhint and optimize UI of manage file explorer page
This commit is contained in:
@@ -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',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user