Feature(custom): add several new features for manage bucket page

This commit is contained in:
Kuingsmile
2026-01-24 22:46:36 +08:00
parent 6ac1b6413d
commit b6fba7d368
22 changed files with 1242 additions and 1360 deletions

View File

@@ -2,37 +2,42 @@
### 🚀 性能优化 ### 🚀 性能优化
- 减少了60-70%的闲置内存占用和20%的打开窗口时内存占用 - 减少了 **60-70%** 的闲置内存占用和 **20%** 的打开窗口时内存占用
- 优化了多个页面的加载速度和浏览性能 - 优化了多个页面的加载速度和浏览性能
### ✨ 新增功能 ### ✨ 新增功能
-功能 #### ⚙️ 核心功能
- 现在支持关闭GPU加速解决部分兼容性问题
- 新增高级动画设置开启后可获得更好的UI体验
- windows下新增便携模式无需安装即可运行数据存储在程序目录下的`data`文件夹中,支持自动更新
- Linux下新增`rpm`安装包
- 管理页面新增图床编辑卡片页面,避免了之前多配置切换时的混乱
- UI - 现在支持手动关闭 GPU 加速,解决部分硬件兼容性导致的黑屏或闪烁问题。
- 新增自定义主题功能,主题仓库[PicList ThemeHub](https://github.com/Kuingsmile/PicList-ThemeHub) - 新增高级动画设置,开启后可获得更佳的 UI 交互体验。
- 12个内置主题供选择如bilibili、二次元、极夜紫等风格 - Windows 便携模式,无需安装运行,数据存储在程序目录下的 `data` 文件夹中,且支持自动更新。
- 重新设计了管理功能的全部页面 - Linux 新增 `rpm` 安装包。
- 重构了几乎全部页面优化了数十项UI细节问题整体风格更加统一 - 新增图床编辑卡片页面,解决多配置切换时的混乱问题。
- 相册页面多项优化支持显示已选择图片数量匹配的url列表和记忆过滤器打开状态 - 文件浏览页面新增列表模式支持。
- 优化了管理文件浏览页面侧边栏名字的显示,现在在超出宽度时会滚动显示完整名称
- 插件页面现在可以浏览所有插件列表,查看详情和安装
- 新增教学引导页面,首次运行时会自动弹出
- 其它 #### 🎨 UI 界面
- 原管理功能重命名为`云端`,更符合实际功能
- 现在重置图床后不再自动返回上一页面 - 接入主题仓库 [PicList ThemeHub](https://github.com/Kuingsmile/PicList-ThemeHub),支持自定义下载。
- 提供 12 个内置主题(如 bilibili、二次元、极夜紫等风格
- 重新设计了管理功能的全部页面,重构了几乎所有业务页面,优化数十项 UI 细节。
- 优化相册页面卡片样式,边界更清晰,提升选择框视觉效果。
- 优化多个页面在窄屏下的显示,避免内容溢出。
- 文件浏览侧边栏名称超出宽度时支持滚动显示完整名称。
- 相册页面支持显示已选数量、匹配的 URL 列表和记忆过滤器状态。
- 支持浏览完整插件列表、查看详情及一键安装。
- 新增新手引导页面,首次运行自动弹出。
#### 📝 其它
- 原“管理”功能重命名为 **“云端”**,更符合实际功能定义。
- 重置图床后不再自动返回上一页面。
### 🐛 问题修复 ### 🐛 问题修复
- 修复了管理页面中排序下拉框显示异常的问题 - 修复了管理页面中排序下拉框显示异常的问题
- 修复了管理页面图床列表没有正确为当前选中图床高亮的问题 - 修复了管理页面图床列表未正确高亮当前选中项的问题
- 修复了暗色模式下任务页面的显示问题 - 修复了暗色模式下任务页面的显示异常。
- 修复了图床设置页面设置为默认图床按钮状态没有及时更新的问题 - 修复了图床设置页面设置为默认图床按钮状态更新不及时的问题
- 修复了预处理设置页面,图床水印独立设置按钮状态没有及时更新的问题 - 修复了预处理设置页面,图床水印独立设置按钮状态不同步的问题
- 修复了部分页面底部元素被遮挡的问题 - 修复了部分页面底部元素被遮挡的问题

View File

@@ -2,24 +2,42 @@
### 🚀 Performance Optimization ### 🚀 Performance Optimization
- Reduced idle memory usage by 60-70% and memory usage when opening windows by 20% - Reduced **60-70%** idle memory usage and **20%** memory usage when opening windows.
- Optimized loading speed and browsing performance for multiple pages
### ✨ New Features ### ✨ New Features
- Added portable mode on Windows, allowing the program to run without installation. Data is stored in the `data` folder within the program directory, and automatic updates are supported. Added `rpm` installation package for Linux #### ⚙️ Core Features
- Added custom theme functionality, with a theme repository available at [PicList ThemeHub](https://github.com/Kuingsmile/PicList-ThemeHub)
- 12 built-in themes available, such as bilibili, ACG, Night Purple styles - Now supports manually disabling GPU acceleration to resolve black screen or flickering issues caused by some hardware compatibility.
- Refactored almost all pages, optimizing dozens of UI detail issues - Added advanced animation settings for a better UI interaction experience.
- Multiple optimizations on the album page, supporting display of the number of selected images, matching URL list, and remembering filter open state - Windows portable mode, no installation required, data is stored in the `data` folder in the program directory, and supports automatic updates.
- Plugin page now allows browsing of all plugin lists, viewing details, and installation - Added `rpm` installation package for Linux.
- Added tutorial guide page, which automatically pops up on first run - Added image hosting editing card page to solve confusion when switching multiple configurations.
- Now supports disabling GPU acceleration to resolve some compatibility issues - Added list mode support for the file browsing page.
- Added advanced animation settings for a better UI experience
- Optimized the loading speed of multiple pages and browsing performance #### 🎨 UI Interface
- Integrated theme repository [PicList ThemeHub](https://github.com/Kuingsmile/PicList-ThemeHub), supporting custom downloads.
- Provided 12 built-in themes (such as bilibili, 二次元, 极夜紫 styles).
- Redesigned all pages of the management feature, refactored almost all business pages, and optimized dozens of UI details.
- Optimized album page card styles, clearer boundaries, and improved selection box visual effects.
- Optimized the display of multiple pages on narrow screens to avoid content overflow.
- Supported scrolling to display the full name when the file browsing sidebar name exceeds the width.
- The album page supports displaying the number of selected items, matching URL lists, and remembering filter states.
- Supports browsing the complete plugin list, viewing details, and one-click installation.
- Added a new user guide page that automatically pops up on the first run.
#### 📝 Others
- The original "Management" feature has been renamed to **"Cloud"** to better reflect its actual functionality.
- After resetting the image hosting, it no longer automatically returns to the previous page.
### 🐛 Bug Fixes ### 🐛 Bug Fixes
- Fixed an issue where the sort dropdown in the management page displayed abnormally - Fixed the issue of abnormal display of the sorting dropdown box on the management page.
- Fixed an issue where the task page displayed incorrectly in dark mode - Fixed the issue where the image hosting list on the management page did not correctly highlight the currently selected item.
- Fixed an issue where the default image bed button status on the image bed settings page did not update in time - Fixed the display issue of the task page in dark mode.
- Fixed an issue where the button status for independent watermark settings on the image bed watermark settings page did not update in time - Fixed the issue where the "Set as Default Image Hosting" button status on the image hosting settings page was not updated in a timely manner.
- Fixed the issue where the independent watermark setting button status on the image hosting was not synchronized in the preprocessing settings page.
- Fixed the issue where bottom elements of some pages were obscured.

View File

@@ -53,9 +53,7 @@ const buildMiniPageMenu = () => {
label: $t('HIDE_MINI_WINDOW'), label: $t('HIDE_MINI_WINDOW'),
click() { click() {
const miniWindow = windowManager.get(IWindowList.MINI_WINDOW) const miniWindow = windowManager.get(IWindowList.MINI_WINDOW)
console.log('hide mini window', miniWindow)
miniWindow?.close() miniWindow?.close()
console.log('mini window closed')
}, },
}, },
{ {

View File

@@ -1,17 +1,22 @@
<template> <template>
<div class="image-container"> <div class="relative flex h-full w-full items-center justify-center p-0">
<div v-if="isLoading" class="loading-placeholder"> <div v-if="isLoading" class="flex h-full w-full items-center justify-center">
<div class="loading-spinner" /> <div class="h-[34px] w-[34px] animate-spin rounded-full border-3 border-t-3 border-border border-t-accent" />
</div> </div>
<img <img
v-else-if="!hasError" v-else-if="!hasError"
:src="isShowThumbnail && item.isImage ? base64Image : `./assets/icons/${getFileIconPath(item.fileName ?? '')}`" :src="isShowThumbnail && item.isImage ? base64Image : `./assets/icons/${getFileIconPath(item.fileName ?? '')}`"
alt="" alt=""
class="image" class="h-full w-full object-contain"
@load="handleImageLoad" @load="handleImageLoad"
@error="handleImageError" @error="handleImageError"
/> />
<img v-else :src="`./assets/icons/${getFileIconPath(item.fileName ?? '')}`" alt="" class="image" /> <img
v-else
:src="`./assets/icons/${getFileIconPath(item.fileName ?? '')}`"
alt=""
class="h-full w-full object-contain"
/>
</div> </div>
</template> </template>
@@ -60,49 +65,3 @@ onBeforeMount(async () => {
await createBase64Image() await createBase64Image()
}) })
</script> </script>
<style scoped>
.image-container {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin: 0 auto;
width: 100%;
height: 100px;
}
.image {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.loading-placeholder {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.loading-spinner {
border: 2px solid #e4e7ed;
border-top: 2px solid #409eff;
border-radius: var(--radius-round);
width: 24px;
height: 24px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,17 +1,17 @@
<template> <template>
<div class="image-container"> <div class="relative flex h-full w-full items-center justify-center p-0">
<div v-if="isLoading" class="loading-placeholder"> <div v-if="isLoading" class="flex h-full w-full items-center justify-center">
<div class="loading-spinner" /> <div class="h-[34px] w-[34px] animate-spin rounded-full border-3 border-t-3 border-border border-t-accent" />
</div> </div>
<img <img
v-else-if="!hasError" v-else-if="!hasError"
:src="imageSource" :src="imageSource"
alt="" alt=""
class="image" class="h-full w-full object-contain"
@load="handleImageLoad" @load="handleImageLoad"
@error="handleImageError" @error="handleImageError"
/> />
<img v-else :src="iconPath" alt="" class="image" /> <img v-else :src="iconPath" alt="" class="h-full w-full object-contain" />
</div> </div>
</template> </template>
@@ -76,49 +76,3 @@ watch(() => [props.url, props.item], getUrl, { deep: true })
onMounted(getUrl) onMounted(getUrl)
</script> </script>
<style scoped>
.image-container {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin: 0 auto;
width: 100%;
height: 100px;
}
.image {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.loading-placeholder {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.loading-spinner {
border: 2px solid #e4e7ed;
border-top: 2px solid #409eff;
border-radius: var(--radius-round);
width: 24px;
height: 24px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,418 @@
<template>
<transition name="modal">
<div
v-if="gallerySliderControl.visible"
class="image-preview-modal fixed inset-0 z-1000 flex items-center justify-center outline-none"
tabindex="0"
@click.stop
@wheel="handleImageWheel"
@keydown="handleKeydown"
>
<div class="absolute inset-0 bg-black/50" :class="{ 'advanced-animation': enableAdvancedAnimation }" />
<div class="relative max-h-[90vh] max-w-[90vw] overflow-hidden rounded-xl bg-surface shadow-lg">
<button
class="absolute top-4 right-4 z-10 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border border-danger bg-danger/70 text-white hover:bg-danger hover:text-white"
@click="handleClose"
>
<XIcon :size="24" />
</button>
<!-- Zoom controls -->
<div class="absolute top-4 left-4 z-10 flex items-center gap-2 rounded-lg bg-black/70 p-2">
<button class="zoom-btn" :disabled="imagePreviewState.scale <= 0.1" @click="zoomOut">
<span>-</span>
</button>
<span class="min-w-[50px] text-center text-sm font-medium text-white"
>{{ Math.round(imagePreviewState.scale * 100) }}%</span
>
<button class="zoom-btn" :disabled="imagePreviewState.scale >= 5" @click="zoomIn">
<span>+</span>
</button>
<button class="zoom-btn reset-btn" @click="resetImageTransform">Reset</button>
</div>
<div class="relative flex items-center">
<button class="nav-button prev" :disabled="gallerySliderControl.index === 0" @click.stop="navigateImage(-1)">
<ChevronLeftIcon :size="24" />
</button>
<div
class="relative flex h-[80vh] w-[90vw] items-center justify-center overflow-hidden bg-black select-none active:cursor-grab!"
@mousedown="handleImageMouseDown"
@mousemove="handleImageMouseMove"
@mouseup="handleImageMouseUp"
@mouseleave="handleImageMouseUp"
@touchstart="handleImageTouchStart"
@touchmove="handleImageTouchMove"
@touchend="handleImageTouchEnd"
>
<img
ref="previewImageRef"
:src="currentPreviewImage?.src"
:alt="currentPreviewImage?.intro"
class="block h-auto max-h-none w-auto max-w-none origin-center object-contain"
:style="imageTransformStyle"
@load="onPreviewImageLoad"
@dragstart.prevent
@contextmenu.prevent
/>
</div>
<button
class="nav-button next"
:disabled="gallerySliderControl.index === filterList.length - 1"
@click.stop="navigateImage(1)"
>
<ChevronRightIcon :size="24" />
</button>
</div>
<div class="flex items-center justify-between border border-border-secondary px-6 py-4">
<h3 class="m-0 mr-4 flex-1 overflow-hidden text-base font-semibold text-ellipsis text-main">
{{ currentPreviewImage?.intro }}
</h3>
<div class="mr-4 text-sm font-semibold whitespace-nowrap text-main">
{{ gallerySliderControl.index + 1 }} / {{ filterList.length }}
</div>
<div class="text-center text-xs font-medium text-main">
{{ t('pages.gallery.previewHelp') }}
</div>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-vue-next'
import { computed, nextTick, onMounted, reactive, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { getConfig } from '@/utils/dataSender'
const gallerySliderControl = defineModel<{
visible: boolean
index: number
}>('gallerySliderControl', { required: true })
const { filterList, isAlwaysForceReload } = defineProps<{
filterList: {
src: string
imgUrl?: string
galleryPath?: string
intro?: string
}[]
isAlwaysForceReload: boolean
}>()
const { t } = useI18n()
const previewImageRef = useTemplateRef('previewImageRef')
const enableAdvancedAnimation = ref(false)
const imagePreviewState = reactive({
scale: 1,
translateX: 0,
translateY: 0,
isDragging: false,
startX: 0,
startY: 0,
startTranslateX: 0,
startTranslateY: 0,
isSwipeMode: false,
swipeStartX: 0,
swipeThreshold: 100,
})
const imageTransformStyle = computed(() => {
// Check if image overflows the viewport
const imageElement = previewImageRef.value
let isDraggable = false
if (imageElement && imageElement.naturalWidth && imageElement.naturalHeight) {
const viewerElement = imageElement.parentElement
if (viewerElement) {
const viewerRect = viewerElement.getBoundingClientRect()
const currentImageWidth = imageElement.naturalWidth * imagePreviewState.scale
const currentImageHeight = imageElement.naturalHeight * imagePreviewState.scale
isDraggable = currentImageWidth > viewerRect.width + 1 || currentImageHeight > viewerRect.height + 1
}
}
return {
transform: `translate(${imagePreviewState.translateX}px, ${imagePreviewState.translateY}px) scale(${imagePreviewState.scale})`,
cursor: imagePreviewState.isDragging ? 'grabbing' : isDraggable ? 'grab' : 'default',
transition: 'none',
}
})
const currentPreviewImage = computed(() => {
const item = filterList[gallerySliderControl.value.index]
if (!item) return null
const cacheBustedItem = { ...item }
if (isAlwaysForceReload) {
if (cacheBustedItem.imgUrl) {
cacheBustedItem.imgUrl = addCacheBustParam(cacheBustedItem.imgUrl)
}
if (cacheBustedItem.galleryPath) {
cacheBustedItem.galleryPath = addCacheBustParam(cacheBustedItem.galleryPath)
}
}
const src = cacheBustedItem.src || cacheBustedItem.galleryPath || cacheBustedItem.imgUrl || ''
cacheBustedItem.src = isAlwaysForceReload ? addCacheBustParam(src) : src
return cacheBustedItem
})
function handleImageWheel(event: WheelEvent) {
event.preventDefault()
const delta = event.deltaY > 0 ? -1 : 1
const zoomFactor = 1.1
const newScale =
delta > 0 ? Math.min(imagePreviewState.scale * zoomFactor, 5) : Math.max(imagePreviewState.scale / zoomFactor, 0.1)
zoomToScale(newScale)
}
function zoomToScale(newScale: number) {
const oldScale = imagePreviewState.scale
imagePreviewState.scale = newScale
const optimalScale = calculateOptimalScale()
if (newScale <= optimalScale) {
imagePreviewState.translateX = 0
imagePreviewState.translateY = 0
} else {
const scaleDiff = newScale / oldScale
imagePreviewState.translateX *= scaleDiff
imagePreviewState.translateY *= scaleDiff
}
}
function calculateOptimalScale(): number {
const imageElement = previewImageRef.value
if (!imageElement) {
return 1
}
if (!imageElement.naturalWidth || !imageElement.naturalHeight) {
return 1
}
const viewerElement = imageElement.parentElement
if (!viewerElement) {
return 1
}
const viewerRect = viewerElement.getBoundingClientRect()
const viewerWidth = viewerRect.width
const viewerHeight = viewerRect.height
const imageWidth = imageElement.naturalWidth
const imageHeight = imageElement.naturalHeight
const scaleX = viewerWidth / imageWidth
const scaleY = viewerHeight / imageHeight
const optimalScale = Math.min(scaleX, scaleY, 1)
return optimalScale
}
function navigateImage(direction: number) {
const newIndex = gallerySliderControl.value.index + direction
if (newIndex >= 0 && newIndex < filterList.length) {
gallerySliderControl.value.index = newIndex
resetImageTransform()
}
}
function resetImageTransform() {
const optimalScale = calculateOptimalScale()
imagePreviewState.scale = optimalScale
imagePreviewState.translateX = 0
imagePreviewState.translateY = 0
imagePreviewState.isDragging = false
}
function handleKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowLeft':
event.preventDefault()
navigateImage(-1)
break
case 'ArrowRight':
event.preventDefault()
navigateImage(1)
break
case 'Escape':
event.preventDefault()
handleClose()
break
case '=':
case '+':
event.preventDefault()
zoomIn()
break
case '-':
event.preventDefault()
zoomOut()
break
case '0':
event.preventDefault()
resetImageTransform()
break
}
}
function zoomOut() {
const newScale = Math.max(imagePreviewState.scale / 1.2, 0.1)
zoomToScale(newScale)
}
function zoomIn() {
zoomToScale(Math.min(imagePreviewState.scale * 1.2, 5))
}
function handleClose() {
gallerySliderControl.value.index = 0
gallerySliderControl.value.visible = false
resetImageTransform()
}
function handleImageMouseDown(event: MouseEvent) {
const imageElement = previewImageRef.value
let isImageLargerThanViewer = false
if (imageElement && imageElement.naturalWidth && imageElement.naturalHeight) {
const viewerElement = imageElement.parentElement
if (viewerElement) {
const viewerRect = viewerElement.getBoundingClientRect()
const currentImageWidth = imageElement.naturalWidth * imagePreviewState.scale
const currentImageHeight = imageElement.naturalHeight * imagePreviewState.scale
isImageLargerThanViewer = currentImageWidth > viewerRect.width + 1 || currentImageHeight > viewerRect.height + 1
}
}
if (!isImageLargerThanViewer) {
imagePreviewState.isSwipeMode = true
imagePreviewState.swipeStartX = event.clientX
} else {
imagePreviewState.isDragging = true
imagePreviewState.startX = event.clientX
imagePreviewState.startY = event.clientY
imagePreviewState.startTranslateX = imagePreviewState.translateX
imagePreviewState.startTranslateY = imagePreviewState.translateY
}
event.preventDefault()
}
function handleImageMouseMove(event: MouseEvent) {
if (imagePreviewState.isDragging) {
const deltaX = event.clientX - imagePreviewState.startX
const deltaY = event.clientY - imagePreviewState.startY
imagePreviewState.translateX = imagePreviewState.startTranslateX + deltaX
imagePreviewState.translateY = imagePreviewState.startTranslateY + deltaY
}
}
function handleImageMouseUp(event: MouseEvent) {
if (imagePreviewState.isSwipeMode) {
const deltaX = event.clientX - imagePreviewState.swipeStartX
if (Math.abs(deltaX) > imagePreviewState.swipeThreshold) {
if (deltaX > 0) {
navigateImage(-1)
} else {
navigateImage(1)
}
}
imagePreviewState.isSwipeMode = false
}
imagePreviewState.isDragging = false
}
function handleImageTouchStart(event: TouchEvent) {
const touch = event.touches[0]
const imageElement = previewImageRef.value
let isImageLargerThanViewer = false
if (imageElement && imageElement.naturalWidth && imageElement.naturalHeight) {
const viewerElement = imageElement.parentElement
if (viewerElement) {
const viewerRect = viewerElement.getBoundingClientRect()
const currentImageWidth = imageElement.naturalWidth * imagePreviewState.scale
const currentImageHeight = imageElement.naturalHeight * imagePreviewState.scale
isImageLargerThanViewer = currentImageWidth > viewerRect.width + 1 || currentImageHeight > viewerRect.height + 1
}
}
if (!isImageLargerThanViewer) {
imagePreviewState.isSwipeMode = true
imagePreviewState.swipeStartX = touch.clientX
} else {
imagePreviewState.isDragging = true
imagePreviewState.startX = touch.clientX
imagePreviewState.startY = touch.clientY
imagePreviewState.startTranslateX = imagePreviewState.translateX
imagePreviewState.startTranslateY = imagePreviewState.translateY
}
event.preventDefault()
}
function handleImageTouchMove(event: TouchEvent) {
if (imagePreviewState.isDragging) {
const touch = event.touches[0]
const deltaX = touch.clientX - imagePreviewState.startX
const deltaY = touch.clientY - imagePreviewState.startY
imagePreviewState.translateX = imagePreviewState.startTranslateX + deltaX
imagePreviewState.translateY = imagePreviewState.startTranslateY + deltaY
}
event.preventDefault()
}
function handleImageTouchEnd(event: TouchEvent) {
if (imagePreviewState.isSwipeMode && event.changedTouches.length > 0) {
const touch = event.changedTouches[0]
const deltaX = touch.clientX - imagePreviewState.swipeStartX
if (Math.abs(deltaX) > imagePreviewState.swipeThreshold) {
if (deltaX > 0) {
navigateImage(-1)
} else {
navigateImage(1)
}
}
imagePreviewState.isSwipeMode = false
}
imagePreviewState.isDragging = false
}
function onPreviewImageLoad() {
nextTick(() => {
resetImageTransform()
})
}
const addCacheBustParam = (url: string | undefined) => {
if (!url) {
return ''
}
if (!(url.startsWith('http://') || url.startsWith('https://'))) {
return url
}
try {
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}cbplist=${new Date().getTime()}`
} catch (_e) {
return url
}
}
async function initConf() {
const settingConfig = await getConfig<any>('settings')
enableAdvancedAnimation.value = settingConfig.enableAdvancedAnimation || false
}
onMounted(() => {
initConf()
resetImageTransform()
nextTick(() => {
const modal = document.querySelector('.image-preview-modal') as HTMLElement
if (modal) {
modal.focus()
}
})
})
</script>

View File

@@ -977,7 +977,7 @@
<div class="form-group"> <div class="form-group">
<label class="title-text">{{ $t('pages.settings.upload.availablePlaceholders') }}</label> <label class="title-text">{{ $t('pages.settings.upload.availablePlaceholders') }}</label>
<placeholderTable :list="advancedRenameList" :title-list="advancedRenameTitleList" /> <PlaceholderTable :list="advancedRenameList" :title-list="advancedRenameTitleList" />
</div> </div>
</div> </div>
</div> </div>
@@ -1014,7 +1014,7 @@ import { useI18n } from 'vue-i18n'
import customRadioOption from '@/components/common/CustomRadioOption.vue' import customRadioOption from '@/components/common/CustomRadioOption.vue'
import customRange from '@/components/common/CustomRange.vue' import customRange from '@/components/common/CustomRange.vue'
import customSwitch from '@/components/common/CustomSwitch.vue' import customSwitch from '@/components/common/CustomSwitch.vue'
import placeholderTable from '@/components/common/PlaceholderTable.vue' import PlaceholderTable from '@/components/common/PlaceholderTable.vue'
import PerPicbedSetting from '@/components/PerPicbedSetting.vue' import PerPicbedSetting from '@/components/PerPicbedSetting.vue'
import { getRawData } from '@/utils/common' import { getRawData } from '@/utils/common'
import { configPaths } from '@/utils/configPaths' import { configPaths } from '@/utils/configPaths'

View File

@@ -1,17 +1,17 @@
<template> <template>
<div class="image-container"> <div class="relative flex h-full w-full items-center justify-center p-0">
<div v-if="isLoading" class="loading-placeholder"> <div v-if="isLoading" class="flex h-full w-full items-center justify-center">
<div class="loading-spinner" /> <div class="h-[34px] w-[34px] animate-spin rounded-full border-3 border-t-3 border-border border-t-accent" />
</div> </div>
<img <img
v-else-if="!hasError" v-else-if="!hasError"
:src="imageSource" :src="imageSource"
alt="" alt=""
class="image" class="h-full w-full object-contain"
@load="handleImageLoad" @load="handleImageLoad"
@error="handleImageError" @error="handleImageError"
/> />
<img v-else :src="iconPath" alt="" class="image" /> <img v-else :src="iconPath" alt="" class="h-full w-full object-contain" />
</div> </div>
</template> </template>
@@ -104,49 +104,3 @@ watch(() => [props.url, props.item], fetchImage, { deep: true })
onMounted(fetchImage) onMounted(fetchImage)
</script> </script>
<style scoped>
.image-container {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin: 0 auto;
width: 100%;
height: 100px;
}
.image {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.loading-placeholder {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.loading-spinner {
border: 2px solid #e4e7ed;
border-top: 2px solid #409eff;
border-radius: var(--radius-round);
width: 24px;
height: 24px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -21,7 +21,7 @@
</button> </button>
<div <div
v-show="dropDownOpen" v-show="dropDownOpen"
class="multiselect-dropdown shadow-lg; fixed z-1000 mt-[2px] no-scrollbar max-h-[280px] min-w-[185px] overflow-y-auto rounded-md border border-border-secondary bg-bg-tertiary px-2 py-1.5 text-main" class="multiselect-dropdown fixed z-10000 mt-[2px] max-h-[150px] min-w-[185px] overflow-y-auto rounded-md border border-border-secondary bg-bg-tertiary px-2 py-1.5 text-main shadow-lg"
> >
<label <label
v-for="item in allList" v-for="item in allList"

View File

@@ -542,7 +542,7 @@
"refresh": "Refresh", "refresh": "Refresh",
"reset": "Reset", "reset": "Reset",
"save": "Save", "save": "Save",
"savedConfigs": "Enter Saved Cloud", "savedConfigs": "Saved Cloud",
"selectPlaceholder": "Please select", "selectPlaceholder": "Please select",
"tips": "Tips", "tips": "Tips",
"title": "Cloud Management", "title": "Cloud Management",

View File

@@ -542,7 +542,7 @@
"refresh": "刷新", "refresh": "刷新",
"reset": "重置", "reset": "重置",
"save": "保存", "save": "保存",
"savedConfigs": "进入已配置云端", "savedConfigs": "已配置云端",
"selectPlaceholder": "请选择", "selectPlaceholder": "请选择",
"tips": "提示", "tips": "提示",
"title": "云端管理", "title": "云端管理",

View File

@@ -542,7 +542,7 @@
"refresh": "重新整理", "refresh": "重新整理",
"reset": "重設", "reset": "重設",
"save": "儲存", "save": "儲存",
"savedConfigs": "進入已配置雲端", "savedConfigs": "已配置雲端",
"selectPlaceholder": "請選擇", "selectPlaceholder": "請選擇",
"tips": "提示", "tips": "提示",
"title": "雲端存儲管理", "title": "雲端存儲管理",

View File

@@ -178,4 +178,16 @@
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
@keyframes slide-right {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -40,7 +40,7 @@
> >
<div class="flex h-full w-full"> <div class="flex h-full w-full">
<div <div
class="flex min-h-0 max-w-[400px] min-w-[120px] flex-col border-r-2 border-r-border transition-all duration-100 ease-out" class="flex min-h-0 max-w-[400px] min-w-[40px] flex-col border-r-2 border-r-border transition-all duration-100 ease-out"
:style="{ width: sidebarWidth + 'px' }" :style="{ width: sidebarWidth + 'px' }"
> >
<div class="shrink-0 border-b-2 border-b-border-secondary p-2"> <div class="shrink-0 border-b-2 border-b-border-secondary p-2">

View File

@@ -0,0 +1,24 @@
<template>
<div class="flex flex-1 flex-row flex-wrap items-center gap-2 p-0">
<div class="ml-2 flex items-center gap-2 p-1">
<FileIcon class="h-[13px] w-[13px] text-accent" />
<span class="text-xs font-semibold text-secondary">{{
`${t('pages.manage.bucket.fileNum', { num: currentPageFilesInfo.length })}`
}}</span>
<HardDriveIcon class="h-[13px] w-[13px] text-accent" />
<span class="text-xs font-semibold text-secondary">{{
`${t('pages.manage.bucket.pageFileSize', { size: calculateAllFileSize })}`
}}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { FileIcon, HardDriveIcon } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { currentPageFilesInfo, calculateAllFileSize } = defineProps<{
currentPageFilesInfo: any[]
calculateAllFileSize: string
}>()
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="group relative inline-block">
<button
class="relative flex cursor-pointer items-center justify-center gap-1 rounded-md p-2 text-xs font-medium transition-all duration-150 ease-apple hover:-translate-y-px disabled:cursor-not-allowed disabled:opacity-50"
:class="{
'border-none bg-accent text-white hover:bg-accent-hover': type === 'primary',
'border-[1.5px] border-border text-main hover:bg-accent/50 hover:text-white': type === 'secondary',
'border-none bg-error/80 text-white hover:bg-error': type === 'danger',
}"
:disabled="disabled"
@click="emit('click')"
>
<component :is="icon" v-if="icon" class="h-[16px] w-[16px]" />
<span v-if="title">{{ title }}</span>
<span
v-if="tips"
class="invisible absolute top-[125%] left-1/2 z-10 w-max max-w-[200px] translate-x-[-50%] rounded-md border border-border bg-bg-tertiary p-2 text-center text-xs text-main opacity-0 shadow-md transition-opacity duration-300 group-hover:visible group-hover:opacity-100"
>{{ tips }}</span
>
</button>
</div>
</template>
<script setup lang="ts">
const {
icon = null,
title = '',
tips = '',
type,
disabled = false,
} = defineProps<{
icon?: any
title?: string
tips?: string
disabled?: boolean
type: 'primary' | 'secondary' | 'danger'
}>()
const emit = defineEmits<(e: 'click') => void>()
</script>

View File

@@ -1,14 +1,7 @@
/* BucketPage Styles */ @import "tailwindcss" reference;
@import "../../../assets/css/theme.css" reference;
@import "../../../assets/css/utilities.css" reference;
.bucket-container {
position: relative;
display: flex;
overflow: hidden;
height: 100%;
padding: 0.25rem;
flex-direction: column;
gap: 1rem;
}
/* Header Card */ /* Header Card */
.bucket-card { .bucket-card {
@@ -197,8 +190,7 @@
} }
.action-icon { .action-icon {
width: 16px; @apply w-[16px] h-[16px];
height: 16px;
} }
.search-input { .search-input {
@@ -546,29 +538,9 @@
} }
.file-action-button { .file-action-button {
display: flex; @apply flex justify-center items-center border-none rounded-sm p-1.5 w-[32px] h-[32px] text-secondary bg-bg-secondary transition-all duration-fast ease-apple cursor-pointer;
justify-content: center; @apply hover:bg-accent/10 hover:text-main hover:-translate-y-px;
align-items: center; @apply hover:[.danger]:text-error hover:[.danger]:bg-error/10;
border: none;
border-radius: var(--radius-sm);
padding: 0.375rem;
width: 32px;
height: 32px;
color: var(--color-text-secondary);
background: var(--color-background-secondary);
transition: var(--transition-fast);
cursor: pointer;
}
.file-action-button:hover {
color: var(--color-text-primary);
background: var(--color-surface);
transform: translateY(-1px);
}
.file-action-button.danger:hover {
color: var(--color-error);
background: rgb(239 68 68 / 10%);
} }
.file-checkbox { .file-checkbox {

View File

@@ -5,7 +5,7 @@
class="relative z-1 no-scrollbar flex h-full w-full flex-col items-center justify-start gap-4 overflow-auto rounded-xl border-none p-4 shadow-sm" class="relative z-1 no-scrollbar flex h-full w-full flex-col items-center justify-start gap-4 overflow-auto rounded-xl border-none p-4 shadow-sm"
> >
<div <div
class="flex w-full items-center justify-between gap-4 rounded-2xl border border-border-secondary px-6 py-2 shadow-md max-md:items-stretch max-md:p-5" class="flex w-full items-center justify-between gap-4 rounded-2xl border border-border-secondary px-6 py-0 shadow-md max-md:items-stretch max-md:p-5"
> >
<div class="flex flex-1 items-center gap-4 p-1"> <div class="flex flex-1 items-center gap-4 p-1">
<ImagesIcon :size="24" class="text-accent" /> <ImagesIcon :size="24" class="text-accent" />
@@ -205,21 +205,21 @@
v-else v-else
:key="componentKey" :key="componentKey"
ref="virtualScrollerRef" ref="virtualScrollerRef"
:items="filterList"
:view-mode="viewMode" :view-mode="viewMode"
class="virtual-gallery-scroller min-h-0 w-full flex-1 p-3" class="virtual-gallery-scroller min-h-0 w-full flex-1 p-3"
:items="filterList"
:item-height="300" :item-height="300"
:grid-breakpoints="effectiveGridBreakpoints" :grid-breakpoints="effectiveGridBreakpoints"
key-field="key" key-field="key"
> >
<template #default="{ item, index }"> <template #default="{ item, index }">
<div <div
class="group/image m-0 box-border flex h-[calc(100%-8px)] w-full cursor-pointer flex-col overflow-hidden rounded-lg border border-border-secondary transition-all duration-fast ease-apple hover:-translate-y-[2px] hover:border-border hover:shadow-md [.selected]:border-2 [.selected]:border-accent [.selected]:shadow-md" class="group/image m-0 box-border flex h-[calc(100%-8px)] w-full cursor-pointer flex-col overflow-hidden rounded-lg border-2 border-border shadow-sm transition-all duration-fast ease-apple hover:-translate-y-[2px] hover:border-accent hover:shadow-md [.selected]:border-2 [.selected]:border-accent [.selected]:shadow-md"
:class="{ selected: choosedList[item.id || ''] }" :class="{ selected: choosedList[item.id || ''] }"
@click="handleChooseImage(!choosedList[item.id || ''], index)" @click="handleChooseImage(!choosedList[item.id || ''], index)"
> >
<div <div
class="relative flex aspect-auto min-h-0 flex-1 items-center justify-center overflow-hidden" class="relative mb-2 flex aspect-auto min-h-0 flex-1 items-center justify-center overflow-hidden border-b border-dashed border-b-accent/40"
@click.stop="zoomImage(index)" @click.stop="zoomImage(index)"
> >
<img <img
@@ -245,16 +245,16 @@
</div> </div>
</div> </div>
<div class="flex min-h-[80px] shrink-0 flex-col justify-between p-3"> <div class="flex shrink-0 flex-col justify-between">
<div <div
class="mb-3 overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap text-main" class="mb-1.5 overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap text-main"
:title="(item.fileName || '').toString().length > 30 ? item.fileName || '' : ''" :title="(item.fileName || '').toString().length > 30 ? item.fileName || '' : ''"
> >
{{ formatFileName(item.fileName || '') }} <div class="text-center">{{ formatFileName(item.fileName || '') }}</div>
</div> </div>
<div class="flex items-center justify-between"> <div class="mr-2 flex items-center justify-between">
<div class="flex gap-2"> <div class="flex flex-1 justify-center gap-2">
<button :title="t('pages.gallery.copy')" class="icon-button copy-icon" @click.stop="copy(item)"> <button :title="t('pages.gallery.copy')" class="icon-button copy-icon" @click.stop="copy(item)">
<ClipboardIcon :size="16" /> <ClipboardIcon :size="16" />
</button> </button>
@@ -282,7 +282,7 @@
@change="e => handleChooseImage((e.target as HTMLInputElement).checked, index)" @change="e => handleChooseImage((e.target as HTMLInputElement).checked, index)"
/> />
<span <span
class="relative inline-block h-[16px] w-[16px] rounded-sm border-2 border-border transition-all duration-fast ease-apple peer-checked:border-accent-hover peer-checked:bg-accent peer-checked:after:absolute peer-checked:after:top-[-2px] peer-checked:after:left-px peer-checked:after:text-[12px] peer-checked:after:font-bold peer-checked:after:text-white peer-checked:after:content-['✓']" class="relative inline-block h-[16px] w-[16px] rounded-sm border-2 border-accent/50 transition-all duration-fast ease-apple peer-checked:border-accent-hover peer-checked:bg-accent peer-checked:after:absolute peer-checked:after:top-[-2px] peer-checked:after:left-px peer-checked:after:text-[12px] peer-checked:after:font-bold peer-checked:after:text-white peer-checked:after:content-['✓']"
/> />
</label> </label>
</div> </div>
@@ -293,92 +293,11 @@
</div> </div>
</div> </div>
<!-- Custom Image Preview Modal --> <!-- Custom Image Preview Modal -->
<transition name="modal"> <ImagePreview
<div v-model:gallery-slider-control="gallerySliderControl"
v-if="gallerySliderControl.visible" :filter-list="filterList"
class="image-preview-modal fixed inset-0 z-1000 flex items-center justify-center outline-none" :is-always-force-reload="isAlwaysForceReload"
tabindex="0" />
@click.stop
@wheel="handleImageWheel"
@keydown="handleKeydown"
>
<div class="absolute inset-0 bg-black/50" :class="{ 'advanced-animation': enableAdvancedAnimation }" />
<div class="relative max-h-[90vh] max-w-[90vw] overflow-hidden rounded-xl bg-surface shadow-lg">
<button
class="absolute top-4 right-4 z-10 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border border-danger bg-danger/70 text-white hover:bg-danger hover:text-white"
@click="handleClose"
>
<XIcon :size="24" />
</button>
<!-- Zoom controls -->
<div class="absolute top-4 left-4 z-10 flex items-center gap-2 rounded-lg bg-black/70 p-2">
<button class="zoom-btn" :disabled="imagePreviewState.scale <= 0.1" @click="zoomOut">
<span>-</span>
</button>
<span class="min-w-[50px] text-center text-sm font-medium text-white"
>{{ Math.round(imagePreviewState.scale * 100) }}%</span
>
<button class="zoom-btn" :disabled="imagePreviewState.scale >= 5" @click="zoomIn">
<span>+</span>
</button>
<button class="zoom-btn reset-btn" @click="resetImageTransform">Reset</button>
</div>
<div class="relative flex items-center">
<button
class="nav-button prev"
:disabled="gallerySliderControl.index === 0"
@click.stop="navigateImage(-1)"
>
<ChevronLeftIcon :size="24" />
</button>
<div
class="relative flex h-[80vh] w-[90vw] items-center justify-center overflow-hidden bg-black select-none active:cursor-grab!"
@mousedown="handleImageMouseDown"
@mousemove="handleImageMouseMove"
@mouseup="handleImageMouseUp"
@mouseleave="handleImageMouseUp"
@touchstart="handleImageTouchStart"
@touchmove="handleImageTouchMove"
@touchend="handleImageTouchEnd"
>
<img
ref="previewImageRef"
:src="currentPreviewImage?.src"
:alt="currentPreviewImage?.intro"
class="block h-auto max-h-none w-auto max-w-none origin-center object-contain"
:style="imageTransformStyle"
@load="onPreviewImageLoad"
@dragstart.prevent
@contextmenu.prevent
/>
</div>
<button
class="nav-button next"
:disabled="gallerySliderControl.index === filterList.length - 1"
@click.stop="navigateImage(1)"
>
<ChevronRightIcon :size="24" />
</button>
</div>
<div class="flex items-center justify-between border border-border-secondary px-6 py-4">
<h3 class="m-0 mr-4 flex-1 overflow-hidden text-base font-semibold text-ellipsis text-main">
{{ currentPreviewImage?.intro }}
</h3>
<div class="mr-4 text-sm font-semibold whitespace-nowrap text-main">
{{ gallerySliderControl.index + 1 }} / {{ filterList.length }}
</div>
<div class="text-center text-xs font-medium text-main">
{{ t('pages.gallery.previewHelp') }}
</div>
</div>
</div>
</div>
</transition>
<!-- Edit URL Modal --> <!-- Edit URL Modal -->
<transition name="modal"> <transition name="modal">
@@ -410,7 +329,7 @@
> >
<div class="p-6"> <div class="p-6">
<div class="mb-6 last:mb-0"> <div class="mb-6 last:mb-0">
<label class="form-label"> <label class="mb-2 flex items-center gap-2 text-sm font-medium text-main">
{{ t('pages.gallery.regexPattern', { matched: matchedCount || 0 }) }} {{ t('pages.gallery.regexPattern', { matched: matchedCount || 0 }) }}
</label> </label>
<input <input
@@ -441,7 +360,7 @@
</div> </div>
<div class="mb-6 last:mb-0"> <div class="mb-6 last:mb-0">
<label class="form-label"> <label class="mb-2 flex items-center gap-2 text-sm font-medium text-main">
{{ t('pages.gallery.replacedWith') }} {{ t('pages.gallery.replacedWith') }}
<button <button
class="flex h-[20px] w-[20px] cursor-pointer items-center justify-around rounded-full border-none bg-accent text-white transition-all duration-fast ease-apple hover:bg-accent-hover" class="flex h-[20px] w-[20px] cursor-pointer items-center justify-around rounded-full border-none bg-accent text-white transition-all duration-fast ease-apple hover:bg-accent-hover"
@@ -456,60 +375,7 @@
<!-- Format Info Panel --> <!-- Format Info Panel -->
<div v-if="showFormatInfo" class="mb-6 last:mb-0"> <div v-if="showFormatInfo" class="mb-6 last:mb-0">
<label>{{ t('pages.settings.upload.availablePlaceholders') }}</label> <label>{{ t('pages.settings.upload.availablePlaceholders') }}</label>
<div <PlaceholderTable :list="advancedRenameList" :title-list="advancedRenameTitleList" />
class="mt-3 max-h-[400px] overflow-y-auto rounded-lg border border-border bg-bg-tertiary p-0 shadow-sm"
>
<div class="border-b border-b-border last:border-b-0">
<div class="category-title">
{{ t('pages.settings.upload.placeholder.categoryTime') }}
</div>
<div class="placeholder-grid">
<div
v-for="item in advancedRenameList.categoryTime"
:key="item.value"
class="placeholder-item"
@click="copyPlaceholder(item.value)"
>
<code>{{ item.value }}</code>
<span>{{ item.label }}</span>
</div>
</div>
</div>
<div class="placeholder-category">
<div class="category-title">
{{ t('pages.settings.upload.placeholder.categoryHash') }}
</div>
<div class="placeholder-grid">
<div
v-for="item in advancedRenameList.categoryHash"
:key="item.value"
class="placeholder-item"
@click="copyPlaceholder(item.value)"
>
<code>{{ item.value }}</code>
<span>{{ item.label }}</span>
</div>
</div>
</div>
<div class="placeholder-category">
<div class="category-title">
{{ t('pages.settings.upload.placeholder.categoryFile') }}
</div>
<div class="placeholder-grid">
<div
v-for="item in advancedRenameList.categoryFile"
:key="item.value"
class="placeholder-item"
@click="copyPlaceholder(item.value)"
>
<code>{{ item.value }}</code>
<span>{{ item.label }}</span>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<template #footer> <template #footer>
@@ -526,8 +392,6 @@ import { useStorage } from '@vueuse/core'
import { import {
CheckSquareIcon, CheckSquareIcon,
ChevronDownIcon, ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronUpIcon, ChevronUpIcon,
ClipboardIcon, ClipboardIcon,
EditIcon, EditIcon,
@@ -560,7 +424,9 @@ import ALLApi from '@/apis/allApi'
import CustomButton from '@/components/common/CustomButton.vue' import CustomButton from '@/components/common/CustomButton.vue'
import CustomModal from '@/components/common/CustomModal.vue' import CustomModal from '@/components/common/CustomModal.vue'
import MultiSelect from '@/components/common/MultiSelect.vue' import MultiSelect from '@/components/common/MultiSelect.vue'
import PlaceholderTable from '@/components/common/PlaceholderTable.vue'
import SingleSelect from '@/components/common/SingleSelect.vue' import SingleSelect from '@/components/common/SingleSelect.vue'
import ImagePreview from '@/components/ImagePreview.vue'
import VirtualScroller from '@/components/VirtualScroller.vue' import VirtualScroller from '@/components/VirtualScroller.vue'
import useConfirm from '@/hooks/useConfirm' import useConfirm from '@/hooks/useConfirm'
import { usePicBed } from '@/hooks/useGlobal' import { usePicBed } from '@/hooks/useGlobal'
@@ -586,7 +452,6 @@ const { picBedG } = usePicBed()
const images = ref<ImgInfo[]>([]) const images = ref<ImgInfo[]>([])
const virtualScrollerRef = useTemplateRef('virtualScrollerRef') const virtualScrollerRef = useTemplateRef('virtualScrollerRef')
const previewImageRef = useTemplateRef('previewImageRef')
const dialogVisible = ref(false) const dialogVisible = ref(false)
const imgInfo = reactive({ const imgInfo = reactive({
id: '', id: '',
@@ -630,22 +495,15 @@ const userGridColumns = useStorage<number>('galleryGridColumns', 4)
const imageLoadStates = reactive<Record<string, boolean>>({}) const imageLoadStates = reactive<Record<string, boolean>>({})
const imageErrorStates = reactive<Record<string, boolean>>({}) const imageErrorStates = reactive<Record<string, boolean>>({})
const imagePreviewState = reactive({
scale: 1,
translateX: 0,
translateY: 0,
isDragging: false,
startX: 0,
startY: 0,
startTranslateX: 0,
startTranslateY: 0,
isSwipeMode: false,
swipeStartX: 0,
swipeThreshold: 100,
})
const pasteStyleList = ['markdown', 'HTML', 'URL', 'UBB', 'Custom'] const pasteStyleList = ['markdown', 'HTML', 'URL', 'UBB', 'Custom']
const shortURLList = [t('pages.gallery.shortUrl'), t('pages.gallery.longUrl')] const shortURLList = [t('pages.gallery.shortUrl'), t('pages.gallery.longUrl')]
const advancedRenameTitleList = computed(() => ({
categoryTime: t('pages.settings.upload.placeholder.categoryTime'),
categoryHash: t('pages.settings.upload.placeholder.categoryHash'),
categoryFile: t('pages.settings.upload.placeholder.categoryFile'),
}))
const advancedRenameList = { const advancedRenameList = {
categoryTime: [ categoryTime: [
{ label: t('pages.settings.upload.placeholder.year4'), value: '{Y}' }, { label: t('pages.settings.upload.placeholder.year4'), value: '{Y}' },
@@ -711,45 +569,6 @@ const selectedCount = computed(() => {
return Object.values(choosedList).filter(v => v).length return Object.values(choosedList).filter(v => v).length
}) })
const currentPreviewImage = computed(() => {
const item = filterList.value[gallerySliderControl.index]
if (!item) return null
const cacheBustedItem = { ...item }
if (isAlwaysForceReload.value) {
if (cacheBustedItem.imgUrl) {
cacheBustedItem.imgUrl = addCacheBustParam(cacheBustedItem.imgUrl)
}
if (cacheBustedItem.galleryPath) {
cacheBustedItem.galleryPath = addCacheBustParam(cacheBustedItem.galleryPath)
}
}
const src = cacheBustedItem.src || cacheBustedItem.galleryPath || cacheBustedItem.imgUrl || ''
cacheBustedItem.src = isAlwaysForceReload.value ? addCacheBustParam(src) : src
return cacheBustedItem
})
const imageTransformStyle = computed(() => {
// Check if image overflows the viewport
const imageElement = previewImageRef.value
let isDraggable = false
if (imageElement && imageElement.naturalWidth && imageElement.naturalHeight) {
const viewerElement = imageElement.parentElement
if (viewerElement) {
const viewerRect = viewerElement.getBoundingClientRect()
const currentImageWidth = imageElement.naturalWidth * imagePreviewState.scale
const currentImageHeight = imageElement.naturalHeight * imagePreviewState.scale
isDraggable = currentImageWidth > viewerRect.width + 1 || currentImageHeight > viewerRect.height + 1
}
}
return {
transform: `translate(${imagePreviewState.translateX}px, ${imagePreviewState.translateY}px) scale(${imagePreviewState.scale})`,
cursor: imagePreviewState.isDragging ? 'grabbing' : isDraggable ? 'grab' : 'default',
transition: 'none',
}
})
const dateRange = computed({ const dateRange = computed({
get: () => { get: () => {
if (dateRangeStart.value && dateRangeEnd.value) { if (dateRangeStart.value && dateRangeEnd.value) {
@@ -812,11 +631,6 @@ watch(searchTextURL, newVal => {
}, 300) }, 300)
}) })
function copyPlaceholder(placeholder: string) {
window.electron.clipboard.writeText(String(placeholder))
message.success(t('pages.settings.upload.copySuccess', { content: placeholder }))
}
function onImageLoad(id: string) { function onImageLoad(id: string) {
imageLoadStates[id] = true imageLoadStates[id] = true
} }
@@ -826,224 +640,6 @@ function onImageError(id: string) {
imageErrorStates[id] = true imageErrorStates[id] = true
} }
function onPreviewImageLoad() {
nextTick(() => {
resetImageTransform()
})
}
function navigateImage(direction: number) {
const newIndex = gallerySliderControl.index + direction
if (newIndex >= 0 && newIndex < filterList.value.length) {
gallerySliderControl.index = newIndex
resetImageTransform()
}
}
function resetImageTransform() {
const optimalScale = calculateOptimalScale()
imagePreviewState.scale = optimalScale
imagePreviewState.translateX = 0
imagePreviewState.translateY = 0
imagePreviewState.isDragging = false
}
function calculateOptimalScale(): number {
const imageElement = previewImageRef.value
if (!imageElement) {
return 1
}
if (!imageElement.naturalWidth || !imageElement.naturalHeight) {
return 1
}
const viewerElement = imageElement.parentElement
if (!viewerElement) {
return 1
}
const viewerRect = viewerElement.getBoundingClientRect()
const viewerWidth = viewerRect.width
const viewerHeight = viewerRect.height
const imageWidth = imageElement.naturalWidth
const imageHeight = imageElement.naturalHeight
const scaleX = viewerWidth / imageWidth
const scaleY = viewerHeight / imageHeight
const optimalScale = Math.min(scaleX, scaleY, 1)
return optimalScale
}
function zoomIn() {
zoomToScale(Math.min(imagePreviewState.scale * 1.2, 5))
}
function zoomOut() {
const newScale = Math.max(imagePreviewState.scale / 1.2, 0.1)
zoomToScale(newScale)
}
function zoomToScale(newScale: number) {
const oldScale = imagePreviewState.scale
imagePreviewState.scale = newScale
const optimalScale = calculateOptimalScale()
if (newScale <= optimalScale) {
imagePreviewState.translateX = 0
imagePreviewState.translateY = 0
} else {
const scaleDiff = newScale / oldScale
imagePreviewState.translateX *= scaleDiff
imagePreviewState.translateY *= scaleDiff
}
}
function handleImageWheel(event: WheelEvent) {
event.preventDefault()
const delta = event.deltaY > 0 ? -1 : 1
const zoomFactor = 1.1
const newScale =
delta > 0 ? Math.min(imagePreviewState.scale * zoomFactor, 5) : Math.max(imagePreviewState.scale / zoomFactor, 0.1)
zoomToScale(newScale)
}
function handleKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowLeft':
event.preventDefault()
navigateImage(-1)
break
case 'ArrowRight':
event.preventDefault()
navigateImage(1)
break
case 'Escape':
event.preventDefault()
handleClose()
break
case '=':
case '+':
event.preventDefault()
zoomIn()
break
case '-':
event.preventDefault()
zoomOut()
break
case '0':
event.preventDefault()
resetImageTransform()
break
}
}
function handleImageMouseDown(event: MouseEvent) {
const imageElement = previewImageRef.value
let isImageLargerThanViewer = false
if (imageElement && imageElement.naturalWidth && imageElement.naturalHeight) {
const viewerElement = imageElement.parentElement
if (viewerElement) {
const viewerRect = viewerElement.getBoundingClientRect()
const currentImageWidth = imageElement.naturalWidth * imagePreviewState.scale
const currentImageHeight = imageElement.naturalHeight * imagePreviewState.scale
isImageLargerThanViewer = currentImageWidth > viewerRect.width + 1 || currentImageHeight > viewerRect.height + 1
}
}
if (!isImageLargerThanViewer) {
imagePreviewState.isSwipeMode = true
imagePreviewState.swipeStartX = event.clientX
} else {
imagePreviewState.isDragging = true
imagePreviewState.startX = event.clientX
imagePreviewState.startY = event.clientY
imagePreviewState.startTranslateX = imagePreviewState.translateX
imagePreviewState.startTranslateY = imagePreviewState.translateY
}
event.preventDefault()
}
function handleImageMouseMove(event: MouseEvent) {
if (imagePreviewState.isDragging) {
const deltaX = event.clientX - imagePreviewState.startX
const deltaY = event.clientY - imagePreviewState.startY
imagePreviewState.translateX = imagePreviewState.startTranslateX + deltaX
imagePreviewState.translateY = imagePreviewState.startTranslateY + deltaY
}
}
function handleImageMouseUp(event: MouseEvent) {
if (imagePreviewState.isSwipeMode) {
const deltaX = event.clientX - imagePreviewState.swipeStartX
if (Math.abs(deltaX) > imagePreviewState.swipeThreshold) {
if (deltaX > 0) {
navigateImage(-1)
} else {
navigateImage(1)
}
}
imagePreviewState.isSwipeMode = false
}
imagePreviewState.isDragging = false
}
function handleImageTouchStart(event: TouchEvent) {
const touch = event.touches[0]
const imageElement = previewImageRef.value
let isImageLargerThanViewer = false
if (imageElement && imageElement.naturalWidth && imageElement.naturalHeight) {
const viewerElement = imageElement.parentElement
if (viewerElement) {
const viewerRect = viewerElement.getBoundingClientRect()
const currentImageWidth = imageElement.naturalWidth * imagePreviewState.scale
const currentImageHeight = imageElement.naturalHeight * imagePreviewState.scale
isImageLargerThanViewer = currentImageWidth > viewerRect.width + 1 || currentImageHeight > viewerRect.height + 1
}
}
if (!isImageLargerThanViewer) {
imagePreviewState.isSwipeMode = true
imagePreviewState.swipeStartX = touch.clientX
} else {
imagePreviewState.isDragging = true
imagePreviewState.startX = touch.clientX
imagePreviewState.startY = touch.clientY
imagePreviewState.startTranslateX = imagePreviewState.translateX
imagePreviewState.startTranslateY = imagePreviewState.translateY
}
event.preventDefault()
}
function handleImageTouchMove(event: TouchEvent) {
if (imagePreviewState.isDragging) {
const touch = event.touches[0]
const deltaX = touch.clientX - imagePreviewState.startX
const deltaY = touch.clientY - imagePreviewState.startY
imagePreviewState.translateX = imagePreviewState.startTranslateX + deltaX
imagePreviewState.translateY = imagePreviewState.startTranslateY + deltaY
}
event.preventDefault()
}
function handleImageTouchEnd(event: TouchEvent) {
if (imagePreviewState.isSwipeMode && event.changedTouches.length > 0) {
const touch = event.changedTouches[0]
const deltaX = touch.clientX - imagePreviewState.swipeStartX
if (Math.abs(deltaX) > imagePreviewState.swipeThreshold) {
if (deltaX > 0) {
navigateImage(-1)
} else {
navigateImage(1)
}
}
imagePreviewState.isSwipeMode = false
}
imagePreviewState.isDragging = false
}
function toggleViewMode() { function toggleViewMode() {
viewMode.value = viewMode.value === 'grid' ? 'list' : 'grid' viewMode.value = viewMode.value === 'grid' ? 'list' : 'grid'
} }
@@ -1214,20 +810,6 @@ function clearChoosedList() {
function zoomImage(index: number) { function zoomImage(index: number) {
gallerySliderControl.index = index gallerySliderControl.index = index
gallerySliderControl.visible = true gallerySliderControl.visible = true
resetImageTransform()
nextTick(() => {
const modal = document.querySelector('.image-preview-modal') as HTMLElement
if (modal) {
modal.focus()
}
})
}
function handleClose() {
gallerySliderControl.index = 0
gallerySliderControl.visible = false
resetImageTransform()
} }
async function copy(item: ImgInfo) { async function copy(item: ImgInfo) {

View File

@@ -314,6 +314,7 @@
<CustomSwitch <CustomSwitch
v-model="formOfSetting.autoImport" v-model="formOfSetting.autoImport"
small small
no-border
:title="t('pages.settings.upload.autoImportInManage')" :title="t('pages.settings.upload.autoImportInManage')"
:description="t('pages.settings.upload.autoImportInManageHint')" :description="t('pages.settings.upload.autoImportInManageHint')"
/> />

View File

@@ -9,7 +9,7 @@
> >
<div class="flex max-w-[calc(100%-300px)] flex-1 flex-wrap items-center gap-2 max-md:order-1"> <div class="flex max-w-[calc(100%-300px)] flex-1 flex-wrap items-center gap-2 max-md:order-1">
<button <button
class="provider-button group/provider flex w-auto min-w-[150px] shrink-0 cursor-pointer items-center gap-3 rounded-lg border border-border-secondary bg-bg-secondary px-4 py-2 font-[inherit] duration-fast ease-standard hover:-translate-y-px hover:border-accent-hover/70 hover:bg-surface hover:shadow-sm focus-visible:focus-ring max-xs:w-full max-xs:min-w-[100px]" class="provider-button group/provider flex w-auto min-w-[150px] shrink-0 cursor-pointer items-center gap-3 rounded-lg bg-bg-secondary px-4 py-2 font-[inherit] shadow-sm duration-fast ease-standard hover:-translate-y-px hover:border-accent-hover/70 hover:bg-surface hover:shadow-sm focus-visible:focus-ring max-xs:w-full max-xs:min-w-[100px]"
:title="t('pages.upload.uploadViewHint')" :title="t('pages.upload.uploadViewHint')"
@click="handlePicBedNameClick(picBedName)" @click="handlePicBedNameClick(picBedName)"
> >
@@ -47,7 +47,7 @@
<button <button
v-for="picbedType in favoritePicbeds" v-for="picbedType in favoritePicbeds"
:key="picbedType.id" :key="picbedType.id"
class="group/badge relative flex w-[85px] shrink-0 cursor-pointer items-center gap-2 overflow-hidden rounded-md border border-border-secondary bg-bg-secondary pt-1.5 pr-2 pb-1.5 pl-3 text-xs font-medium whitespace-nowrap text-secondary transition-all duration-fast ease-standard select-none hover:-translate-y-px hover:border-accent-hover hover:bg-bg-tertiary hover:text-accent-hover [.is-active]:border-[0.1rem] [.is-active]:border-accent-hover [.is-active]:font-semibold [.show-delete]:pr-2" class="group/badge relative flex w-[85px] shrink-0 cursor-pointer items-center gap-2 overflow-hidden rounded-md bg-bg-secondary pt-1.5 pr-2 pb-1.5 pl-3 text-xs font-medium whitespace-nowrap text-secondary shadow-sm transition-all duration-fast ease-standard select-none hover:-translate-y-px hover:border-accent-hover hover:bg-bg-tertiary hover:text-accent-hover [.is-active]:border-[0.1rem] [.is-active]:border-accent-hover [.is-active]:font-semibold [.show-delete]:pr-2"
:class="{ 'is-active': isCurrentPicbed(picbedType), 'show-delete': longPressedBadge === picbedType.id }" :class="{ 'is-active': isCurrentPicbed(picbedType), 'show-delete': longPressedBadge === picbedType.id }"
:title="t('pages.upload.longPressToRemoveFromFavorites') + getPicbedName(picbedType)" :title="t('pages.upload.longPressToRemoveFromFavorites') + getPicbedName(picbedType)"
@click="handleBadgeClick(picbedType)" @click="handleBadgeClick(picbedType)"
@@ -82,7 +82,7 @@
</transition-group> </transition-group>
</div> </div>
<div class="flex flex-wrap items-center gap-3 max-md:order-2 max-md:justify-stretch"> <div class="flex flex-wrap items-center gap-3 max-md:order-2 max-md:justify-stretch">
<div class="inline-flex overflow-hidden rounded-md border border-border-secondary bg-bg-secondary"> <div class="inline-flex overflow-hidden rounded-md bg-bg-secondary shadow-sm">
<button <button
class="segmented-button" class="segmented-button"
:title="t('pages.upload.imageProcessNameSingle')" :title="t('pages.upload.imageProcessNameSingle')"

View File

@@ -13,7 +13,7 @@
} }
.filter-group { .filter-group {
@apply flex flex-col gap-3 flex-1 min-w-[140px]; @apply flex flex-col gap-1 flex-1 min-w-[140px];
} }
.filter-label { .filter-label {
@@ -61,32 +61,6 @@
@apply [.prev]:left-4 [.next]:right-4; @apply [.prev]:left-4 [.next]:right-4;
} }
/* Modal Overlay */
.modal-overlay {
@apply fixed inset-0 z-1000 flex justify-center items-center bg-[rgb(0_0_0_/50%)];
}
.modal-container {
@apply overflow-auto rounded-xl w-[90%] max-w-[700px] max-h-[90vh] bg-bg-tertiary shadow-lg;
}
.modal-header {
@apply flex justify-between items-center border-b border-border-secondary p-6;
}
.modal-close-btn {
@apply flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border border-border bg-surface-elevated text-secondary transition-all duration-fast ease-apple;
@apply hover:scale-105 hover:border-danger hover:bg-danger hover:text-white focus-visible:focus-ring;
}
.modal-footer {
@apply flex justify-end border-t border-border-secondary p-6 gap-3;
}
.form-label {
@apply flex items-center mb-2 text-sm font-medium text-main gap-2;
}
.form-input { .form-input {
@apply border border-border rounded-md p-3 w-full text-sm text-main bg-bg-secondary transition-all duration-fast ease-apple box-border; @apply border border-border rounded-md p-3 w-full text-sm text-main bg-bg-secondary transition-all duration-fast ease-apple box-border;
@apply focus:border-accent focus:outline-none focus:shadow-md; @apply focus:border-accent focus:outline-none focus:shadow-md;