修复(权限管理):统一 PM 系统导航与权限校验
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(["项目管理"]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user