参考设置页拆分管理板块并优化单页布局

增加分组侧栏和按权限显示的用户入口,保留首页基本信息与现有弹窗;补齐证书导入绑定。新增板块切换及完整模板渲染回归,98项前端测试及生产构建通过。
This commit is contained in:
xuans 2026-09-25 13:40:15 +08:00
parent be6bc63102
commit a99a310f50
6 changed files with 583 additions and 10 deletions

View File

@ -1,9 +1,121 @@
import assert from "node:assert/strict";
import test from "node:test";
import { nextTick } from "vue";
import { nextTick, ref } from "vue";
import { useDashboard } from "./Dashboard.js";
import { useDashboardNavigation } from "./DashboardNavigation.js";
import { aliyunProvider, deferred, mountComposable, verifiedResponse } from "../../test-support/mount.js";
test("板块导航默认只选中基本信息,超级管理员可切换全部板块", () => {
const navigation = useDashboardNavigation(ref({ role: "super_admin" }));
assert.equal(navigation.activeSection.value, "overview");
assert.equal(navigation.sections.value.length, 6);
for (const section of navigation.sections.value) {
navigation.selectSection(section.id);
assert.equal(navigation.activeSection.value, section.id);
assert.equal(navigation.activeLabel.value, section.label);
}
navigation.selectSection("invalid");
assert.equal(navigation.activeSection.value, "users");
});
test("用户管理导航按权限显示,权限变化后回到基本信息", () => {
const user = ref(null);
const navigation = useDashboardNavigation(user);
assert.equal(navigation.sections.value.some((section) => section.id === "users"), false);
navigation.selectSection("users");
assert.equal(navigation.activeSection.value, "overview");
user.value = { role: "super_admin" };
navigation.selectSection("users");
assert.equal(navigation.activeSection.value, "users");
user.value = { role: "admin" };
assert.equal(navigation.activeSection.value, "overview");
assert.equal(navigation.sections.value.length, 5);
});
test("单板块布局使用互斥条件,切换重置滚动且保留弹窗入口", async () => {
const { readFile } = await import("node:fs/promises");
const template = await readFile(new URL("./Dashboard.vue", import.meta.url), "utf8");
const stylesheet = await readFile(new URL("./DashboardLayout.css", import.meta.url), "utf8");
for (const id of ["overview", "activity", "domains", "servers", "keys", "users"]) {
assert.ok(template.includes(`v-if="activeSection === '${id}'"`));
}
assert.match(template, /:key="activeSection"/);
assert.match(template, /role="region"\s+:aria-label="activeLabel"\s+tabindex="0"/);
for (const modal of ["SettingsModal", "DomainModal", "ServerModal", "SSHKeyModal"]) {
assert.ok(template.includes(`<${modal} v-if=`));
}
assert.match(stylesheet, /height: 100dvh/);
assert.match(stylesheet, /\.dashboard-content\s*\{[^}]*overflow: auto/);
});
test("侧栏支持键盘焦点、当前项标记和悬浮说明", async () => {
const { readFile } = await import("node:fs/promises");
const template = await readFile(new URL("./DashboardNavigation.vue", import.meta.url), "utf8");
const stylesheet = await readFile(new URL("./DashboardNavigation.css", import.meta.url), "utf8");
assert.match(template, /<button/);
assert.match(template, /:aria-current=/);
assert.match(template, /:title="section.hint"/);
assert.match(stylesheet, /\.navigation-item:focus-visible/);
});
test("证书导入使用组合式状态导出,避免点击时缺失处理函数", async () => {
const { readFile } = await import("node:fs/promises");
const template = await readFile(new URL("./Dashboard.vue", import.meta.url), "utf8");
const script = template.split("</script>")[0];
for (const name of ["showCertificateImport", "openCertificateImport", "closeCertificateImport", "importCertificate", "selectCertificateImportFile"]) {
assert.ok(script.includes(` ${name},`));
}
});
test("完整页面逐个渲染六个板块,每次只有一个内容标题", async (t) => {
const { readFile } = await import("node:fs/promises");
const { parse, compileTemplate } = await import("@vue/compiler-sfc");
const { renderToString } = await import("@vue/server-renderer");
const Vue = await import("vue");
const source = await readFile(new URL("./Dashboard.vue", import.meta.url), "utf8");
const { descriptor, errors } = parse(source);
assert.deepEqual(errors, []);
const compiled = compileTemplate({
source: descriptor.template.content,
filename: "Dashboard.vue",
id: "dashboard-panels",
compilerOptions: { mode: "function" },
});
assert.deepEqual(compiled.errors, []);
const render = new Function("Vue", compiled.code)(Vue);
const { state } = await setup(t, () => verifiedResponse());
state.closeSettings();
state.currentUser.value = { account: "test-admin", role: "super_admin" };
const navigation = useDashboardNavigation(state.currentUser);
const headings = {
overview: "基本信息",
activity: "最近活动",
domains: "托管域名",
servers: "部署服务器",
keys: "SSH 私钥",
users: "用户管理",
};
for (const section of navigation.sections.value) {
navigation.selectSection(section.id);
const app = Vue.createSSRApp({
render,
setup: () => ({ ...state, ...navigation, dashboard: state }),
});
for (const name of ["ChangePassword", "DashboardNavigation", "SettingsModal", "DomainModal", "ServerModal", "SSHKeyModal"]) {
app.component(name, { render: () => null });
}
app.component("UserManagement", { render: () => Vue.h("h2", headings.users) });
const warnings = [];
app.config.warnHandler = (message) => warnings.push(message);
const html = await renderToString(app);
assert.deepEqual([...html.matchAll(/<h2>(.*?)<\/h2>/g)].map((match) => match[1]), [headings[section.id]]);
assert.deepEqual(warnings, []);
if (section.id === "overview") {
assert.match(html, /test-admin/);
assert.doesNotMatch(html, /立即检查续期/);
}
}
});
async function setup(t, request) {
const mounted = await mountComposable(t, useDashboard, async (path) => {
if (path === "/api/v1/dns/test") return request();

View File

@ -5,6 +5,8 @@ import SettingsModal from "./SettingsModal.vue";
import DomainModal from "./DomainModal.vue";
import ServerModal from "./ServerModal.vue";
import SSHKeyModal from "./SSHKeyModal.vue";
import DashboardNavigation from "./DashboardNavigation.vue";
import { useDashboardNavigation } from "./DashboardNavigation.js";
import { useDashboard } from "./Dashboard.js";
const dashboard = useDashboard();
@ -59,6 +61,15 @@ const {
sshKeyPrivatePath,
sshKeyPublicPath,
showSettings,
showCertificateImport,
importDomain,
importSaving,
importError,
importResult,
openCertificateImport,
closeCertificateImport,
selectCertificateImportFile,
importCertificate,
settingsSaving,
settingsError,
dnsProviderCatalog,
@ -113,6 +124,7 @@ const {
daysRemaining,
statusClass,
} = dashboard;
const { activeSection, activeLabel, sections, selectSection } = useDashboardNavigation(currentUser);
</script>
<template>
@ -127,7 +139,7 @@ const {
<button class="secondary-button" type="button" @click="loadData">
刷新状态
</button>
<button class="secondary-button" type="button" @click="runRenewalNow">
<button v-if="activeSection === 'activity'" class="secondary-button" type="button" @click="runRenewalNow">
立即检查续期
</button>
<button class="secondary-button" type="button" @click="openSettings">
@ -137,6 +149,7 @@ const {
class="secondary-button"
type="button"
@click="openSSHKeyCreate"
v-if="activeSection === 'keys'"
>
添加 SSH 私钥
</button>
@ -144,17 +157,35 @@ const {
class="secondary-button"
type="button"
@click="openServerCreate"
v-if="activeSection === 'servers'"
>
添加服务器
</button>
<button class="primary-button" type="button" @click="openCreate">
<button v-if="activeSection === 'domains'" class="primary-button" type="button" @click="openCreate">
添加域名
</button>
</div>
</header>
<p v-if="error" class="error-banner">{{ error }}</p>
<p v-if="notice" class="notice-banner">{{ notice }}</p>
<div class="dashboard-messages" aria-live="polite">
<p v-if="error" class="error-banner">{{ error }}</p>
<p v-if="notice" class="notice-banner">{{ notice }}</p>
</div>
<div class="dashboard-workspace">
<DashboardNavigation
:active-section="activeSection"
:sections="sections"
@select="selectSection"
/>
<div
:key="activeSection"
class="dashboard-content"
role="region"
:aria-label="activeLabel"
tabindex="0"
>
<div v-if="activeSection === 'overview'" class="overview-section">
<section class="panel overview-panel">
<div class="panel-heading">
@ -241,9 +272,11 @@ const {
</article>
</section>
<UserManagement />
</div>
<section class="panel operations-panel">
<UserManagement v-if="activeSection === 'users'" />
<section v-if="activeSection === 'activity'" class="panel operations-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">运行概览</p>
@ -312,7 +345,7 @@ const {
</div>
</section>
<section class="panel">
<section v-if="activeSection === 'domains'" class="panel">
<div class="panel-heading">
<div>
<p class="eyebrow">域名配置</p>
@ -388,7 +421,7 @@ const {
</div>
</section>
<section class="panel server-panel">
<section v-if="activeSection === 'keys'" class="panel server-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">安全凭据</p>
@ -429,7 +462,7 @@ const {
</div>
</section>
<section class="panel server-panel">
<section v-if="activeSection === 'servers'" class="panel server-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">远程部署</p>
@ -476,6 +509,9 @@ const {
</div>
</section>
</div>
</div>
<SettingsModal v-if="showSettings" :state="dashboard" />
@ -568,3 +604,4 @@ const {
</main>
</template>
<style scoped src="./Dashboard.css"></style>
<style scoped src="./DashboardLayout.css"></style>

View File

@ -0,0 +1,213 @@
.page-shell {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 16px;
width: 100%;
max-width: 1600px;
height: 100dvh;
padding: 24px;
overflow: hidden;
}
.topbar {
gap: 16px;
}
.topbar h1 {
font-size: 26px;
letter-spacing: -0.03em;
}
.topbar .eyebrow {
margin-bottom: 6px;
}
.topbar-actions {
gap: 8px;
}
.topbar-actions > button {
padding: 9px 13px;
font-size: 13px;
}
.dashboard-messages:empty {
display: none;
}
.page-shell:not(:has(.dashboard-messages > p)) {
grid-template-rows: auto minmax(0, 1fr);
}
.dashboard-messages p {
margin: 0;
}
.dashboard-messages p + p {
margin-top: 8px;
}
.dashboard-workspace {
display: grid;
grid-template-columns: 208px minmax(0, 1fr);
gap: 18px;
min-height: 0;
}
.dashboard-content {
min-width: 0;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
border-radius: 18px;
}
.dashboard-content:focus-visible {
outline: 2px solid #315aaa;
outline-offset: -2px;
}
.dashboard-content > .panel,
.dashboard-content :deep(.user-management) {
margin: 0;
min-height: 100%;
padding: 24px;
border-radius: 18px;
}
.overview-section {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
}
.overview-section > .overview-panel {
flex: 1;
margin: 0;
padding: 24px;
}
.overview-section > .metrics-grid {
margin: 0;
}
.metric-card {
padding: 16px 18px;
border-radius: 14px;
}
.metric-card strong {
margin-top: 8px;
font-size: 20px;
overflow-wrap: anywhere;
}
.panel-heading h2 {
font-size: 22px;
}
.overview-summary-card strong {
font-size: 18px;
}
.overview-status {
white-space: normal;
}
.domain-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px;
}
.domain-row .row-actions {
grid-column: 1 / -1;
justify-content: flex-end;
flex-wrap: wrap;
}
.domain-main,
.domain-meta,
.domain-expiry {
min-width: 0;
overflow-wrap: anywhere;
}
.record-list {
max-height: max(240px, calc(100dvh - 300px));
}
@media (max-width: 1100px) {
.dashboard-workspace {
grid-template-columns: 180px minmax(0, 1fr);
gap: 14px;
}
.operation-columns {
grid-template-columns: 1fr;
}
.record-list {
max-height: 260px;
}
.overview-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview-summary-card:last-child {
grid-column: 1 / -1;
}
.overview-domain-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview-domain-main {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
.page-shell {
display: flex;
flex-direction: column;
height: auto;
min-height: 100dvh;
padding: 16px;
overflow: visible;
}
.topbar {
align-items: flex-start;
flex-direction: column;
}
.topbar-actions {
justify-content: flex-start;
}
.dashboard-workspace {
grid-template-columns: 1fr;
}
.dashboard-content {
overflow: visible;
}
.dashboard-content > .panel,
.dashboard-content :deep(.user-management),
.overview-section > .overview-panel {
padding: 18px;
}
.overview-summary-grid,
.overview-domain-row {
grid-template-columns: 1fr;
}
.metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

View File

@ -0,0 +1,118 @@
.dashboard-navigation {
display: flex;
flex-direction: column;
gap: 5px;
min-height: 0;
overflow-y: auto;
padding: 22px 12px 14px;
border: 1px solid #e2e8f2;
border-radius: 18px;
background: #fff;
}
.navigation-heading {
display: flex;
align-items: center;
gap: 10px;
padding: 0 10px 18px;
}
.navigation-heading div {
display: grid;
gap: 4px;
}
.navigation-heading strong {
color: #263752;
font-size: 19px;
}
.navigation-heading span,
.navigation-group,
.navigation-footer {
color: #7a879d;
font-size: 11px;
}
.navigation-group {
margin: 14px 12px 4px;
}
.navigation-item {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
min-height: 43px;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: #62718a;
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.navigation-item:hover {
background: #f5f7fb;
color: #315aaa;
}
.navigation-item.active {
border-color: #dce6f6;
background: #edf3fc;
color: #315aaa;
font-weight: 700;
}
.navigation-item:focus-visible {
outline: 2px solid #315aaa;
outline-offset: 2px;
}
.navigation-item svg {
flex: none;
width: 18px;
height: 18px;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.navigation-current {
width: 5px;
height: 5px;
margin-left: auto;
border-radius: 50%;
background: currentColor;
}
.navigation-footer {
margin: auto 12px 0;
padding-top: 28px;
}
@media (max-width: 760px) {
.dashboard-navigation {
flex-direction: row;
flex-wrap: wrap;
gap: 6px;
padding: 10px;
overflow: visible;
}
.navigation-heading,
.navigation-group,
.navigation-footer {
display: none;
}
.navigation-item {
width: auto;
min-height: 38px;
padding: 8px 10px;
}
}

View File

@ -0,0 +1,31 @@
import { computed, ref } from "vue";
export const dashboardSections = [
{ id: "overview", label: "基本信息", group: "总览", hint: "查看用户、域名状态、证书到期时间和部署服务器" },
{ id: "activity", label: "运行概览", group: "总览", hint: "查看证书、任务与审计日志" },
{ id: "domains", label: "域名配置", group: "证书与部署", hint: "管理域名、申请证书和执行部署" },
{ id: "servers", label: "部署服务器", group: "证书与部署", hint: "配置部署目标及 SSH 认证方式" },
{ id: "keys", label: "SSH 私钥", group: "证书与部署", hint: "管理加密保存的 SSH 私钥" },
{ id: "users", label: "用户管理", group: "系统管理", hint: "管理登录账号、角色与使用状态" },
];
export function useDashboardNavigation(currentUser) {
const selectedSection = ref("overview");
const sections = computed(() => dashboardSections.filter(
(section) => section.id !== "users" || currentUser.value?.role === "super_admin",
));
const activeSection = computed(() => sections.value.some(
(section) => section.id === selectedSection.value,
) ? selectedSection.value : "overview");
const activeLabel = computed(() => sections.value.find(
(section) => section.id === activeSection.value,
)?.label || dashboardSections[0].label);
function selectSection(id) {
if (sections.value.some((section) => section.id === id)) {
selectedSection.value = id;
}
}
return { activeSection, activeLabel, sections, selectSection };
}

View File

@ -0,0 +1,62 @@
<script setup>
defineProps({
activeSection: { type: String, required: true },
sections: { type: Array, required: true },
});
defineEmits(["select"]);
</script>
<template>
<nav class="dashboard-navigation" aria-label="管理板块">
<div class="navigation-heading">
<img src="/Scert.png" alt="" width="32" height="32" />
<div>
<strong>Scert</strong>
<span>证书管理控制台</span>
</div>
</div>
<template v-for="(section, index) in sections" :key="section.id">
<p v-if="index === 0 || sections[index - 1].group !== section.group" class="navigation-group">
{{ section.group }}
</p>
<button
:id="`navigation-${section.id}`"
class="navigation-item"
:class="{ active: activeSection === section.id }"
:aria-current="activeSection === section.id ? 'page' : undefined"
:title="section.hint"
type="button"
@click="$emit('select', section.id)"
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path v-if="section.id === 'overview'" d="m3 10 9-7 9 7v10H3V10Zm6 10v-7h6v7" />
<path v-else-if="section.id === 'activity'" d="M3 12h4l3-8 4 16 3-8h4" />
<g v-else-if="section.id === 'domains'">
<circle cx="12" cy="12" r="9" />
<ellipse cx="12" cy="12" rx="4" ry="9" />
<path d="M3 12h18" />
</g>
<g v-else-if="section.id === 'servers'">
<rect x="3" y="3" width="18" height="7" rx="2" />
<rect x="3" y="14" width="18" height="7" rx="2" />
<path d="M7 6.5h.01M7 17.5h.01M12 6.5h5M12 17.5h5" />
</g>
<g v-else-if="section.id === 'keys'">
<circle cx="8" cy="8" r="5" />
<path d="m12 12 9 9m-6-6 3-3m0 6 3-3" />
</g>
<g v-else>
<circle cx="12" cy="8" r="4" />
<path d="M4 21v-2a8 8 0 0 1 16 0v2" />
</g>
</svg>
<span>{{ section.label }}</span>
<i v-if="activeSection === section.id" class="navigation-current" aria-hidden="true"></i>
</button>
</template>
<p class="navigation-footer">SSL 证书管理</p>
</nav>
</template>
<style scoped src="./DashboardNavigation.css"></style>