保存单屏布局与三级菜单优化进度,继续完善验收

This commit is contained in:
smail 2026-09-06 17:04:13 +08:00
parent 64de3a94f8
commit 09c7f6e6df
17 changed files with 345 additions and 100 deletions

View File

@ -1,6 +1,6 @@
.config-overview {
min-width: 0;
padding-bottom: 18px;
padding-bottom: 10px;
border-bottom: 1px solid var(--pm-border-color);
}
@ -8,7 +8,7 @@
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
gap: 12px;
}
.overview-copy {
@ -31,7 +31,7 @@
}
.overview-copy .settings-desc {
margin: 6px 0 0;
margin: 4px 0 0;
}
.overview-actions {
@ -47,7 +47,7 @@
display: flex;
flex-wrap: wrap;
gap: 10px 0;
margin: 16px 0 0;
margin: 8px 0 0;
}
.overview-meta-item {
@ -56,7 +56,7 @@
flex-wrap: wrap;
gap: 4px 8px;
min-width: 0;
padding: 0 16px;
padding: 0 12px;
border-left: 1px solid var(--pm-border-color);
font-size: 12px;
line-height: 1.7;

View File

@ -2,7 +2,7 @@
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
gap: 4px;
min-width: 0;
}
@ -31,6 +31,40 @@
font-weight: 400;
}
.field-help {
display: inline-grid;
place-items: center;
flex: none;
width: 16px;
height: 16px;
padding: 0;
border: 1px solid var(--pm-border-color);
border-radius: 50%;
background: var(--pm-bg-primary);
color: var(--pm-text-secondary);
font: inherit;
font-size: 11px;
cursor: pointer;
}
.field-help:hover,
.field-help:focus-visible {
color: var(--pm-primary-color);
border-color: currentColor;
outline: 2px solid color-mix(in srgb, var(--pm-primary-color) 15%, transparent);
}
/* 说明由可聚焦的帮助按钮展开,同时保留表单控件的无障碍描述。 */
.field-description {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.config-field > .el-input-number,
.config-field > .el-select {
width: 100%;
@ -115,7 +149,7 @@
color: var(--pm-text-secondary);
}
@container config-main (max-width: 620px) {
@container config-main (max-width: 440px) {
.network-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
padding: 10px;

View File

@ -1,5 +1,6 @@
import { computed } from 'vue'
import { computed, nextTick } from 'vue'
import { fieldKey } from '../ConfigForm/ConfigForm.js'
import { usePagination } from '../ConfigPagination/ConfigPagination.js'
export { fieldKey }
@ -10,6 +11,7 @@ export function useField(props, emit) {
props.error && `${id.value}-error`
].filter(Boolean).join(' ') || undefined)
const entries = computed(() => Array.isArray(props.value) ? props.value : [])
const pagination = usePagination(entries, 2)
const selectOptions = computed(() => {
const values = props.field.options || []
const current = props.value ?? props.field.fallback ?? ''
@ -23,6 +25,11 @@ export function useField(props, emit) {
const updateEntry = (index, value) => update(entries.value.map((entry, current) => current === index ? value : entry))
const updateTarget = (index, key, value) => updateEntry(index, { ...entries.value[index], [key]: value ?? null })
const removeEntry = index => update(entries.value.filter((_, current) => current !== index))
const addEntry = async () => {
update([...entries.value, props.field.type === 'network' ? { scheme: 'https', host: '', port: 443 } : ''])
await nextTick()
pagination.currentPage.value = pagination.pageCount.value
}
return { id, descriptionId, entries, selectOptions, update, updateEntry, updateTarget, removeEntry }
return { id, descriptionId, entries, selectOptions, update, updateEntry, updateTarget, removeEntry, addEntry, ...pagination }
}

View File

@ -4,6 +4,11 @@
{{ field.label }}
<span v-if="field.required" class="field-required" aria-hidden="true">*</span>
<span v-if="field.locked" class="field-locked">系统维护</span>
<el-popover v-if="field.hint" trigger="click" :width="300" :content="field.hint" placement="top">
<template #reference>
<button type="button" class="field-help" :aria-label="`${field.label}填写说明`" @click.prevent>?</button>
</template>
</el-popover>
</label>
<el-select
v-if="field.type === 'select'"
@ -46,10 +51,10 @@
@update:model-value="update"
/>
<div v-else-if="field.type === 'list'" class="field-list" role="group" :aria-labelledby="`${id}-label`">
<div v-for="(entry, index) in entries" :key="index" class="field-list-row">
<div v-for="{ entry, index } in pageItems" :key="index" class="field-list-row">
<el-input
class="field-text-input"
:id="index === 0 ? id : `${id}-${index}`"
:id="index === pageItems[0]?.index ? id : `${id}-${index}`"
:model-value="entry"
:disabled="disabled || field.locked"
:placeholder="field.placeholder || '填写内容'"
@ -60,12 +65,12 @@
/>
<el-button :disabled="disabled || field.locked" :aria-label="`移除${field.label}第 ${index + 1} 项`" @click="removeEntry(index)">移除</el-button>
</div>
<el-button class="field-add" :disabled="disabled || field.locked" plain @click="update([...entries, ''])">添加一项</el-button>
<el-button class="field-add" :disabled="disabled || field.locked" plain @click="addEntry">添加一项</el-button>
</div>
<div v-else-if="field.type === 'network'" class="field-list" role="group" :aria-labelledby="`${id}-label`">
<div v-for="(entry, index) in entries" :key="index" class="network-row">
<div v-for="{ entry, index } in pageItems" :key="index" class="network-row">
<el-select
:id="index === 0 ? id : `${id}-${index}`"
:id="index === pageItems[0]?.index ? id : `${id}-${index}`"
:model-value="entry.scheme"
:disabled="disabled || field.locked"
:aria-label="`网络目标 ${index + 1} 协议`"
@ -101,7 +106,7 @@
/>
<el-button :disabled="disabled || field.locked" :aria-label="`移除网络目标 ${index + 1}`" @click="removeEntry(index)">移除</el-button>
</div>
<el-button class="field-add" :disabled="disabled || field.locked" plain @click="update([...entries, { scheme: 'https', host: '', port: 443 }])">添加网络目标</el-button>
<el-button class="field-add" :disabled="disabled || field.locked" plain @click="addEntry">添加网络目标</el-button>
</div>
<el-input
v-else
@ -115,6 +120,13 @@
autocomplete="off"
@update:model-value="update"
/>
<ConfigPagination
v-if="['list', 'network'].includes(field.type)"
v-model:current-page="currentPage"
:page-count="pageCount"
:total="total"
:label="`${field.label}分页`"
/>
<div v-if="field.recommendedPort !== undefined" class="field-port-guide">
<span :class="{ 'port-custom': value !== field.recommendedPort }">
{{ value === field.recommendedPort ? '已使用推荐端口' : '推荐端口' }}:{{ field.recommendedPort }}
@ -130,13 +142,14 @@
使用推荐端口
</el-button>
</div>
<p v-if="field.hint" :id="`${id}-hint`" class="field-hint">{{ field.hint }}</p>
<p v-if="field.hint" :id="`${id}-hint`" class="field-hint field-description">{{ field.hint }}</p>
<p v-if="error" :id="`${id}-error`" class="field-error" role="alert">{{ error }}</p>
</div>
</template>
<script setup>
import { fieldKey, useField } from './ConfigField.js'
import ConfigPagination from '../ConfigPagination/ConfigPagination.vue'
const props = defineProps({
field: { type: Object, required: true },
@ -145,7 +158,10 @@ const props = defineProps({
error: { type: String, default: '' }
})
const emit = defineEmits(['change'])
const { id, descriptionId, entries, selectOptions, update, updateEntry, updateTarget, removeEntry } = useField(props, emit)
const {
id, descriptionId, selectOptions, update, updateEntry, updateTarget, removeEntry,
addEntry, currentPage, pageCount, pageItems, total
} = useField(props, emit)
</script>
<style scoped src="./ConfigField.css"></style>

View File

@ -1,11 +1,11 @@
.config-form {
display: grid;
gap: 20px;
gap: 12px;
min-width: 0;
}
.config-form:not(:first-child) {
margin-top: 16px;
margin-top: 10px;
}
.form-group {
@ -13,14 +13,14 @@
}
.form-group + .form-group {
padding-top: 20px;
padding-top: 12px;
border-top: 1px solid var(--pm-border-color);
}
.credential-status,
.form-group-note,
.form-empty {
margin: 0 0 12px;
margin: 0 0 8px;
color: var(--pm-text-secondary);
font-size: 12px;
line-height: 1.7;
@ -31,12 +31,37 @@
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 20px;
gap: 12px 16px;
}
@container config-main (max-width: 620px) {
@container config-main (min-width: 640px) {
.form-grid {
grid-template-columns: minmax(0, 1fr);
gap: 18px;
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 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@container config-main (max-width: 440px) {
.form-grid {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
}
}

View File

@ -1,7 +1,7 @@
<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" :aria-label="group.title">
<section v-for="group in groups" :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">

View File

@ -1,4 +1,5 @@
import { usePage } from '../ConfigSettings.js'
import { usePagination } from '../ConfigPagination/ConfigPagination.js'
export function useMaintenance(pageKey) {
const page = usePage(pageKey)
@ -8,5 +9,7 @@ export function useMaintenance(pageKey) {
page.rollbackPreview.value = null
page.rollbackConfirmed.value = false
}
return { ...page, selectBackup }
const auditPagination = usePagination(page.auditItems, 6)
const backupPagination = usePagination(page.backups, 6)
return { ...page, selectBackup, auditPagination, backupPagination }
}

View File

@ -120,7 +120,7 @@
<el-empty v-else-if="!backups.length && !backupsLoading" description="暂无可用配置备份" :image-size="72" />
<div v-else class="backup-list">
<button
v-for="backup in backups"
v-for="{ entry: backup } in backupRows"
:key="backup.generation"
type="button"
class="backup-item"
@ -133,6 +133,7 @@
<small>{{ formatTime(backup.createdAt) }}</small>
</button>
</div>
<ConfigPagination v-model:current-page="backupPage" :page-count="backupPageCount" :total="backups.length" label="备份列表" />
<el-checkbox v-model="rollbackConfirmed" :disabled="!!working || !rollbackPreview" class="confirmation-check">
我已阅读回滚预览,并确认执行一次性回滚
</el-checkbox>
@ -161,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="item in auditItems" :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>
@ -170,6 +171,7 @@
</div>
</div>
<el-empty v-else-if="!auditLoading" description="暂无配置审计记录" :image-size="72" />
<ConfigPagination v-model:current-page="auditPage" :page-count="auditPageCount" :total="auditItems.length" label="审计记录" />
</div>
</section>
<el-empty v-else description="当前账号无权访问此页面" />
@ -179,6 +181,7 @@
<script setup>
import { useMaintenance } from './ConfigMaintenance.js'
import ConfigPagination from '../ConfigPagination/ConfigPagination.vue'
const props = defineProps({
pageKey: { type: String, required: true },
@ -194,8 +197,10 @@ const {
canRollback, backupsLoading, backupsError, backups, rollbackGeneration, rollbackConfirmed,
rollbackPreview, loadBackups, selectBackup, previewRollback, executeRollback,
canAudit, canExportAudit, auditLoading, auditError, auditItems, loadAudit, exportAudit,
formatTime, sectionLabel, operationLabel
formatTime, sectionLabel, operationLabel, auditPagination, backupPagination
} = useMaintenance(props.pageKey)
const { currentPage: auditPage, pageCount: auditPageCount, pageItems: auditRows } = auditPagination
const { currentPage: backupPage, pageCount: backupPageCount, pageItems: backupRows } = backupPagination
defineExpose({ beforeLeave })
</script>

View File

@ -0,0 +1,18 @@
.config-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
color: var(--pm-text-secondary);
font-size: 12px;
}
.config-pagination > span {
margin-right: auto;
}
.config-pagination .el-button {
margin: 0;
}

View File

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

View File

@ -0,0 +1,20 @@
<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>
</nav>
</template>
<script setup>
defineProps({
currentPage: { type: Number, required: true },
pageCount: { type: Number, required: true },
total: { type: Number, required: true },
label: { type: String, default: '列表分页' },
disabled: Boolean
})
const emit = defineEmits(['update:currentPage'])
</script>
<style scoped src="./ConfigPagination.css"></style>

View File

@ -1,14 +1,13 @@
.config-settings {
display: grid;
gap: 20px;
padding-bottom: 16px;
gap: 12px;
--config-accent: var(--pm-primary-color);
}
.config-page,
.config-page-body {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
gap: 12px;
min-width: 0;
}
.config-navigation {
@ -16,7 +15,7 @@
position: sticky;
top: 0;
display: grid;
gap: 20px;
gap: 12px;
}
.config-navigation > .settings-submenu {
grid-area: auto;
@ -83,11 +82,11 @@
.editor-panel,
.maintenance-panel {
border: 1px solid var(--pm-border-color);
border-radius: 16px;
border-radius: 12px;
background: var(--pm-bg-secondary);
}
.editor-panel {
padding: 20px;
padding: 16px;
}
.tag-list {
display: flex;
@ -100,16 +99,16 @@
}
.config-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 152px;
grid-template-columns: minmax(0, 1fr) 196px;
grid-template-areas: "main submenu";
align-items: start;
gap: 20px;
gap: 16px;
}
.config-main {
grid-area: main;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
gap: 12px;
min-width: 0;
container: config-main / inline-size;
}
@ -138,9 +137,9 @@
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(180px, 1fr);
align-items: center;
gap: 16px;
margin-top: 16px;
padding: 14px;
gap: 12px;
margin-top: 10px;
padding: 10px 12px;
border: 1px solid var(--pm-border-color);
border-radius: 10px;
background: var(--pm-bg-primary);
@ -163,8 +162,8 @@
align-items: flex-start;
}
.editor-footer {
margin-top: 18px;
padding-top: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--pm-border-color);
}
.editor-footer .confirmation-check {
@ -183,7 +182,7 @@
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
margin-top: 12px;
}
.action-row > .el-button,
.audit-actions > .el-button {
@ -263,7 +262,7 @@
transform: rotate(225deg);
}
.maintenance-body {
padding: 18px 20px 20px;
padding: 14px 16px;
}
.backup-list {
display: grid;
@ -320,16 +319,13 @@
display: grid;
gap: 10px;
margin-top: 18px;
max-height: 360px;
overflow-y: auto;
overscroll-behavior: contain;
}
.audit-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
padding: 8px 12px;
border-radius: var(--pm-radius-sm);
background: var(--pm-bg-primary);
}
@ -404,12 +400,12 @@
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
margin-top: 10px;
}
.migration-source > div {
display: grid;
gap: 6px;
padding: 12px 14px;
gap: 4px;
padding: 8px 12px;
border-radius: var(--pm-radius-sm);
background: var(--pm-bg-primary);
}
@ -423,12 +419,12 @@
}
.migration-form {
max-width: 880px;
margin-top: 16px;
margin-top: 10px;
}
.migration-connection-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: 0 20px;
gap: 6px 16px;
}
.migration-connection-grid :deep(.el-input-number) {
width: 100%;
@ -441,6 +437,40 @@
.migration-form :deep(.el-form-item) {
margin-bottom: 0;
}
.migration-form :deep(.el-form-item__label) {
margin-bottom: 4px;
line-height: 20px;
}
.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;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px 16px;
}
.editor-footer .confirmation-check {
flex: 1 1 280px;
}
.editor-footer .action-row {
margin-top: 0;
flex: 0 1 auto;
gap: 8px;
}
.editor-footer > .el-alert,
.editor-footer .action-hint {
flex-basis: 100%;
margin-top: 0;
}
}
.migration-result-heading {
display: flex;
align-items: center;

View File

@ -1,9 +1,9 @@
.mail-port-summary {
margin-bottom: 24px;
margin-bottom: 12px;
}
.summary-note {
margin: 0 0 14px;
margin: 0 0 8px;
color: var(--pm-text-secondary);
font-size: 12px;
line-height: 1.7;
@ -16,14 +16,14 @@
}
.protocol-card {
padding: 16px;
padding: 10px 12px;
border: 1px solid var(--pm-border-color);
border-radius: 10px;
min-width: 0;
}
.protocol-card dl {
margin: 12px 0 0;
margin: 8px 0 0;
}
.port-row {
@ -31,7 +31,7 @@
flex-wrap: wrap;
justify-content: space-between;
gap: 6px 12px;
margin-top: 8px;
margin-top: 4px;
font-size: 12px;
}
@ -45,7 +45,7 @@
font-variant-numeric: tabular-nums;
}
@container config-main (max-width: 680px) {
@container config-main (max-width: 480px) {
.summary-grid {
grid-template-columns: minmax(0, 1fr);
}

View File

@ -75,7 +75,7 @@
/* 抽屉由 Teleport 挂载,使用专属类限定,不能依赖祖先的作用域属性。 */
:global(.settings-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 24px;
padding: 16px 20px;
border-bottom: 1px solid var(--pm-border-color);
}
@ -108,14 +108,14 @@
:global(.settings-drawer > .el-drawer__body) {
display: flex;
min-height: 0;
padding: 24px;
padding: 16px 20px;
overflow: hidden;
}
.settings-tabs > :deep(.el-tabs__header) {
flex: 0 0 184px;
margin: 0 0 0 20px !important;
padding: 12px 8px;
flex: 0 0 168px;
margin: 0 0 0 16px !important;
padding: 8px;
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-lg);
background: var(--pm-bg-primary);
@ -136,10 +136,10 @@
.settings-tabs > :deep(.el-tabs__header .el-tabs__item) {
height: auto;
min-height: 64px;
min-height: 56px;
justify-content: flex-start;
margin: 3px 0;
padding: 12px !important;
padding: 10px !important;
border: 1px solid transparent;
border-radius: 12px;
color: var(--pm-text-secondary);
@ -200,7 +200,6 @@
min-width: 0;
padding-right: 4px;
overflow: auto;
scrollbar-gutter: stable;
container: settings-content / inline-size;
}

View File

@ -20,7 +20,7 @@
<!-- 设置抽屉保留右侧导航,一级分类不影响内部标签页。 -->
<el-drawer
v-model="globalStatus.settingsDrawerVisible"
:size="isMobile ? '100%' : 'min(1120px, calc(100vw - 32px))'"
:size="isMobile ? '100%' : 'min(1600px, calc(100vw - 24px))'"
:title="lang.settings"
class="settings-drawer"
:with-header="true"

View File

@ -3,15 +3,18 @@
position: sticky;
top: 0;
min-width: 0;
padding: 4px 0 4px 14px;
padding: 2px 0 2px 12px;
border-left: 1px solid var(--pm-border-color);
--submenu-accent: var(--pm-primary-color);
}
.settings-submenu-heading {
display: grid;
gap: 5px;
padding: 4px 10px 16px;
display: flex;
flex-direction: row-reverse;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 2px 8px 10px;
}
.settings-submenu-caption {
@ -28,14 +31,14 @@
.settings-submenu-list {
display: grid;
gap: 4px;
gap: 2px;
}
.settings-submenu-item {
position: relative;
width: 100%;
min-height: 40px;
padding: 10px 12px;
min-height: 34px;
padding: 7px 10px;
border: 0;
border-radius: 8px;
background: transparent;
@ -62,9 +65,9 @@
.settings-submenu-item.active::before {
content: '';
position: absolute;
top: 12px;
bottom: 12px;
left: -15px;
top: 10px;
bottom: 10px;
left: -13px;
width: 3px;
border-radius: 3px;
background: var(--submenu-accent);
@ -75,29 +78,49 @@
outline-offset: -2px;
}
.settings-submenu-count {
.settings-submenu-chevron {
float: right;
min-width: 18px;
border-radius: 5px;
width: 6px;
height: 6px;
margin: 5px 3px 0 8px;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: rotate(45deg);
}
.has-children {
margin-top: 6px;
padding: 4px;
border: 1px solid var(--pm-border-color);
border-radius: 12px;
background: var(--pm-bg-primary);
text-align: center;
font-size: 11px;
}
.has-children > .settings-submenu-item {
background: transparent;
font-size: 12px;
font-weight: 600;
}
.has-children > .settings-submenu-item::before {
content: none;
}
.settings-tertiary-list {
display: grid;
gap: 4px;
margin: 6px 0 8px 12px;
padding-left: 8px;
border-left: 1px solid var(--pm-border-color);
gap: 3px;
padding: 2px;
}
.settings-tertiary-item {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
min-height: 36px;
padding: 8px;
border: 0;
border-radius: 6px;
min-height: 37px;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--pm-text-secondary);
font: inherit;
@ -110,9 +133,38 @@
.settings-tertiary-item.active {
color: var(--submenu-accent);
background: color-mix(in srgb, var(--submenu-accent) 8%, var(--pm-bg-secondary));
border-color: color-mix(in srgb, var(--submenu-accent) 22%, var(--pm-border-color));
font-weight: 600;
}
.settings-tertiary-icon {
display: grid;
place-items: center;
flex: none;
width: 24px;
height: 24px;
border-radius: 6px;
color: var(--submenu-accent);
background: color-mix(in srgb, var(--submenu-accent) 8%, transparent);
}
.settings-tertiary-icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.settings-tertiary-current {
width: 5px;
height: 5px;
margin-left: auto;
border-radius: 50%;
background: currentColor;
}
.settings-tertiary-item:hover:not(.active) {
color: var(--pm-text-primary);
background: var(--pm-bg-hover);
@ -147,7 +199,7 @@
.settings-submenu-list {
display: flex;
overflow-x: auto;
flex-wrap: wrap;
padding: 2px;
}
@ -161,8 +213,12 @@
flex: 0 0 auto;
}
.has-children {
flex-basis: 100%;
}
.settings-tertiary-list {
margin-left: 4px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.settings-submenu-item.active::before {

View File

@ -5,20 +5,20 @@
<strong>{{ title }}</strong>
</div>
<div class="settings-submenu-list">
<div v-for="item in items" :key="item.value" class="settings-submenu-entry">
<div v-for="item in items" :key="item.value" class="settings-submenu-entry" :class="{ 'has-children': item.children?.length }">
<button
type="button"
class="settings-submenu-item"
:class="{ active: modelValue === item.value || item.children?.some(child => child.value === modelValue) }"
:aria-current="modelValue === item.value ? 'page' : undefined"
:aria-expanded="item.children ? item.children.some(child => child.value === modelValue) : undefined"
:aria-expanded="item.children?.length ? true : undefined"
@click="select(item.value)"
>
<span>{{ item.label }}</span>
<span v-if="item.children" class="settings-submenu-count">{{ item.children.length }}</span>
<span v-if="item.children?.length" class="settings-submenu-chevron" aria-hidden="true"></span>
</button>
<div
v-if="item.children?.some(child => child.value === modelValue)"
v-if="item.children?.length"
class="settings-tertiary-list"
role="group"
:aria-label="`${item.label}三级菜单`"
@ -32,7 +32,22 @@
:aria-current="modelValue === child.value ? 'page' : undefined"
@click="select(child.value)"
>
{{ child.label }}
<span class="settings-tertiary-icon" :data-plugin="child.value" aria-hidden="true">
<svg v-if="child.value === 'spam_block'" viewBox="0 0 24 24" fill="none">
<path d="m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6l8-3Z" />
<path d="m8 12 3 3 5-6" />
</svg>
<svg v-else-if="child.value === 'telegram_push'" viewBox="0 0 24 24" fill="none">
<path d="m3 10 18-7-5 18-5-7-8-4Z" />
<path d="m11 14 5-6" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="none">
<path d="M20 11a8 8 0 0 1-8 8H5l-3 2 1-6a8 8 0 1 1 17-4Z" />
<path d="M7 10h.01M12 10h.01M17 10h.01" stroke-width="3" />
</svg>
</span>
<span>{{ child.label }}</span>
<span v-if="modelValue === child.value" class="settings-tertiary-current" aria-hidden="true"></span>
</button>
</div>
</div>