修复(权限管理):统一 PM 系统导航与权限校验

This commit is contained in:
Cheng Zhou
2026-07-10 22:12:59 +08:00
parent f11a5c84d9
commit f2fa10c178
47 changed files with 4071 additions and 1555 deletions
@@ -74,11 +74,15 @@ describe("ApiEndpointPermissions.vue", () => {
it("keeps matrix read-only and renders authorization states", async () => {
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
expect(source).not.toContain("只读查看各角色权限覆盖");
expect(source).toContain("有效权限矩阵");
expect(source).toContain(">只读</el-tag>");
expect(source).toContain("permission-state--allowed");
expect(source).toContain("permission-state--disabled");
expect(source).toContain("CircleCloseFilled");
expect(source).not.toContain('? "✓" : "—"');
expect(source).not.toContain("CircleCloseFilled");
expect(source).toContain("<template v-else>—</template>");
expect(source).toContain('filterAuthorization.value === "different"');
expect(source).toContain('max-height="calc(100dvh - 330px)"');
expect(source).toContain('label="权限类型" width="120" fixed="left"');
expect(source).not.toContain("defineEmits");
expect(source).not.toContain("el-checkbox");
});
@@ -2,7 +2,11 @@
<div class="api-permissions">
<div class="api-permissions-toolbar">
<div class="toolbar-left">
<span class="toolbar-title">项目级权限矩阵</span>
<div class="toolbar-heading">
<span class="toolbar-title">有效权限矩阵</span>
<el-tag size="small" effect="plain" type="info">只读</el-tag>
</div>
<span class="toolbar-result">显示 {{ filteredOperations.length }} / {{ displayOperations.length }} 项权限</span>
</div>
<div class="toolbar-filters">
<el-input
@@ -29,6 +33,13 @@
<el-option label="删除" value="delete" />
<el-option label="导出" value="export" />
</el-select>
<el-select v-model="filterAuthorization" placeholder="授权状态" style="width: 140px">
<el-option label="全部授权状态" value="" />
<el-option label="存在角色差异" value="different" />
<el-option label="至少一项授权" value="granted" />
<el-option label="全部未授权" value="ungranted" />
</el-select>
<el-button :disabled="!hasActiveFilters" @click="resetFilters">重置</el-button>
</div>
</div>
@@ -37,10 +48,12 @@
:data="filteredOperations"
border
stripe
max-height="calc(100dvh - 330px)"
empty-text="未找到匹配的权限"
:span-method="spanMethod"
class="perm-matrix-table"
>
<el-table-column prop="permission_category" label="权限类型" width="120">
<el-table-column prop="permission_category" label="权限类型" width="120" fixed="left">
<template #default="{ row }">
<div class="hierarchy-cell hierarchy-cell--category" :data-category="getPermissionCategory(row)">
<span class="hierarchy-bar" />
@@ -49,7 +62,7 @@
</template>
</el-table-column>
<el-table-column prop="module" label="模块" width="160">
<el-table-column prop="module" label="模块" width="160" fixed="left">
<template #default="{ row }">
<div class="hierarchy-cell hierarchy-cell--module">
<span class="module-dot" />
@@ -94,7 +107,7 @@
:title="isOperationAllowed(row.operation_key, role) ? '已授权' : '未授权'"
>
<template v-if="isOperationAllowed(row.operation_key, role)">✓</template>
<CircleCloseFilled v-else />
<template v-else>—</template>
</span>
</template>
</el-table-column>
@@ -105,7 +118,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { CircleCloseFilled, Search } from "@element-plus/icons-vue";
import { Search } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import type { ApiEndpointPermissionsResponse } from "@/types/api";
import { fetchApiOperations } from "@/api/projectPermissions";
@@ -144,8 +157,20 @@ const { roleLabel, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTem
const searchText = ref("");
const filterModule = ref("");
const filterAction = ref("");
const filterAuthorization = ref("");
const operations = ref<Operation[]>([]);
const hasActiveFilters = computed(() => Boolean(
searchText.value || filterModule.value || filterAction.value || filterAuthorization.value
));
const resetFilters = () => {
searchText.value = "";
filterModule.value = "";
filterAction.value = "";
filterAuthorization.value = "";
};
const moduleLabel = projectPermissionModuleLabel;
const getPermissionCategory = (row: Operation): "common" | "business" => projectPermissionCategory(row.module);
const getPermissionCategoryLabel = (row: Operation): string => projectPermissionCategoryLabel(row.module);
@@ -216,8 +241,14 @@ const filteredOperations = computed(() => {
const matchModule = !filterModule.value || operation.module === filterModule.value;
const matchAction = !filterAction.value || getOperationVerb(operation) === filterAction.value;
const roleStates = roles.value.map((role) => isOperationAllowed(operation.operation_key, role));
const matchAuthorization =
!filterAuthorization.value ||
(filterAuthorization.value === "different" && roleStates.some(Boolean) && roleStates.some((allowed) => !allowed)) ||
(filterAuthorization.value === "granted" && roleStates.some(Boolean)) ||
(filterAuthorization.value === "ungranted" && roleStates.every((allowed) => !allowed));
return matchSearch && matchModule && matchAction;
return matchSearch && matchModule && matchAction && matchAuthorization;
});
// 按权限类型、模块分组排序
@@ -394,19 +425,19 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
<style scoped>
.api-permissions {
padding: 20px 0;
padding: 8px 0;
}
.api-permissions-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding: 14px 18px;
gap: 10px;
margin-bottom: 8px;
padding: 8px 10px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
@@ -414,23 +445,35 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
display: flex;
flex-direction: column;
gap: 2px;
flex-shrink: 0;
}
.toolbar-heading {
display: flex;
align-items: center;
gap: 6px;
}
.toolbar-title {
font-size: 15px;
font-size: 13px;
font-weight: 600;
color: #1a2332;
}
.toolbar-result {
color: #7a8a9a;
font-size: 10.5px;
}
.toolbar-filters {
display: flex;
align-items: center;
gap: 10px;
gap: 6px;
}
.api-permissions-table-wrapper {
overflow-x: auto;
border-radius: 12px;
border-radius: 8px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
@@ -442,7 +485,7 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
.hierarchy-cell {
display: flex;
align-items: center;
gap: 10px;
gap: 6px;
height: 100%;
}
@@ -458,7 +501,7 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 36px;
height: 24px;
border-radius: 0 3px 3px 0;
background: linear-gradient(180deg, #94a3b8 0%, #cbd5e1 100%);
}
@@ -472,7 +515,7 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
}
.category-label {
font-size: 14px;
font-size: 12px;
font-weight: 700;
color: #0f172a;
letter-spacing: 0.3px;
@@ -481,15 +524,15 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
/* 模块:实心圆点 + 中等加粗字 */
.module-dot {
flex-shrink: 0;
width: 7px;
height: 7px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
}
.module-label {
font-size: 13px;
font-size: 12px;
font-weight: 600;
color: #1e293b;
}
@@ -498,11 +541,11 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
.section-chip {
display: inline-flex;
align-items: center;
padding: 3px 10px;
padding: 2px 7px;
border-radius: 999px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
font-size: 12px;
font-size: 10.5px;
font-weight: 500;
color: #475569;
white-space: nowrap;
@@ -511,17 +554,17 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
.operation-cell {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
}
.action-tag {
flex-shrink: 0;
min-width: 40px;
min-width: 36px;
text-align: center;
}
.operation-name {
font-size: 13px;
font-size: 12px;
color: #475569;
}
@@ -529,10 +572,10 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
width: 18px;
height: 18px;
color: #94a3b8;
font-size: 14px;
font-size: 12px;
font-weight: 600;
}
@@ -544,11 +587,46 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
}
.permission-state--disabled {
color: #94a3b8;
color: #c3ccd5;
font-weight: 500;
}
.permission-state--disabled :deep(svg) {
width: 16px;
height: 16px;
:deep(.api-permissions .el-input__wrapper),
:deep(.api-permissions .el-select__wrapper) {
min-height: 28px;
}
:deep(.api-permissions .el-button) {
min-height: 28px;
padding: 4px 8px;
font-size: 11.5px;
}
:deep(.perm-matrix-table .el-table__header-wrapper th.el-table__cell) {
padding: 6px 0;
font-size: 11px;
}
:deep(.perm-matrix-table .el-table__body-wrapper td.el-table__cell) {
padding: 4px 0;
font-size: 12px;
}
@media (max-width: 1180px) {
.api-permissions-toolbar {
align-items: flex-start;
flex-direction: column;
}
.toolbar-filters {
flex-wrap: wrap;
width: 100%;
}
}
@media (max-width: 720px) {
.toolbar-filters > * {
width: 100% !important;
}
}
</style>
+10 -2
View File
@@ -495,6 +495,7 @@ import {
} from "../runtime";
import { dispatchDesktopRefreshCurrentView } from "../composables/useDesktopRefresh";
import { useDesktopShortcuts } from "../composables/useDesktopShortcuts";
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
import type { DesktopCommand } from "../types/desktopCommands";
import DesktopCommandPalette from "./DesktopCommandPalette.vue";
@@ -532,6 +533,12 @@ const DESKTOP_ADMIN_SECTION_LABEL = "系统管理";
const isAdmin = computed(() => !!auth.user?.is_admin);
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canAccessAdminPermissions = computed(() => isAdmin.value || projectRole.value === "PM");
const canAccessAuditLogs = computed(() =>
isAdmin.value || (
projectRole.value === "PM" &&
isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["audit_logs:read"])
),
);
const canAccessProjectPath = (path: string) =>
hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
const hasAnyProjectModuleAccess = computed(() => projectRouteLandingPaths.some((path) => canAccessProjectPath(path)));
@@ -618,17 +625,18 @@ const toggleNavigationGroup = (item: LayoutNavigationItem) => {
collapsedNavigationGroups.value = nextCollapsed;
};
const adminNavigationItems = computed(() =>
study.currentStudy
!isAdminContext.value
? []
: buildAdminNavigationItems({
hasUser: Boolean(auth.user),
isAdmin: isAdmin.value,
canAccessAdminPermissions: canAccessAdminPermissions.value,
canAccessAuditLogs: canAccessAuditLogs.value,
}),
);
const projectNavigationItems = computed(() =>
buildProjectNavigationItems({
hasCurrentStudy: Boolean(study.currentStudy),
hasCurrentStudy: Boolean(study.currentStudy) && !isAdminContext.value,
hasAnyProjectModuleAccess: hasAnyProjectModuleAccess.value,
canAccessProjectPath,
}),
@@ -111,6 +111,7 @@ describe("desktop layout shell", () => {
expect(source).toContain("grid-template-rows: 52px auto minmax(0, 1fr);");
expect(source).toContain("const adminDesktopNavigationItems");
expect(source).toContain("const projectDesktopNavigationItems");
expect(source).toContain("hasCurrentStudy: Boolean(study.currentStudy) && !isAdminContext.value");
expect(source).toContain('id: "desktop:refresh"');
expect(source).toContain("shortcut: formatDesktopShortcut(desktopShortcutPreferences.value.refresh)");
expect(source).toContain('command === "ctms.desktop.back"');
@@ -95,6 +95,17 @@ describe("PermissionAccessLogs", () => {
expect(source).not.toContain("grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);");
});
it("keeps the access log table edge-to-edge without removing filter or pagination spacing", () => {
const source = readSource();
expect(source).toMatch(/\.access-log-card \{[\s\S]*?padding: 0;[\s\S]*?overflow: hidden;/);
expect(source).toMatch(/\.audit-filters \{[\s\S]*?padding: 10px 12px;[\s\S]*?margin-bottom: 0;/);
expect(source).toMatch(/\.table-shell \{[\s\S]*?border: 0;[\s\S]*?border-radius: 0;/);
expect(source).toMatch(/\.logs-pagination \{[\s\S]*?padding: 8px 12px;[\s\S]*?border-top:/);
expect(source).toContain(".access-table :deep(.el-scrollbar__wrap)");
expect(source).toContain(".access-table :deep(.el-scrollbar__bar.is-horizontal)");
});
it("removes the duplicated audit hero copy", () => {
const source = readSource();
@@ -143,7 +154,11 @@ describe("PermissionAccessLogs", () => {
expect(source).toContain("document.removeEventListener(\"visibilitychange\", onVisibilityChange)");
expect(source).toContain("stopTimers()");
expect(source).toContain("const REALTIME_POLL_INTERVAL_MS = 30_000;");
expect(source).toContain('const timeRangePreset = ref<TimeRangePreset>("24h")');
expect(source).toContain('const timeRangePreset = ref<AccessTimeRangePreset>("24h")');
expect(source).toContain("权限趋势下钻{{ drilldownRangeLabel");
expect(source).toContain("const applyTrendFilter = async");
expect(source).toContain('params.event_type = "permission"');
expect(source).toContain("defineExpose({ refresh, applyTrendFilter })");
expect(source).toContain("accessLogDialogVisible");
expect(source).toContain("accessTerminalWindowRef");
expect(source).toContain("accessDialogLines.join");
@@ -30,6 +30,17 @@
<section class="access-log-card">
<div class="audit-filters">
<div class="filter-inputs">
<el-tag
v-if="trendDrilldownActive"
closable
effect="plain"
size="small"
type="primary"
class="trend-drilldown-tag"
@close="clearTrendDrilldownRange"
>
权限趋势下钻{{ drilldownRangeLabel ? ` · ${drilldownRangeLabel}` : "" }}
</el-tag>
<el-select v-model="filters.role" placeholder="角色" clearable size="small" class="filter-role" @change="onFilterChange">
<el-option v-for="role in roleFilterOptions" :key="role.value" :label="role.label" :value="role.value" />
</el-select>
@@ -324,6 +335,17 @@ const TIME_RANGE_PRESETS = [
] as const;
type TimeRangePreset = (typeof TIME_RANGE_PRESETS)[number]["value"];
type AccessTimeRangePreset = TimeRangePreset | "custom";
interface TrendDrilldownPayload {
preset?: Exclude<TimeRangePreset, "all">;
startAt?: string;
endAt?: string;
allowed?: boolean;
role?: string;
clientType?: string;
keyword?: string;
}
interface IpRankingRow {
ip: string;
@@ -385,8 +407,10 @@ const summary = ref<AccessLogsSummary>(emptySummary);
const total = ref(0);
const page = ref(1);
const pageSize = ref(50);
const timeRangePreset = ref<TimeRangePreset>("24h");
const timeRangePreset = ref<AccessTimeRangePreset>("24h");
const ipRankingPeriod = ref<TimeRangePreset>("24h");
const trendDrilldownRange = ref<[Date, Date] | null>(null);
const trendDrilldownActive = ref(false);
const accessLogDialogVisible = ref(false);
const ipRankingDialogVisible = ref(false);
const accessTerminalWindowRef = ref<HTMLElement | null>(null);
@@ -636,7 +660,8 @@ const formatLastUpdated = (value: Date) => {
return `${value.getFullYear()}/${value.getMonth() + 1}/${value.getDate()} ${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}:${String(value.getSeconds()).padStart(2, "0")}`;
};
const buildTimeRangeFromPreset = (preset: TimeRangePreset): [Date, Date] | null => {
const buildTimeRangeFromPreset = (preset: AccessTimeRangePreset): [Date, Date] | null => {
if (preset === "custom") return null;
const option = TIME_RANGE_PRESETS.find((item) => item.value === preset) || TIME_RANGE_PRESETS[1];
if (!option.hours) return null;
const end = new Date();
@@ -691,6 +716,7 @@ const onFilterChange = () => {
};
const onTimePresetChange = () => {
trendDrilldownRange.value = null;
onFilterChange();
};
@@ -704,6 +730,8 @@ const onKeywordInput = () => {
const resetFilters = () => {
timeRangePreset.value = "24h";
trendDrilldownRange.value = null;
trendDrilldownActive.value = false;
filters.role = undefined;
filters.allowed = undefined;
filters.clientType = undefined;
@@ -724,12 +752,13 @@ const buildQueryParams = (overrides: Record<string, any> = {}) => {
if (filters.role) params.role = filters.role;
if (filters.allowed !== undefined) params.allowed = filters.allowed;
if (filters.clientType) params.client_type = filters.clientType;
if (trendDrilldownActive.value) params.event_type = "permission";
const keywordToken = filters.keyword.trim();
if (keywordToken) {
params.keyword = keywordToken;
if (/^[0-9a-f:.]+$/i.test(keywordToken)) params.client_ip = keywordToken;
}
const presetRange = timeRangePreset.value ? buildTimeRangeFromPreset(timeRangePreset.value) : null;
const presetRange = trendDrilldownRange.value || buildTimeRangeFromPreset(timeRangePreset.value);
if (presetRange) {
params.start_time = presetRange[0].toISOString();
params.end_time = presetRange[1].toISOString();
@@ -796,6 +825,37 @@ const refresh = () => {
loadData();
};
const drilldownRangeLabel = computed(() => {
if (!trendDrilldownRange.value) return "";
const [start, end] = trendDrilldownRange.value;
const compact = (value: Date) => `${value.getMonth() + 1}/${value.getDate()} ${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}`;
return `${compact(start)}–${compact(end)}`;
});
const clearTrendDrilldownRange = () => {
trendDrilldownRange.value = null;
trendDrilldownActive.value = false;
timeRangePreset.value = "24h";
onFilterChange();
};
const applyTrendFilter = async (payload: TrendDrilldownPayload) => {
page.value = 1;
trendDrilldownActive.value = true;
filters.allowed = payload.allowed;
filters.role = payload.role;
filters.clientType = payload.clientType;
filters.keyword = payload.keyword || "";
if (payload.startAt && payload.endAt) {
trendDrilldownRange.value = [new Date(payload.startAt), new Date(payload.endAt)];
timeRangePreset.value = "custom";
} else {
trendDrilldownRange.value = null;
timeRangePreset.value = payload.preset || "24h";
}
await loadData();
};
const openLogDetail = (row: AccessLogItem) => {
selectedLog.value = row;
detailDrawerVisible.value = true;
@@ -1023,7 +1083,7 @@ onBeforeUnmount(() => {
stopTimers();
});
defineExpose({ refresh });
defineExpose({ refresh, applyTrendFilter });
</script>
<style scoped>
@@ -1089,12 +1149,12 @@ defineExpose({ refresh });
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 0 0 8px;
padding: 10px 12px;
background: transparent;
border: none;
box-shadow: none;
border-bottom: 1px solid var(--audit-border);
margin-bottom: 8px;
margin-bottom: 0;
}
.filter-inputs {
@@ -1249,19 +1309,14 @@ defineExpose({ refresh });
.access-log-card {
min-width: 0;
padding: 10px;
padding: 0;
overflow: hidden;
border: 1px solid var(--audit-border);
border-radius: var(--card-radius);
background: #fff;
box-shadow: var(--card-shadow);
}
.access-log-card {
overflow: hidden;
}
.section-head {
display: flex;
align-items: flex-start;
@@ -1302,14 +1357,14 @@ defineExpose({ refresh });
.log-surface {
display: flex;
flex-direction: column;
gap: 10px;
gap: 0;
}
.table-shell {
min-width: 0;
overflow: hidden;
border: 1px solid #edf2f7;
border-radius: 10px;
border: 0;
border-radius: 0;
}
.access-table {
@@ -1331,6 +1386,14 @@ defineExpose({ refresh });
padding: 0 8px;
}
.access-table :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.access-table :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.time-cell,
.ip-cell,
.location-cell,
@@ -1608,7 +1671,8 @@ defineExpose({ refresh });
.logs-pagination {
display: flex;
justify-content: flex-end;
padding: 8px 10px;
padding: 8px 12px;
border-top: 1px solid var(--audit-border);
background: #fff;
}
@@ -122,7 +122,13 @@ describe("PermissionMonitoring.vue", () => {
expect(source).toContain(".permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tabs__content)");
expect(source).toContain("overflow: hidden");
expect(source).toContain(".soybean-monitoring-tabs :deep(.el-tabs__content)");
expect(source).toContain("overflow: auto");
expect(source).toContain("flex: 1 1 0;");
expect(source).toContain("height: auto;");
expect(source).toContain("overflow-y: auto;");
expect(source).toContain('height: auto;\n min-height: 100%;');
expect(source).toContain('.permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tab-pane[aria-hidden="false"])');
expect(source).toContain("flex: 0 0 52px;");
expect(source).toContain("overscroll-behavior: contain;");
});
it("resizes the source-analysis chart when its tab becomes active", () => {
@@ -141,10 +147,12 @@ describe("PermissionMonitoring.vue", () => {
expect(source).toContain('label="运行概览"');
expect(source).toContain('label="安全中心"');
expect(source).toContain('label="性能趋势"');
expect(source).toContain('label="运行趋势"');
expect(source).toContain('label="访问日志"');
expect(source).toContain('label="来源分析"');
expect(source).toContain('label="性能趋势" name="trends" lazy');
expect(source).toContain('label="运行趋势" name="trends" lazy');
expect(source).toContain('@drilldown="openTrendDrilldown"');
expect(source).toContain('accessLogsRef.value?.applyTrendFilter(payload)');
expect(source).toContain('label="访问日志" name="logs" lazy');
expect(source).toContain('label="来源分析" name="ip-locations" lazy');
expect(source).toContain('<SecurityCenter v-if="props.isAdmin"');
@@ -159,7 +167,12 @@ describe("PermissionMonitoring.vue", () => {
expect(source).toContain('const formatResponseTime');
expect(source).toContain('return "<0.1ms"');
expect(source).toContain("业务样本不足");
expect(source).toContain("组件在线不代表指标已完成评估");
expect(source).not.toContain("组件在线不代表指标已完成评估");
expect(source).not.toContain("评分由性能、拒绝率、缓存、告警和组件状态共同计算");
expect(source).not.toContain("最近一次检查未发现需要处理的问题");
expect(source).toContain('class="health-summary-label">综合状态');
expect(source).toContain('class="health-signal-item"');
expect(source).toContain('item.deduction ? `-${item.deduction}` : "正常"');
expect(source).toContain('class="overview-pane"');
expect(source).toContain('class="overview-hero-card"');
expect(source).toContain('class="operations-card"');
+112 -26
View File
@@ -79,16 +79,16 @@
</div>
</template>
</el-progress>
<div class="health-summary-copy">
<strong>{{ health.sample_sufficient ? "当前运行正常" : "等待业务样本" }}</strong>
<span v-if="health.sample_sufficient">评分由性能、拒绝率、缓存、告警和组件状态共同计算</span>
<span v-else>组件在线不代表指标已完成评估</span>
<div class="health-summary-copy" :class="`is-${healthDisplayStatus}`">
<span class="health-summary-label">综合状态</span>
<strong>{{ health.sample_sufficient ? getHealthSummaryLabel(health.status) : "待评估" }}</strong>
</div>
</div>
<div v-if="health.sample_sufficient" class="health-signal-list">
<span v-for="item in healthScoreItems" :key="item.label" :class="{ 'is-warning': item.deduction }">
{{ item.label }}{{ item.deduction ? ` -${item.deduction}` : " 正常" }}
</span>
<div v-for="item in healthScoreItems" :key="item.label" class="health-signal-item" :class="{ 'is-warning': item.deduction }">
<span>{{ item.label }}</span>
<strong>{{ item.deduction ? `-${item.deduction}` : "正常" }}</strong>
</div>
</div>
<div v-else class="sample-warning">
近 1 小时仅 {{ health.last_hour.total_checks }} 次权限检查,业务样本不足
@@ -195,7 +195,6 @@
<span class="empty-alert-icon">✓</span>
<div>
<strong>暂无活动告警</strong>
<small>最近一次检查未发现需要处理的问题</small>
</div>
</div>
<el-empty v-else description="告警数据尚未加载" :image-size="44" />
@@ -222,14 +221,14 @@
</div>
</el-tab-pane>
<el-tab-pane label="性能趋势" name="trends" lazy>
<el-tab-pane label="运行趋势" name="trends" lazy>
<template #label>
<span class="soybean-tab-label">
<component :is="IconActivity" />
<span>性能趋势</span>
<span>运行趋势</span>
</span>
</template>
<PermissionTrendCharts ref="trendChartsRef" />
<PermissionTrendCharts ref="trendChartsRef" @drilldown="openTrendDrilldown" />
</el-tab-pane>
<el-tab-pane v-if="props.isAdmin" label="安全中心" name="security" lazy>
@@ -364,6 +363,22 @@ type IpLocationsExpose = {
};
const ipLocationsRef = ref<IpLocationsExpose>();
interface TrendDrilldownPayload {
preset?: "24h" | "7d" | "30d";
startAt?: string;
endAt?: string;
allowed?: boolean;
role?: string;
clientType?: string;
keyword?: string;
}
const openTrendDrilldown = async (payload: TrendDrilldownPayload) => {
activeTab.value = "logs";
await nextTick();
await accessLogsRef.value?.applyTrendFilter(payload);
};
const securityCenterRef = ref<InstanceType<typeof SecurityCenter>>();
const formatTime = (timestamp: number): string => {
@@ -384,6 +399,11 @@ const getHealthLabel = (status: string) => {
return map[status] || status;
};
const getHealthSummaryLabel = (status: string) => {
const map: Record<string, string> = { healthy: "正常", degraded: "需关注", unhealthy: "异常" };
return map[status] || "待评估";
};
const getComponentStatusLabel = (status: string) => {
const map: Record<string, string> = { healthy: "正常", degraded: "降级", unhealthy: "异常", unknown: "未知" };
return map[status] || status;
@@ -593,6 +613,9 @@ defineExpose({ refresh });
}
.soybean-monitoring-tabs :deep(.el-tabs__header) {
position: relative;
z-index: 10;
flex: 0 0 52px;
height: 52px;
margin: 0 0 10px;
padding: 0 24px;
@@ -656,10 +679,14 @@ defineExpose({ refresh });
}
.soybean-monitoring-tabs :deep(.el-tabs__content) {
flex: 1 1 auto;
flex: 1 1 0;
height: auto;
min-height: 0;
padding: 0;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tabs__content) {
@@ -671,8 +698,14 @@ defineExpose({ refresh });
}
.soybean-monitoring-tabs :deep(.el-tab-pane[aria-hidden="false"]) {
height: auto;
min-height: 100%;
}
.permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tab-pane[aria-hidden="false"]) {
height: 100%;
min-height: 0;
overflow: hidden;
}
.ip-locations-pane {
@@ -1314,26 +1347,58 @@ defineExpose({ refresh });
.health-summary {
display: flex;
align-items: center;
gap: 10px;
gap: 12px;
min-height: 96px;
}
.health-summary-copy {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
justify-content: center;
gap: 2px;
min-width: 92px;
padding: 9px 11px;
border: 1px solid #bbf7d0;
border-radius: 10px;
background: linear-gradient(145deg, #f0fdf4, #ecfdf5);
}
.health-summary-copy strong {
color: var(--mon-ink);
font-size: 13px;
color: #047857;
font-size: 17px;
line-height: 1.2;
}
.health-summary-copy span {
.health-summary-copy .health-summary-label {
color: var(--mon-muted);
font-size: 11px;
line-height: 1.45;
font-size: 10px;
}
.health-summary-copy.is-degraded {
border-color: #fde68a;
background: linear-gradient(145deg, #fffbeb, #fef3c7);
}
.health-summary-copy.is-degraded strong {
color: #b45309;
}
.health-summary-copy.is-unhealthy {
border-color: #fecaca;
background: linear-gradient(145deg, #fef2f2, #fee2e2);
}
.health-summary-copy.is-unhealthy strong {
color: #dc2626;
}
.health-summary-copy.is-unknown {
border-color: #dbe3ef;
background: #f8fafc;
}
.health-summary-copy.is-unknown strong {
color: #64748b;
}
.score-inner strong {
@@ -1348,16 +1413,36 @@ defineExpose({ refresh });
.health-signal-list {
display: flex;
flex-wrap: wrap;
gap: 4px 6px;
gap: 5px;
}
.health-signal-list span {
color: var(--mon-muted);
font-size: 11px;
.health-signal-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 7px;
border: 1px solid #d1fae5;
border-radius: 999px;
background: #f0fdf4;
font-size: 10px;
white-space: nowrap;
}
.health-signal-list .is-warning {
.health-signal-item span {
color: var(--mon-muted);
}
.health-signal-item strong {
color: #047857;
font-size: 10px;
}
.health-signal-item.is-warning {
border-color: #fde68a;
background: #fffbeb;
}
.health-signal-item.is-warning strong {
color: #b45309;
}
@@ -1729,6 +1814,7 @@ defineExpose({ refresh });
}
.soybean-monitoring-tabs :deep(.el-tabs__header) {
flex-basis: 48px;
margin-bottom: 8px;
padding: 0 12px;
}
@@ -0,0 +1,21 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTemplateSelector.vue"), "utf8");
describe("PermissionTemplateSelector.vue", () => {
it("renders accessible role actions with explicit project status", () => {
const source = readSource();
expect(source).toContain("<button");
expect(source).toContain(":aria-label=");
expect(source).toContain("使用中");
expect(source).toContain("未启用");
expect(source).toContain("配置权限");
expect(source).toContain("grid-template-columns: repeat(5, minmax(0, 1fr));");
expect(source).toContain("min-height: 122px;");
expect(source).toContain("margin-top: auto;");
expect(source).not.toContain('PM: "👑"');
});
});
@@ -2,17 +2,20 @@
<div class="template-selector">
<div class="selector-header">
<div class="selector-title-group">
<h3>角色权限概览</h3>
<h3>角色策略概览</h3>
</div>
<el-tag effect="plain" size="small" type="info" round>{{ templates.length }} 个角色</el-tag>
</div>
<div class="template-grid">
<div
<button
v-for="template in sortedTemplates"
:key="template.id"
type="button"
class="template-card"
:class="{ inactive: template.category && !isRoleActive(template.category) }"
:disabled="!template.category"
:aria-label="template.category ? `配置 ${template.name} 权限` : template.name"
@click="template.category && emit('edit-role', template.category!)"
>
<div class="card-top">
@@ -21,7 +24,9 @@
<span class="card-name">{{ template.name }}</span>
<span v-if="template.description" class="card-desc">{{ template.description }}</span>
</div>
<el-tag v-if="!template.is_system" size="small" effect="dark" round type="success" class="card-badge">自定义</el-tag>
<span :class="['card-status', isRoleActive(template.category || '') ? 'card-status--active' : 'card-status--inactive']">
{{ isRoleActive(template.category || '') ? '使用中' : '未启用' }}
</span>
</div>
<div class="card-stats">
<template v-if="props.currentPermissions && template.category && props.currentPermissions[template.category]">
@@ -31,11 +36,11 @@
<div class="stat-numbers">
<span class="stat enabled">
<span class="stat-dot green" />
{{ countCurrentEnabled(template.category) }} 启用
{{ countCurrentEnabled(template.category) }} 已授权
</span>
<span class="stat disabled">
<span class="stat-dot red" />
{{ countCurrentDisabled(template.category) }} 禁用
{{ countCurrentDisabled(template.category) }} 未授权
</span>
</div>
</template>
@@ -46,16 +51,17 @@
<div class="stat-numbers">
<span class="stat enabled">
<span class="stat-dot green" />
{{ countEnabled(template) }} 启用
{{ countEnabled(template) }} 已授权
</span>
<span class="stat disabled">
<span class="stat-dot red" />
{{ countDisabled(template) }} 禁用
{{ countDisabled(template) }} 未授权
</span>
</div>
</template>
</div>
</div>
<div class="card-action">配置权限 <span aria-hidden="true">→</span></div>
</button>
</div>
</div>
</template>
@@ -94,10 +100,10 @@ const sortedTemplates = computed(() =>
const roleIcon = (category: string | null) => {
const icons: Record<string, string> = {
PM: "👑", CRA: "📋", PV: "🔍",
QA: "🏥", CTA: "📦",
PM: "PM", CRA: "CRA", PV: "PV",
QA: "QA", CTA: "CTA",
};
return icons[category ?? ""] ?? "📄";
return icons[category ?? ""] ?? "—";
};
const isRoleActive = (role: string) => props.activeRoles?.includes(role) ?? false;
@@ -155,38 +161,45 @@ watch(() => props.refreshKey, loadTemplates);
<style scoped>
.template-selector {
padding: 8px 0 12px;
padding: 4px 0 6px;
}
.selector-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
margin-bottom: 8px;
}
.selector-title-group h3 {
margin: 0 0 4px;
font-size: 15px;
margin: 0;
font-size: 13px;
font-weight: 600;
color: #1a2332;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: start;
gap: 12px;
margin-bottom: 12px;
gap: 8px;
margin-bottom: 6px;
}
.template-card {
position: relative;
padding: 12px 14px;
width: 100%;
min-height: 122px;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 12px;
border-radius: 8px;
background: #fff;
cursor: pointer;
color: inherit;
font: inherit;
display: flex;
flex-direction: column;
text-align: left;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
@@ -197,11 +210,19 @@ watch(() => props.refreshKey, loadTemplates);
transform: translateY(-2px);
}
.template-card:focus-visible {
outline: 3px solid rgba(59, 130, 246, 0.25);
outline-offset: 2px;
}
.template-card:disabled {
cursor: default;
}
.template-card.inactive {
border-style: dashed;
border-color: #e2e8f0;
background: #fafbfc;
opacity: 0.7;
}
.template-card.inactive:hover {
@@ -213,12 +234,21 @@ watch(() => props.refreshKey, loadTemplates);
.card-top {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 12px;
gap: 7px;
margin-bottom: 7px;
}
.card-icon {
font-size: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 7px;
background: #eef5fa;
color: #315f7a;
font-size: 9px;
font-weight: 800;
line-height: 1;
flex-shrink: 0;
}
@@ -232,7 +262,7 @@ watch(() => props.refreshKey, loadTemplates);
}
.card-name {
font-size: 14px;
font-size: 12px;
font-weight: 600;
color: #1a2332;
}
@@ -242,13 +272,49 @@ watch(() => props.refreshKey, loadTemplates);
}
.card-desc {
font-size: 11px;
font-size: 10px;
color: #94a3b8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-status {
display: inline-flex;
align-items: center;
flex-shrink: 0;
padding: 2px 5px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 9.5px;
font-weight: 600;
}
.card-status--active {
border-color: #b9ddcc;
background: #edf8f2;
color: #267054;
}
.card-status--inactive {
border-color: #dbe3ea;
background: #f5f7f9;
color: #7a8997;
}
.card-action {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-top: auto;
padding-top: 6px;
border-top: 1px solid #edf1f5;
color: #416b83;
font-size: 10.5px;
font-weight: 600;
}
.card-badge {
flex-shrink: 0;
}
@@ -256,11 +322,11 @@ watch(() => props.refreshKey, loadTemplates);
.card-stats {
display: flex;
flex-direction: column;
gap: 8px;
gap: 5px;
}
.stat-bar {
height: 4px;
height: 3px;
border-radius: 2px;
background: #f1f5f9;
overflow: hidden;
@@ -275,20 +341,20 @@ watch(() => props.refreshKey, loadTemplates);
.stat-numbers {
display: flex;
gap: 12px;
gap: 8px;
}
.stat {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-size: 10.5px;
color: #64748b;
}
.stat-dot {
width: 6px;
height: 6px;
width: 5px;
height: 5px;
border-radius: 50%;
flex-shrink: 0;
}
@@ -300,4 +366,22 @@ watch(() => props.refreshKey, loadTemplates);
.template-card.inactive .stat-dot.red { background: #cbd5e1; }
.template-card.inactive .stat-fill { background: #cbd5e1; }
@media (max-width: 1180px) {
.template-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 920px) {
.template-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.template-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -5,39 +5,56 @@ import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTrendCharts.vue"), "utf8");
describe("PermissionTrendCharts", () => {
it("renders explicit empty states instead of zero-value charts when samples are unavailable", () => {
it("uses accurate permission trend language and removes the misleading all-period option", () => {
const source = readSource();
expect(source).toContain("暂无有效响应时间样本");
expect(source).toContain("暂无缓存访问样本");
expect(source).toContain("暂无权限检查样本");
expect(source).toContain("const hasResponseSamples = computed");
expect(source).toContain("const hasCacheSamples = computed");
expect(source).toContain("权限检查趋势");
expect(source).toContain("权限检查量");
expect(source).toContain("权限检查耗时");
expect(source).toContain("权限拒绝率");
expect(source).toContain("权限缓存命中率");
expect(source).not.toContain("系统性能趋势");
expect(source).not.toContain('{ value: "all", label: "全部" }');
});
it("sorts points chronologically and includes the date in 24-hour labels", () => {
it("renders dense period summaries with previous-period and sample context", () => {
const source = readSource();
expect(source).toContain('String(d.getHours()).padStart(2, "0")');
expect(source).toContain("dataPoints.value = [...res.data.data_points].sort(");
expect(source).toContain("new Date(left.bucket_time).getTime() - new Date(right.bucket_time).getTime()");
expect(source).toContain('class="metric-strip"');
expect(source).toContain("P95 检查耗时");
expect(source).toContain("活跃范围");
expect(source).toContain("较上期");
expect(source).toContain("cache_sample_available");
expect(source).toContain("不等于命中率为 0");
});
it("keeps period selection, update time and refresh together in the tab toolbar", () => {
it("uses honest chart semantics and visible health thresholds", () => {
const source = readSource();
expect(source).toContain('class="toolbar-actions"');
expect(source).toContain("最近成功更新:{{ formatLastUpdated(lastUpdatedAt) }}");
expect(source).toContain('@click="loadData">刷新');
expect(source).toContain('stack: "checks"');
expect(source).toContain("smooth: false");
expect(source).toContain('formatter: "慢检查 50ms"');
expect(source).toContain('formatter: "健康线 80%"');
expect(source).toContain("connectNulls: false");
});
it("uses compact chart surfaces while preserving a two-column desktop layout", () => {
it("provides denial, latency and client attribution with access-log drilldown", () => {
const source = readSource();
expect(source).toContain("padding: 0 12px 12px");
expect(source).toContain("grid-template-columns: repeat(2, 1fr)");
expect(source).toContain("min-height: 276px");
expect(source).toContain("height: 212px");
expect(source).toContain("gap: 10px");
expect(source).toContain("拒绝最多");
expect(source).toContain("耗时最高");
expect(source).toContain("客户端表现");
expect(source).toContain('@click="onChecksChartClick"');
expect(source).toContain('emit("drilldown"');
expect(source).toContain("openAttributionLogs");
});
it("keeps the information-dense layout responsive", () => {
const source = readSource();
expect(source).toContain("grid-template-columns: repeat(5, minmax(0, 1fr))");
expect(source).toContain("grid-template-columns: minmax(0, 2.1fr)");
expect(source).toContain("grid-template-columns: repeat(3, minmax(0, 1fr))");
expect(source).toContain("@media (max-width: 900px)");
});
});
File diff suppressed because it is too large Load Diff
@@ -39,6 +39,16 @@ describe("SecurityCenter", () => {
expect(source).not.toContain('<section class="audit-filters">\n <el-select');
});
it("keeps the security table edge-to-edge while preserving filter and pagination spacing", () => {
const source = readSource();
expect(source).toMatch(/\.security-table-wrap \{[\s\S]*?padding: 0;[\s\S]*?overflow: hidden;/);
expect(source).toMatch(/\.table-head \{[\s\S]*?margin-bottom: 0;[\s\S]*?padding: 10px 12px;/);
expect(source).toMatch(/\.pagination-wrap \{[\s\S]*?padding: 8px 12px;[\s\S]*?border-top:/);
expect(source).toContain(".security-table :deep(.el-scrollbar__wrap)");
expect(source).toContain(".security-table :deep(.el-scrollbar__bar.is-horizontal)");
});
it("keeps risk levels on the page and opens abnormal sources in a compact ranking dialog", () => {
const source = readSource();
+17 -10
View File
@@ -938,7 +938,8 @@ defineExpose({ refresh: loadSecurityEvents });
.security-table-wrap {
min-width: 0;
padding: 10px;
padding: 0;
overflow: hidden;
border: 1px solid var(--audit-border);
border-radius: var(--card-radius);
background: #fff;
@@ -964,6 +965,14 @@ defineExpose({ refresh: loadSecurityEvents });
padding: 0 8px;
}
.security-table :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.security-table :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.time-cell,
.ip-cell,
.location-cell,
@@ -1222,7 +1231,9 @@ defineExpose({ refresh: loadSecurityEvents });
.table-head {
align-items: center;
flex-wrap: wrap;
margin-bottom: 10px;
margin-bottom: 0;
padding: 10px 12px;
border-bottom: 1px solid var(--audit-border);
}
.table-head .section-title-group {
@@ -1490,15 +1501,12 @@ defineExpose({ refresh: loadSecurityEvents });
text-align: right;
}
.security-table-wrap {
overflow: hidden;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 10px;
padding: 8px 16px 0;
margin-top: 0;
padding: 8px 12px;
border-top: 1px solid var(--audit-border);
}
.category-pill {
@@ -1806,8 +1814,7 @@ defineExpose({ refresh: loadSecurityEvents });
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.security-analysis-card,
.security-table-wrap {
.security-analysis-card {
padding: 10px;
}
+13 -6
View File
@@ -34,7 +34,7 @@
<el-icon><Suitcase /></el-icon>
<span>{{ TEXT.menu.projectManagement }}</span>
</el-menu-item>
<el-menu-item v-if="isAdmin" index="/admin/audit-logs">
<el-menu-item v-if="canAccessAuditLogs" index="/admin/audit-logs">
<el-icon><Document /></el-icon>
<span>{{ TEXT.menu.auditLogs }}</span>
</el-menu-item>
@@ -55,7 +55,7 @@
<span>{{ TEXT.menu.permissionManagement }}</span>
</template>
<el-menu-item index="/admin/permissions/system">系统级权限</el-menu-item>
<el-menu-item index="/admin/permissions/project">项目权限配置</el-menu-item>
<el-menu-item index="/admin/permissions/project">项目访问控制</el-menu-item>
</el-sub-menu>
</el-menu-item-group>
@@ -414,6 +414,7 @@ import {
} from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
import {
@@ -446,6 +447,12 @@ const isAdmin = computed(() => !!auth.user?.is_admin);
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canAccessAdminPermissions = computed(() => isAdmin.value || projectRole.value === "PM");
const canAccessAuditLogs = computed(() =>
isAdmin.value || (
projectRole.value === "PM" &&
isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["audit_logs:read"])
),
);
const canAccessProjectPath = (path: string) =>
hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
const canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path));
@@ -844,7 +851,7 @@ const breadcrumbs = computed(() => {
const items: any[] = [];
if (route.path.startsWith("/admin")) {
// 顶级管理后台菜单(按权限过滤),供末级面包屑横跳
// 顶级系统管理菜单(按权限过滤),供末级面包屑横跳
const topItems: { label: string; path: string }[] = [];
if (isAdmin.value) topItems.push({ label: TEXT.menu.accountManagement, path: "/admin/users" });
topItems.push({ label: TEXT.menu.projectManagement, path: "/admin/projects" });
@@ -856,10 +863,10 @@ const breadcrumbs = computed(() => {
// 权限管理子页,供「权限管理」父级与末级横跳
const permItems = [
{ label: "系统级权限", path: "/admin/permissions/system" },
{ label: "项目权限配置", path: "/admin/permissions/project" },
{ label: "项目访问控制", path: "/admin/permissions/project" },
];
// 首项「管理后台」为分组标签,不可点击
// 首项「系统管理」为分组标签,不可点击
items.push({ label: TEXT.menu.admin });
// 第二级:当前所属的顶级模块,带下拉横跳到其它顶级模块
@@ -890,7 +897,7 @@ const breadcrumbs = computed(() => {
}
// 项目详情:项目名由 viewContext 作为末项提供,继续走通用逻辑
} else {
// 顶级页面:模块名带同级下拉,可在管理后台各模块间横跳
// 顶级页面:模块名带同级下拉,可在系统管理各模块间横跳
items.push({
label: curTop?.label || (route.meta?.title as string),
path: route.path,
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { buildAdminNavigationItems } from "./navigation";
const labels = (items: ReturnType<typeof buildAdminNavigationItems>) => items.map((item) => item.label);
describe("system management navigation", () => {
it("exposes project management, audit logs, and permission management to PMs", () => {
const items = buildAdminNavigationItems({
hasUser: true,
isAdmin: false,
canAccessAdminPermissions: true,
canAccessAuditLogs: true,
});
expect(labels(items)).toEqual(["项目管理", "审计日志", "权限管理"]);
});
it("does not expose PM system modules to a non-PM account", () => {
const items = buildAdminNavigationItems({
hasUser: true,
isAdmin: false,
canAccessAdminPermissions: false,
canAccessAuditLogs: false,
});
expect(labels(items)).toEqual(["项目管理"]);
});
});
+11 -8
View File
@@ -58,6 +58,7 @@ export const buildAdminNavigationItems = (options: {
hasUser: boolean;
isAdmin: boolean;
canAccessAdminPermissions: boolean;
canAccessAuditLogs: boolean;
}): LayoutNavigationItem[] => {
if (!options.hasUser) return [];
@@ -78,15 +79,17 @@ export const buildAdminNavigationItems = (options: {
icon: "project",
keywords: ["project"],
});
if (options.isAdmin || options.canAccessAuditLogs) {
items.push({
label: TEXT.menu.auditLogs,
path: "/admin/audit-logs",
group: TEXT.menu.admin,
icon: "audit",
keywords: ["audit"],
});
}
if (options.isAdmin) {
items.push(
{
label: TEXT.menu.auditLogs,
path: "/admin/audit-logs",
group: TEXT.menu.admin,
icon: "audit",
keywords: ["audit"],
},
{
label: TEXT.menu.systemMonitoring,
path: "/admin/system-monitoring",
@@ -128,7 +131,7 @@ export const buildAdminNavigationItems = (options: {
keywords: ["permission", "system"],
},
{
label: "项目权限配置",
label: "项目访问控制",
path: "/admin/permissions/project",
group: TEXT.menu.permissionManagement,
icon: "key",