098bfd1bfb
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.
318 lines
8.4 KiB
Vue
318 lines
8.4 KiB
Vue
<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>
|