61857c3ce1
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.
319 lines
8.6 KiB
Vue
319 lines
8.6 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 {
|
||
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>
|