feat: 优化
This commit is contained in:
@@ -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>
|
||||
|
||||
19
frontend/app/web-gold/src/components/ui/sheet/Sheet.vue
Normal file
19
frontend/app/web-gold/src/components/ui/sheet/Sheet.vue
Normal 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>
|
||||
15
frontend/app/web-gold/src/components/ui/sheet/SheetClose.vue
Normal file
15
frontend/app/web-gold/src/components/ui/sheet/SheetClose.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
21
frontend/app/web-gold/src/components/ui/sheet/SheetTitle.vue
Normal file
21
frontend/app/web-gold/src/components/ui/sheet/SheetTitle.vue
Normal 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>
|
||||
@@ -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>
|
||||
8
frontend/app/web-gold/src/components/ui/sheet/index.ts
Normal file
8
frontend/app/web-gold/src/components/ui/sheet/index.ts
Normal 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"
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user