AppShell 应用框架

应用的外层框架,安置侧栏、页眉与主区域。

创作者中心

概览

切换侧栏,观察标识、分组与页脚的位置。窄屏时打开抽屉。

<script setup lang="ts">
  import { ref } from 'vue'
  import { FileText, House, Images, Settings } from '@lucide/vue'
  import {
    AppShell,
    Avatar,
    Heading,
    Inline,
    NavLink,
    Sidebar,
    SidebarGroup,
    SidebarLabel,
    SidebarTrigger,
    Stack,
    Text,
  } from '@hina-ui/vue'

  const selected = ref('overview')
  const groups = [
    {
      label: '内容',
      items: [
        { id: 'overview', label: '概览', icon: House },
        { id: 'articles', label: '文章', icon: FileText },
        { id: 'library', label: '媒体库', icon: Images },
      ],
    },
    { label: '管理', items: [{ id: 'settings', label: '设置', icon: Settings }] },
  ]
</script>

<template>
  <AppShell mobile-title="Hina UI" class="border-line h-112 w-full rounded-lg border">
    <template #sidebar>
      <Sidebar>
        <template #icon>
          <Avatar src="/favicon.png" name="Hina UI" class="rounded-md" />
        </template>
        <template #wordmark>
          <Stack gap="none">
            <Text weight="medium" class="truncate">Hina UI</Text>
            <Text size="xs" tone="muted" class="truncate">工作空间</Text>
          </Stack>
        </template>

        <SidebarGroup v-for="group in groups" :key="group.label" :label="group.label">
          <NavLink
            v-for="item in group.items"
            :key="item.id"
            :href="`#${item.id}`"
            :label="item.label"
            :active="selected === item.id"
            @click.prevent="selected = item.id"
          >
            <template #icon><component :is="item.icon" /></template>
            {{ item.label }}
          </NavLink>
        </SidebarGroup>

        <template #footer>
          <Inline gap="sm" align="center" :wrap="false">
            <Avatar src="/avatars/paper.webp" name="星见书音" />
            <SidebarLabel as="div" class="flex-1">
              <Stack gap="none">
                <Text size="sm" weight="medium" class="truncate">星见书音</Text>
                <Text size="xs" tone="muted" class="truncate">管理员</Text>
              </Stack>
            </SidebarLabel>
          </Inline>
        </template>
      </Sidebar>
    </template>
    <template #header>
      <SidebarTrigger />
      <Heading :level="2" size="sm" class="truncate">创作者中心</Heading>
    </template>
    <Stack gap="sm" class="p-6">
      <Heading :level="3" size="sm">
        {{ groups.flatMap(group => group.items).find(item => item.id === selected)?.label }}
      </Heading>
      <Text size="sm" tone="muted">切换侧栏,观察标识、分组与页脚的位置。窄屏时打开抽屉。</Text>
    </Stack>
  </AppShell>
</template>
vue

用法

import { AppShell } from '@hina-ui/vue'
ts

AppShell 占满视口,把界面分成三块:sidebar 插槽是左侧栏,header 插槽是顶部条,默认插槽是主区域。三块都是可选的。此外还有 banner 插槽,位于最顶部,横贯侧栏与主区域,用于放置 Banner。

它同时是侧栏状态的提供方。Sidebar、SidebarGroup、SidebarTrigger 以及侧栏内的 NavLink 都从这里取状态,脱离 AppShell 时它们退回展开形态,SidebarTrigger 不会渲染。

主区域的内容放在默认插槽里。

<script setup lang="ts">
  import { AppShell, NavLink, Sidebar, Text } from '@hina-ui/vue'
</script>

<template>
  <AppShell class="border-line h-72 w-full rounded-lg border">
    <template #sidebar>
      <Sidebar>
        <NavLink href="#" active label="概览">概览</NavLink>
        <NavLink href="#" label="我的书架">我的书架</NavLink>
      </Sidebar>
    </template>

    <Text size="sm" tone="muted" class="block p-6">主区域的内容放在默认插槽里。</Text>
  </AppShell>
</template>
vue

组件默认高度为一屏。嵌在页面中的局部框架可以用 class 覆盖高度,本页示例用的就是这个办法。

主区域是独立的滚动容器。默认插槽的直接子元素加上 min-h-full,就至少占满主区域的可视高度,有公告条时也成立。顶部条的高度由根元素上的 --hn-app-shell-header-h 给出,壳内任意层级都可以读取。

<AppShell>
  <template #header>…</template>
  <div class="flex min-h-full flex-col">
    <article class="flex-1">…</article>
    <footer>…</footer>
  </div>
</AppShell>
vue

示例

收起方式

collapsible 决定桌面端收起后的形态:rail 只留图标一条,hidden 让整条侧栏退场。默认为 rail。

侧栏状态可以用 v-model:sidebar 双向绑定,取值为 expanded、rail、hidden。需要在别处读出或写入当前形态时绑定它,否则交给组件自己维护即可。

收起过程中标识、头像和已展开条目的位置保持不变。Sidebar 的品牌插槽 自动处理图标与字标的显隐;自定义页眉与页脚内容可通过 SidebarLabel 使用相同过渡。

collapsible="rail" · expanded

创作者中心

概览

切换侧栏,观察标识、分组与页脚的位置。窄屏时打开抽屉。

collapsible="hidden" · expanded

创作者中心

概览

切换侧栏,观察标识、分组与页脚的位置。窄屏时打开抽屉。

<script setup lang="ts">
  import { ref } from 'vue'
  import { FileText, House, Images, Settings } from '@lucide/vue'
  import {
    AppShell,
    Avatar,
    Heading,
    Inline,
    NavLink,
    Sidebar,
    SidebarGroup,
    SidebarLabel,
    SidebarTrigger,
    Stack,
    Text,
    type SidebarState,
  } from '@hina-ui/vue'

  const selected = ref('overview')
  const groups = [
    {
      label: '内容',
      items: [
        { id: 'overview', label: '概览', icon: House },
        { id: 'articles', label: '文章', icon: FileText },
        { id: 'library', label: '媒体库', icon: Images },
      ],
    },
    { label: '管理', items: [{ id: 'settings', label: '设置', icon: Settings }] },
  ]
  const examples = ref<{ mode: 'rail' | 'hidden'; state: SidebarState }[]>([
    { mode: 'rail', state: 'expanded' },
    { mode: 'hidden', state: 'expanded' },
  ])
</script>

<template>
  <Stack gap="lg" class="w-full">
    <Stack v-for="example in examples" :key="example.mode" gap="sm">
      <Text size="sm" tone="faint">collapsible="{{ example.mode }}" · {{ example.state }}</Text>
      <AppShell
        v-model:sidebar="example.state"
        :collapsible="example.mode"
        mobile-title="Hina UI"
        class="border-line h-112 w-full rounded-lg border"
      >
        <template #sidebar>
          <Sidebar>
            <template #icon>
              <Avatar src="/favicon.png" name="Hina UI" class="rounded-md" />
            </template>
            <template #wordmark>
              <Stack gap="none">
                <Text weight="medium" class="truncate">Hina UI</Text>
                <Text size="xs" tone="muted" class="truncate">工作空间</Text>
              </Stack>
            </template>

            <SidebarGroup v-for="group in groups" :key="group.label" :label="group.label">
              <NavLink
                v-for="item in group.items"
                :key="item.id"
                :href="`#${item.id}`"
                :label="item.label"
                :active="selected === item.id"
                @click.prevent="selected = item.id"
              >
                <template #icon><component :is="item.icon" /></template>
                {{ item.label }}
              </NavLink>
            </SidebarGroup>

            <template #footer>
              <Inline gap="sm" align="center" :wrap="false">
                <Avatar src="/avatars/paper.webp" name="星见书音" />
                <SidebarLabel as="div" class="flex-1">
                  <Stack gap="none">
                    <Text size="sm" weight="medium" class="truncate">星见书音</Text>
                    <Text size="xs" tone="muted" class="truncate">管理员</Text>
                  </Stack>
                </SidebarLabel>
              </Inline>
            </template>
          </Sidebar>
        </template>
        <template #header>
          <SidebarTrigger />
          <Heading :level="2" size="sm" class="truncate">创作者中心</Heading>
        </template>
        <Stack gap="sm" class="p-6">
          <Heading :level="3" size="sm">
            {{ groups.flatMap(group => group.items).find(item => item.id === selected)?.label }}
          </Heading>
          <Text size="sm" tone="muted">切换侧栏,观察标识、分组与页脚的位置。窄屏时打开抽屉。</Text>
        </Stack>
      </AppShell>
    </Stack>
  </Stack>
</template>
vue

桌面侧栏切换完成后触发一次 @size-stable,无动画的切换也会触发。图表等测量成本较高的内容可以在此时调整尺寸;使用这个事件时,应关闭图表自身的持续尺寸监听,避免重复响应。内部的 ScrollArea 会在侧栏过渡期间暂缓测量,结束后统一更新,期间仍可正常滚动。

主区域滚动

主区域自带滚动容器,内容再长也只在其内部滚动,页眉与侧栏保持不动。

标题栏不随内容滚动

第 1 段正文。

第 2 段正文。

第 3 段正文。

第 4 段正文。

第 5 段正文。

第 6 段正文。

第 7 段正文。

第 8 段正文。

第 9 段正文。

第 10 段正文。

第 11 段正文。

第 12 段正文。

<script setup lang="ts">
  import { AppShell, Heading, NavLink, Sidebar, Stack, Text } from '@hina-ui/vue'

  const paragraphs = Array.from({ length: 12 }, (_, i) => `第 ${i + 1} 段正文。`)
</script>

<template>
  <AppShell class="border-line h-72 w-full rounded-lg border">
    <template #sidebar>
      <Sidebar>
        <NavLink href="#" active label="正文">正文</NavLink>
        <NavLink href="#" label="注释">注释</NavLink>
      </Sidebar>
    </template>

    <template #header>
      <Heading :level="2" size="sm">标题栏不随内容滚动</Heading>
    </template>

    <Stack gap="sm" class="p-6">
      <Text v-for="p in paragraphs" :key="p" size="sm" tone="muted">{{ p }}</Text>
    </Stack>
  </AppShell>
</template>
vue

行为

  • 视口宽度达到 1024 像素时为桌面布局,侧栏常驻左侧;低于此宽度时侧栏改由抽屉承载,sidebar 插槽的内容原样搬进抽屉。
  • 桌面端点击 SidebarTrigger 在展开与 collapsible 指定的形态之间切换;窄屏则切换抽屉开合。
  • 抽屉的开合状态可用 v-model:mobileOpen 绑定。
  • 移动端 Drawer 不额外显示标题栏,关闭按钮默认位于 Sidebar 页眉内,可在 Sidebar 上设置 :closable="false" 隐藏。mobileTitle 仅设置抽屉的无障碍名,默认取自界面语言。
  • 抽屉内侧栏占满可用高度,页眉和页脚固定在两端,导航区填满剩余空间并独立滚动。
  • autoClose 为真时路由变化会关闭抽屉,避免跳转后抽屉仍挡在内容前面。默认开启。
  • 设置 restoreKey 后,组件自行保存并恢复主区域的滚动位置:位置按历史记录条目写入 history.state,刷新、后退与前进时恢复,进入新页面回到顶部,带 #hash 的新页面滚动到对应元素。同一页面上有多个壳时使用不同的键。
  • 服务端输出带一段内联脚本,在激活之前把位置设好,刷新时不会先停在顶部再跳动。内容安全策略禁止内联脚本时,这段脚本不执行,位置在激活后恢复。
  • 恢复不依赖具体的路由库:通过历史记录的变化和主区域内容的更新判断页面切换,内容尚未渲染到足够高度时会等待,最多 3 秒。
  • keepScrollPosition() 让接下来的一次页面变化保持当前位置,不回到顶部,用于切换标签页、筛选或排序这类会改变路径或查询的导航。在发起导航之前调用,只生效一次;后退与前进仍然恢复各自保存的位置。
  • 组件内置浮层提供方,侧栏在 rail 形态下的悬停提示不需要另行包裹。

无障碍

  • 页眉渲染为 header,主区域渲染为 main,侧栏的导航地标由 Sidebar 自行提供。
  • SidebarTrigger 带有随界面语言给出的无障碍名(简体中文为「切换侧栏」)。
  • 抽屉形态下焦点被限制在抽屉内,关闭后归还给触发按钮,这由 Drawer 保证。

API

Props

属性
类型
默认值
说明
collapsible
'rail' | 'hidden'
'rail'
桌面端收起后的形态
autoClose
boolean
true
路由变化时是否关闭移动端抽屉
mobileTitle
string
取自界面语言
移动端抽屉的无障碍名,不显示标题栏
restoreKey
string
—
设置后保存并恢复主区域的滚动位置,值是这个滚动容器的标识
class
string
—
追加到根元素的类

双向绑定

名称
类型
默认值
说明
sidebar
SidebarState
'expanded'
桌面端侧栏形态
mobileOpen
boolean
false
移动端抽屉是否打开

事件

事件
参数
说明
size-stable
—
侧栏状态变化后的布局已稳定;中途反向切换只在最终布局稳定后通知

CSS 变量

变量默认值
说明
--hn-app-shell-header-hcalc(3.5rem + 1px)
顶部条的高度,含下边线;在 class 中改写会同时改变顶部条

Slots

插槽
说明
banner
最顶部的公告条,横贯整个壳
sidebar
侧栏内容,窄屏时搬入抽屉
header
顶部条内容
default
主区域内容

Expose

名称
类型
说明
mainViewport
HTMLElement | undefined
主区域滚动容器的视口元素
mainArea
ScrollArea | undefined
主区域的滚动容器组件

函数

函数
说明
keepScrollPosition(key?: string)
让下一次页面变化保持主区域的滚动位置;传入 restoreKey 时只作用于对应的壳
import { keepScrollPosition } from '@hina-ui/vue'

function sort(order: string) {
  keepScrollPosition()
  router.replace({ query: { ...route.query, order } })
}
ts