This commit is contained in:
parent
e8e14236db
commit
c37759585c
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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>
|
||||
106
test/frontend/settings-menu.test.mjs
Normal file
106
test/frontend/settings-menu.test.mjs
Normal 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\)/)
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user