🚧 WIP(custom): re-design manage main page

This commit is contained in:
Kuingsmile
2026-01-23 22:51:55 +08:00
parent 63b82e76dc
commit f7430310d2
5 changed files with 135 additions and 383 deletions

View File

@@ -20,6 +20,7 @@
- 重新设计了管理功能的全部页面
- 重构了几乎全部页面优化了数十项UI细节问题整体风格更加统一
- 相册页面多项优化支持显示已选择图片数量匹配的url列表和记忆过滤器打开状态
- 优化了管理文件浏览页面侧边栏名字的显示,现在在超出宽度时会滚动显示完整名称
- 插件页面现在可以浏览所有插件列表,查看详情和安装
- 新增教学引导页面,首次运行时会自动弹出

View File

@@ -1,194 +0,0 @@
<template>
<div class="switch-container">
<div class="switch-label-wrapper">
<span class="switch-label-text">
<span v-for="(segment, index) in segments" :key="index" :style="segment.style">
{{ segment.text }}
</span>
<div v-if="tooltip" class="tooltip-wrapper">
<div class="info-icon" @click="toggleTooltip">
<svg viewBox="0 0 20 20" fill="currentColor" class="info-svg">
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule="evenodd"
/>
</svg>
</div>
<div v-show="showTooltip" class="tooltip-content">
{{ tooltip }}
</div>
</div>
</span>
</div>
<div class="switch-control">
<label class="switch">
<input v-model="value" type="checkbox" class="switch-input" />
<span class="switch-slider">
<span class="switch-button" />
</span>
</label>
<div v-if="activeText || inactiveText" class="switch-text">
{{ value ? activeText : inactiveText }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
defineProps<{
tooltip?: string
activeText?: string
inactiveText?: string
segments?: { text: string; style: string }[]
}>()
const value = defineModel<boolean>()
const showTooltip = ref(false)
const toggleTooltip = () => {
showTooltip.value = !showTooltip.value
}
</script>
<style scoped>
.switch-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1rem;
background: var(--color-background-secondary);
}
.switch-label-wrapper {
flex: 1;
}
.switch-label-text {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary);
}
.tooltip-wrapper {
position: relative;
}
.info-icon {
display: flex;
justify-content: center;
align-items: center;
border-radius: var(--radius-round);
padding: 2px;
width: 20px;
height: 20px;
color: var(--color-text-secondary);
transition: var(--transition-fast);
cursor: pointer;
}
.info-icon:hover {
color: var(--color-accent);
background: var(--color-background-secondary);
}
.info-svg {
width: 16px;
height: 16px;
}
.tooltip-content {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.75rem;
min-width: 200px;
max-width: 300px;
font-size: 0.75rem;
color: var(--color-text-primary);
background: var(--color-surface-elevated);
box-shadow: var(--shadow-lg);
line-height: 1.4;
}
.switch-control {
display: flex;
align-items: center;
gap: 0.75rem;
}
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 20px;
}
.switch-input {
width: 0;
height: 0;
opacity: 0;
}
.switch-slider {
position: absolute;
inset: 0;
border-radius: 24px;
background: linear-gradient(180deg, #d0d3d9 0%, #c0c4cc 100%);
box-shadow: inset 0 1px 3px rgb(0 0 0 / 15%);
transition: all var(--transition-medium);
cursor: pointer;
}
.switch-button {
position: absolute;
bottom: 2px;
left: 2px;
border-radius: var(--radius-round);
width: 17px;
height: 17px;
background: linear-gradient(180deg, #ffffff 0%, #f5f5f5 100%);
box-shadow:
0 2px 6px rgb(0 0 0 / 20%),
0 1px 2px rgb(0 0 0 / 10%);
transition: all var(--transition-medium);
content: '';
}
.switch-input:checked + .switch-slider {
background: var(--color-accent);
box-shadow:
inset 0 1px 3px rgb(0 0 0 / 10%),
0 2px 8px color-mix(in srgb, var(--color-accent), transparent 30%);
}
.switch-input:checked + .switch-slider .switch-button {
transform: translateX(23px);
}
.switch-input:focus + .switch-slider {
box-shadow: 0 0 0 2px rgb(0 122 255 / 20%);
}
.switch-text {
min-width: 50px;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-secondary);
}
.switch-input:checked ~ .switch-text {
color: var(--color-accent);
}
</style>

View File

@@ -1,14 +1,14 @@
<template>
<div class="empty-page">
<div class="empty-container">
<div class="empty-icon">
<FolderOpenIcon class="icon" />
<div class="flex h-full items-center justify-center p-8">
<div class="flex max-w-[400px] flex-col items-center text-center">
<div class="mb-6">
<FolderOpenIcon class="h-[64px] w-[64px] text-secondary" />
</div>
<div class="empty-content">
<h3 class="empty-title">
<div class="flex flex-col gap-2">
<h3 class="mb-2 text-xl font-semibold text-main">
{{ t('pages.manage.empty.noData') }}
</h3>
<p class="empty-description">
<p class="text-sm leading-[1.5] text-secondary">
{{ t('pages.manage.empty.noDataDesc') }}
</p>
</div>
@@ -22,40 +22,3 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<style lang="stylus" scoped>
.empty-page
height 100%
display flex
align-items center
justify-content center
padding 2rem
.empty-container
display flex
flex-direction column
align-items center
text-align center
max-width 400px
.empty-icon
margin-bottom 1.5rem
.icon
width 64px
height 64px
color var(--color-text-secondary)
.empty-content
.empty-title
font-size 1.25rem
font-weight 600
color var(--color-text-primary)
margin 0 0 0.5rem 0
.empty-description
font-size 0.875rem
color var(--color-text-secondary)
margin 0
line-height 1.5
</style>

View File

@@ -1,52 +1,60 @@
<template>
<div class="manage-container">
<div
class="relative z-1 flex h-full w-full flex-col items-center justify-start gap-2 rounded-xl border-none p-2 shadow-sm"
>
<!-- Header Card -->
<div class="manage-card header-card">
<div class="card-header">
<div class="header-content">
<div class="header-icon">
<img :src="`./assets/${currentPagePicBedConfig.picBedName}.webp`" class="header-icon-img" />
</div>
<div class="header-text">
<h2 class="header-title">
{{ supportedPicBedList[currentPagePicBedConfig.picBedName].name }}
</h2>
<p class="header-subtitle">
{{ menuTitleMap[currentPicBedName] }}
</p>
</div>
<div class="flex w-full items-center justify-between gap-4 rounded-xl border border-border-secondary p-0 shadow-sm">
<div class="flex flex-wrap items-center gap-1 p-1 max-md:justify-center max-md:text-center">
<div class="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-md bg-bg-secondary">
<img :src="`./assets/${currentPagePicBedConfig.picBedName}.webp`" class="h-[24px] w-[24px] object-contain" />
</div>
<div class="header-actions">
<button class="action-button secondary" @click="openPicBedUrl">
<ExternalLinkIcon class="button-icon" />
{{ t('pages.manage.main.openPicBedUrl') }}
</button>
<button
v-if="showNewIconList.includes(currentPicBedName)"
class="action-button primary"
@click="openNewBucketDrawer"
>
<PlusIcon class="button-icon" />
{{ t('pages.manage.main.newBucket') }}
</button>
<div class="flex flex-row items-center justify-center gap-2 max-md:text-center">
<h2 class="m-0 text-xl font-bold tracking-tight text-main">
{{ supportedPicBedList[currentPagePicBedConfig.picBedName].name }}
</h2>
<p class="m-0 text-sm font-semibold text-secondary">
{{ menuTitleMap[currentPicBedName] }}
</p>
</div>
</div>
<div class="mr-2 flex items-center justify-center gap-3">
<CustomButton
type="secondary"
:text="t('pages.manage.main.openPicBedUrl')"
:icon="ExternalLinkIcon"
@click="openPicBedUrl"
/>
<CustomButton
v-if="showNewIconList.includes(currentPicBedName)"
type="secondary"
:text="t('pages.manage.main.newBucket')"
:icon="PlusIcon"
@click="openNewBucketDrawer"
/>
</div>
</div>
<!-- Main Content Card -->
<div class="manage-card main-card">
<div class="main-layout">
<div class="sidebar" :style="{ width: sidebarWidth + 'px' }">
<div class="sidebar-header">
<h3 class="sidebar-title">
<div
class="flex w-full flex-1 items-center gap-4 overflow-hidden rounded-xl border border-border-secondary p-2 shadow-md"
>
<div class="flex h-full w-full">
<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"
:style="{ width: sidebarWidth + 'px' }"
>
<div class="shrink-0 border-b-2 border-b-border-secondary p-2">
<h3 class="m-0 text-center text-sm font-semibold text-secondary">
{{ menuTitleMap[currentPicBedName] }}
</h3>
</div>
<div class="sidebar-content">
<div v-if="isLoadingBucketList" class="loading-container">
<div class="loading-spinner" />
<span class="loading-text">{{ t('pages.manage.main.loading') }}</span>
<div class="min-h-0 flex-1 overflow-y-auto p-2">
<div v-if="isLoadingBucketList" class="flex flex-col items-center justify-center gap-2 p-8">
<div
class="h-[25px] w-[25px] animate-spin rounded-full border-3 border-t-2 border-border border-t-accent"
/>
<span class="text-sm font-semibold text-secondary">{{ t('pages.manage.main.loading') }}</span>
</div>
<div v-else class="menu-list">
<div
@@ -56,41 +64,54 @@
:class="{ active: item === currentSelectedBucket }"
@click="handleSelectMenu(item)"
>
<FolderIcon
v-if="currentSelectedBucket === item && currentPicBedName !== 'github'"
class="menu-icon active"
/>
<FolderIcon v-else-if="currentPicBedName !== 'github'" class="menu-icon" />
<GitBranchIcon v-else-if="currentPicBedName === 'github'" class="menu-icon" />
<span class="menu-text" :title="item">
{{ truncateText(item, currentPicBedName) }}
<span
class="group/badge overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap text-secondary"
>
<div class="min-w-0 flex-1 overflow-hidden">
<div
class="flex overflow-hidden text-ellipsis whitespace-nowrap group-hover/badge:w-fit group-hover/badge:animate-[badge-scroll_5s_linear_infinite] group-hover/badge:text-clip"
>
<span class="leading-none whitespace-nowrap group-hover/badge:pr-[20px]">{{ item }}</span>
<span class="hidden leading-none whitespace-nowrap group-hover/badge:block">{{ item }}</span>
</div>
</div>
</span>
</div>
</div>
</div>
<div class="sidebar-footer">
<div class="footer-actions">
<button class="footer-action-item" @click="switchPicBed('main')">
<HomeIcon class="action-icon" />
<span class="action-text">{{ t('pages.manage.main.backToHome') }}</span>
</button>
<button class="footer-action-item" @click="changePicBed">
<ArrowLeftRightIcon class="action-icon" />
<span class="action-text">{{ t('pages.manage.main.switchPicBed') }}</span>
</button>
<button class="footer-action-item" @click="openBucketPageSetting">
<SettingsIcon class="action-icon" />
<span class="action-text">{{ t('pages.manage.main.settings') }}</span>
</button>
<div class="border-t border-t-border-secondary p-2">
<div class="flex flex-col gap-1">
<CustomButton
type="secondary"
:text="t('pages.manage.main.backToHome')"
:icon="HomeIcon"
class="border-none"
@click="switchPicBed('main')"
/>
<CustomButton
type="secondary"
:text="t('pages.manage.main.switchPicBed')"
:icon="ArrowLeftRightIcon"
class="border-none"
@click="changePicBed"
/>
<CustomButton
type="secondary"
:text="t('pages.manage.main.settings')"
:icon="SettingsIcon"
class="border-none"
@click="openBucketPageSetting"
/>
</div>
</div>
</div>
<!-- Resize Handle -->
<div class="resize-handle" @mousedown="startResize">
<div class="resize-line" />
</div>
<div
class="group/resize relative flex w-[4px] shrink-0 cursor-col-resize items-center justify-center bg-transparent hover:bg-accent/70"
@mousedown="startResize"
></div>
<div class="content-area">
<router-view />
@@ -106,54 +127,51 @@
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<div v-if="picBedSwitchDialogVisible" class="dialog-overlay" @click="picBedSwitchDialogVisible = false">
<div class="dialog-container" @click.stop>
<div class="dialog-header">
<h3 class="dialog-title">
{{ t('pages.manage.main.switchPicBed') }}
</h3>
<button class="dialog-close" @click="picBedSwitchDialogVisible = false">
<XIcon class="close-icon" />
</button>
</div>
<div class="dialog-content">
<div class="choice-cos">
<!-- Back to main card -->
<div class="picbed-card main-card" @click="switchPicBed('main')">
<div class="card-icon">
<HomeIcon class="main-icon" />
</div>
<div class="card-content">
<div class="card-title main-title">
{{ $t('pages.manage.main.backToHome') }}
</div>
<CustomModal
v-if="picBedSwitchDialogVisible"
v-model:visible="picBedSwitchDialogVisible"
:title="t('pages.manage.main.switchPicBed')"
>
<div class="no-scrollbar h-full w-full overflow-auto p-8">
<div class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-4">
<!-- Back to main card -->
<div
class="relative flex cursor-pointer flex-col items-center rounded-lg border-2 border-success/80 bg-bg-secondary p-6 transition-all duration-fast ease-apple"
@click="switchPicBed('main')"
>
<div class="mb-3 flex h-[40px] w-[40px] items-center justify-center">
<HomeIcon class="h-[24px] w-[24px] text-main" />
</div>
<div class="text-center">
<div class="text-sm font-semibold text-main">
{{ $t('pages.manage.main.backToHome') }}
</div>
</div>
</div>
<!-- PicBed cards -->
<div
v-for="(config, alias) in allPicBedConfigure"
:key="String(alias)"
class="picbed-card"
:class="{ active: String(alias) === currentAlias }"
@click="switchPicBed(String(alias))"
>
<div class="card-icon">
<img :src="`./assets/${config.picBedName}.webp`" class="picbed-icon" />
</div>
<div class="card-content">
<div class="card-title">
{{ config.alias }}
</div>
</div>
<div v-if="String(alias) === currentAlias" class="check-icon">
<CheckIcon />
<!-- PicBed cards -->
<div
v-for="(config, alias) in allPicBedConfigure"
:key="String(alias)"
class="relative flex cursor-pointer flex-col items-center rounded-lg border-2 border-border/80 bg-bg-secondary p-6 transition-all duration-fast ease-apple [.active]:border-accent"
:class="{ active: String(alias) === currentAlias }"
@click="switchPicBed(String(alias))"
>
<div class="mb-3 flex h-[40px] w-[40px] items-center justify-center">
<img :src="`./assets/${config.picBedName}.webp`" class="h-[32px] w-[32px] object-contain" />
</div>
<div class="text-center">
<div class="text-sm font-semibold text-main">
{{ config.alias }}
</div>
</div>
<div v-if="String(alias) === currentAlias" class="absolute top-2 right-2 h-[20px] w-[20px] text-accent">
<CheckIcon />
</div>
</div>
</div>
</div>
</div>
</CustomModal>
</transition>
<!-- New Bucket Drawer -->
@@ -268,17 +286,17 @@ import {
CheckIcon,
ChevronDownIcon,
ExternalLinkIcon,
FolderIcon,
GitBranchIcon,
HomeIcon,
PlusIcon,
SettingsIcon,
XIcon,
} from 'lucide-vue-next'
import { computed, onBeforeMount, reactive, ref, watch } from 'vue'
import { onBeforeMount, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import CustomButton from '@/components/common/CustomButton.vue'
import CustomModal from '@/components/common/CustomModal.vue'
import useMessage from '@/hooks/useMessage'
import { useManageStore } from '@/manage/store/manageStore'
import { supportedPicBedList } from '@/manage/utils/constants'
@@ -309,46 +327,6 @@ const isLoadingBucketList = ref(false)
const nweBucketDrawerVisible = ref(false)
const picBedSwitchDialogVisible = ref(false)
const maxTextLength = computed(() => {
const fixedSpace = 16 + 12 + 24 + 8
const availableWidth = sidebarWidth.value - fixedSpace
const estimatedCharWidth = 14 * 0.6
const maxChars = Math.floor(availableWidth / estimatedCharWidth)
return Math.max(6, Math.min(maxChars, 60))
})
const truncateText = (text: string, picBedName: string): string => {
if (!text) return ''
if (picBedName === 'tcyun') {
const baseName = text.slice(0, text.length - 11)
if (baseName.length <= maxTextLength.value) {
return baseName
}
return `${baseName.slice(0, maxTextLength.value - 3)}...`
} else if (picBedName === 'github') {
if (text.length <= maxTextLength.value) {
return text
}
const minSideLength = 3
const totalEllipsis = 2 // '..'
const availableForContent = maxTextLength.value - totalEllipsis
if (availableForContent < minSideLength * 2) {
return `${text.slice(0, maxTextLength.value - 3)}...`
}
const prefixLength = Math.ceil(availableForContent / 2)
const suffixLength = availableForContent - prefixLength
return `${text.slice(0, prefixLength)}..${text.slice(-suffixLength)}`
} else {
if (text.length <= maxTextLength.value) {
return text
}
return `${text.slice(0, maxTextLength.value - 3)}...`
}
}
watch(
route,
async newRoute => {

View File

@@ -39,6 +39,10 @@
tag="div"
class="flex max-w-[calc(100%-300px)] flex-wrap items-center gap-[0.2rem] [.has-many]:max-w-[300px]"
:class="{ 'has-many': favoritePicbeds.length >= 4 }"
enter-active-class="transition-all duration-200 ease-apple"
leave-active-class="transition-all duration-200 ease-apple"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<button
v-for="picbedType in favoritePicbeds"