mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-03 00:02:28 +08:00
Initial commit
This commit is contained in:
55
frontend/src/components/layout/AppShell.vue
Normal file
55
frontend/src/components/layout/AppShell.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="app-shell" :class="{ 'pt-24': showNotice }">
|
||||
<div
|
||||
v-if="showNotice"
|
||||
class="fixed top-6 left-0 right-0 z-50 flex justify-center px-4"
|
||||
>
|
||||
<slot name="notice" />
|
||||
</div>
|
||||
|
||||
<div class="app-shell__backdrop">
|
||||
<div class="app-shell__gradient app-shell__gradient--primary"></div>
|
||||
<div class="app-shell__gradient app-shell__gradient--accent"></div>
|
||||
</div>
|
||||
|
||||
<div class="app-shell__body">
|
||||
<aside
|
||||
v-if="$slots.sidebar"
|
||||
class="app-shell__sidebar"
|
||||
:class="sidebarClass"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
</aside>
|
||||
|
||||
<div class="app-shell__content" :class="contentClass">
|
||||
<slot name="header" />
|
||||
<main :class="mainClass">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
showNotice?: boolean
|
||||
contentClass?: string
|
||||
mainClass?: string
|
||||
sidebarClass?: string
|
||||
}>(), {
|
||||
showNotice: false,
|
||||
contentClass: '',
|
||||
mainClass: '',
|
||||
sidebarClass: '',
|
||||
})
|
||||
|
||||
const showNotice = computed(() => props.showNotice)
|
||||
|
||||
// contentClass and mainClass are now just the props, base classes are in template
|
||||
const contentClass = computed(() => props.contentClass)
|
||||
const mainClass = computed(() => ['app-shell__main', props.mainClass].filter(Boolean).join(' '))
|
||||
const sidebarClass = computed(() => props.sidebarClass)
|
||||
</script>
|
||||
103
frontend/src/components/layout/CardSection.vue
Normal file
103
frontend/src/components/layout/CardSection.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<Card :class="cardClasses">
|
||||
<div v-if="title || description || $slots.header" :class="headerClasses">
|
||||
<slot name="header">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 v-if="title" class="text-lg font-medium leading-6 text-foreground">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p v-if="description" class="mt-1 text-sm text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div :class="contentClasses">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.footer" :class="footerClasses">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
description?: string
|
||||
variant?: 'default' | 'elevated' | 'glass'
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'default',
|
||||
padding: 'md',
|
||||
})
|
||||
|
||||
const cardClasses = computed(() => {
|
||||
const classes = []
|
||||
|
||||
if (props.variant === 'elevated') {
|
||||
classes.push('shadow-md')
|
||||
} else if (props.variant === 'glass') {
|
||||
classes.push('surface-glass')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
const headerClasses = computed(() => {
|
||||
const paddingMap = {
|
||||
none: '',
|
||||
sm: 'px-3 py-3',
|
||||
md: 'px-4 py-5 sm:p-6',
|
||||
lg: 'px-6 py-6 sm:p-8',
|
||||
}
|
||||
|
||||
const classes = [paddingMap[props.padding]]
|
||||
|
||||
if (props.padding !== 'none') {
|
||||
classes.push('border-b border-border')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
const contentClasses = computed(() => {
|
||||
const paddingMap = {
|
||||
none: '',
|
||||
sm: 'px-3 py-3',
|
||||
md: 'px-4 py-5 sm:p-6',
|
||||
lg: 'px-6 py-6 sm:p-8',
|
||||
}
|
||||
|
||||
return paddingMap[props.padding]
|
||||
})
|
||||
|
||||
const footerClasses = computed(() => {
|
||||
const paddingMap = {
|
||||
none: '',
|
||||
sm: 'px-3 py-3',
|
||||
md: 'px-4 py-5 sm:p-6',
|
||||
lg: 'px-6 py-6 sm:p-8',
|
||||
}
|
||||
|
||||
const classes = [paddingMap[props.padding]]
|
||||
|
||||
if (props.padding !== 'none') {
|
||||
classes.push('border-t border-border')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
</script>
|
||||
123
frontend/src/components/layout/MobileNav.vue
Normal file
123
frontend/src/components/layout/MobileNav.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="lg:hidden">
|
||||
<div class="sticky top-0 z-40 space-y-4 pb-4">
|
||||
<!-- Logo头部 - 移动端优化 -->
|
||||
<div class="flex items-center gap-3 rounded-2xl bg-card/90 px-4 py-3 shadow-lg shadow-primary/20 ring-1 ring-border backdrop-blur">
|
||||
<div class="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-2xl bg-background shadow-md shadow-primary/20 flex-shrink-0">
|
||||
<img src="/aether_adaptive.svg" alt="Logo" class="h-8 w-8 sm:h-10 sm:w-10" />
|
||||
</div>
|
||||
<!-- 文字部分 - 小屏隐藏 -->
|
||||
<div class="hidden sm:block">
|
||||
<p class="text-sm font-semibold text-foreground">
|
||||
Aether
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
AI 控制中心
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded-2xl bg-card/80 px-4 py-3 shadow-lg shadow-primary/20 ring-1 ring-border backdrop-blur transition hover:ring-primary/30"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary flex-shrink-0">
|
||||
<Menu class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col text-left min-w-0">
|
||||
<span class="text-sm font-semibold text-foreground truncate">
|
||||
快速导航
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground truncate">
|
||||
{{ activeItem ? `当前:${activeItem.name}` : '选择功能页面' }}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 text-muted-foreground transition-transform duration-200 flex-shrink-0"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2 scale-95"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 -translate-y-1 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="space-y-3 rounded-3xl bg-card/95 p-4 shadow-2xl ring-1 ring-border backdrop-blur-xl"
|
||||
>
|
||||
<SidebarNav
|
||||
:items="props.items"
|
||||
:is-active="isLinkActive"
|
||||
:active-path="props.activePath"
|
||||
list-class="space-y-2"
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ChevronDown, Menu } from 'lucide-vue-next'
|
||||
import SidebarNav from '@/components/layout/SidebarNav.vue'
|
||||
|
||||
export interface NavigationItem {
|
||||
name: string
|
||||
href: string
|
||||
icon: Component
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface NavigationGroup {
|
||||
title?: string
|
||||
items: NavigationItem[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
items: NavigationGroup[]
|
||||
activePath?: string
|
||||
isActive?: (href: string) => boolean
|
||||
isDark?: boolean
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const activeItem = computed(() => {
|
||||
for (const group of props.items) {
|
||||
const found = group.items.find(item => isLinkActive(item.href))
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function isLinkActive(href: string) {
|
||||
if (props.isActive) {
|
||||
return props.isActive(href)
|
||||
}
|
||||
if (props.activePath) {
|
||||
return props.activePath === href || props.activePath.startsWith(`${href}/`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
function handleNavigate() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
watch(() => props.activePath, () => {
|
||||
isOpen.value = false
|
||||
})
|
||||
</script>
|
||||
47
frontend/src/components/layout/PageContainer.vue
Normal file
47
frontend/src/components/layout/PageContainer.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div :class="containerClasses">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full'
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
maxWidth: '2xl',
|
||||
padding: 'md',
|
||||
})
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
const classes = ['w-full mx-auto']
|
||||
|
||||
// Max width
|
||||
const maxWidthMap = {
|
||||
sm: 'max-w-screen-sm',
|
||||
md: 'max-w-screen-md',
|
||||
lg: 'max-w-screen-lg',
|
||||
xl: 'max-w-screen-xl',
|
||||
'2xl': 'max-w-screen-2xl',
|
||||
full: 'max-w-full',
|
||||
}
|
||||
classes.push(maxWidthMap[props.maxWidth])
|
||||
|
||||
// Padding
|
||||
const paddingMap = {
|
||||
none: '',
|
||||
sm: 'px-4 py-4',
|
||||
md: 'px-4 py-6 sm:px-6 lg:px-8',
|
||||
lg: 'px-6 py-8 sm:px-8 lg:px-12',
|
||||
}
|
||||
if (props.padding !== 'none') {
|
||||
classes.push(paddingMap[props.padding])
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
</script>
|
||||
38
frontend/src/components/layout/PageHeader.vue
Normal file
38
frontend/src/components/layout/PageHeader.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<slot name="icon">
|
||||
<div v-if="icon" class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
|
||||
<component :is="icon" class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-foreground sm:text-3xl">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="description" class="mt-1 text-sm text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions" class="flex items-center gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
description?: string
|
||||
icon?: Component
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
54
frontend/src/components/layout/Section.vue
Normal file
54
frontend/src/components/layout/Section.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<section :class="sectionClasses">
|
||||
<div v-if="title || description || $slots.header" class="mb-6">
|
||||
<slot name="header">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 v-if="title" class="text-lg font-medium text-foreground">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="description" class="mt-1 text-sm text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
description?: string
|
||||
spacing?: 'none' | 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
spacing: 'md',
|
||||
})
|
||||
|
||||
const sectionClasses = computed(() => {
|
||||
const classes = []
|
||||
|
||||
const spacingMap = {
|
||||
none: '',
|
||||
sm: 'mb-4',
|
||||
md: 'mb-6',
|
||||
lg: 'mb-8',
|
||||
}
|
||||
|
||||
if (props.spacing !== 'none') {
|
||||
classes.push(spacingMap[props.spacing])
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
</script>
|
||||
88
frontend/src/components/layout/SidebarNav.vue
Normal file
88
frontend/src/components/layout/SidebarNav.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<nav class="sidebar-nav w-full px-3">
|
||||
<div v-for="(group, index) in items" :key="index" class="space-y-1 mb-5">
|
||||
|
||||
<!-- Section Header -->
|
||||
<div v-if="group.title" class="px-2.5 pb-1 flex items-center gap-2" :class="index > 0 ? 'pt-1' : ''">
|
||||
<span class="text-[10px] font-medium text-muted-foreground/50 font-mono tabular-nums">{{ String(index + 1).padStart(2, '0') }}</span>
|
||||
<span class="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.1em]">{{ group.title }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Links -->
|
||||
<div class="space-y-0.5">
|
||||
<template v-for="item in group.items" :key="item.href">
|
||||
<RouterLink
|
||||
:to="item.href"
|
||||
class="group relative flex items-center justify-between px-2.5 py-2 rounded-lg transition-all duration-200"
|
||||
:class="[
|
||||
isItemActive(item.href)
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
]"
|
||||
@click="handleNavigate(item.href)"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 transition-colors duration-200"
|
||||
:class="isItemActive(item.href) ? 'text-primary' : 'text-muted-foreground/70 group-hover:text-foreground'"
|
||||
:stroke-width="isItemActive(item.href) ? 2 : 1.75"
|
||||
/>
|
||||
<span class="text-[13px] tracking-tight">{{ item.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Active Indicator -->
|
||||
<div
|
||||
v-if="isItemActive(item.href)"
|
||||
class="w-1 h-1 rounded-full bg-primary"
|
||||
></div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export interface NavigationItem {
|
||||
name: string
|
||||
href: string
|
||||
icon: Component
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface NavigationGroup {
|
||||
title?: string
|
||||
items: NavigationItem[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
items: NavigationGroup[]
|
||||
activePath?: string
|
||||
isActive?: (href: string) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'navigate', href: string): void
|
||||
}>()
|
||||
|
||||
function isItemActive(href: string) {
|
||||
if (props.isActive) {
|
||||
return props.isActive(href)
|
||||
}
|
||||
if (props.activePath) {
|
||||
return props.activePath === href || props.activePath.startsWith(`${href}/`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function handleNavigate(href: string) {
|
||||
emit('navigate', href)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Navigation styles handled by Tailwind */
|
||||
</style>
|
||||
15
frontend/src/components/layout/index.ts
Normal file
15
frontend/src/components/layout/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Layout Components Library
|
||||
* 基于 shadcn/ui 的自定义布局组件库
|
||||
*/
|
||||
|
||||
// 页面布局组件
|
||||
export { default as PageHeader } from './PageHeader.vue'
|
||||
export { default as PageContainer } from './PageContainer.vue'
|
||||
export { default as Section } from './Section.vue'
|
||||
export { default as CardSection } from './CardSection.vue'
|
||||
|
||||
// 应用外壳组件
|
||||
export { default as AppShell } from './AppShell.vue'
|
||||
export { default as MobileNav } from './MobileNav.vue'
|
||||
export { default as SidebarNav } from './SidebarNav.vue'
|
||||
Reference in New Issue
Block a user