Files
paper-doc/frontend/src/views/templates/FieldManageView.vue
T
govin 61857c3ce1 frontend: let the list tables fill the window instead of their rows
A list page is a table with controls around it, and the table was as tall as
its rows: on a large screen most of the window sat empty below the last row
while the page stayed the thing that scrolled. The three list pages now stretch
the table to the height the window offers — the toolbar and the pagination stay
put and the rows scroll inside the table — and give the height back on a small
screen.

Pure CSS, no resize listener and no measured pixel height, so nothing goes
stale when the rail collapses or the window is resized. Four links carry it,
each load-bearing: `.page--fill` is `min-height: 100%` (a floor, not a cap),
`.card--fill` makes the card a column, its body gets `min-height: 0` so a flex
child may shrink below its content, and `.table-fill` is `flex: 1 1 0` so the
table takes the leftover height rather than claiming its content height as its
basis. Element Plus then pins the header and scrolls the rows.

Verified by rendering the pages in a headless browser at 1920x1080, 1440x900
and 1280x620: the table measures 781/601/321px, the page never scrolls, the
rows scroll inside it, header and body columns stay aligned to 0px through a
400px horizontal scroll, the pinned action column stays pinned, and the empty
state is centred in the full-height body rather than stranded at the top. A
1280x420 window hits the 240px floor and falls back to the page scrolling, and
the paper document page is untouched.
2026-09-18 18:26:52 +08:00

319 lines
8.6 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 {
batchDeleteTemplateFieldLibrary,
deleteTemplateFieldLibrary,
listTemplateFieldLibrary,
type TemplateFieldLibrary,
} from '@/api/templateFieldLibrary'
import FieldFormDialog from '@/components/fields/FieldFormDialog.vue'
import { formatDateTime, levelIndent, typographyStyle } from '@/utils/format'
const loading = ref(false)
const rows = ref<TemplateFieldLibrary[]>([])
const total = ref(0)
const selection = ref<TemplateFieldLibrary[]>([])
const query = reactive({
keyword: '',
level: null as number | null,
page: 1,
page_size: 20,
})
const dialogVisible = ref(false)
const editing = ref<TemplateFieldLibrary | 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 listTemplateFieldLibrary(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: TemplateFieldLibrary): void {
editing.value = row
dialogVisible.value = true
}
async function onDelete(row: TemplateFieldLibrary): Promise<void> {
try {
await ElMessageBox.confirm(`确定删除字段「${row.name}」?`, '删除字段', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return // dismissed
}
try {
await deleteTemplateFieldLibrary(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 batchDeleteTemplateFieldLibrary(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 page--fill">
<el-card shadow="never" class="card--fill">
<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 table-fill"
height="100%"
@selection-change="(value: TemplateFieldLibrary[]) => (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>