This commit is contained in:
parent
09c7f6e6df
commit
8178b02636
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,6 +50,7 @@
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"]) {
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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:/)
|
||||
})
|
||||
|
||||
|
||||
92
test/frontend/config-pagination.test.mjs
Normal file
92
test/frontend/config-pagination.test.mjs
Normal 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/)
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user