Initial commit

This commit is contained in:
fawney19
2025-12-10 20:52:44 +08:00
commit f784106826
485 changed files with 110993 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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'