优化设置一二级菜单层次并在配置页面优化前建立检查点
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-05 21:28:04 +08:00
parent e8e14236db
commit c37759585c
12 changed files with 539 additions and 143 deletions

View File

@ -100,43 +100,11 @@
}
.config-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 176px;
grid-template-columns: minmax(0, 1fr) 152px;
grid-template-areas: "main submenu";
align-items: start;
gap: 20px;
}
.config-submenu {
grid-area: submenu;
position: sticky;
top: 0;
display: grid;
gap: 6px;
align-self: start;
padding: 8px;
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-lg);
background: var(--pm-bg-secondary);
}
.config-submenu-item {
width: 100%;
padding: 12px 14px;
color: var(--pm-text-secondary);
text-align: left;
font: inherit;
border: 0;
border-radius: var(--pm-radius-sm);
background: transparent;
cursor: pointer;
transition: color .2s ease, background-color .2s ease;
}
.config-submenu-item:hover,
.config-submenu-item.active {
color: var(--pm-primary-color);
background: var(--el-color-primary-light-9);
}
.config-submenu-item.active {
font-weight: 600;
}
.config-main {
grid-area: main;
display: grid;
@ -293,7 +261,7 @@
.audit-item time {
flex: none;
}
@media (max-width: 640px) {
@container settings-content (max-width: 560px) {
.status-panel,
.editor-panel,
.rollback-panel,
@ -320,18 +288,6 @@
"main";
gap: 16px;
}
.config-submenu {
position: static;
display: flex;
gap: 6px;
overflow-x: auto;
padding: 6px;
}
.config-submenu-item {
flex: 0 0 auto;
width: auto;
white-space: nowrap;
}
.selected-section-label {
white-space: normal;
}

View File

@ -20,19 +20,13 @@
</div>
<div v-else-if="!loading" class="config-workspace">
<nav class="config-submenu" aria-label="配置分区">
<button
v-for="item in sectionOptions"
:key="item.value"
type="button"
class="config-submenu-item"
:class="{ active: selectedSection === item.value }"
:aria-current="selectedSection === item.value ? 'page' : undefined"
@click="selectSection(item.value)"
>
{{ item.label }}
</button>
</nav>
<SettingsSubmenu
title="配置管理"
caption="配置分区"
:items="sectionOptions"
:model-value="selectedSection"
@select="selectSection"
/>
<div class="config-main">
<section class="status-panel">
@ -285,6 +279,7 @@
<script setup>
import { computed } from 'vue'
import { usePage } from './ConfigSettings.js'
import SettingsSubmenu from '../SettingsSubmenu/SettingsSubmenu.vue'
const {
availabilityError,

View File

@ -66,25 +66,148 @@
}
.settings-tabs {
width: 100%;
height: 100%;
min-height: 0;
--settings-accent: var(--pm-primary-color);
}
.settings-drawer :deep(.el-drawer__body) {
/* 抽屉由 Teleport 挂载,使用专属类限定,不能依赖祖先的作用域属性。 */
:global(.settings-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 24px;
border-bottom: 1px solid var(--pm-border-color);
}
.settings-drawer-heading {
display: flex;
align-items: center;
gap: 18px;
min-width: 0;
}
.settings-drawer-heading h2 {
margin: 0;
color: var(--pm-text-primary);
font-size: 22px;
font-weight: 700;
}
.settings-location {
display: inline-flex;
align-items: center;
gap: 18px;
color: var(--pm-text-secondary);
font-size: 14px;
}
.settings-location > span {
color: var(--pm-text-muted);
}
:global(.settings-drawer > .el-drawer__body) {
display: flex;
min-height: 0;
padding: 0 20px 20px;
padding: 24px;
overflow: hidden;
}
.settings-tabs :deep(.el-tabs__header) {
flex: 0 0 176px;
margin: 0 0 0 20px;
.settings-tabs > :deep(.el-tabs__header) {
flex: 0 0 184px;
margin: 0 0 0 20px !important;
padding: 12px 8px;
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-lg);
background: var(--pm-bg-primary);
}
.settings-tabs :deep(.el-tabs__content) {
.settings-tabs > :deep(.el-tabs__header .el-tabs__nav-wrap::after),
.settings-tabs > :deep(.el-tabs__header .el-tabs__active-bar) {
display: none;
}
.settings-tabs > :deep(.el-tabs__header .el-tabs__nav-wrap) {
margin: 0;
}
.settings-tabs > :deep(.el-tabs__header .el-tabs__nav) {
border: 0;
}
.settings-tabs > :deep(.el-tabs__header .el-tabs__item) {
height: auto;
min-height: 64px;
justify-content: flex-start;
margin: 3px 0;
padding: 12px !important;
border: 1px solid transparent;
border-radius: 12px;
color: var(--pm-text-secondary);
text-align: left;
transition: background-color .18s ease, color .18s ease, border-color .18s ease;
}
.settings-tabs > :deep(.el-tabs__header .el-tabs__item:hover:not(.is-active)) {
color: var(--pm-text-primary);
background: var(--pm-bg-hover);
}
.settings-tabs > :deep(.el-tabs__header .el-tabs__item.is-active) {
color: #fff;
background: var(--pm-primary-color);
border-color: var(--pm-primary-color);
box-shadow: var(--pm-shadow-sm);
}
.settings-tabs > :deep(.el-tabs__header .el-tabs__item:focus-visible) {
outline: 2px solid var(--settings-accent);
outline-offset: -3px;
box-shadow: inset 0 0 0 4px var(--pm-surface-solid);
}
.settings-category {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.settings-category-icon {
flex: none;
font-size: 20px;
}
.settings-category-copy {
display: grid;
gap: 4px;
}
.settings-category-label {
font-size: 14px;
font-weight: 650;
line-height: 1.4;
}
.settings-category-hint {
font-size: 11px;
font-weight: 400;
line-height: 1.4;
opacity: .85;
}
.settings-tabs > :deep(.el-tabs__content) {
flex: 1;
min-width: 0;
padding-right: 4px;
overflow: auto;
scrollbar-gutter: stable;
container: settings-content / inline-size;
}
@media (prefers-color-scheme: dark) {
.settings-tabs {
--settings-accent: var(--pm-primary-light-3);
}
}
@media (max-width: 768px) {
@ -106,17 +229,34 @@
display: block;
}
.settings-drawer :deep(.el-drawer__body) {
:global(.settings-drawer > .el-drawer__body) {
padding: 0 10px 10px;
overflow: auto;
}
.settings-tabs :deep(.el-tabs__header) {
flex: none;
margin: 0;
:global(.settings-drawer > .el-drawer__header) {
padding: 18px 14px;
}
.settings-tabs :deep(.el-tabs__content) {
.settings-tabs > :deep(.el-tabs__header) {
flex: none;
margin: 12px 0 16px !important;
padding: 4px;
border-radius: 14px;
}
.settings-tabs > :deep(.el-tabs__header .el-tabs__item) {
min-height: 44px;
margin: 0 2px;
padding: 8px 12px !important;
}
.settings-category-hint {
display: none;
}
.settings-tabs > :deep(.el-tabs__content) {
padding-right: 0;
overflow: visible;
}
}

View File

@ -1,4 +1,5 @@
import {EpMenu} from "vue-icons-plus/ep";
import { Lock, Connection, Setting, FolderOpened, Filter, User, Grid } from '@element-plus/icons-vue';
import {computed, ref, onMounted, onUnmounted} from 'vue'
@ -42,15 +43,17 @@ export function usePage() {
});
const settingsMenu = computed(() => [
{ key: 'security', label: lang.security, component: SecuritySettings, capability: 'password.change_own' },
{ key: 'third-party', label: '第三方授权', component: ThirdPartyAuthorization, capability: 'plugin.write_own' },
{ key: 'config', label: '配置管理', component: ConfigSettings, capability: 'config.read' },
{ key: 'group', label: lang.group_settings, component: GroupSettings, capability: 'user.read' },
{ key: 'rule', label: lang.rule_setting, component: RuleSettings, capability: 'user.read' },
{ key: 'users', label: lang.user_management, component: UserManagement, capability: 'user.manage' },
{ key: 'plugins', label: lang.plugin_settings, component: PluginSettings, capability: 'plugin.read' },
{ key: 'security', label: lang.security, hint: '账号与登录保护', icon: Lock, component: SecuritySettings, capability: 'password.change_own' },
{ key: 'third-party', label: '第三方授权', hint: '邮件客户端接入', icon: Connection, component: ThirdPartyAuthorization, capability: 'plugin.write_own' },
{ key: 'config', label: '配置管理', hint: '服务与运行配置', icon: Setting, component: ConfigSettings, capability: 'config.read' },
{ key: 'group', label: lang.group_settings, hint: '邮箱分组管理', icon: FolderOpened, component: GroupSettings, capability: 'user.read' },
{ key: 'rule', label: lang.rule_setting, hint: '邮件处理规则', icon: Filter, component: RuleSettings, capability: 'user.read' },
{ key: 'users', label: lang.user_management, hint: '账号与权限管理', icon: User, component: UserManagement, capability: 'user.manage' },
{ key: 'plugins', label: lang.plugin_settings, hint: '扩展功能管理', icon: Grid, component: PluginSettings, capability: 'plugin.read' },
].filter(item => globalStatus.hasCapability(item.capability)))
const activeSettings = computed(() => settingsMenu.value.find(item => item.key === globalStatus.settingsActiveTab))
return {
EpMenu,
ref,
@ -70,7 +73,8 @@ export function usePage() {
userInfos,
isMobile,
handleResize,
settingsMenu
settingsMenu,
activeSettings
}
}

View File

@ -17,11 +17,51 @@
</div>
<!-- Settings Drawer -->
<el-drawer v-model="globalStatus.settingsDrawerVisible" :size="isMobile ? '100%' : '50vw'" :title="lang.settings" class="settings-drawer" :with-header="true" direction="rtl">
<el-tabs v-model="globalStatus.settingsActiveTab" :tab-position="isMobile ? 'top' : 'right'" class="settings-tabs">
<el-tab-pane v-for="item in settingsMenu" :key="item.key" :name="item.key" :label="item.label" lazy>
<component v-if="globalStatus.settingsDrawerVisible && globalStatus.settingsActiveTab === item.key" :is="item.component" />
<!-- 设置抽屉保留右侧导航,一级分类不影响内部标签页。 -->
<el-drawer
v-model="globalStatus.settingsDrawerVisible"
:size="isMobile ? '100%' : 'min(1120px, calc(100vw - 32px))'"
:title="lang.settings"
class="settings-drawer"
:with-header="true"
direction="rtl"
>
<template #header="{ titleId }">
<div class="settings-drawer-heading">
<h2 :id="titleId">{{ lang.settings }}</h2>
<span class="settings-location" aria-live="polite">
<span aria-hidden="true">/</span>
{{ activeSettings?.label }}
</span>
</div>
</template>
<el-tabs
v-model="globalStatus.settingsActiveTab"
:tab-position="isMobile ? 'top' : 'right'"
class="settings-tabs"
>
<el-tab-pane
v-for="item in settingsMenu"
:key="item.key"
:name="item.key"
:label="item.label"
lazy
>
<template #label>
<span class="settings-category">
<el-icon class="settings-category-icon" aria-hidden="true">
<component :is="item.icon" />
</el-icon>
<span class="settings-category-copy">
<span class="settings-category-label">{{ item.label }}</span>
<span class="settings-category-hint" aria-hidden="true">{{ item.hint }}</span>
</span>
</span>
</template>
<component
v-if="globalStatus.settingsDrawerVisible && globalStatus.settingsActiveTab === item.key"
:is="item.component"
/>
</el-tab-pane>
</el-tabs>
</el-drawer>
@ -38,7 +78,8 @@ const {
globalStatus,
isMobile,
lang,
settingsMenu
settingsMenu,
activeSettings
} = usePage()
</script>

View File

@ -29,14 +29,45 @@
overflow: hidden;
}
.custom-tabs :deep(.el-tabs__header) {
.custom-tabs > :deep(.el-tabs__header) {
margin-bottom: 0;
background: var(--pm-bg-primary);
padding: 0 16px;
padding: 8px 12px;
border-bottom: 1px solid var(--pm-border-color);
}
.custom-tabs :deep(.el-tabs__content) {
.custom-tabs > :deep(.el-tabs__header .el-tabs__nav-wrap::after),
.custom-tabs > :deep(.el-tabs__header .el-tabs__active-bar) {
display: none;
}
.custom-tabs > :deep(.el-tabs__header .el-tabs__item) {
height: 38px;
margin-right: 6px;
padding: 0 14px !important;
border-radius: 8px;
color: var(--pm-text-secondary);
font-size: 13px;
}
.custom-tabs > :deep(.el-tabs__header .el-tabs__item:hover:not(.is-active)) {
color: var(--pm-text-primary);
background: var(--pm-bg-hover);
}
.custom-tabs > :deep(.el-tabs__header .el-tabs__item.is-active) {
color: var(--settings-accent, var(--pm-primary-color));
background: color-mix(in srgb, var(--pm-primary-color) 10%, var(--pm-bg-secondary));
font-weight: 600;
box-shadow: inset 0 -2px var(--settings-accent, var(--pm-primary-color));
}
.custom-tabs > :deep(.el-tabs__header .el-tabs__item:focus-visible) {
outline: 2px solid var(--settings-accent, var(--pm-primary-color));
outline-offset: -2px;
}
.custom-tabs > :deep(.el-tabs__content) {
height: 100%;
}

View File

@ -1,44 +1,18 @@
.settings-card { padding: 0; }
.security-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 176px;
grid-template-columns: minmax(0, 1fr) 152px;
grid-template-areas: "main submenu";
align-items: start;
gap: 20px;
}
.security-submenu {
grid-area: submenu;
position: sticky;
top: 0;
display: grid;
gap: 6px;
padding: 8px;
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-lg);
background: var(--pm-bg-secondary);
}
.security-submenu-item {
width: 100%;
padding: 12px 14px;
color: var(--pm-text-secondary);
text-align: left;
font: inherit;
border: 0;
border-radius: var(--pm-radius-sm);
background: transparent;
cursor: pointer;
transition: color .2s ease, background-color .2s ease;
}
.security-submenu-item:hover,
.security-submenu-item.active {
color: var(--pm-primary-color);
background: var(--el-color-primary-light-9);
}
.security-submenu-item.active { font-weight: 600; }
.security-main {
grid-area: main;
min-width: 0;
}
.security-main > .settings-divider:first-child {
display: none;
}
.settings-header { margin-bottom: 24px; }
.settings-header h3 { font-size: 18px; font-weight: 600; color: var(--pm-text-primary); margin: 0 0 8px; }
.settings-desc { font-size: 14px; color: var(--pm-text-secondary); margin: 0; }
@ -59,7 +33,7 @@
.credential-info span { color: var(--pm-text-secondary); font-size: 13px; }
.credential-warning { width: 100%; color: #b45309 !important; font-size: 13px; }
.credential-actions { display: flex; flex: none; gap: 8px; }
@media (max-width: 640px) {
@container settings-content (max-width: 560px) {
.security-workspace {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
@ -67,18 +41,6 @@
"main";
gap: 16px;
}
.security-submenu {
position: static;
display: flex;
gap: 6px;
overflow-x: auto;
padding: 6px;
}
.security-submenu-item {
flex: 0 0 auto;
width: auto;
white-space: nowrap;
}
.security-key-header, .credential-item { flex-direction: column; align-items: stretch; }
.credential-actions { width: 100%; }
.credential-actions .el-button { flex: 1; }

View File

@ -1,19 +1,12 @@
<template>
<div class="settings-card security-settings-card" v-loading="securityLoading">
<div class="security-workspace">
<nav class="security-submenu" aria-label="安全设置">
<button
v-for="item in sectionOptions"
:key="item.value"
type="button"
class="security-submenu-item"
:class="{ active: selectedSection === item.value }"
:aria-current="selectedSection === item.value ? 'page' : undefined"
@click="selectSection(item.value)"
>
{{ item.label }}
</button>
</nav>
<SettingsSubmenu
title="安全设置"
:items="sectionOptions"
:model-value="selectedSection"
@select="selectSection"
/>
<div class="security-main">
<section v-if="selectedSection === 'password' && !isSuperAdmin" class="security-section">
@ -101,6 +94,7 @@
<script setup>
import { usePage } from './SecuritySettings.js'
import SettingsSubmenu from '../SettingsSubmenu/SettingsSubmenu.vue'
const {
lang, isSuperAdmin, supported, webAuthnEnabled, sectionOptions, selectedSection, selectSection,

View File

@ -0,0 +1,120 @@
.settings-submenu {
grid-area: submenu;
position: sticky;
top: 0;
min-width: 0;
padding: 4px 0 4px 14px;
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;
}
.settings-submenu-caption {
color: var(--pm-text-muted);
font-size: 11px;
line-height: 1.5;
}
.settings-submenu-heading strong {
color: var(--pm-text-secondary);
font-size: 13px;
font-weight: 600;
}
.settings-submenu-list {
display: grid;
gap: 4px;
}
.settings-submenu-item {
position: relative;
width: 100%;
min-height: 40px;
padding: 10px 12px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--pm-text-secondary);
font: inherit;
font-size: 13px;
line-height: 1.5;
text-align: left;
cursor: pointer;
transition: color .18s ease, background-color .18s ease;
}
.settings-submenu-item:hover:not(.active) {
color: var(--pm-text-primary);
background: var(--pm-bg-hover);
}
.settings-submenu-item.active {
color: var(--submenu-accent);
font-weight: 600;
background: color-mix(in srgb, var(--submenu-accent) 10%, var(--pm-bg-secondary));
}
.settings-submenu-item.active::before {
content: '';
position: absolute;
top: 12px;
bottom: 12px;
left: -15px;
width: 3px;
border-radius: 3px;
background: var(--submenu-accent);
}
.settings-submenu-item:focus-visible {
outline: 2px solid var(--submenu-accent);
outline-offset: -2px;
}
@media (prefers-color-scheme: dark) {
.settings-submenu {
--submenu-accent: var(--pm-primary-light-3);
}
}
@container settings-content (max-width: 560px) {
.settings-submenu {
position: static;
padding: 12px;
border: 1px solid var(--pm-border-color);
border-radius: 12px;
background: var(--pm-bg-primary);
}
.settings-submenu-heading {
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px 8px;
}
.settings-submenu-list {
display: flex;
overflow-x: auto;
padding: 2px;
}
.settings-submenu-item {
flex: 0 0 auto;
width: auto;
white-space: nowrap;
}
.settings-submenu-item.active::before {
top: auto;
bottom: 2px;
left: 12px;
right: 12px;
width: auto;
height: 2px;
}
}

View File

@ -0,0 +1,16 @@
export const submenuProps = {
title: { type: String, required: true },
caption: { type: String, default: '设置选项' },
items: { type: Array, required: true },
modelValue: { type: String, required: true }
}
export function useSubmenu(props, emit) {
function select(value) {
if (value !== props.modelValue && props.items.some(item => item.value === value)) {
emit('select', value)
}
}
return { select }
}

View File

@ -0,0 +1,31 @@
<template>
<nav class="settings-submenu" :aria-label="title">
<div class="settings-submenu-heading">
<span class="settings-submenu-caption">{{ caption }}</span>
<strong>{{ title }}</strong>
</div>
<div class="settings-submenu-list">
<button
v-for="item in items"
:key="item.value"
type="button"
class="settings-submenu-item"
:class="{ active: modelValue === item.value }"
:aria-current="modelValue === item.value ? 'page' : undefined"
@click="select(item.value)"
>
<span>{{ item.label }}</span>
</button>
</div>
</nav>
</template>
<script setup>
import { submenuProps, useSubmenu } from './SettingsSubmenu.js'
const props = defineProps(submenuProps)
const emit = defineEmits(['select'])
const { select } = useSubmenu(props, emit)
</script>
<style scoped src="./SettingsSubmenu.css"></style>

View File

@ -0,0 +1,106 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { compileFunction } from 'node:vm'
import { computed, reactive, ref } from './vue.mjs'
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc')
const root = new URL('../../frontend/src/views/components/', import.meta.url)
const read = path => readFileSync(new URL(path, root), 'utf8')
const useSubmenu = compileFunction(
read('SettingsSubmenu/SettingsSubmenu.js').replaceAll('export ', '') + '\nreturn useSubmenu'
)()
test('二级菜单只发送可见且尚未选中的选项,不提前修改父组件状态', () => {
const props = reactive({
modelValue: 'question',
items: [{ value: 'question' }, { value: 'external-email' }]
})
const events = []
const { select } = useSubmenu(props, (...event) => events.push(event))
select('password')
select('question')
select('external-email')
assert.deepEqual(events, [['select', 'external-email']])
assert.equal(props.modelValue, 'question')
props.modelValue = 'external-email'
select('external-email')
props.items = [{ value: 'external-email' }]
select('question')
assert.equal(events.length, 1)
})
test('一级菜单保留能力过滤和当前分类标题联动', () => {
const source = read('HomeHeader/HomeHeader.js')
const names = [
'EpMenu', 'Lock', 'Connection', 'Setting', 'FolderOpened', 'Filter', 'User', 'Grid',
'SecuritySettings', 'ThirdPartyAuthorization', 'ConfigSettings', 'GroupSettings',
'RuleSettings', 'UserManagement', 'PluginSettings', 'BrandLogo'
]
const createPage = compileFunction(
source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage')
.replace(/export \{[\s\S]*?\}\s*$/, '') + '\nreturn usePage()',
['computed', 'ref', 'onMounted', 'onUnmounted', 'window', 'useGlobalStatusStore', 'lang', ...names]
)
const capabilities = reactive(new Set(['password.change_own', 'plugin.write_own']))
const store = reactive({
settingsActiveTab: 'security',
hasCapability: value => capabilities.has(value)
})
const page = createPage(computed, ref, () => {}, () => {}, { innerWidth: 1440 }, () => store,
{ security: '安全' }, ...names.map(name => ({ name })))
assert.deepEqual(page.settingsMenu.value.map(item => item.key), ['security', 'third-party'])
assert.equal(page.activeSettings.value.label, '安全')
capabilities.add('config.read')
store.settingsActiveTab = 'config'
assert.equal(page.activeSettings.value.label, '配置管理')
assert.equal(page.settingsMenu.value.length, 3)
assert.ok(page.settingsMenu.value.every(item => item.icon && item.hint))
capabilities.delete('config.read')
assert.equal(page.activeSettings.value, undefined)
})
test('菜单模板可编译并保留按钮、当前项语义及原有选区处理器', () => {
for (const name of ['HomeHeader', 'SettingsSubmenu', 'SecuritySettings', 'ConfigSettings']) {
const source = read(`${name}/${name}.vue`)
const { descriptor, errors } = parse(source)
assert.deepEqual(errors, [])
assert.doesNotThrow(() => compileScript(descriptor, { id: 'settings-test', inlineTemplate: true }))
}
const submenu = read('SettingsSubmenu/SettingsSubmenu.vue')
assert.match(submenu, /<button[\s\S]*type="button"/)
assert.match(submenu, /:aria-current="modelValue === item.value \? 'page' : undefined"/)
for (const name of ['SecuritySettings', 'ConfigSettings']) {
assert.match(read(`${name}/${name}.vue`), /<SettingsSubmenu[\s\S]*@select="selectSection"/)
}
assert.match(read('HomeHeader/HomeHeader.vue'), /globalStatus.settingsDrawerVisible && globalStatus.settingsActiveTab === item.key/)
})
test('一级标签样式不会穿透到插件内部,抽屉样式可命中传送后的节点', () => {
const { code, errors } = compileStyle({
source: read('HomeHeader/HomeHeader.css'), id: 'data-v-settings-test', scoped: true
})
assert.deepEqual(errors, [])
assert.match(code, /\.settings-tabs\[data-v-settings-test\]\s*>\s*\.el-tabs__header/)
assert.doesNotMatch(code, /\.settings-tabs\[data-v-settings-test\]\s+\.el-tabs__header/)
assert.match(code, /\.settings-drawer > \.el-drawer__body\s*\{/)
assert.match(code, /container: settings-content \/ inline-size/)
})
test('两级菜单分别提供选中、非选中悬停和键盘焦点样式', () => {
for (const path of [
'HomeHeader/HomeHeader.css',
'SettingsSubmenu/SettingsSubmenu.css',
'PluginSettings/PluginSettings.css'
]) {
const source = read(path)
assert.match(source, /:hover:not\(/)
assert.match(source, /:focus-visible/)
assert.match(source, /(?:\.is-active|\.active)/)
}
for (const name of ['SecuritySettings', 'ConfigSettings', 'SettingsSubmenu']) {
assert.match(read(`${name}/${name}.css`), /@container settings-content \(max-width: 560px\)/)
}
})