frontend: make the logo block exactly as wide as the second-level menu
The logo block stopped wherever its text happened to end (~140px) while the rail below it is 208px, so the left edge was a step rather than a column. Both now read `asideWidth()` from src/layout.ts, so they cannot drift apart, and collapsing the rail narrows the logo block with it — the wordmark is dropped and the mark centres in the 56px strip. The block also carries the rail's separator up through the header, so the column reads as one piece. The header's own 20px padding had to go for this to work: the logo block has to start at x=0 to be flush with the rail, so the nav and the right-hand mark now own their insets. The mark's inset comes from the same `ASIDE_INSET` as the rail's section title, so the two line up. A side effect worth keeping: the rail is 208px and the top nav's first item and .el-main are each inset a further 20px by their own Element Plus defaults, so the nav text, the page body and the page header all start on one vertical line. The widths live in a module rather than CSS custom properties because el-aside takes its width through a prop and would otherwise fight an inline style.
This commit is contained in:
+26
-6
@@ -145,13 +145,14 @@ Conventions worth knowing:
|
||||
### Shell
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ [book mark] paper-doc 论文 模板 [mark] │ header
|
||||
├───────────────────┬──────────────────────────────────────┤
|
||||
┌───────────────────┬──────────────────────────────────────┐
|
||||
│ [mark] paper-doc │ 论文 模板 [mk] │ header
|
||||
├───────────────────┼──────────────────────────────────────┤
|
||||
│ 模板配置 │ │ second-level
|
||||
│ 模板列表 │ <RouterView> │ menu, left
|
||||
│ 字段管理 │ │
|
||||
└───────────────────┴──────────────────────────────────────┘
|
||||
<- 208px ->
|
||||
```
|
||||
|
||||
The mark appears at both ends of the header. The second-level menu sits on the
|
||||
@@ -159,9 +160,28 @@ The mark appears at both ends of the header. The second-level menu sits on the
|
||||
which menu it belongs to and deep links render correctly on first paint. Routes
|
||||
without a `section` (the welcome page) show no menu.
|
||||
|
||||
Which side the menu lands on follows from its DOM order inside the flex row in
|
||||
`App.vue` — first child, left — so moving it is a one-block change there rather
|
||||
than a stylesheet override.
|
||||
Two things about this layout are deliberate and easy to break by accident:
|
||||
|
||||
- **Which side the menu lands on follows from its DOM order** inside the flex
|
||||
row in `App.vue` — first child, left. Moving it is a one-block change there,
|
||||
not a stylesheet override.
|
||||
- **The logo block is exactly as wide as the rail.** Both read `asideWidth()`
|
||||
from `src/layout.ts`, so they cannot drift apart, and collapsing the rail
|
||||
narrows the logo block with it (dropping the wordmark and centring the mark).
|
||||
The header's own padding is zeroed for this; the two insets come from
|
||||
`ASIDE_INSET` so the mark lines up with the section title below it.
|
||||
|
||||
The column also carries through to the content: the rail is 208px and both the
|
||||
top nav's first item and `.el-main` are inset a further 20px by their own
|
||||
Element Plus defaults, so the menu text, the page body, and the page header all
|
||||
start on the same vertical line.
|
||||
|
||||
### Shell metrics
|
||||
|
||||
`src/layout.ts` is the single source for the rail's width and inset. It is a
|
||||
module rather than CSS custom properties because `el-aside` takes its width
|
||||
through a prop, which would otherwise fight an inline style coming from the
|
||||
stylesheet.
|
||||
|
||||
### Routes
|
||||
|
||||
|
||||
+52
-4
@@ -12,8 +12,11 @@ import { RouterView, RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
import SectionAside from '@/components/SectionAside.vue'
|
||||
import { ASIDE_INSET, asideWidth } from '@/layout'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
|
||||
/** The two top-level sections. `index` is the path `el-menu` routes to. */
|
||||
const TOP_MENU = [
|
||||
@@ -36,12 +39,27 @@ const activeTop = computed(() => {
|
||||
})
|
||||
|
||||
const section = computed(() => (route.meta.section as string | undefined) ?? '')
|
||||
|
||||
/**
|
||||
* The logo block is exactly as wide as the second-level menu below it, so the
|
||||
* two stack into one column instead of leaving a ragged left edge. Both read
|
||||
* `asideWidth`, so collapsing the rail narrows the logo block with it.
|
||||
*/
|
||||
const brandWidth = computed(() => asideWidth(appStore.asideCollapsed))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="app-shell">
|
||||
<el-header class="app-header" height="60px">
|
||||
<RouterLink to="/" class="brand">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="brand"
|
||||
:class="{
|
||||
'brand--column': Boolean(section),
|
||||
'brand--compact': appStore.asideCollapsed,
|
||||
}"
|
||||
:style="{ width: brandWidth, '--aside-inset': ASIDE_INSET }"
|
||||
>
|
||||
<span class="brand-mark"><AppLogo :size="20" /></span>
|
||||
<span class="brand-text">paper-doc</span>
|
||||
</RouterLink>
|
||||
@@ -85,7 +103,11 @@ const section = computed(() => (route.meta.section as string | undefined) ?? '')
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
/* No gap and no padding: the logo block and the nav own their own insets.
|
||||
That is what lets the logo block start at x=0, flush with the rail below
|
||||
it, and be exactly the rail's width. */
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
@@ -93,10 +115,34 @@ const section = computed(() => (route.meta.section as string | undefined) ?? '')
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
/* The width comes from the shared shell metric through :style. Only the
|
||||
inset lives here, and it is the rail's own inset, so the mark lines up
|
||||
with the section title underneath it. */
|
||||
padding-left: var(--aside-inset);
|
||||
/* The brand links home; strip the anchor defaults. */
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
flex: 0 0 auto;
|
||||
transition:
|
||||
width 0.2s ease,
|
||||
padding 0.2s ease;
|
||||
}
|
||||
|
||||
/* Continues the rail's separator up through the header, so the logo block and
|
||||
the menu read as one column. Shown only when a rail is actually present. */
|
||||
.brand--column {
|
||||
border-right: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
/* Collapsed, the rail is a 56px icon strip: centre the mark in the same width
|
||||
and drop the wordmark rather than letting it overflow. */
|
||||
.brand--compact {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.brand--compact .brand-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
@@ -110,7 +156,8 @@ const section = computed(() => (route.meta.section as string | undefined) ?? '')
|
||||
box-shadow: 0 2px 6px rgb(64 158 255 / 30%);
|
||||
}
|
||||
|
||||
/* The right-hand echo: no badge, no weight, just the shape. */
|
||||
/* The right-hand echo: no badge, no weight, just the shape. The header no
|
||||
longer carries padding, so its inset lives here. */
|
||||
.brand-mark--ghost {
|
||||
width: auto;
|
||||
height: auto;
|
||||
@@ -119,6 +166,7 @@ const section = computed(() => (route.meta.section as string | undefined) ?? '')
|
||||
box-shadow: none;
|
||||
color: var(--el-text-color-placeholder);
|
||||
opacity: 0.7;
|
||||
margin: 0 20px 0 12px;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { computed, type Component } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Document, Expand, Files, Fold, Setting } from '@element-plus/icons-vue'
|
||||
|
||||
import { ASIDE_INSET, asideWidth } from '@/layout'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
interface AsideItem {
|
||||
@@ -46,13 +47,21 @@ const MENUS: Record<string, AsideMenu> = {
|
||||
|
||||
const menu = computed<AsideMenu | null>(() => MENUS[props.section] ?? null)
|
||||
const collapsed = computed(() => appStore.asideCollapsed)
|
||||
|
||||
/**
|
||||
* The rail's width, from the one place the header's logo block reads too, so
|
||||
* the two cannot drift apart.
|
||||
*/
|
||||
const width = computed(() => asideWidth(collapsed.value))
|
||||
const inset = ASIDE_INSET
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-aside
|
||||
v-if="menu"
|
||||
class="app-aside"
|
||||
:width="collapsed ? '56px' : '208px'"
|
||||
:width="width"
|
||||
:style="{ '--aside-inset': inset }"
|
||||
>
|
||||
<div class="aside-head" :class="{ 'is-collapsed': collapsed }">
|
||||
<span v-if="!collapsed" class="aside-title">{{ menu.title }}</span>
|
||||
@@ -107,7 +116,9 @@ const collapsed = computed(() => appStore.asideCollapsed)
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
height: 48px;
|
||||
padding: 0 8px 0 16px;
|
||||
/* Left inset comes from the shared metric so the section title lines up with
|
||||
the mark in the header above it. */
|
||||
padding: 0 8px 0 var(--aside-inset);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Shell metrics shared by the header and the second-level menu.
|
||||
*
|
||||
* The logo block at the top of the header and the second-level menu below it
|
||||
* form one column, so their widths have to be the same number — not two
|
||||
* numbers that happen to match today. Both call `asideWidth`, which is why the
|
||||
* column stays aligned when the rail is collapsed.
|
||||
*
|
||||
* The values live here rather than in CSS because `el-aside` takes its width
|
||||
* through a prop, so the two consumers cannot both read a custom property
|
||||
* without one of them fighting an inline style.
|
||||
*/
|
||||
|
||||
/** Width of the second-level menu rail, in both states. */
|
||||
export const ASIDE_WIDTH = {
|
||||
expanded: '208px',
|
||||
collapsed: '56px',
|
||||
} as const
|
||||
|
||||
/** The rail's width for a given collapsed state. */
|
||||
export function asideWidth(collapsed: boolean): string {
|
||||
return collapsed ? ASIDE_WIDTH.collapsed : ASIDE_WIDTH.expanded
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner inset of the rail, and of the logo block that sits above it.
|
||||
*
|
||||
* Shared for the same reason as the width: the mark in the header has to line
|
||||
* up with the section title below it, which it only does if both are inset by
|
||||
* the same amount.
|
||||
*/
|
||||
export const ASIDE_INSET = '16px'
|
||||
Reference in New Issue
Block a user