新增DNS账户管理及归属显示
增加 DNS 账户列表接口、归属用户记录、脱敏账号标识和管理页面导航,补充前端回归覆盖。
This commit is contained in:
parent
443df8165e
commit
e6c40d85a4
@ -90,6 +90,7 @@ type ACMEConfig struct {
|
||||
}
|
||||
|
||||
type DNSConfig struct {
|
||||
OwnerUserID string `json:"owner_user_id,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
CredentialRef string `json:"credential_ref"`
|
||||
VerificationState string `json:"verification_state"`
|
||||
|
||||
107
backend/internal/httpapi/dns_accounts_api.go
Normal file
107
backend/internal/httpapi/dns_accounts_api.go
Normal file
@ -0,0 +1,107 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||
)
|
||||
|
||||
type dnsAccountView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
AccountIdentifier string `json:"account_identifier"`
|
||||
OwnerUserID string `json:"owner_user_id,omitempty"`
|
||||
OwnerUsername string `json:"owner_username"`
|
||||
OwnerState string `json:"owner_state"`
|
||||
VerificationState string `json:"verification_state"`
|
||||
CredentialState string `json:"credential_state"`
|
||||
}
|
||||
|
||||
// listDNSAccounts 仅列出当前绑定的配置,不枚举凭据仓库中的临时或历史密钥。
|
||||
func (s *Server) listDNSAccounts(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
s.configMu.RLock()
|
||||
cfg := s.config
|
||||
s.configMu.RUnlock()
|
||||
items := make([]dnsAccountView, 0, 1)
|
||||
if strings.TrimSpace(cfg.DNS.CredentialRef) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
return
|
||||
}
|
||||
item := dnsAccountView{
|
||||
ID: "system-dns", Name: "系统 DNS 账户", Provider: cfg.DNS.Provider,
|
||||
ProviderName: "未知服务商", AccountIdentifier: "未提供账号标识",
|
||||
OwnerUserID: cfg.DNS.OwnerUserID, OwnerState: "unassigned", OwnerUsername: "未分配",
|
||||
VerificationState: cfg.DNS.VerificationState, CredentialState: "available",
|
||||
}
|
||||
var owner repository.User
|
||||
if cfg.DNS.OwnerUserID == "primary-super-admin" {
|
||||
owner = repository.User{ID: cfg.DNS.OwnerUserID, Username: cfg.Admin.Username, Role: repository.UserRoleSuperAdmin}
|
||||
} else if cfg.DNS.OwnerUserID != "" {
|
||||
if s.users == nil {
|
||||
s.writeError(c, http.StatusServiceUnavailable, "用户归属查询暂不可用", nil)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
owner, err = s.users.GetUserByID(c.Request.Context(), cfg.DNS.OwnerUserID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
s.writeError(c, http.StatusServiceUnavailable, "用户归属查询暂不可用", nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
item.OwnerState, item.OwnerUsername = "missing", "原用户已不存在"
|
||||
}
|
||||
}
|
||||
if owner.ID != "" {
|
||||
item.OwnerState, item.OwnerUsername = "assigned", owner.Username
|
||||
}
|
||||
if descriptor, err := dnsprovider.GetDescriptor(cfg.DNS.Provider); err == nil {
|
||||
item.ProviderName = descriptor.Name
|
||||
}
|
||||
credentials, err := s.readDNSCredentials(c.Request.Context(), cfg.DNS.CredentialRef)
|
||||
if err != nil {
|
||||
item.CredentialState, item.AccountIdentifier = "unavailable", "暂不可读取"
|
||||
} else {
|
||||
item.AccountIdentifier = dnsAccountIdentifier(cfg.DNS.Provider, credentials)
|
||||
if _, err := dnsprovider.ValidateCredentials(cfg.DNS.Provider, credentials); err != nil {
|
||||
item.CredentialState = "invalid"
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// dnsAccountIdentifier 只允许输出公开标识的脱敏结果,绝不输出令牌或私钥片段。
|
||||
func dnsAccountIdentifier(provider string, credentials map[string]string) string {
|
||||
var value string
|
||||
switch provider {
|
||||
case dnsprovider.Aliyun, dnsprovider.HuaweiCloud, dnsprovider.Route53:
|
||||
value = credentials["access_key_id"]
|
||||
case dnsprovider.DNSPod, dnsprovider.TencentCloud:
|
||||
value = credentials["secret_id"]
|
||||
case dnsprovider.Cloudflare:
|
||||
return "令牌账号(已隐藏)"
|
||||
case dnsprovider.GoogleCloud:
|
||||
return "服务账号(已隐藏)"
|
||||
case dnsprovider.CNAME:
|
||||
return "委派账号(已隐藏)"
|
||||
default:
|
||||
return "账号标识已隐藏"
|
||||
}
|
||||
identifier := []rune(strings.TrimSpace(value))
|
||||
if len(identifier) == 0 {
|
||||
return "未提供账号标识"
|
||||
}
|
||||
if len(identifier) <= 8 {
|
||||
return "********"
|
||||
}
|
||||
return string(identifier[:4]) + "****" + string(identifier[len(identifier)-4:])
|
||||
}
|
||||
@ -196,6 +196,7 @@ func (s *Server) registerRoutes() {
|
||||
adminAPI.GET("/settings", s.getSettings)
|
||||
adminAPI.PUT("/settings", s.updateSettings)
|
||||
adminAPI.GET("/dns/providers", s.listAdminDNSProviders)
|
||||
adminAPI.GET("/dns/accounts", s.listDNSAccounts)
|
||||
adminAPI.POST("/dns/test", s.dnsOperation, s.testAdminDNS)
|
||||
adminAPI.POST("/dns/aliyun/check", s.dnsOperation, s.checkAliyunDNSDomain)
|
||||
adminAPI.POST("/renewal/run", s.longOperation(certificateTaskTimeout), s.runRenewalNow)
|
||||
|
||||
@ -170,6 +170,11 @@ func (s *Server) getSettings(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (s *Server) updateSettings(c *gin.Context) {
|
||||
session, authenticated := s.currentAuthSession(c)
|
||||
if !authenticated {
|
||||
s.writeError(c, http.StatusUnauthorized, "请先登录管理后台", nil)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(s.configPath) == "" {
|
||||
s.writeError(c, http.StatusInternalServerError, "配置文件路径未配置", errors.New("配置文件路径为空"))
|
||||
return
|
||||
@ -210,6 +215,12 @@ func (s *Server) updateSettings(c *gin.Context) {
|
||||
if dnsCredentialRef != "" && dnsCredentialRef != previous.DNS.CredentialRef {
|
||||
createdCredentialRefs = append(createdCredentialRefs, dnsCredentialRef)
|
||||
}
|
||||
// 只有实际更换凭据才记录新归属,修改其他设置不能认领历史账号。
|
||||
if updated.DNS.CredentialRef == "" {
|
||||
updated.DNS.OwnerUserID = ""
|
||||
} else if updated.DNS.CredentialRef != previous.DNS.CredentialRef {
|
||||
updated.DNS.OwnerUserID = session.UserID
|
||||
}
|
||||
if err := updated.Validate(); err != nil {
|
||||
s.rollbackSetupCredentialsWithLog(createdCredentialRefs)
|
||||
s.configMu.Unlock()
|
||||
|
||||
@ -130,6 +130,7 @@ func Apply(cfg *config.Config, input Input) error {
|
||||
cfg.Paths.Certificate = strings.TrimSpace(input.CertificateRoot)
|
||||
cfg.DNS.Provider = strings.TrimSpace(input.DNSProvider)
|
||||
cfg.DNS.CredentialRef = strings.TrimSpace(input.DNSCredentialRef)
|
||||
cfg.DNS.OwnerUserID = "primary-super-admin"
|
||||
cfg.DNS.VerificationState = "verified"
|
||||
cfg.Notification.HTTPEnabled = input.EmailHTTPEnabled
|
||||
cfg.Notification.SMTPEnabled = input.EmailSMTPEnabled
|
||||
|
||||
137
frontend/src/views/DNSAccounts/DNSAccounts.css
Normal file
137
frontend/src/views/DNSAccounts/DNSAccounts.css
Normal file
@ -0,0 +1,137 @@
|
||||
.dns-accounts-panel {
|
||||
padding: 24px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.dns-accounts-heading,
|
||||
.dns-pagination,
|
||||
.dns-page-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dns-accounts-heading {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dns-accounts-heading h2 {
|
||||
margin: 0 0 8px;
|
||||
color: #20314f;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.dns-accounts-count,
|
||||
.dns-muted,
|
||||
.dns-empty {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dns-refresh,
|
||||
.dns-pagination button,
|
||||
.dns-pagination select {
|
||||
padding: 9px 12px;
|
||||
border: 1px solid #d5ddea;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #32405a;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.dns-refresh,
|
||||
.dns-pagination button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
select:focus-visible,
|
||||
.dns-table-scroll:focus-visible {
|
||||
outline: 2px solid #315aaa;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.dns-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.dns-accounts-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dns-accounts-table th,
|
||||
.dns-accounts-table td {
|
||||
padding: 16px 12px;
|
||||
border-bottom: 1px solid #e8edf4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dns-accounts-table th {
|
||||
color: #66758d;
|
||||
background: #f7f9fc;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dns-identifier {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.dns-status {
|
||||
display: inline-block;
|
||||
padding: 5px 9px;
|
||||
border-radius: 7px;
|
||||
background: #fff4db;
|
||||
color: #805300;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dns-status.verified {
|
||||
background: #eaf7ef;
|
||||
color: #216044;
|
||||
}
|
||||
|
||||
.dns-error {
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
background: #fff0f0;
|
||||
color: #a92e2e;
|
||||
}
|
||||
|
||||
.dns-empty {
|
||||
margin: 0;
|
||||
padding: 40px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dns-pagination {
|
||||
flex-wrap: wrap;
|
||||
margin-top: 20px;
|
||||
font-size: 13px;
|
||||
color: #52627b;
|
||||
}
|
||||
|
||||
.dns-pagination select {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.dns-accounts-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dns-accounts-table {
|
||||
min-width: 640px;
|
||||
}
|
||||
}
|
||||
60
frontend/src/views/DNSAccounts/DNSAccounts.js
Normal file
60
frontend/src/views/DNSAccounts/DNSAccounts.js
Normal file
@ -0,0 +1,60 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
export function dnsVerificationLabel(item) {
|
||||
if (item.credential_state === "unavailable") return "凭据不可读取";
|
||||
if (item.credential_state === "invalid") return "凭据配置不完整";
|
||||
return item.verification_state === "verified" ? "已验证" : "待验证";
|
||||
}
|
||||
|
||||
export function useDNSAccounts() {
|
||||
const accounts = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const pageSize = ref(10);
|
||||
const page = ref(1);
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(accounts.value.length / pageSize.value)));
|
||||
const visibleAccounts = computed(() => accounts.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value));
|
||||
let requestVersion = 0;
|
||||
let controller;
|
||||
|
||||
async function loadAccounts() {
|
||||
const version = ++requestVersion;
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
accounts.value = [];
|
||||
page.value = 1;
|
||||
try {
|
||||
const response = await fetch("/api/v1/dns/accounts", {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.status === 401) throw new Error("登录会话已过期,请重新登录");
|
||||
if (response.status === 403) throw new Error("当前账号没有查看 DNS 账户的权限");
|
||||
if (!response.ok) throw new Error("DNS 账户加载失败,请稍后重试");
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data.items) || !data.items.every((item) => item && typeof item.id === "string")) {
|
||||
throw new Error("DNS 账户响应格式不正确,请刷新重试");
|
||||
}
|
||||
if (version === requestVersion) accounts.value = data.items;
|
||||
} catch (reason) {
|
||||
if (version === requestVersion) error.value = reason.message || "DNS 账户加载失败,请稍后重试";
|
||||
} finally {
|
||||
if (version === requestVersion) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function changePageSize() {
|
||||
page.value = 1;
|
||||
}
|
||||
|
||||
onMounted(loadAccounts);
|
||||
onBeforeUnmount(() => {
|
||||
requestVersion += 1;
|
||||
controller?.abort();
|
||||
});
|
||||
|
||||
return { accounts, loading, error, pageSize, page, pageCount, visibleAccounts, loadAccounts, changePageSize };
|
||||
}
|
||||
75
frontend/src/views/DNSAccounts/DNSAccounts.vue
Normal file
75
frontend/src/views/DNSAccounts/DNSAccounts.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<script setup>
|
||||
import { watch } from "vue";
|
||||
import { dnsVerificationLabel, useDNSAccounts } from "./DNSAccounts.js";
|
||||
|
||||
const props = defineProps({ refreshKey: { type: Object, default: null } });
|
||||
const {
|
||||
accounts, loading, error, pageSize, page, pageCount,
|
||||
visibleAccounts, loadAccounts, changePageSize,
|
||||
} = useDNSAccounts();
|
||||
|
||||
watch(() => props.refreshKey, loadAccounts);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="dns-accounts-panel" :aria-busy="loading">
|
||||
<header class="dns-accounts-heading">
|
||||
<div>
|
||||
<h2>DNS 账户管理</h2>
|
||||
<span class="dns-accounts-count">{{ loading ? '正在加载' : error ? '加载失败' : `共 ${accounts.length} 个账号` }}</span>
|
||||
</div>
|
||||
<button class="dns-refresh" type="button" :disabled="loading" @click="loadAccounts">
|
||||
{{ loading ? '加载中…' : '刷新列表' }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p v-if="error" class="dns-error" role="alert">{{ error }}</p>
|
||||
<p v-else-if="loading" class="dns-empty" role="status">正在读取已配置的 DNS 账号…</p>
|
||||
<p v-else-if="accounts.length === 0" class="dns-empty">暂无可查看的已配置 DNS 账号</p>
|
||||
<template v-else>
|
||||
<div class="dns-table-scroll" tabindex="0" role="region" aria-label="DNS 账户列表">
|
||||
<table class="dns-accounts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">账户名称</th>
|
||||
<th scope="col">DNS 服务商</th>
|
||||
<th scope="col" title="仅展示脱敏后的公开账号标识,不展示密钥或令牌">账号标识</th>
|
||||
<th scope="col" title="归属来自已保存的用户记录;历史配置未记录归属时显示未分配">归属用户</th>
|
||||
<th scope="col" title="已验证表示保存配置时通过验证,不代表此刻云端权限仍然有效">验证状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="account in visibleAccounts" :key="account.id">
|
||||
<td>{{ account.name }}</td>
|
||||
<td>{{ account.provider_name }}</td>
|
||||
<td class="dns-identifier">{{ account.account_identifier }}</td>
|
||||
<td :class="{ 'dns-muted': account.owner_state !== 'assigned' }">{{ account.owner_username }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="dns-status"
|
||||
:class="{ verified: account.credential_state === 'available' && account.verification_state === 'verified' }"
|
||||
>{{ dnsVerificationLabel(account) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<footer class="dns-pagination">
|
||||
<label>
|
||||
每页
|
||||
<select v-model.number="pageSize" aria-label="DNS 账户每页条数" @change="changePageSize">
|
||||
<option v-for="size in [5, 10, 15, 30]" :key="size" :value="size">{{ size }}</option>
|
||||
</select>
|
||||
条
|
||||
</label>
|
||||
<div class="dns-page-actions">
|
||||
<button type="button" :disabled="page <= 1" @click="page -= 1">上一页</button>
|
||||
<span>第 {{ page }} / {{ pageCount }} 页</span>
|
||||
<button type="button" :disabled="page >= pageCount" @click="page += 1">下一页</button>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="./DNSAccounts.css"></style>
|
||||
@ -10,7 +10,7 @@ import { aliyunProvider, deferred, mountComposable, verifiedResponse } from "../
|
||||
test("板块导航默认只选中基本信息,超级管理员可切换全部板块", () => {
|
||||
const navigation = useDashboardNavigation(ref({ role: "super_admin" }));
|
||||
assert.equal(navigation.activeSection.value, "overview");
|
||||
assert.equal(navigation.sections.value.length, 6);
|
||||
assert.equal(navigation.sections.value.length, 7);
|
||||
for (const section of navigation.sections.value) {
|
||||
navigation.selectSection(section.id);
|
||||
assert.equal(navigation.activeSection.value, section.id);
|
||||
@ -31,7 +31,7 @@ test("用户管理导航按权限显示,权限变化后回到基本信息", ()
|
||||
assert.equal(navigation.activeSection.value, "users");
|
||||
user.value = { role: "admin" };
|
||||
assert.equal(navigation.activeSection.value, "users");
|
||||
assert.equal(navigation.sections.value.length, 6);
|
||||
assert.equal(navigation.sections.value.length, 7);
|
||||
user.value = null;
|
||||
assert.equal(navigation.activeSection.value, "overview");
|
||||
assert.equal(navigation.sections.value.length, 5);
|
||||
@ -41,7 +41,7 @@ test("单板块布局使用互斥条件,切换重置滚动且保留弹窗入
|
||||
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"]) {
|
||||
for (const id of ["overview", "activity", "domains", "servers", "keys", "dns-accounts", "users"]) {
|
||||
assert.ok(template.includes(`v-if="activeSection === '${id}'"`));
|
||||
}
|
||||
assert.match(template, /:key="activeSection"/);
|
||||
@ -97,6 +97,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as
|
||||
domains: "托管域名",
|
||||
servers: "部署服务器",
|
||||
keys: "SSH 私钥",
|
||||
"dns-accounts": "DNS 账户管理",
|
||||
users: "用户管理",
|
||||
};
|
||||
for (const section of navigation.sections.value) {
|
||||
@ -109,6 +110,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as
|
||||
app.component(name, { render: () => null });
|
||||
}
|
||||
app.component("UserManagement", { render: () => Vue.h("h2", headings.users) });
|
||||
app.component("DNSAccounts", { render: () => Vue.h("h2", headings["dns-accounts"]) });
|
||||
app.component("DeploymentResult", { render: () => null });
|
||||
const warnings = [];
|
||||
app.config.warnHandler = (message) => warnings.push(message);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import UserManagement from "../UserManagement/UserManagement.vue";
|
||||
import DNSAccounts from "../DNSAccounts/DNSAccounts.vue";
|
||||
import SettingsModal from "./SettingsModal.vue";
|
||||
import DomainModal from "./DomainModal.vue";
|
||||
import ServerModal from "./ServerModal.vue";
|
||||
@ -322,6 +323,7 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
|
||||
</div>
|
||||
|
||||
<UserManagement v-if="activeSection === 'users'" />
|
||||
<DNSAccounts v-if="activeSection === 'dns-accounts'" :refresh-key="settings" />
|
||||
|
||||
<section v-if="activeSection === 'activity'" class="panel operations-panel">
|
||||
<div class="panel-heading">
|
||||
|
||||
@ -6,13 +6,14 @@ export const dashboardSections = [
|
||||
{ id: "domains", label: "域名配置", group: "证书与部署", hint: "管理域名、申请证书和执行部署" },
|
||||
{ id: "servers", label: "部署服务器", group: "证书与部署", hint: "配置部署目标及 SSH 认证方式" },
|
||||
{ id: "keys", label: "SSH 私钥", group: "证书与部署", hint: "管理加密保存的 SSH 私钥" },
|
||||
{ id: "dns-accounts", label: "DNS 账户管理", group: "证书与部署", hint: "查看已配置的 DNS 账号及归属用户,凭据标识脱敏显示" },
|
||||
{ id: "users", label: "用户管理", group: "系统管理", hint: "管理登录账号、角色与使用状态" },
|
||||
];
|
||||
|
||||
export function useDashboardNavigation(currentUser) {
|
||||
const selectedSection = ref("overview");
|
||||
const sections = computed(() => dashboardSections.filter(
|
||||
(section) => section.id !== "users" || ["super_admin", "admin"].includes(currentUser.value?.role),
|
||||
(section) => !["users", "dns-accounts"].includes(section.id) || ["super_admin", "admin"].includes(currentUser.value?.role),
|
||||
));
|
||||
const activeSection = computed(() => sections.value.some(
|
||||
(section) => section.id === selectedSection.value,
|
||||
|
||||
@ -46,6 +46,11 @@ defineEmits(["select", "settings"]);
|
||||
<circle cx="8" cy="8" r="5" />
|
||||
<path d="m12 12 9 9m-6-6 3-3m0 6 3-3" />
|
||||
</g>
|
||||
<g v-else-if="section.id === 'dns-accounts'">
|
||||
<rect x="3" y="4" width="18" height="16" rx="3" />
|
||||
<circle cx="9" cy="10" r="2" />
|
||||
<path d="M5 17a4 4 0 0 1 8 0m2-8h4m-4 4h4" />
|
||||
</g>
|
||||
<g v-else>
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 21v-2a8 8 0 0 1 16 0v2" />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user