优化单屏页面布局与长列表分页
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-06 17:14:02 +08:00
parent 09c7f6e6df
commit 8178b02636
14 changed files with 271 additions and 32 deletions

View File

@ -34,24 +34,57 @@
gap: 12px 16px;
}
.form-sections {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding-bottom: 10px;
border-bottom: 1px solid var(--pm-border-color);
}
.form-sections button {
padding: 8px 12px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--pm-text-secondary);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.form-sections button.active {
color: var(--pm-primary-color);
border-color: color-mix(in srgb, var(--pm-primary-color) 20%, transparent);
background: color-mix(in srgb, var(--pm-primary-color) 8%, transparent);
font-weight: 600;
}
.form-sections button:hover:not(.active) {
background: var(--pm-bg-hover);
}
.form-sections button:focus-visible {
outline: 2px solid var(--pm-primary-color);
outline-offset: 2px;
}
.section-error {
margin-left: 6px;
color: var(--el-color-danger);
font-size: 12px;
}
.config-form[data-form-section="base"] .form-group {
padding-top: 0;
border-top: 0;
}
@container config-main (min-width: 640px) {
.form-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.config-form[data-form-section="base"] {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.config-form[data-form-section="base"] .form-group {
align-self: start;
padding: 12px;
border: 1px solid var(--pm-border-color);
border-radius: 10px;
background: var(--pm-bg-primary);
}
.config-form[data-form-section="base"] .form-grid,
.config-form[data-form-section="email"] .form-grid,
.form-group[data-group="verification"] .form-grid {
@ -59,6 +92,17 @@
}
}
@media (min-width: 769px) and (max-height: 800px) {
.config-form,
.form-grid {
gap: 8px 12px;
}
.form-group + .form-group {
padding-top: 8px;
}
}
@container config-main (max-width: 440px) {
.form-grid {
grid-template-columns: minmax(0, 1fr);

View File

@ -1,7 +1,27 @@
<template>
<div class="config-form" :data-form-section="section">
<p v-if="!groups.length" class="form-empty">当前插件配置不存在,请刷新配置或检查初始化状态。</p>
<section v-for="group in groups" :key="group.id" class="form-group" :data-group="group.id" :aria-label="group.title">
<nav v-if="section === 'base' && groups.length > 1" class="form-sections" aria-label="基础配置分区">
<button
v-for="group in groups"
:key="group.id"
type="button"
:class="{ active: activeGroup === group.id }"
:aria-pressed="activeGroup === group.id"
@click="activeGroup = group.id"
>
{{ group.title }}
<span v-if="group.fields.some(field => errors[fieldKey(field)])" class="section-error">待修正</span>
</button>
</nav>
<section
v-for="group in groups"
v-show="section !== 'base' || activeGroup === group.id"
:key="group.id"
class="form-group"
:data-group="group.id"
:aria-label="group.title"
>
<p v-if="group.secretConfigured !== undefined" class="credential-status">{{ group.secretConfigured ? '凭据已配置' : '未配置凭据' }}</p>
<p v-if="group.note" class="form-group-note" role="note">{{ group.note }}</p>
<div class="form-grid">
@ -20,7 +40,7 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { fieldKey, getFieldGroups, getValue } from './ConfigForm.js'
import ConfigField from '../ConfigField/ConfigField.vue'
@ -32,6 +52,16 @@ const props = defineProps({
})
const emit = defineEmits(['change'])
const groups = computed(() => getFieldGroups(props.section, props.config))
const activeGroup = ref(groups.value[0]?.id)
// 切换分区只改变展示,不卸载字段或丢弃尚未保存的配置。
watch(groups, value => {
if (!value.some(group => group.id === activeGroup.value)) activeGroup.value = value[0]?.id
})
watch(() => props.errors, errors => {
const invalid = groups.value.find(group => group.fields.some(field => errors[fieldKey(field)]))
if (invalid) activeGroup.value = invalid.id
})
</script>
<style scoped src="./ConfigForm.css"></style>

View File

@ -162,7 +162,7 @@
</div>
<el-alert v-if="auditError" :title="auditError" type="warning" :closable="false" show-icon />
<div v-else-if="auditItems.length" class="audit-list">
<div v-for="{ entry: item } in auditRows" :key="item.id || `${item.createdAt}-${item.operation}`" class="audit-item">
<div v-for="{ entry: item } in auditRows" :key="item.id || `${item.createdAt}-${item.operation}`" class="audit-item">
<div>
<strong>{{ operationLabel(item.operation) }}</strong>
<span>{{ sectionLabel(item.section) }} · {{ item.actorId || '系统' }}</span>

View File

@ -1,17 +1,21 @@
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
// 只分页展示,不切割源配置;绝对索引确保编辑和移除始终命中原条目。
export function usePagination(items, pageSize = 6) {
const size = Math.max(1, Math.floor(Number(pageSize)) || 6)
const requestedPage = ref(1)
const total = computed(() => items.value.length)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size)))
const currentPage = computed({
get: () => Math.min(requestedPage.value, pageCount.value),
set: value => { requestedPage.value = Math.max(1, Math.min(pageCount.value, Number(value) || 1)) }
set: value => { requestedPage.value = Math.max(1, Math.min(pageCount.value, Math.floor(Number(value)) || 1)) }
})
watch(pageCount, count => {
requestedPage.value = Math.min(requestedPage.value, count)
}, { flush: 'sync' })
const pageItems = computed(() => {
const start = (currentPage.value - 1) * pageSize
return items.value.slice(start, start + pageSize).map((entry, offset) => ({ entry, index: start + offset }))
const start = (currentPage.value - 1) * size
return items.value.slice(start, start + size).map((entry, offset) => ({ entry, index: start + offset }))
})
return { currentPage, pageCount, pageItems, total }
}

View File

@ -1,8 +1,22 @@
<template>
<nav v-if="pageCount > 1" class="config-pagination" :aria-label="label">
<span aria-live="polite">共 {{ total }} 项 · {{ currentPage }} / {{ pageCount }} 页</span>
<el-button size="small" :disabled="disabled || currentPage <= 1" :aria-label="`${label}上一页`" @click="emit('update:currentPage', currentPage - 1)">上一页</el-button>
<el-button size="small" :disabled="disabled || currentPage >= pageCount" :aria-label="`${label}下一页`" @click="emit('update:currentPage', currentPage + 1)">下一页</el-button>
<el-button
size="small"
:disabled="disabled || currentPage <= 1"
:aria-label="`${label}上一页`"
@click="emit('update:currentPage', currentPage - 1)"
>
上一页
</el-button>
<el-button
size="small"
:disabled="disabled || currentPage >= pageCount"
:aria-label="`${label}下一页`"
@click="emit('update:currentPage', currentPage + 1)"
>
下一页
</el-button>
</nav>
</template>

View File

@ -444,11 +444,6 @@
.migration-form > * + * {
margin-top: 8px;
}
.maintenance-pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
@container config-main (min-width: 700px) {
.editor-footer {
display: flex;
@ -500,6 +495,24 @@
--config-accent: var(--pm-primary-light-3);
}
}
@media (min-width: 769px) and (max-height: 800px) {
.config-page,
.config-page-body,
.config-main {
gap: 8px;
}
.editor-panel {
padding: 12px;
}
.editor-footer {
margin-top: 8px;
padding-top: 8px;
}
.secret-editor {
margin-top: 8px;
padding: 8px 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.maintenance-summary,
.disclosure-arrow {

View File

@ -41,3 +41,23 @@
.telegram-actions .el-button {
margin-left: 0;
}
.telegram-settings :deep(.el-form) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 20px;
}
.telegram-settings :deep(.el-form-item) {
min-width: 0;
margin-bottom: 0;
}
.telegram-settings :deep(.el-form-item:first-child) {
grid-column: 1 / -1;
}
@media (max-width: 768px) {
.telegram-settings :deep(.el-form) {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@ -50,6 +50,7 @@
.form-actions {
display: flex;
justify-content: flex-start;
margin-top: 12px;
}
.add-btn {

View File

@ -6,7 +6,7 @@
</div>
<div class="table-container">
<el-table :data="data" class="modern-table" style="width: 100%">
<el-table :data="pageItems.map(item => item.entry)" class="modern-table" style="width: 100%" empty-text="暂无邮件规则">
<el-table-column prop="name" :label="lang.rule_name" min-width="150" show-overflow-tooltip/>
<el-table-column prop="action" :label="lang.rule_do" width="120">
<template #default="scope">
@ -31,6 +31,8 @@
</el-table>
</div>
<ConfigPagination v-model:current-page="currentPage" :page-count="pageCount" :total="data.length" label="邮件规则" />
<div class="form-actions">
<el-button type="primary" @click="createRule" class="add-btn">
<el-icon><Plus /></el-icon> {{ lang.new_rule }}
@ -109,6 +111,8 @@
<script setup>
import { usePage, Plus } from './RuleSettings.js'
import { usePagination } from '../ConfigSettings/ConfigPagination/ConfigPagination.js'
import ConfigPagination from '../ConfigSettings/ConfigPagination/ConfigPagination.vue'
const {
lang,
@ -134,6 +138,7 @@ const {
reflushGroupInfos,
groupData
} = usePage()
const { currentPage, pageCount, pageItems } = usePagination(data, 6)
</script>
<style scoped src="./RuleSettings.css"></style>

View File

@ -165,6 +165,22 @@
background: currentColor;
}
@media (min-width: 769px) and (max-height: 800px) {
.settings-submenu-item {
min-height: 30px;
padding-block: 5px;
}
.settings-tertiary-item {
min-height: 33px;
padding-block: 4px;
}
.settings-submenu-heading {
padding-bottom: 8px;
}
}
.settings-tertiary-item:hover:not(.active) {
color: var(--pm-text-primary);
background: var(--pm-bg-hover);

View File

@ -29,7 +29,7 @@ export function usePage() {
const title = ref(lang.editUser)
const reflushList = function () {
http.post('/api/v1/user/list', {"current_page": currentPage.value, "page_size": 10}).then(res => {
http.post('/api/v1/user/list', {"current_page": currentPage.value, "page_size": 6}).then(res => {
userList.length = 0
totalPage.value = res.data.total_page
if (res.data["list"]) {

View File

@ -6,7 +6,7 @@
</div>
<div class="table-container">
<el-table :data="userList" class="modern-table" style="width: 100%">
<el-table :data="userList" class="modern-table" style="width: 100%" empty-text="暂无用户">
<el-table-column label="ID" prop="ID" width="80" />
<el-table-column :label="lang.account" prop="Account" min-width="150" show-overflow-tooltip />
<el-table-column :label="lang.user_name" prop="Name" min-width="120" show-overflow-tooltip />

View File

@ -124,7 +124,7 @@ test('分组不显示重复标题和介绍,保留无障碍名称、凭据状
assert.doesNotMatch(css, /form-group-heading|form-group-description/)
const formStyle = css.match(/\.config-form\s*\{([^}]+)\}/)?.[1] || ''
assert.doesNotMatch(formStyle, /margin-top|padding-top/)
assert.match(css, /\.config-form:not\(:first-child\)\s*\{\s*margin-top: 16px;/)
assert.match(css, /\.config-form:not\(:first-child\)\s*\{\s*margin-top: 10px;/)
assert.match(css, /\.form-group \+ \.form-group\s*\{[^}]*border-top:/)
})

View File

@ -0,0 +1,92 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { compileFunction } from 'node:vm'
import { createRequire } from 'node:module'
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const { computed, ref, watch, nextTick, effectScope } = require('vue')
const root = new URL('../../frontend/src/views/components/ConfigSettings/', import.meta.url)
const paginationSource = readFileSync(new URL('ConfigPagination/ConfigPagination.js', root), 'utf8')
const usePagination = compileFunction(
paginationSource.replace(/^import .*$/gm, '').replace('export function', 'function') + '\nreturn usePagination',
['computed', 'ref', 'watch']
)(computed, ref, watch)
test('列表分页保留源数据、绝对索引及最后一页边界', () => {
const scope = effectScope()
scope.run(() => {
const items = ref(Array.from({ length: 14 }, (_, index) => ({ id: index })))
const page = usePagination(items, 6)
assert.equal(page.pageCount.value, 3)
page.currentPage.value = 2.8
assert.equal(page.currentPage.value, 2)
assert.deepEqual(page.pageItems.value.map(item => item.index), [6, 7, 8, 9, 10, 11])
assert.equal(page.pageItems.value[0].entry, items.value[6])
page.currentPage.value = 999
assert.equal(page.pageItems.value.length, 2)
items.value.splice(12)
assert.equal(page.currentPage.value, 2)
items.value.push({ id: 14 })
assert.equal(page.currentPage.value, 2)
page.currentPage.value = -1
assert.equal(page.currentPage.value, 1)
assert.equal(items.value.length, 13)
items.value = []
assert.equal(page.pageCount.value, 1)
assert.deepEqual(page.pageItems.value, [])
})
scope.stop()
})
test('网络和文本列表跨页编辑、添加、删除仅更新目标条目', async () => {
const fieldSource = readFileSync(new URL('ConfigField/ConfigField.js', root), 'utf8')
const useField = compileFunction(
fieldSource.replace(/^import .*$/gm, '').replace(/^export \{.*$/gm, '').replace('export function', 'function') + '\nreturn useField',
['computed', 'nextTick', 'fieldKey', 'usePagination']
)(computed, nextTick, field => field.path.join('.'), usePagination)
for (const type of ['list', 'network']) {
const scope = effectScope()
const value = ref(type === 'list' ? ['a.example', 'b.example', 'c.example'] : [
{ scheme: 'https', host: 'a.example', port: 443 },
{ scheme: 'https', host: 'b.example', port: 443 },
{ scheme: 'https', host: 'c.example', port: 443 }
])
const props = { field: { path: ['targets'], type }, get value() { return value.value } }
const page = scope.run(() => useField(props, (_event, _path, updated) => { value.value = updated }))
const first = structuredClone(type === 'list' ? value.value[0] : { ...value.value[0] })
page.currentPage.value = 2
const index = page.pageItems.value[0].index
if (type === 'list') page.updateEntry(index, 'changed.example')
else page.updateTarget(index, 'host', 'changed.example')
assert.equal(type === 'list' ? value.value[2] : value.value[2].host, 'changed.example')
assert.deepEqual(type === 'list' ? value.value[0] : { ...value.value[0] }, first)
await page.addEntry()
assert.equal(page.currentPage.value, page.pageCount.value)
assert.equal(value.value.length, 4)
page.removeEntry(3)
page.removeEntry(2)
assert.equal(page.currentPage.value, 1)
assert.equal(value.value.length, 2)
scope.stop()
}
})
test('基础分区保留完整表单,分页和展开菜单模板均能编译', () => {
const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc')
for (const name of ['ConfigPagination', 'ConfigForm', 'ConfigField', 'ConfigMaintenance']) {
const text = readFileSync(new URL(`${name}/${name}.vue`, root), 'utf8')
const { descriptor, errors } = parse(text)
assert.deepEqual(errors, [])
assert.doesNotThrow(() => compileScript(descriptor, { id: name, inlineTemplate: true }))
assert.doesNotMatch(text, /&#x[0-9a-f]+;/i)
}
const form = readFileSync(new URL('ConfigForm/ConfigForm.vue', root), 'utf8')
assert.match(form, /v-show="section !== 'base' \|\| activeGroup === group.id"/)
assert.match(form, /watch\(\(\) => props.errors/)
const css = readFileSync(new URL('ConfigForm/ConfigForm.css', root), 'utf8')
assert.deepEqual(compileStyle({ source: css, id: 'data-v-form', scoped: true }).errors, [])
const menu = readFileSync(new URL('../SettingsSubmenu/SettingsSubmenu.vue', root), 'utf8')
assert.match(menu, /v-if="item.children\?\.length"\s+class="settings-tertiary-list"/)
assert.doesNotMatch(menu, /v-if="item.children\?\.some/)
})