参考设置页拆分管理板块并优化单页布局
增加分组侧栏和按权限显示的用户入口,保留首页基本信息与现有弹窗;补齐证书导入绑定。新增板块切换及完整模板渲染回归,98项前端测试及生产构建通过。
This commit is contained in:
parent
be6bc63102
commit
a99a310f50
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
213
frontend/src/views/Dashboard/DashboardLayout.css
Normal file
213
frontend/src/views/Dashboard/DashboardLayout.css
Normal 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));
|
||||
}
|
||||
}
|
||||
118
frontend/src/views/Dashboard/DashboardNavigation.css
Normal file
118
frontend/src/views/Dashboard/DashboardNavigation.css
Normal 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;
|
||||
}
|
||||
}
|
||||
31
frontend/src/views/Dashboard/DashboardNavigation.js
Normal file
31
frontend/src/views/Dashboard/DashboardNavigation.js
Normal 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 };
|
||||
}
|
||||
62
frontend/src/views/Dashboard/DashboardNavigation.vue
Normal file
62
frontend/src/views/Dashboard/DashboardNavigation.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user