frontend: add the template configuration UI

The shell: the app mark at both ends of the header, the two top-level
entries between them, and the second-level menu on the right. That menu is
driven entirely by `route.meta.section`, so a route declares which menu it
belongs to and a deep link renders the right one on first paint; routes with
no section (the welcome page) show none.

Screens:
  /                    welcome, with live template and field counts so the
                       page doubles as a connectivity check
  /papers              论文, content still to be decided
  /templates/list      the template table: search, create, edit, preview,
                       delete, batch delete, pagination
  /templates/fields    the field library: the same CRUD, plus level filter

The template form is the centre of it. Fields are chosen from the flat
library by clicking — any subset, any order, the same field more than once —
and the selection is always rendered sorted by `sort`, so editing a number
reorders the outline immediately. Repeats and duplicate sorts are surfaced as
warnings rather than blocked, because a repeat is often deliberate and a tie
is legal; a "自动排序" button renumbers the selection 1..N. The panel on the
right of the picker opens 字段管理 in a second tab, so a half-filled form is
never lost to a navigation.

The preview drawer renders the outline exactly as it will read, at each
field's own size and colour, with the sort value shown alongside — the
quickest way to confirm the ordering before writing against a template.

Also in this change:

  - ApiError now extends Error. It was a plain object, so the common
    `error instanceof Error ? error.message : ...` idiom fell through to a
    generic message and threw away what the server actually said — such as
    which template name is already taken. Server messages now reach the user.
  - font colours are normalised client-side on blur, matching the backend,
    so a hand-typed rgb(255, 0, 0) is tidied up rather than rejected later.
  - api/health.ts is removed: the rewritten welcome page was its only
    consumer, and the counts already prove connectivity.
This commit is contained in:
2026-09-18 16:00:02 +08:00
parent 13e5fc2cc1
commit 098bfd1bfb
19 changed files with 2575 additions and 137 deletions
+147
View File
@@ -0,0 +1,147 @@
<script setup lang="ts">
/**
* The second-level menu, pinned to the right of the content area.
*
* It is driven entirely by `route.meta.section`, so the menu a URL belongs to
* is a property of the route table rather than of component state — deep
* linking to `/templates/fields` shows the right menu on first paint, and the
* aside disappears on routes that declare no section (the welcome page).
*/
import { computed, type Component } from 'vue'
import { useRoute } from 'vue-router'
import { Document, Expand, Files, Fold, Setting } from '@element-plus/icons-vue'
import { useAppStore } from '@/stores/app'
interface AsideItem {
index: string
label: string
icon: Component
hint?: string
}
interface AsideMenu {
title: string
items: AsideItem[]
}
const props = defineProps<{ section: string }>()
const route = useRoute()
const appStore = useAppStore()
const MENUS: Record<string, AsideMenu> = {
papers: {
title: '论文',
items: [{ index: '/papers', label: '我的论文', icon: Document, hint: '待定' }],
},
templates: {
title: '模板配置',
items: [
{ index: '/templates/list', label: '模板列表', icon: Files },
{ index: '/templates/fields', label: '字段管理', icon: Setting },
],
},
}
const menu = computed<AsideMenu | null>(() => MENUS[props.section] ?? null)
const collapsed = computed(() => appStore.asideCollapsed)
</script>
<template>
<el-aside
v-if="menu"
class="app-aside"
:width="collapsed ? '56px' : '208px'"
>
<div class="aside-head" :class="{ 'is-collapsed': collapsed }">
<span v-if="!collapsed" class="aside-title">{{ menu.title }}</span>
<el-tooltip
:content="collapsed ? '展开菜单' : '收起菜单'"
placement="left"
:show-after="200"
>
<el-button
link
:icon="collapsed ? Expand : Fold"
class="aside-toggle"
@click="appStore.toggleAside()"
/>
</el-tooltip>
</div>
<el-menu
:default-active="route.path"
:collapse="collapsed"
:collapse-transition="false"
router
class="aside-menu"
>
<el-menu-item v-for="item in menu.items" :key="item.index" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<template #title>
<span class="aside-label">{{ item.label }}</span>
<el-tag v-if="item.hint" size="small" type="info" effect="plain">
{{ item.hint }}
</el-tag>
</template>
</el-menu-item>
</el-menu>
</el-aside>
</template>
<style scoped>
.app-aside {
display: flex;
flex-direction: column;
/* The wrapper owns the border so it stays put while the menu scrolls. */
border-left: 1px solid var(--el-border-color);
background-color: var(--el-bg-color);
overflow: hidden;
}
.aside-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
height: 48px;
padding: 0 8px 0 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
flex: 0 0 auto;
}
.aside-head.is-collapsed {
justify-content: center;
padding: 0;
}
.aside-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-regular);
letter-spacing: 0.02em;
white-space: nowrap;
}
.aside-toggle {
color: var(--el-text-color-secondary);
}
.aside-menu {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
border-right: none;
}
/* A collapsed el-menu keeps a fixed 64px width that no longer matches the
56px rail, so the items would sit off-centre. */
.aside-menu.el-menu--collapse {
width: 100%;
}
.aside-label {
margin-right: 6px;
}
</style>