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
@@ -0,0 +1,317 @@
<script setup lang="ts">
/**
* 字段管理 — the reusable heading library.
*
* This is the table behind every template's picker, so it carries the full
* CRUD set: search, single and batch delete, and create/edit through a dialog.
* Deleting is guarded on the server: a field that a template still places is
* refused, and the message names the field, because dropping it silently would
* remove a heading from that template's outline.
*/
import { onMounted, reactive, ref } from 'vue'
import { Delete, Edit, Plus, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { errorMessage } from '@/api/client'
import {
batchDeleteSectionFields,
deleteSectionField,
listSectionFields,
type SectionField,
} from '@/api/sectionFields'
import FieldFormDialog from '@/components/fields/FieldFormDialog.vue'
import { formatDateTime, levelIndent, typographyStyle } from '@/utils/format'
const loading = ref(false)
const rows = ref<SectionField[]>([])
const total = ref(0)
const selection = ref<SectionField[]>([])
const query = reactive({
keyword: '',
level: null as number | null,
page: 1,
page_size: 20,
})
const dialogVisible = ref(false)
const editing = ref<SectionField | null>(null)
const LEVEL_FILTERS = [
{ value: null, label: '全部等级' },
{ value: 1, label: '1 级' },
{ value: 2, label: '2 级' },
{ value: 3, label: '3 级' },
]
async function load(): Promise<void> {
loading.value = true
try {
const result = await listSectionFields(query)
rows.value = result.items
total.value = result.total
// A delete can empty the last page. Stepping back keeps the table from
// showing "no data" while the pager still claims a page beyond the end.
if (result.items.length === 0 && result.total > 0 && query.page > 1) {
query.page -= 1
await load()
}
} catch (error) {
ElMessage.error(errorMessage(error))
} finally {
loading.value = false
}
}
function search(): void {
query.page = 1
load()
}
function resetFilters(): void {
query.keyword = ''
query.level = null
query.page = 1
load()
}
function onCreate(): void {
editing.value = null
dialogVisible.value = true
}
function onEdit(row: SectionField): void {
editing.value = row
dialogVisible.value = true
}
async function onDelete(row: SectionField): Promise<void> {
try {
await ElMessageBox.confirm(`确定删除字段「${row.name}」?`, '删除字段', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return // dismissed
}
try {
await deleteSectionField(row.id)
ElMessage.success('已删除')
await load()
} catch (error) {
ElMessage.error(errorMessage(error))
}
}
async function onBatchDelete(): Promise<void> {
const ids = selection.value.map((row) => row.id)
if (ids.length === 0) return
try {
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个字段?`, '批量删除', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return // dismissed
}
try {
const result = await batchDeleteSectionFields(ids)
ElMessage.success(`已删除 ${result.deleted} 个字段`)
selection.value = []
await load()
} catch (error) {
// Refusals list every field still in use, so this is worth showing whole.
ElMessage.error(errorMessage(error))
}
}
onMounted(load)
</script>
<template>
<div class="page">
<el-card shadow="never">
<template #header>
<div class="card-header">
<div>
<span class="card-title">字段库</span>
<span class="card-subtitle">
字段是全局复用的可被任意多个模板以任意顺序引用
</span>
</div>
<el-tag type="info" effect="plain"> {{ total }} </el-tag>
</div>
</template>
<div class="toolbar">
<el-input
v-model="query.keyword"
placeholder="搜索字段名称"
clearable
class="search"
@keyup.enter="search"
@clear="search"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-model="query.level" class="level-filter" @change="search">
<el-option
v-for="item in LEVEL_FILTERS"
:key="String(item.value)"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button @click="search">查询</el-button>
<el-button text @click="resetFilters">重置</el-button>
<div class="toolbar-spacer" />
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
<el-button
type="danger"
plain
:icon="Delete"
:disabled="selection.length === 0"
@click="onBatchDelete"
>
批量删除{{ selection.length ? `${selection.length}` : '' }}
</el-button>
<el-button type="primary" :icon="Plus" @click="onCreate">新增字段</el-button>
</div>
<el-table
v-loading="loading"
:data="rows"
row-key="id"
stripe
class="table"
@selection-change="(value: SectionField[]) => (selection = value)"
>
<el-table-column type="selection" width="46" reserve-selection />
<el-table-column label="字段名称" min-width="260">
<template #default="{ row }">
<div class="name-cell" :style="{ paddingLeft: levelIndent(row.level) }">
<span :style="typographyStyle(row)">{{ row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="等级" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ row.level }} 级</el-tag>
</template>
</el-table-column>
<el-table-column label="字体大小" width="110" align="right">
<template #default="{ row }">{{ row.font_size }} pt</template>
</el-table-column>
<el-table-column label="字体颜色" width="150">
<template #default="{ row }">
<div class="color-cell">
<span class="swatch" :style="{ backgroundColor: row.font_color }" />
<code>{{ row.font_color }}</code>
</div>
</template>
</el-table-column>
<el-table-column label="更新时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :icon="Edit" @click="onEdit(row)">编辑</el-button>
<el-button link type="danger" :icon="Delete" @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty :description="query.keyword ? '没有匹配的字段' : '字段库还是空的,先新增一个'">
<el-button type="primary" :icon="Plus" @click="onCreate">新增字段</el-button>
</el-empty>
</template>
</el-table>
<div class="table-pagination">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.page_size"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="load"
@size-change="search"
/>
</div>
</el-card>
<FieldFormDialog v-model="dialogVisible" :field="editing" @saved="load" />
</div>
</template>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.card-title {
font-weight: 600;
margin-right: 10px;
}
.card-subtitle {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.search {
width: 220px;
}
.level-filter {
width: 130px;
}
.table {
margin-top: 16px;
}
.name-cell {
min-width: 0;
overflow-wrap: anywhere;
}
.color-cell {
display: flex;
align-items: center;
gap: 8px;
}
.swatch {
width: 16px;
height: 16px;
border-radius: 4px;
border: 1px solid var(--el-border-color);
flex: 0 0 auto;
}
.color-cell code {
font-size: 12px;
color: var(--el-text-color-regular);
}
</style>