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:
@@ -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>
|
||||
Reference in New Issue
Block a user