feat: 优化

This commit is contained in:
2026-03-15 23:54:45 +08:00
parent 6fa977b229
commit c8c62a1427
13 changed files with 663 additions and 211 deletions

View File

@@ -1,12 +1,38 @@
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { navConfig, navIcons } from '@/router'
import { navConfig } from '@/router'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem
} from '@/components/ui/sidebar'
import { Progress } from '@/components/ui/progress'
import { Icon } from '@iconify/vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const iconMap = {
grid: 'lucide:layout-grid',
text: 'lucide:file-text',
mic: 'lucide:mic',
wave: 'lucide:audio-waveform',
user: 'lucide:user',
video: 'lucide:video',
folder: 'lucide:folder',
scissors: 'lucide:scissors',
robot: 'lucide:bot',
home: 'lucide:home'
}
function filterVisibleGroups(config, isLoggedIn) {
return config
.filter(group => !group.requiresAuth || isLoggedIn)
@@ -21,204 +47,86 @@ const visibleNavConfig = computed(() => {
return filterVisibleGroups(navConfig, userStore.isLoggedIn)
})
// 脱敏手机号
const maskedMobile = computed(() => {
const mobile = userStore.mobile
if (!mobile) return '未设置'
return mobile.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
})
// 剩余额度百分比
const remainingPercent = computed(() => {
const total = userStore.totalStorage
const remaining = userStore.remainingStorage
if (total === 0) return 0
return Math.min(100, Math.round((remaining / total) * 100))
})
function navigateTo(item) {
if (item.params) {
router.push({ name: item.name, params: item.params })
} else {
router.push({ name: item.name })
}
}
function isActive(item) {
return route.name === item.name
}
</script>
<template>
<aside class="sidebar">
<nav class="sidebar__nav">
<div v-for="group in visibleNavConfig" :key="group.group" class="nav-group">
<div class="nav-group__title">{{ group.group }}</div>
<router-link
v-for="item in group.items"
:key="item.name"
:to="{ name: item.name, ...(item.params && { params: item.params }) }"
class="nav-item"
:class="{ 'is-active': route.name === item.name }"
custom
v-slot="{ navigate }"
>
<button class="nav-item" @click="navigate">
<span class="nav-item__icon" v-html="navIcons[item.icon]"></span>
<span class="nav-item__label">{{ item.name }}</span>
</button>
</router-link>
</div>
</nav>
<!-- 底部用户信息卡片 -->
<router-link
v-if="userStore.isLoggedIn"
to="/user/profile"
class="sidebar__footer"
>
<div class="user-card">
<div class="user-card__mobile">{{ maskedMobile }}</div>
<div class="user-card__quota">
<span>剩余额度 {{ userStore.remainingPoints }} </span>
<div class="quota-progress">
<div class="quota-progress__bar" :style="{ width: remainingPercent + '%' }"></div>
<Sidebar collapsible="none" class="h-[calc(100vh-70px)] border-r">
<SidebarContent>
<SidebarGroup v-for="group in visibleNavConfig" :key="group.group">
<SidebarGroupLabel>{{ group.group }}</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in group.items" :key="item.name">
<SidebarMenuButton
:class="{ 'is-active': isActive(item) }"
class="nav-item"
@click="navigateTo(item)"
>
<Icon :icon="iconMap[item.icon] || 'lucide:circle'" />
<span>{{ item.name }}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter v-if="userStore.isLoggedIn">
<router-link to="/user/profile" class="block no-underline">
<div class="p-3 rounded-xl bg-muted cursor-pointer transition-all duration-200 ease-out hover:bg-muted/80 hover:-translate-y-0.5">
<div class="text-sm font-medium text-foreground mb-1.5">
{{ maskedMobile }}
</div>
<div class="text-xs text-muted-foreground">
<span>剩余额度 {{ userStore.remainingPoints }} </span>
<Progress :model-value="remainingPercent" class="mt-1.5 h-1.5" />
</div>
</div>
</div>
</router-link>
</aside>
</router-link>
</SidebarFooter>
</Sidebar>
</template>
<style scoped>
.sidebar {
position: sticky;
top: 70px;
height: calc(100vh - 70px);
width: 220px;
border-right: 1px solid var(--color-border);
background: var(--color-surface);
display: flex;
flex-direction: column;
}
.sidebar__nav {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 12px;
gap: 6px;
}
.nav-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.nav-group__title {
height: 30px;
display: flex;
align-items: center;
padding: 0 8px;
font-size: var(--font-small-size);
color: var(--color-text-secondary);
letter-spacing: .06em;
}
.nav-item {
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
color: var(--color-gray-600);
background: transparent;
border: 1px solid transparent;
cursor: pointer;
transition: background .2s ease, color .2s ease, box-shadow .2s ease, transform .12s ease, border-color .2s ease;
width: 100%;
text-align: left;
font-size: 14px;
font-weight: 400;
}
.nav-item:hover {
background: var(--color-gray-50);
color: var(--color-gray-700);
}
.nav-item.is-active {
background: var(--color-primary-50);
color: var(--color-primary-700);
border-color: transparent;
}
.nav-item.is-active:hover {
background: var(--color-primary-100);
color: var(--color-primary-700);
}
.nav-item__icon {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.nav-item__label {
font-size: 14px;
}
/* 底部用户信息卡片 */
.sidebar__footer {
flex-shrink: 0;
padding: 12px;
border-top: 1px solid var(--color-border);
text-decoration: none;
}
.user-card {
padding: 12px;
border-radius: 12px;
background: var(--color-gray-50);
cursor: pointer;
transition: background .2s ease, transform .12s ease;
}
.user-card:hover {
background: var(--color-gray-100);
transform: translateY(-1px);
}
.user-card__mobile {
font-size: 14px;
:deep(.is-active) {
background: linear-gradient(135deg, oklch(0.45 0.16 254.604 / 0.1) 0%, oklch(0.45 0.16 254.604 / 0.05) 100%);
color: oklch(0.45 0.16 254.604);
font-weight: 500;
color: var(--color-gray-700);
margin-bottom: 6px;
}
.user-card__quota {
font-size: 12px;
color: var(--color-text-secondary);
:deep(.is-active:hover) {
background: linear-gradient(135deg, oklch(0.45 0.16 254.604 / 0.15) 0%, oklch(0.45 0.16 254.604 / 0.08) 100%);
}
.quota-progress {
margin-top: 6px;
height: 6px;
background: var(--color-gray-100);
border-radius: 3px;
overflow: hidden;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.06);
:deep(.sidebar-group-label) {
font-size: 11px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.quota-progress__bar {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #60a5fa);
border-radius: 3px;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
box-shadow: 0 1px 3px rgba(59, 130, 246, 0.4);
}
.quota-progress__bar::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 50%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.3), transparent);
border-radius: 3px 3px 0 0;
:deep(.nav-item) {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
v-slot="slotProps"
data-slot="sheet"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DialogRoot>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogCloseProps } from "reka-ui"
import { DialogClose } from "reka-ui"
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="sheet-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import SheetOverlay from "./SheetOverlay.vue"
interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes["class"]
side?: "top" | "right" | "bottom" | "left"
}
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<SheetContentProps>(), {
side: "right",
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class", "side")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<SheetOverlay />
<DialogContent
data-slot="sheet-content"
:class="cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right'
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left'
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top'
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom'
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
props.class)"
v-bind="{ ...$attrs, ...forwarded }"
>
<slot />
<DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
>
<X class="size-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogDescription } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogDescription
data-slot="sheet-description"
:class="cn('text-muted-foreground text-sm', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogDescription>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<div
data-slot="sheet-footer"
:class="cn('mt-auto flex flex-col gap-2 p-4', props.class)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<div
data-slot="sheet-header"
:class="cn('flex flex-col gap-1.5 p-4', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogOverlay } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogOverlay
data-slot="sheet-overlay"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogOverlay>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogTitle } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogTitle
data-slot="sheet-title"
:class="cn('text-foreground font-semibold', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogTitle>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="sheet-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>

View File

@@ -0,0 +1,8 @@
export { default as Sheet } from "./Sheet.vue"
export { default as SheetClose } from "./SheetClose.vue"
export { default as SheetContent } from "./SheetContent.vue"
export { default as SheetDescription } from "./SheetDescription.vue"
export { default as SheetFooter } from "./SheetFooter.vue"
export { default as SheetHeader } from "./SheetHeader.vue"
export { default as SheetTitle } from "./SheetTitle.vue"
export { default as SheetTrigger } from "./SheetTrigger.vue"

View File

@@ -1,49 +1,24 @@
<script setup>
import { SidebarProvider } from '@/components/ui/sidebar'
import TopNav from '@/components/TopNav.vue'
import SidebarNav from '@/components/SidebarNav.vue'
</script>
<template>
<div class="app-shell">
<SidebarProvider
:style="{ '--sidebar-width': '220px' }"
class="flex flex-col min-h-screen bg-background"
>
<TopNav />
<div class="app-body">
<div class="flex flex-1 pt-[70px]">
<SidebarNav />
<div class="app-content">
<main class="content-scroll">
<main class="flex-1 h-[calc(100vh-70px)] overflow-auto p-4">
<RouterView v-slot="{ Component }">
<keep-alive>
<RouterView />
<component :is="Component" />
</keep-alive>
</main>
</div>
</RouterView>
</main>
</div>
</div>
</SidebarProvider>
</template>
<style scoped>
.app-shell {
min-height: 100vh;
background: var(--color-bg);
}
/* 顶部固定,下面主体需要留出空间 */
.app-body {
padding-top: 70px; /* 与 TopNav 高度对齐 */
display: grid;
overflow: hidden;
grid-template-columns: 220px 1fr; /* 左侧固定宽度侧边栏 */
}
.app-content {
height: calc(100vh - 90px);
display: flex;
flex-direction: column;
}
.content-scroll {
flex: 1 1 auto;
overflow-x: hidden;
overflow-y: auto; /* 右侧内容区域滚动 */
padding: 16px;
}
</style>