Files
paper-doc/frontend/src/views/templates/FieldManageView.vue
T
govin 098bfd1bfb 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.
2026-09-18 16:00:02 +08:00

318 lines
8.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>