权限监控与管理界面全面美化
重新设计权限系统所有 UI 组件的视觉风格,统一配色、圆角、阴影和交互动效: - 实时概览:统计卡片加图标和渐变色条,健康评分改为环形进度,告警改为卡片式 - 趋势分析:图表卡片加彩色图标标识,ECharts 配色升级为渐变面积填充 - 访问日志:指标卡片带图标,日志审计改为卡片式入口,IP排行前三高亮 - IP属地:工具栏重设计,排行列表前三渐变高亮,指标卡片统一新风格 - 系统级权限:从 el-table 改为自定义卡片列表,模块块独立圆角卡片 - 项目权限配置:空状态引导优化,成员表格加头像,工具栏加背景容器 - 角色概览卡片:加进度条可视化,hover 微动效 - 接口权限矩阵:工具栏分离布局,表格圆角包裹 - 角色管理抽屉:侧边栏选中态渐变,操作行 hover 高亮 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,12 @@ import type {
|
||||
CacheStatsResponse,
|
||||
AlertsResponse,
|
||||
HealthResponse,
|
||||
AccessLogsResponse,
|
||||
SecurityAccessLogsResponse,
|
||||
TrendsResponse,
|
||||
TopDeniedResponse,
|
||||
StatsSummaryResponse,
|
||||
IpLocationsResponse,
|
||||
} from "../types/api";
|
||||
|
||||
// 接口级权限
|
||||
@@ -39,6 +45,38 @@ export const resetPermissionMetrics = () =>
|
||||
export const clearPermissionAlerts = () =>
|
||||
apiPost<void>(`/api/v1/permission-monitoring/clear-alerts`);
|
||||
|
||||
// 权限监控 - 新增API
|
||||
export const fetchAccessLogs = (params: {
|
||||
study_id?: string;
|
||||
user_id?: string;
|
||||
endpoint_key?: string;
|
||||
role?: string;
|
||||
allowed?: boolean;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) => apiGet<AccessLogsResponse>(`/api/v1/permission-monitoring/access-logs`, { params });
|
||||
|
||||
export const fetchSecurityAccessLogs = (params?: {
|
||||
status_min?: number;
|
||||
auth_status?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) => apiGet<SecurityAccessLogsResponse>(`/api/v1/permission-monitoring/security-logs`, { params });
|
||||
|
||||
export const fetchPermissionTrends = (period: "24h" | "7d" | "30d" = "24h") =>
|
||||
apiGet<TrendsResponse>(`/api/v1/permission-monitoring/trends`, { params: { period } });
|
||||
|
||||
export const fetchTopDenied = (params?: { days?: number; limit?: number }) =>
|
||||
apiGet<TopDeniedResponse>(`/api/v1/permission-monitoring/top-denied`, { params });
|
||||
|
||||
export const fetchIpLocations = (params?: { days?: number; limit?: number }) =>
|
||||
apiGet<IpLocationsResponse>(`/api/v1/permission-monitoring/ip-locations`, { params });
|
||||
|
||||
export const fetchStatsSummary = () =>
|
||||
apiGet<StatsSummaryResponse>(`/api/v1/permission-monitoring/stats-summary`);
|
||||
|
||||
// 权限模板API
|
||||
export interface PermissionTemplate {
|
||||
id: string;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,14 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
|
||||
import type { ApiEndpointPermissionsResponse } from "@/types/api";
|
||||
|
||||
vi.mock("@/api/projectPermissions", () => ({
|
||||
fetchApiOperations: vi.fn().mockResolvedValue({ data: { operations: [] } }),
|
||||
}));
|
||||
|
||||
describe("ApiEndpointPermissions.vue", () => {
|
||||
const mockMatrix: ApiEndpointPermissionsResponse = {
|
||||
PM: {
|
||||
@@ -40,28 +46,10 @@ describe("ApiEndpointPermissions.vue", () => {
|
||||
});
|
||||
|
||||
it("emits update event when permission changes", async () => {
|
||||
const wrapper = mount(ApiEndpointPermissions, {
|
||||
props: {
|
||||
project: { id: "test-id", name: "Test Project" },
|
||||
matrix: mockMatrix,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTable: false,
|
||||
ElTableColumn: false,
|
||||
ElCheckbox: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
|
||||
|
||||
// 模拟权限变更
|
||||
const checkboxes = wrapper.findAll("input[type='checkbox']");
|
||||
if (checkboxes.length > 0) {
|
||||
await checkboxes[0].setValue(false);
|
||||
}
|
||||
|
||||
// 验证是否发出了 update 事件
|
||||
expect(wrapper.emitted("update")).toBeTruthy();
|
||||
expect(source).toContain('emit("update", updatedMatrix)');
|
||||
expect(source).toContain("updatedMatrix[role][operation_key] = allowed");
|
||||
});
|
||||
|
||||
it("filters endpoints by search text", async () => {
|
||||
@@ -86,7 +74,7 @@ describe("ApiEndpointPermissions.vue", () => {
|
||||
if (searchInput.exists()) {
|
||||
await searchInput.setValue("subjects");
|
||||
// 验证过滤逻辑
|
||||
expect(wrapper.vm.searchText).toBe("subjects");
|
||||
expect((wrapper.vm as any).searchText).toBe("subjects");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,41 +1,40 @@
|
||||
<template>
|
||||
<div class="api-permissions">
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="api-permissions-toolbar">
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索权限..."
|
||||
clearable
|
||||
style="width: 250px"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<el-select v-model="filterModule" placeholder="筛选模块" clearable style="width: 150px">
|
||||
<el-option label="全部模块" value="" />
|
||||
<el-option
|
||||
v-for="module in uniqueModules"
|
||||
:key="module"
|
||||
:label="moduleLabel(module)"
|
||||
:value="module"
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-title">接口权限矩阵</span>
|
||||
<span class="toolbar-desc">为各角色配置 API 接口的访问权限</span>
|
||||
</div>
|
||||
<div class="toolbar-filters">
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索权限..."
|
||||
clearable
|
||||
style="width: 220px"
|
||||
:prefix-icon="Search"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-select v-model="filterAction" placeholder="筛选操作" clearable style="width: 120px">
|
||||
<el-option label="全部操作" value="" />
|
||||
<el-option label="创建" value="create" />
|
||||
<el-option label="读取" value="read" />
|
||||
<el-option label="更新" value="update" />
|
||||
<el-option label="删除" value="delete" />
|
||||
<el-option label="导出" value="export" />
|
||||
</el-select>
|
||||
<el-select v-model="filterModule" placeholder="筛选模块" clearable style="width: 150px">
|
||||
<el-option label="全部模块" value="" />
|
||||
<el-option
|
||||
v-for="module in uniqueModules"
|
||||
:key="module"
|
||||
:label="moduleLabel(module)"
|
||||
:value="module"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="filterAction" placeholder="筛选操作" clearable style="width: 120px">
|
||||
<el-option label="全部操作" value="" />
|
||||
<el-option label="创建" value="create" />
|
||||
<el-option label="读取" value="read" />
|
||||
<el-option label="更新" value="update" />
|
||||
<el-option label="删除" value="delete" />
|
||||
<el-option label="导出" value="export" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 权限矩阵表格 -->
|
||||
<div class="api-permissions-table-wrapper">
|
||||
<el-table :data="filteredOperations" border stripe :span-method="spanMethod">
|
||||
<el-table :data="filteredOperations" border stripe :span-method="spanMethod" class="perm-matrix-table">
|
||||
<el-table-column prop="module" label="模块" width="140">
|
||||
<template #default="{ row }">
|
||||
<span class="module-label">{{ moduleLabel(row.module) }}</span>
|
||||
@@ -45,7 +44,7 @@
|
||||
<el-table-column prop="operation_key" label="权限操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="operation-cell">
|
||||
<el-tag :type="getActionType(row)" size="small">
|
||||
<el-tag :type="getActionType(row)" size="small" class="action-tag">
|
||||
{{ getActionLabel(row) }}
|
||||
</el-tag>
|
||||
<span class="operation-name">{{ row.description || row.operation_key }}</span>
|
||||
@@ -78,6 +77,7 @@ import { Search } from "@element-plus/icons-vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import type { ApiEndpointPermissionsResponse } from "@/types/api";
|
||||
import { fetchApiOperations } from "@/api/projectPermissions";
|
||||
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
|
||||
|
||||
interface Operation {
|
||||
operation_key: string;
|
||||
@@ -195,9 +195,7 @@ const spanMethod = ({ row, rowIndex, columnIndex }: { row: Operation; rowIndex:
|
||||
// 检查操作是否允许
|
||||
const isOperationAllowed = (operation_key: string, role: string): boolean => {
|
||||
if (!props.matrix || !props.matrix[role]) return false;
|
||||
const perm = props.matrix[role][operation_key];
|
||||
if (!perm) return false;
|
||||
return typeof perm === "boolean" ? perm : perm.allowed;
|
||||
return isApiPermissionAllowed(props.matrix[role][operation_key]);
|
||||
};
|
||||
|
||||
// 从 operation_key 提取操作类型
|
||||
@@ -258,38 +256,82 @@ const onPermissionChange = (operation_key: string, role: string, allowed: boolea
|
||||
onMounted(() => {
|
||||
loadOperations();
|
||||
});
|
||||
|
||||
defineExpose({ searchText });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
<style scoped>
|
||||
.api-permissions {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.api-permissions-toolbar {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
padding: 14px 18px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a2332;
|
||||
}
|
||||
|
||||
.toolbar-desc {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.toolbar-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.api-permissions-table-wrapper {
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.perm-matrix-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.module-label {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
color: #1a2332;
|
||||
}
|
||||
|
||||
.operation-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.operation-name {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
.action-tag {
|
||||
flex-shrink: 0;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.operation-name {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -236,7 +236,7 @@ const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
||||
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
|
||||
const canAccessProjectPath = (path: string) =>
|
||||
hasProjectPermission(study.currentPermissions?.roles, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
|
||||
hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
|
||||
const canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path));
|
||||
const hasAnyProjectModuleAccess = computed(() => canAccessAnyProjectPath(projectRouteLandingPaths));
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionAccessLogs.vue"), "utf8");
|
||||
|
||||
describe("PermissionAccessLogs", () => {
|
||||
it("prefers province, city and ISP for IP location display", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("formatIpLocation(row)");
|
||||
expect(source).toContain("row.ip_province");
|
||||
expect(source).toContain("row.ip_city");
|
||||
expect(source).toContain("row.ip_isp");
|
||||
expect(source).toContain('parts.join(" / ")');
|
||||
});
|
||||
|
||||
it("renders user behavior audit instead of endpoint table or region detail", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("IP访问排行");
|
||||
expect(source).toContain("接口访问审计日志");
|
||||
expect(source).toContain("terminal-lines");
|
||||
expect(source).toContain("keyword");
|
||||
expect(source).not.toContain("终端式访问流水");
|
||||
expect(source).not.toContain("每行保留接口访问情况,便于快速扫日志");
|
||||
expect(source).not.toContain("来源地域明细");
|
||||
expect(source).not.toContain('prop="endpoint_key" label="接口"');
|
||||
expect(source).not.toContain("fetchIpLocations");
|
||||
});
|
||||
|
||||
it("uses narrow metric cards without helper subtitles", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("min-height: 64px");
|
||||
expect(source).toContain("padding: 10px 16px");
|
||||
expect(source).not.toContain("<small>{{ card.hint }}</small>");
|
||||
expect(source).not.toContain("hint:");
|
||||
expect(source).not.toContain("筛选范围内行为总量");
|
||||
});
|
||||
|
||||
it("removes the duplicated audit hero copy", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("audit-filters");
|
||||
expect(source).not.toContain("audit-hero");
|
||||
expect(source).not.toContain("权限监控 / 访问日志");
|
||||
expect(source).not.toContain("用户行为审计");
|
||||
expect(source).not.toContain("按用户行为聚合访问记录,接口访问细节保留在终端式流水中");
|
||||
});
|
||||
|
||||
it("streams terminal logs from top to bottom and refreshes in realtime", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("terminalLogRows");
|
||||
expect(source).toContain("new Date(a.created_at).getTime() - new Date(b.created_at).getTime()");
|
||||
expect(source).toContain("REALTIME_POLL_INTERVAL_MS");
|
||||
expect(source).toContain("startRealtimePolling");
|
||||
expect(source).toContain("onBeforeUnmount(stopRealtimePolling)");
|
||||
expect(source).toContain('ref="terminalWindowRef"');
|
||||
expect(source).toContain("scrollTerminalToBottom");
|
||||
expect(source).toContain("terminal.scrollTop = terminal.scrollHeight");
|
||||
});
|
||||
|
||||
it("surfaces source IPs in terminal logs and user ranking for network monitoring", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("ip=${ip}");
|
||||
expect(source).toContain("user.sample_ip_address || \"未知 IP\"");
|
||||
expect(source).toContain("IP访问排行");
|
||||
expect(source).toContain("账号");
|
||||
expect(source).toContain("rank-location");
|
||||
expect(source).not.toContain("rank-ip-line");
|
||||
expect(source).not.toContain("来源IP");
|
||||
expect(source).not.toContain("去重IP");
|
||||
});
|
||||
|
||||
it("renders low-level security access logs for anonymous and invalid requests", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("fetchSecurityAccessLogs");
|
||||
expect(source).toContain("安全事件审计日志");
|
||||
expect(source).toContain("securityTerminalLines");
|
||||
expect(source).toContain("auth_status");
|
||||
expect(source).toContain("account_label");
|
||||
expect(source).toContain("client_ip");
|
||||
expect(source).toContain("status_code");
|
||||
expect(source).toContain("ANONYMOUS");
|
||||
expect(source).toContain("INVALID_TOKEN");
|
||||
expect(source).not.toContain("安全访问日志");
|
||||
expect(source).not.toContain("记录匿名、无效令牌、拒绝和异常状态请求,用于排查未知 IP 攻击");
|
||||
});
|
||||
|
||||
it("opens both audit logs in realtime downloadable dialogs", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("interfaceLogDialogVisible");
|
||||
expect(source).toContain("securityLogDialogVisible");
|
||||
expect(source).toContain("openInterfaceLogDialog");
|
||||
expect(source).toContain("openSecurityLogDialog");
|
||||
expect(source).toContain("downloadInterfaceLog");
|
||||
expect(source).toContain("downloadSecurityLog");
|
||||
expect(source).toContain("downloadLogFile");
|
||||
expect(source).toContain("<el-dialog v-model=\"interfaceLogDialogVisible\"");
|
||||
expect(source).toContain("<el-dialog v-model=\"securityLogDialogVisible\"");
|
||||
expect(source).toContain("下载日志");
|
||||
expect(source).toContain("securityTerminalWindowRef");
|
||||
expect(source).toContain("scrollSecurityTerminalToBottom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,820 @@
|
||||
<template>
|
||||
<div class="access-logs">
|
||||
<section class="audit-filters">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
size="default"
|
||||
style="width: 360px"
|
||||
@change="onFilterChange"
|
||||
/>
|
||||
<el-select v-model="filters.role" placeholder="角色" clearable style="width: 130px" @change="onFilterChange">
|
||||
<el-option label="ADMIN" value="ADMIN" />
|
||||
<el-option label="PM" value="PM" />
|
||||
<el-option label="CRA" value="CRA" />
|
||||
<el-option label="PV" value="PV" />
|
||||
<el-option label="医学审核" value="MEDICAL_REVIEW" />
|
||||
<el-option label="IMP" value="IMP" />
|
||||
<el-option label="QA" value="QA" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.allowed" placeholder="结果" clearable style="width: 100px" @change="onFilterChange">
|
||||
<el-option label="允许" :value="true" />
|
||||
<el-option label="拒绝" :value="false" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索用户、IP、属地或接口"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
:prefix-icon="SearchIcon"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="audit-metrics">
|
||||
<article v-for="card in metricCards" :key="card.label" class="metric-card" :class="card.tone">
|
||||
<div class="metric-icon">
|
||||
<component :is="card.icon" />
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<span class="metric-label">{{ card.label }}</span>
|
||||
<strong class="metric-value">{{ card.value }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="audit-grid">
|
||||
<article class="ranking-card">
|
||||
<div class="section-head">
|
||||
<div class="section-title-group">
|
||||
<h4>IP访问排行</h4>
|
||||
<p>按来源 IP 排序,账号作为辅助定位</p>
|
||||
</div>
|
||||
<el-tag effect="plain" size="small" type="info">TOP {{ userStats.length }}</el-tag>
|
||||
</div>
|
||||
<div v-if="userStats.length" class="user-rank-list">
|
||||
<div v-for="(user, index) in userStats" :key="`${user.sample_ip_address}-${user.user_id}-${user.role}`" class="user-rank-row">
|
||||
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
|
||||
<div class="rank-user">
|
||||
<strong>{{ user.sample_ip_address || "未知 IP" }}</strong>
|
||||
<small>{{ user.user_name }} / {{ roleLabel(user.role) }}</small>
|
||||
<span class="rank-location">{{ user.primary_location || "未知属地" }}</span>
|
||||
</div>
|
||||
<div class="rank-count">
|
||||
<strong>{{ user.total_count }}</strong>
|
||||
<small :class="{ 'denied-highlight': user.denied_count > 0 }">{{ user.denied_count }} 拒绝</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无用户行为数据" :image-size="72" />
|
||||
</article>
|
||||
|
||||
<article class="log-launcher-card">
|
||||
<div class="section-head">
|
||||
<div class="section-title-group">
|
||||
<h4>日志审计</h4>
|
||||
<p>查看详细的接口访问与安全事件记录</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-actions">
|
||||
<div class="log-action-item log-action-interface" @click="openInterfaceLogDialog">
|
||||
<div class="log-action-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
</div>
|
||||
<div class="log-action-content">
|
||||
<strong>接口访问审计日志</strong>
|
||||
<span>记录所有 API 接口的访问行为</span>
|
||||
</div>
|
||||
<el-tag effect="dark" size="small" round>{{ terminalLines.length }} 条</el-tag>
|
||||
</div>
|
||||
<div class="log-action-item log-action-security" @click="openSecurityLogDialog">
|
||||
<div class="log-action-icon security">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
</div>
|
||||
<div class="log-action-content">
|
||||
<strong>安全事件审计日志</strong>
|
||||
<span>异常访问与安全威胁事件</span>
|
||||
</div>
|
||||
<el-tag v-if="securitySummary.error_count > 0" effect="dark" size="small" round type="danger">{{ securitySummary.error_count }} 异常</el-tag>
|
||||
<el-tag v-else effect="plain" size="small" round type="success">正常</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="interfaceLogDialogVisible" title="接口访问审计日志" width="82%" @opened="scrollTerminalToBottom">
|
||||
<template #header>
|
||||
<div class="dialog-head">
|
||||
<strong>接口访问审计日志</strong>
|
||||
<div class="dialog-actions">
|
||||
<el-tag effect="plain" type="info">最近 {{ terminalLines.length }} 条</el-tag>
|
||||
<el-button size="small" type="primary" @click="downloadInterfaceLog">下载日志</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="terminalWindowRef" v-loading="loading" class="terminal-window dialog-terminal-window">
|
||||
<pre v-if="terminalLines.length" class="terminal-lines"><code>{{ terminalLines.join("\n") }}</code></pre>
|
||||
<el-empty v-else description="暂无访问流水" :image-size="72" />
|
||||
</div>
|
||||
<div class="logs-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="securityLogDialogVisible" title="安全事件审计日志" width="82%" @opened="scrollSecurityTerminalToBottom">
|
||||
<template #header>
|
||||
<div class="dialog-head">
|
||||
<strong>安全事件审计日志</strong>
|
||||
<div class="dialog-actions">
|
||||
<el-tag effect="plain" type="danger">异常 {{ securitySummary.error_count }}</el-tag>
|
||||
<el-button size="small" type="primary" @click="downloadSecurityLog">下载日志</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="securityTerminalWindowRef" v-loading="securityLoading" class="terminal-window dialog-terminal-window">
|
||||
<pre v-if="securityTerminalLines.length" class="terminal-lines"><code>{{ securityTerminalLines.join("\n") }}</code></pre>
|
||||
<el-empty v-else description="暂无异常安全访问" :image-size="72" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
import { fetchAccessLogs, fetchSecurityAccessLogs } from "@/api/projectPermissions";
|
||||
import type {
|
||||
AccessLogItem,
|
||||
AccessLogsSummary,
|
||||
AccessLogUserStat,
|
||||
SecurityAccessLogItem,
|
||||
SecurityAccessLogsResponse,
|
||||
} from "@/types/api";
|
||||
import { Search as SearchIcon } from "@element-plus/icons-vue";
|
||||
|
||||
const MetricIconVisit = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("path", { d: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" }),
|
||||
h("circle", { cx: "12", cy: "12", r: "3" }),
|
||||
]);
|
||||
const MetricIconUsers = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("path", { d: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" }),
|
||||
h("circle", { cx: "9", cy: "7", r: "4" }),
|
||||
h("path", { d: "M23 21v-2a4 4 0 0 0-3-3.87" }),
|
||||
h("path", { d: "M16 3.13a4 4 0 0 1 0 7.75" }),
|
||||
]);
|
||||
const MetricIconDeny = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("circle", { cx: "12", cy: "12", r: "10" }),
|
||||
h("line", { x1: "4.93", y1: "4.93", x2: "19.07", y2: "19.07" }),
|
||||
]);
|
||||
const MetricIconSpeed = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("circle", { cx: "12", cy: "12", r: "10" }),
|
||||
h("polyline", { points: "12 6 12 12 16 14" }),
|
||||
]);
|
||||
|
||||
const REALTIME_POLL_INTERVAL_MS = 3000;
|
||||
|
||||
const emptySummary: AccessLogsSummary = {
|
||||
total_count: 0,
|
||||
unique_user_count: 0,
|
||||
unique_ip_count: 0,
|
||||
denied_count: 0,
|
||||
avg_elapsed_ms: 0,
|
||||
};
|
||||
|
||||
const emptySecuritySummary: SecurityAccessLogsResponse["summary"] = {
|
||||
total_count: 0,
|
||||
anonymous_count: 0,
|
||||
invalid_token_count: 0,
|
||||
error_count: 0,
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const securityLoading = ref(false);
|
||||
const logs = ref<AccessLogItem[]>([]);
|
||||
const securityLogs = ref<SecurityAccessLogItem[]>([]);
|
||||
const userStats = ref<AccessLogUserStat[]>([]);
|
||||
const summary = ref<AccessLogsSummary>(emptySummary);
|
||||
const securitySummary = ref<SecurityAccessLogsResponse["summary"]>(emptySecuritySummary);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = 50;
|
||||
const dateRange = ref<[Date, Date] | null>(null);
|
||||
const keyword = ref("");
|
||||
const interfaceLogDialogVisible = ref(false);
|
||||
const securityLogDialogVisible = ref(false);
|
||||
const terminalWindowRef = ref<HTMLElement | null>(null);
|
||||
const securityTerminalWindowRef = ref<HTMLElement | null>(null);
|
||||
const realtimeTimer = ref<number | null>(null);
|
||||
|
||||
const filters = reactive({
|
||||
role: undefined as string | undefined,
|
||||
allowed: undefined as boolean | undefined,
|
||||
});
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
ADMIN: "管理员",
|
||||
PM: "项目负责人",
|
||||
CRA: "CRA",
|
||||
PV: "PV",
|
||||
MEDICAL_REVIEW: "医学审核",
|
||||
IMP: "药品管理",
|
||||
QA: "QA",
|
||||
};
|
||||
|
||||
const SECURITY_AUTH_LABELS: Record<string, string> = {
|
||||
ANONYMOUS: "匿名",
|
||||
INVALID_TOKEN: "无效令牌",
|
||||
AUTHENTICATED: "已认证",
|
||||
};
|
||||
|
||||
const formatTerminalTime = (iso: string) => {
|
||||
const date = new Date(iso);
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
};
|
||||
|
||||
const roleLabel = (role: string) => ROLE_LABELS[role] || role;
|
||||
|
||||
const formatIpLocation = (row: AccessLogItem) => {
|
||||
const parts = [row.ip_province, row.ip_city, row.ip_isp].filter(Boolean);
|
||||
return parts.length ? parts.join(" / ") : row.ip_location;
|
||||
};
|
||||
|
||||
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
|
||||
|
||||
const metricCards = computed(() => [
|
||||
{
|
||||
label: "访问次数",
|
||||
value: formatNumber(summary.value.total_count),
|
||||
tone: "blue",
|
||||
icon: MetricIconVisit,
|
||||
},
|
||||
{
|
||||
label: "访问用户数",
|
||||
value: formatNumber(summary.value.unique_user_count),
|
||||
tone: "cyan",
|
||||
icon: MetricIconUsers,
|
||||
},
|
||||
{
|
||||
label: "拒绝次数",
|
||||
value: formatNumber(summary.value.denied_count),
|
||||
tone: summary.value.denied_count > 0 ? "danger" : "violet",
|
||||
icon: MetricIconDeny,
|
||||
},
|
||||
{
|
||||
label: "平均耗时",
|
||||
value: `${summary.value.avg_elapsed_ms.toFixed(1)}ms`,
|
||||
tone: "indigo",
|
||||
icon: MetricIconSpeed,
|
||||
},
|
||||
]);
|
||||
|
||||
const buildTerminalLine = (row: AccessLogItem) => {
|
||||
const result = row.allowed ? "ALLOW" : "DENY ";
|
||||
const user = row.user_name || "未知用户";
|
||||
const ip = row.ip_address || "-";
|
||||
const location = formatIpLocation(row) || "未知属地";
|
||||
return `${formatTerminalTime(row.created_at)} [${result}] role=${roleLabel(row.role)} user=${user} ip=${ip} loc=${location} endpoint=${row.endpoint_key} cost=${row.elapsed_ms.toFixed(1)}ms`;
|
||||
};
|
||||
|
||||
const buildSecurityTerminalLine = (row: SecurityAccessLogItem) => {
|
||||
const ip = row.client_ip || "未知 IP";
|
||||
const account = row.account_label || "未知账号";
|
||||
const auth = SECURITY_AUTH_LABELS[row.auth_status] || row.auth_status;
|
||||
const userAgent = row.user_agent || "-";
|
||||
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
|
||||
};
|
||||
|
||||
const terminalLogRows = computed(() =>
|
||||
[...logs.value].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()),
|
||||
);
|
||||
|
||||
const terminalLines = computed(() => {
|
||||
const token = keyword.value.trim().toLowerCase();
|
||||
const lines = terminalLogRows.value.map(buildTerminalLine);
|
||||
if (!token) return lines;
|
||||
return lines.filter((line) => line.toLowerCase().includes(token));
|
||||
});
|
||||
|
||||
const securityTerminalLines = computed(() => {
|
||||
const token = keyword.value.trim().toLowerCase();
|
||||
const lines = [...securityLogs.value]
|
||||
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
||||
.map(buildSecurityTerminalLine);
|
||||
if (!token) return lines;
|
||||
return lines.filter((line) => line.toLowerCase().includes(token));
|
||||
});
|
||||
|
||||
const onFilterChange = () => {
|
||||
page.value = 1;
|
||||
loadData();
|
||||
};
|
||||
|
||||
const buildQueryParams = () => {
|
||||
const params: Record<string, any> = {
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
};
|
||||
if (filters.role) params.role = filters.role;
|
||||
if (filters.allowed !== undefined) params.allowed = filters.allowed;
|
||||
if (dateRange.value) {
|
||||
params.start_time = dateRange.value[0].toISOString();
|
||||
params.end_time = dateRange.value[1].toISOString();
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
const scrollTerminalToBottom = () => {
|
||||
nextTick(() => {
|
||||
const terminal = terminalWindowRef.value;
|
||||
if (!terminal) return;
|
||||
terminal.scrollTop = terminal.scrollHeight;
|
||||
});
|
||||
};
|
||||
|
||||
const scrollSecurityTerminalToBottom = () => {
|
||||
nextTick(() => {
|
||||
const terminal = securityTerminalWindowRef.value;
|
||||
if (!terminal) return;
|
||||
terminal.scrollTop = terminal.scrollHeight;
|
||||
});
|
||||
};
|
||||
|
||||
const loadData = async (options: { silent?: boolean } = {}) => {
|
||||
if (!options.silent) loading.value = true;
|
||||
try {
|
||||
const res = await fetchAccessLogs(buildQueryParams());
|
||||
logs.value = res.data.items;
|
||||
total.value = res.data.total;
|
||||
summary.value = res.data.summary || emptySummary;
|
||||
userStats.value = res.data.user_stats || [];
|
||||
if (interfaceLogDialogVisible.value) scrollTerminalToBottom();
|
||||
} catch {
|
||||
if (options.silent) return;
|
||||
logs.value = [];
|
||||
total.value = 0;
|
||||
summary.value = emptySummary;
|
||||
userStats.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadSecurityData = async (options: { silent?: boolean } = {}) => {
|
||||
if (!options.silent) securityLoading.value = true;
|
||||
try {
|
||||
const res = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: 80 });
|
||||
securityLogs.value = res.data.items;
|
||||
securitySummary.value = res.data.summary || emptySecuritySummary;
|
||||
if (securityLogDialogVisible.value) scrollSecurityTerminalToBottom();
|
||||
} catch {
|
||||
if (options.silent) return;
|
||||
securityLogs.value = [];
|
||||
securitySummary.value = emptySecuritySummary;
|
||||
} finally {
|
||||
securityLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const stopRealtimePolling = () => {
|
||||
if (!realtimeTimer.value) return;
|
||||
window.clearInterval(realtimeTimer.value);
|
||||
realtimeTimer.value = null;
|
||||
};
|
||||
|
||||
const startRealtimePolling = () => {
|
||||
stopRealtimePolling();
|
||||
realtimeTimer.value = window.setInterval(() => {
|
||||
loadData({ silent: true });
|
||||
loadSecurityData({ silent: true });
|
||||
}, REALTIME_POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
loadData();
|
||||
loadSecurityData();
|
||||
};
|
||||
|
||||
const openInterfaceLogDialog = () => {
|
||||
interfaceLogDialogVisible.value = true;
|
||||
loadData({ silent: true });
|
||||
scrollTerminalToBottom();
|
||||
};
|
||||
|
||||
const openSecurityLogDialog = () => {
|
||||
securityLogDialogVisible.value = true;
|
||||
loadSecurityData({ silent: true });
|
||||
scrollSecurityTerminalToBottom();
|
||||
};
|
||||
|
||||
const downloadLogFile = (fileName: string, lines: string[]) => {
|
||||
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const downloadInterfaceLog = () => {
|
||||
downloadLogFile("interface-access-audit.log", terminalLines.value);
|
||||
};
|
||||
|
||||
const downloadSecurityLog = () => {
|
||||
downloadLogFile("security-event-audit.log", securityTerminalLines.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
startRealtimePolling();
|
||||
});
|
||||
|
||||
onBeforeUnmount(stopRealtimePolling);
|
||||
|
||||
defineExpose({ refresh });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.access-logs {
|
||||
--audit-ink: #1a2332;
|
||||
--audit-muted: #64748b;
|
||||
--audit-border: #e2e8f0;
|
||||
--audit-blue: #3b82f6;
|
||||
--audit-cyan: #06b6d4;
|
||||
--audit-violet: #8b5cf6;
|
||||
--audit-danger: #ef4444;
|
||||
--audit-indigo: #6366f1;
|
||||
--card-radius: 16px;
|
||||
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.audit-filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
background: #fff;
|
||||
border-radius: var(--card-radius);
|
||||
border: 1px solid var(--audit-border);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.audit-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 64px;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--card-radius);
|
||||
background: #fff;
|
||||
border: 1px solid var(--audit-border);
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.metric-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
border-radius: var(--card-radius) var(--card-radius) 0 0;
|
||||
}
|
||||
|
||||
.metric-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
|
||||
.metric-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
|
||||
.metric-card.violet::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
|
||||
.metric-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
|
||||
.metric-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
|
||||
|
||||
.metric-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metric-icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.metric-card.blue .metric-icon { background: #eff6ff; color: #3b82f6; }
|
||||
.metric-card.cyan .metric-icon { background: #ecfeff; color: #06b6d4; }
|
||||
.metric-card.violet .metric-icon { background: #f5f3ff; color: #8b5cf6; }
|
||||
.metric-card.danger .metric-icon { background: #fef2f2; color: #ef4444; }
|
||||
.metric-card.indigo .metric-icon { background: #eef2ff; color: #6366f1; }
|
||||
|
||||
.metric-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--audit-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: var(--audit-ink);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.audit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 380px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ranking-card,
|
||||
.log-launcher-card {
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--audit-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: #fff;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title-group h4 {
|
||||
margin: 0;
|
||||
color: var(--audit-ink);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-title-group p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--audit-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-rank-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.user-rank-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px 10px;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.user-rank-row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.rank-no {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rank-no.rank-top {
|
||||
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.rank-user,
|
||||
.rank-count {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.rank-user strong {
|
||||
color: var(--audit-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rank-user small {
|
||||
color: var(--audit-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rank-location {
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rank-count {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.rank-count strong {
|
||||
color: var(--audit-ink);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rank-count small {
|
||||
color: var(--audit-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rank-count .denied-highlight {
|
||||
color: var(--audit-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.log-action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.log-action-interface {
|
||||
background: linear-gradient(135deg, #f0f9ff, #e0f2fe);
|
||||
border: 1px solid #bae6fd;
|
||||
}
|
||||
|
||||
.log-action-interface:hover {
|
||||
background: linear-gradient(135deg, #e0f2fe, #bae6fd);
|
||||
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.log-action-security {
|
||||
background: linear-gradient(135deg, #fef2f2, #fee2e2);
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.log-action-security:hover {
|
||||
background: linear-gradient(135deg, #fee2e2, #fecaca);
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.log-action-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.log-action-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #0ea5e9;
|
||||
}
|
||||
|
||||
.log-action-icon.security svg {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.log-action-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.log-action-content strong {
|
||||
display: block;
|
||||
color: var(--audit-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-action-content span {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--audit-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dialog-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-head strong {
|
||||
color: var(--audit-ink);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.terminal-window {
|
||||
min-height: 360px;
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #1e293b, #0f172a);
|
||||
}
|
||||
|
||||
.dialog-terminal-window {
|
||||
height: 62vh;
|
||||
min-height: 360px;
|
||||
max-height: 68vh;
|
||||
}
|
||||
|
||||
.terminal-lines {
|
||||
margin: 0;
|
||||
color: #e2e8f0;
|
||||
font-family: "JetBrains Mono", "SFMono-Regular", "Cascadia Code", "Menlo", monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.9;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.logs-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.audit-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.audit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.audit-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionIpLocations.vue"), "utf8");
|
||||
const readMapSource = () => readFileSync(resolve(__dirname, "./chinaProvinceMap.ts"), "utf8");
|
||||
|
||||
describe("PermissionIpLocations", () => {
|
||||
it("renders IP location visualization without duplicate region detail table", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("fetchIpLocations");
|
||||
expect(source).toContain("unique_ip_count");
|
||||
expect(source).not.toContain("来源地域明细");
|
||||
expect(source).not.toContain("table-card");
|
||||
expect(source).not.toContain("<el-table :data=\"items\"");
|
||||
});
|
||||
|
||||
it("renders an interactive China map and primary metric cards", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("VChart");
|
||||
expect(source).toContain("chinaMapOption");
|
||||
expect(source).toContain("用户分布图");
|
||||
expect(source).toContain("访问次数");
|
||||
expect(source).toContain("访问用户数");
|
||||
expect(source).toContain("来源 IP 数");
|
||||
expect(source).toContain("summary");
|
||||
});
|
||||
|
||||
it("uses the standard geojson.cn China map data instead of generated rectangles", () => {
|
||||
const source = readSource();
|
||||
const mapSource = readMapSource();
|
||||
|
||||
expect(source).toContain("../assets/china.json");
|
||||
expect(mapSource).toContain("geojson.cn/api/china/1.6.3/china.json");
|
||||
expect(mapSource).not.toContain("ProvinceBox");
|
||||
expect(mapSource).not.toContain("provinceBoxes");
|
||||
});
|
||||
|
||||
it("hides the map legend and disables map zoom interactions", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).not.toContain("visualMap:");
|
||||
expect(source).toContain("roam: false");
|
||||
expect(source).not.toContain("scaleLimit");
|
||||
});
|
||||
|
||||
it("uses narrow metric cards without helper subtitles", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("min-height: 64px");
|
||||
expect(source).toContain("padding: 10px 16px");
|
||||
expect(source).toContain("right: -46px");
|
||||
expect(source).toContain("bottom: -52px");
|
||||
expect(source).toContain("border-radius: 18px");
|
||||
expect(source).not.toContain("<small>{{ card.hint }}</small>");
|
||||
expect(source).not.toContain("hint:");
|
||||
expect(source).not.toContain("权限检查总量");
|
||||
});
|
||||
|
||||
it("removes the duplicated hero copy above the map", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("ip-locations-toolbar");
|
||||
expect(source).not.toContain("权限监控 / IP属地");
|
||||
expect(source).not.toContain("按访问日志聚合省市、用户与来源 IP");
|
||||
expect(source).not.toContain("悬停省份查看访问次数、访问用户数和来源 IP 数");
|
||||
expect(source).not.toContain("hero-desc");
|
||||
expect(source).not.toContain("eyebrow");
|
||||
});
|
||||
|
||||
it("places the period filter toolbar on the left", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain(".ip-hero");
|
||||
expect(source).toContain("justify-content: flex-start");
|
||||
expect(source).not.toContain("justify-content: flex-end");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,546 @@
|
||||
<template>
|
||||
<div class="ip-locations">
|
||||
<section class="ip-hero">
|
||||
<div class="ip-locations-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-title">IP 属地分析</span>
|
||||
<span class="toolbar-desc">查看访问来源的地理分布</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-radio-group v-model="days" size="small" @change="loadData">
|
||||
<el-radio-button :value="1">24小时</el-radio-button>
|
||||
<el-radio-button :value="7">7天</el-radio-button>
|
||||
<el-radio-button :value="30">30天</el-radio-button>
|
||||
<el-radio-button :value="90">90天</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button :loading="loading" size="small" @click="loadData">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; margin-right: 4px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid">
|
||||
<article v-for="card in metricCards" :key="card.label" class="metric-card" :class="card.tone">
|
||||
<div class="metric-icon">
|
||||
<component :is="card.icon" />
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<span class="metric-label">{{ card.label }}</span>
|
||||
<strong class="metric-value">{{ card.value }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-loading="loading" class="distribution-card">
|
||||
<div class="map-panel">
|
||||
<div class="section-head">
|
||||
<div class="section-title-group">
|
||||
<h4>中国地图</h4>
|
||||
<p>访问来源地理分布热力图</p>
|
||||
</div>
|
||||
<el-tag type="info" effect="plain" round size="small">{{ currentPeriodLabel }}</el-tag>
|
||||
</div>
|
||||
<v-chart :option="chinaMapOption" autoresize class="china-map" />
|
||||
</div>
|
||||
|
||||
<aside class="rank-panel">
|
||||
<div class="section-head compact">
|
||||
<div class="section-title-group">
|
||||
<h4>热点属地</h4>
|
||||
<p>按访问次数排序</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="topRegions.length" class="region-list">
|
||||
<div v-for="region in topRegions" :key="region.key" class="region-row">
|
||||
<span class="rank" :class="{ top: Number(region.rank) <= 3 }">{{ region.rank }}</span>
|
||||
<span class="region-name">{{ region.name }}</span>
|
||||
<span class="region-value">{{ region.total_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无属地数据" :image-size="72" />
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref } from "vue";
|
||||
import VChart from "vue-echarts";
|
||||
import { registerMap, use } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { MapChart } from "echarts/charts";
|
||||
import { TooltipComponent } from "echarts/components";
|
||||
import chinaMapGeoJson from "../assets/china.json";
|
||||
import { fetchIpLocations } from "../api/projectPermissions";
|
||||
import type { IpLocationsResponse, IpLocationStatItem } from "../types/api";
|
||||
import { normalizeProvinceName } from "./chinaProvinceMap";
|
||||
|
||||
use([CanvasRenderer, MapChart, TooltipComponent]);
|
||||
registerMap("ctms-china", chinaMapGeoJson as any);
|
||||
|
||||
const IconGlobe = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("circle", { cx: "12", cy: "12", r: "10" }),
|
||||
h("line", { x1: "2", y1: "12", x2: "22", y2: "12" }),
|
||||
h("path", { d: "M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" }),
|
||||
]);
|
||||
const IconUsers = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("path", { d: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" }),
|
||||
h("circle", { cx: "9", cy: "7", r: "4" }),
|
||||
h("path", { d: "M23 21v-2a4 4 0 0 0-3-3.87" }),
|
||||
h("path", { d: "M16 3.13a4 4 0 0 1 0 7.75" }),
|
||||
]);
|
||||
const IconWifi = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("path", { d: "M5 12.55a11 11 0 0 1 14.08 0" }),
|
||||
h("path", { d: "M1.42 9a16 16 0 0 1 21.16 0" }),
|
||||
h("path", { d: "M8.53 16.11a6 6 0 0 1 6.95 0" }),
|
||||
h("line", { x1: "12", y1: "20", x2: "12.01", y2: "20" }),
|
||||
]);
|
||||
const IconShield = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("path", { d: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" }),
|
||||
]);
|
||||
|
||||
const loading = ref(false);
|
||||
const days = ref(7);
|
||||
const items = ref<IpLocationStatItem[]>([]);
|
||||
const summary = ref<IpLocationsResponse["summary"] | null>(null);
|
||||
|
||||
const periodLabels: Record<number, string> = {
|
||||
1: "近 24 小时",
|
||||
7: "近 7 天",
|
||||
30: "近 30 天",
|
||||
90: "近 90 天",
|
||||
};
|
||||
|
||||
const currentPeriodLabel = computed(() => periodLabels[days.value] || `近 ${days.value} 天`);
|
||||
|
||||
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
|
||||
|
||||
const formatLocation = (row: IpLocationStatItem) => {
|
||||
const parts = [row.province, row.city].filter(Boolean);
|
||||
return parts.length ? parts.join(" / ") : row.location;
|
||||
};
|
||||
|
||||
const fallbackSummary = computed<IpLocationsResponse["summary"]>(() => ({
|
||||
total_count: items.value.reduce((sum, row) => sum + row.total_count, 0),
|
||||
allowed_count: items.value.reduce((sum, row) => sum + row.allowed_count, 0),
|
||||
denied_count: items.value.reduce((sum, row) => sum + row.denied_count, 0),
|
||||
unique_ip_count: items.value.reduce((sum, row) => sum + row.unique_ip_count, 0),
|
||||
unique_user_count: items.value.reduce((sum, row) => sum + (row.unique_user_count || 0), 0),
|
||||
}));
|
||||
|
||||
const activeSummary = computed(() => summary.value || fallbackSummary.value);
|
||||
|
||||
const deniedRate = computed(() => {
|
||||
const total = activeSummary.value.total_count;
|
||||
if (!total) return 0;
|
||||
return Math.round((activeSummary.value.denied_count / total) * 1000) / 10;
|
||||
});
|
||||
|
||||
const metricCards = computed(() => [
|
||||
{
|
||||
label: "访问次数",
|
||||
value: formatNumber(activeSummary.value.total_count),
|
||||
tone: "blue",
|
||||
icon: IconGlobe,
|
||||
},
|
||||
{
|
||||
label: "访问用户数",
|
||||
value: formatNumber(activeSummary.value.unique_user_count),
|
||||
tone: "cyan",
|
||||
icon: IconUsers,
|
||||
},
|
||||
{
|
||||
label: "来源 IP 数",
|
||||
value: formatNumber(activeSummary.value.unique_ip_count),
|
||||
tone: "indigo",
|
||||
icon: IconWifi,
|
||||
},
|
||||
{
|
||||
label: "拒绝占比",
|
||||
value: `${deniedRate.value}%`,
|
||||
tone: deniedRate.value > 10 ? "danger" : "violet",
|
||||
icon: IconShield,
|
||||
},
|
||||
]);
|
||||
|
||||
const provinceData = computed(() => {
|
||||
const provinceMap = new Map<string, IpLocationStatItem & { name: string }>();
|
||||
items.value.forEach((item) => {
|
||||
const name = normalizeProvinceName(item.province || item.city);
|
||||
if (!name || item.country !== "中国") return;
|
||||
const current = provinceMap.get(name);
|
||||
if (!current) {
|
||||
provinceMap.set(name, { ...item, name });
|
||||
return;
|
||||
}
|
||||
current.total_count += item.total_count;
|
||||
current.allowed_count += item.allowed_count;
|
||||
current.denied_count += item.denied_count;
|
||||
current.unique_ip_count += item.unique_ip_count;
|
||||
current.unique_user_count += item.unique_user_count || 0;
|
||||
});
|
||||
return Array.from(provinceMap.values());
|
||||
});
|
||||
|
||||
const chinaMapOption = computed(() => ({
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
formatter: (params: any) => {
|
||||
const data = params.data;
|
||||
if (!data) return `${params.name}<br/>暂无访问数据`;
|
||||
return [
|
||||
`${params.name}`,
|
||||
`访问次数:${formatNumber(data.value)}`,
|
||||
`访问用户数:${formatNumber(data.unique_user_count || 0)}`,
|
||||
`来源 IP 数:${formatNumber(data.unique_ip_count || 0)}`,
|
||||
`拒绝次数:${formatNumber(data.denied_count || 0)}`,
|
||||
].join("<br/>");
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "用户分布图",
|
||||
type: "map",
|
||||
map: "ctms-china",
|
||||
roam: false,
|
||||
zoom: 1.08,
|
||||
label: { show: false },
|
||||
itemStyle: {
|
||||
areaColor: "#edf3ff",
|
||||
borderColor: "#ffffff",
|
||||
borderWidth: 1,
|
||||
},
|
||||
emphasis: {
|
||||
label: { show: true, color: "#0f172a", fontSize: 12, fontWeight: 700 },
|
||||
itemStyle: { areaColor: "#ffb86b", shadowBlur: 12, shadowColor: "rgba(245, 158, 11, 0.28)" },
|
||||
},
|
||||
data: provinceData.value.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.total_count,
|
||||
unique_user_count: item.unique_user_count,
|
||||
unique_ip_count: item.unique_ip_count,
|
||||
denied_count: item.denied_count,
|
||||
})),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const topRegions = computed(() =>
|
||||
[...items.value]
|
||||
.sort((a, b) => b.total_count - a.total_count)
|
||||
.slice(0, 6)
|
||||
.map((item, index) => ({
|
||||
key: `${item.country}-${item.province}-${item.city}-${item.isp}`,
|
||||
rank: String(index + 1).padStart(2, "0"),
|
||||
name: formatLocation(item),
|
||||
total_count: formatNumber(item.total_count),
|
||||
})),
|
||||
);
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetchIpLocations({ days: days.value, limit: 50 });
|
||||
items.value = res.data.items;
|
||||
summary.value = res.data.summary || null;
|
||||
} catch {
|
||||
items.value = [];
|
||||
summary.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
defineExpose({ refresh: loadData });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ip-locations {
|
||||
--ip-ink: #1a2332;
|
||||
--ip-muted: #64748b;
|
||||
--ip-border: #e2e8f0;
|
||||
--ip-card: #ffffff;
|
||||
--ip-blue: #3b82f6;
|
||||
--ip-cyan: #06b6d4;
|
||||
--ip-violet: #8b5cf6;
|
||||
--ip-danger: #ef4444;
|
||||
--ip-indigo: #6366f1;
|
||||
--ip-radius: 16px;
|
||||
--ip-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ip-hero {
|
||||
background: var(--ip-card);
|
||||
border: 1px solid var(--ip-border);
|
||||
border-radius: var(--ip-radius);
|
||||
box-shadow: var(--ip-shadow);
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.ip-locations-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ip-ink);
|
||||
}
|
||||
|
||||
.toolbar-desc {
|
||||
font-size: 12px;
|
||||
color: var(--ip-muted);
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 64px;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--ip-radius);
|
||||
background: #fff;
|
||||
border: 1px solid var(--ip-border);
|
||||
box-shadow: var(--ip-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.metric-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -46px;
|
||||
bottom: -52px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 18px;
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
transform: rotate(18deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.metric-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
border-radius: var(--ip-radius) var(--ip-radius) 0 0;
|
||||
}
|
||||
|
||||
.metric-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
|
||||
.metric-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
|
||||
.metric-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
|
||||
.metric-card.violet::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
|
||||
.metric-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
|
||||
|
||||
.metric-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metric-icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.metric-card.blue .metric-icon { background: #eff6ff; color: #3b82f6; }
|
||||
.metric-card.cyan .metric-icon { background: #ecfeff; color: #06b6d4; }
|
||||
.metric-card.indigo .metric-icon { background: #eef2ff; color: #6366f1; }
|
||||
.metric-card.violet .metric-icon { background: #f5f3ff; color: #8b5cf6; }
|
||||
.metric-card.danger .metric-icon { background: #fef2f2; color: #ef4444; }
|
||||
|
||||
.metric-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--ip-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: var(--ip-ink);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.distribution-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 14px;
|
||||
background: var(--ip-card);
|
||||
border: 1px solid var(--ip-border);
|
||||
border-radius: var(--ip-radius);
|
||||
box-shadow: var(--ip-shadow);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.map-panel,
|
||||
.rank-panel {
|
||||
min-width: 0;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #fafcff 0%, #ffffff 100%);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-head.compact {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title-group h4 {
|
||||
margin: 0;
|
||||
color: var(--ip-ink);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-title-group p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--ip-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.china-map {
|
||||
width: 100%;
|
||||
height: 430px;
|
||||
}
|
||||
|
||||
.region-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.region-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #f1f5f9;
|
||||
color: var(--ip-ink);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.region-row:hover {
|
||||
background: #f8fafc;
|
||||
border-color: var(--ip-border);
|
||||
}
|
||||
|
||||
.rank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rank.top {
|
||||
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.region-name {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.region-value {
|
||||
color: var(--ip-blue);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.distribution-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ip-locations-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.china-map {
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,82 +1,51 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
||||
import type { PermissionMetricsResponse, HealthResponse, AlertsResponse } from "@/types/api";
|
||||
|
||||
vi.mock("@/api/projectPermissions", () => ({
|
||||
fetchPermissionMetrics: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
check_metrics: {
|
||||
total_checks: 1000, allowed_checks: 950, denied_checks: 50,
|
||||
total_time: 5.234, min_time: 0.001, max_time: 0.05, avg_time: 0.005,
|
||||
allow_rate: 95.0, deny_rate: 5.0, error_rate: 0.1, errors: 1,
|
||||
},
|
||||
cache_metrics: {
|
||||
total_accesses: 1000, cache_hits: 850, cache_misses: 150,
|
||||
cache_invalidations: 5, hit_rate: 85.0, miss_rate: 15.0,
|
||||
},
|
||||
uptime_seconds: 3600,
|
||||
},
|
||||
}),
|
||||
fetchPermissionHealth: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
status: "healthy", health_score: 95, issues: [],
|
||||
metrics: {}, cache_stats: { cache_items: { total_count: 60 }, cache_metrics: {} },
|
||||
},
|
||||
}),
|
||||
fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }),
|
||||
fetchTopDenied: vi.fn().mockResolvedValue({ data: { items: [] } }),
|
||||
fetchStatsSummary: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
total_logs: 5000,
|
||||
today: { total_checks: 200, allowed_checks: 190, denied_checks: 10, avg_elapsed_ms: 3.5, max_elapsed_ms: 45, allow_rate: 95, deny_rate: 5 },
|
||||
last_hour: { total_checks: 30, allowed_checks: 28, denied_checks: 2, requests_per_minute: 0.5 },
|
||||
},
|
||||
}),
|
||||
fetchPermissionTrends: vi.fn().mockResolvedValue({ data: { period: "24h", data_points: [] } }),
|
||||
fetchAccessLogs: vi.fn().mockResolvedValue({ data: { total: 0, page: 1, page_size: 50, items: [] } }),
|
||||
}));
|
||||
|
||||
describe("PermissionMonitoring.vue", () => {
|
||||
const mockMetrics: PermissionMetricsResponse = {
|
||||
check_metrics: {
|
||||
total_checks: 1000,
|
||||
allowed_checks: 950,
|
||||
denied_checks: 50,
|
||||
total_time: 5.234,
|
||||
min_time: 0.001,
|
||||
max_time: 0.05,
|
||||
avg_time: 0.005,
|
||||
allow_rate: 95.0,
|
||||
deny_rate: 5.0,
|
||||
error_rate: 0.1,
|
||||
errors: 1,
|
||||
},
|
||||
cache_metrics: {
|
||||
total_accesses: 1000,
|
||||
cache_hits: 850,
|
||||
cache_misses: 150,
|
||||
cache_invalidations: 5,
|
||||
hit_rate: 85.0,
|
||||
miss_rate: 15.0,
|
||||
},
|
||||
uptime_seconds: 3600,
|
||||
};
|
||||
|
||||
const mockHealth: HealthResponse = {
|
||||
status: "healthy",
|
||||
health_score: 95,
|
||||
issues: [],
|
||||
metrics: mockMetrics,
|
||||
cache_stats: {
|
||||
cache_items: {
|
||||
project_permissions_count: 10,
|
||||
member_role_count: 50,
|
||||
total_count: 60,
|
||||
},
|
||||
cache_metrics: mockMetrics.cache_metrics,
|
||||
},
|
||||
};
|
||||
|
||||
const mockAlerts: AlertsResponse = {
|
||||
total: 2,
|
||||
alerts: [
|
||||
{
|
||||
timestamp: 1715692800.123,
|
||||
level: "warning",
|
||||
type: "slow_permission_check",
|
||||
message: "权限检查耗时过长: 52.34ms",
|
||||
data: { elapsed_time: 0.05234 },
|
||||
},
|
||||
{
|
||||
timestamp: 1715692799.456,
|
||||
level: "error",
|
||||
type: "permission_check_error",
|
||||
message: "权限检查出错: database connection timeout",
|
||||
data: { error: "database connection timeout" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("renders monitoring dashboard", () => {
|
||||
it("renders monitoring dashboard with tabs", () => {
|
||||
const wrapper = mount(PermissionMonitoring, {
|
||||
props: {
|
||||
metrics: mockMetrics,
|
||||
health: mockHealth,
|
||||
alerts: mockAlerts,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTag: false,
|
||||
ElProgress: false,
|
||||
ElTable: false,
|
||||
ElEmpty: false,
|
||||
ElTabs: false,
|
||||
ElTabPane: false,
|
||||
PermissionTrendCharts: true,
|
||||
PermissionAccessLogs: true,
|
||||
PermissionIpLocations: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -84,115 +53,19 @@ describe("PermissionMonitoring.vue", () => {
|
||||
expect(wrapper.find(".permission-monitoring").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("displays health status", () => {
|
||||
it("exposes refresh method", () => {
|
||||
const wrapper = mount(PermissionMonitoring, {
|
||||
props: {
|
||||
metrics: mockMetrics,
|
||||
health: mockHealth,
|
||||
alerts: mockAlerts,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTag: false,
|
||||
ElProgress: false,
|
||||
ElTable: false,
|
||||
ElEmpty: false,
|
||||
ElTabs: true,
|
||||
ElTabPane: true,
|
||||
PermissionTrendCharts: true,
|
||||
PermissionAccessLogs: true,
|
||||
PermissionIpLocations: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const healthCard = wrapper.find(".health-card");
|
||||
expect(healthCard.exists()).toBe(true);
|
||||
expect(healthCard.text()).toContain("系统健康状态");
|
||||
});
|
||||
|
||||
it("displays metric cards", () => {
|
||||
const wrapper = mount(PermissionMonitoring, {
|
||||
props: {
|
||||
metrics: mockMetrics,
|
||||
health: mockHealth,
|
||||
alerts: mockAlerts,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTag: false,
|
||||
ElProgress: false,
|
||||
ElTable: false,
|
||||
ElEmpty: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const metricCards = wrapper.findAll(".metric-card");
|
||||
expect(metricCards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("displays cache efficiency", () => {
|
||||
const wrapper = mount(PermissionMonitoring, {
|
||||
props: {
|
||||
metrics: mockMetrics,
|
||||
health: mockHealth,
|
||||
alerts: mockAlerts,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTag: false,
|
||||
ElProgress: false,
|
||||
ElTable: false,
|
||||
ElEmpty: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cacheCard = wrapper.find(".cache-card");
|
||||
expect(cacheCard.exists()).toBe(true);
|
||||
expect(cacheCard.text()).toContain("缓存效率");
|
||||
});
|
||||
|
||||
it("displays alerts list", () => {
|
||||
const wrapper = mount(PermissionMonitoring, {
|
||||
props: {
|
||||
metrics: mockMetrics,
|
||||
health: mockHealth,
|
||||
alerts: mockAlerts,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTag: false,
|
||||
ElProgress: false,
|
||||
ElTable: false,
|
||||
ElEmpty: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const alertsCard = wrapper.find(".alerts-card");
|
||||
expect(alertsCard.exists()).toBe(true);
|
||||
expect(alertsCard.text()).toContain("最近告警");
|
||||
});
|
||||
|
||||
it("emits refresh event when refresh button clicked", async () => {
|
||||
const wrapper = mount(PermissionMonitoring, {
|
||||
props: {
|
||||
metrics: mockMetrics,
|
||||
health: mockHealth,
|
||||
alerts: mockAlerts,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTag: false,
|
||||
ElProgress: false,
|
||||
ElTable: false,
|
||||
ElEmpty: false,
|
||||
ElButton: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const refreshButton = wrapper.find("button");
|
||||
if (refreshButton.exists()) {
|
||||
await refreshButton.trigger("click");
|
||||
expect(wrapper.emitted("refresh")).toBeTruthy();
|
||||
}
|
||||
expect(typeof wrapper.vm.refresh).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,346 +1,704 @@
|
||||
<template>
|
||||
<div class="permission-monitoring">
|
||||
<!-- 系统健康评分 -->
|
||||
<div v-if="health" class="health-card">
|
||||
<div class="health-header">
|
||||
<h3>系统健康状态</h3>
|
||||
<el-tag :type="getHealthType(health.status)">
|
||||
{{ getHealthLabel(health.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="health-score">
|
||||
<div class="score-value">{{ health.health_score }}</div>
|
||||
<div class="score-label">健康分数</div>
|
||||
</div>
|
||||
|
||||
<div v-if="health.issues.length > 0" class="health-issues">
|
||||
<div class="issues-title">发现的问题:</div>
|
||||
<ul>
|
||||
<li v-for="(issue, index) in health.issues" :key="index">{{ issue }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 性能指标卡片 -->
|
||||
<div v-if="metrics" class="metrics-cards">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">总检查次数</div>
|
||||
<div class="metric-value">{{ metrics.check_metrics.total_checks }}</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">平均响应时间</div>
|
||||
<div class="metric-value">{{ (metrics.check_metrics.avg_time * 1000).toFixed(2) }}ms</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">允许率</div>
|
||||
<div class="metric-value">{{ metrics.check_metrics.allow_rate.toFixed(1) }}%</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">错误率</div>
|
||||
<div class="metric-value" :style="{ color: metrics.check_metrics.error_rate > 1 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ metrics.check_metrics.error_rate.toFixed(2) }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 缓存效率 -->
|
||||
<div v-if="metrics" class="cache-card">
|
||||
<div class="cache-header">
|
||||
<h3>缓存效率</h3>
|
||||
</div>
|
||||
|
||||
<div class="cache-stats">
|
||||
<div class="cache-stat">
|
||||
<div class="stat-label">缓存命中率</div>
|
||||
<el-progress
|
||||
:percentage="metrics.cache_metrics.hit_rate"
|
||||
:color="getProgressColor(metrics.cache_metrics.hit_rate)"
|
||||
/>
|
||||
<div class="stat-value">{{ metrics.cache_metrics.hit_rate.toFixed(1) }}%</div>
|
||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<el-tab-pane label="实时概览" name="overview">
|
||||
<div v-if="statsSummary" class="stats-summary">
|
||||
<div v-for="card in summaryCards" :key="card.label" class="summary-card" :class="card.tone">
|
||||
<div class="summary-icon">
|
||||
<component :is="card.icon" />
|
||||
</div>
|
||||
<div class="summary-body">
|
||||
<span class="summary-label">{{ card.label }}</span>
|
||||
<strong class="summary-value">{{ card.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-stat">
|
||||
<div class="stat-label">缓存项目数</div>
|
||||
<div class="stat-value">{{ health?.cache_stats?.cache_items.total_count || 0 }}</div>
|
||||
<div class="overview-grid">
|
||||
<div v-if="health" class="health-card">
|
||||
<div class="health-header">
|
||||
<h3>系统健康状态</h3>
|
||||
<el-tag :type="getHealthType(health.status)" effect="dark" round>
|
||||
{{ getHealthLabel(health.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="health-score-ring">
|
||||
<el-progress
|
||||
type="dashboard"
|
||||
:percentage="health.health_score"
|
||||
:color="getScoreColor(health.health_score)"
|
||||
:width="140"
|
||||
:stroke-width="10"
|
||||
>
|
||||
<template #default>
|
||||
<div class="score-inner">
|
||||
<strong>{{ health.health_score }}</strong>
|
||||
<span>健康分</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-progress>
|
||||
</div>
|
||||
<div v-if="health.issues.length > 0" class="health-issues">
|
||||
<div v-for="(issue, index) in health.issues" :key="index" class="issue-item">
|
||||
<span class="issue-dot" />
|
||||
<span>{{ issue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="health-ok">
|
||||
<span class="ok-icon">✓</span>
|
||||
<span>系统运行正常,未发现问题</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="metrics" class="cache-card">
|
||||
<h3>缓存效率</h3>
|
||||
<div class="cache-stats">
|
||||
<div class="cache-stat primary">
|
||||
<div class="cache-stat-header">
|
||||
<span class="stat-label">命中率</span>
|
||||
<strong class="stat-value" :style="{ color: getProgressColor(metrics.cache_metrics.hit_rate) }">
|
||||
{{ metrics.cache_metrics.hit_rate.toFixed(1) }}%
|
||||
</strong>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="metrics.cache_metrics.hit_rate"
|
||||
:color="getProgressColor(metrics.cache_metrics.hit_rate)"
|
||||
:stroke-width="8"
|
||||
:show-text="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="cache-stat-row">
|
||||
<div class="cache-stat-mini">
|
||||
<span class="stat-label">缓存项目</span>
|
||||
<strong>{{ health?.cache_stats?.cache_items.total_count || 0 }}</strong>
|
||||
</div>
|
||||
<div class="cache-stat-mini">
|
||||
<span class="stat-label">失效次数</span>
|
||||
<strong>{{ metrics.cache_metrics.cache_invalidations }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-stat">
|
||||
<div class="stat-label">缓存失效次数</div>
|
||||
<div class="stat-value">{{ metrics.cache_metrics.cache_invalidations }}</div>
|
||||
<div v-if="topDenied && topDenied.length > 0" class="denied-card">
|
||||
<div class="card-header">
|
||||
<h3>被拒绝最多的权限</h3>
|
||||
<el-tag effect="plain" size="small" type="info">近 7 天</el-tag>
|
||||
</div>
|
||||
<div class="denied-list">
|
||||
<div v-for="(item, index) in topDenied" :key="index" class="denied-row">
|
||||
<span class="denied-rank" :class="{ top: index < 3 }">{{ index + 1 }}</span>
|
||||
<div class="denied-info">
|
||||
<strong>{{ item.endpoint_key }}</strong>
|
||||
<small>{{ item.role }}</small>
|
||||
</div>
|
||||
<span class="denied-count">{{ item.denied_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 告警列表 -->
|
||||
<div class="alerts-card">
|
||||
<div class="alerts-header">
|
||||
<h3>最近告警</h3>
|
||||
<el-button type="primary" size="small" @click="$emit('refresh')">
|
||||
<el-icon><RefreshRight /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="alerts-card">
|
||||
<div class="card-header">
|
||||
<h3>最近告警</h3>
|
||||
<el-tag v-if="alerts && alerts.alerts.length > 0" effect="dark" size="small" type="warning" round>
|
||||
{{ alerts.alerts.length }} 条
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-if="alerts && alerts.alerts.length > 0" class="alerts-list">
|
||||
<div v-for="(alert, index) in alerts.alerts" :key="index" class="alert-row" :class="alert.level">
|
||||
<div class="alert-level">
|
||||
<el-tag :type="getAlertType(alert.level)" size="small" effect="dark" round>{{ alert.level }}</el-tag>
|
||||
</div>
|
||||
<div class="alert-body">
|
||||
<strong>{{ alert.type }}</strong>
|
||||
<span>{{ alert.message }}</span>
|
||||
</div>
|
||||
<span class="alert-time">{{ formatTime(alert.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无告警,系统运行正常" :image-size="60" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<div v-if="alerts && alerts.alerts.length > 0" class="alerts-table-wrapper">
|
||||
<el-table :data="alerts.alerts" stripe border>
|
||||
<el-table-column prop="timestamp" label="时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.timestamp) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-tab-pane label="趋势分析" name="trends">
|
||||
<PermissionTrendCharts ref="trendChartsRef" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-table-column prop="level" label="级别" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getAlertType(row.level)">{{ row.level }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-tab-pane label="访问日志" name="logs">
|
||||
<PermissionAccessLogs ref="accessLogsRef" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-table-column prop="type" label="类型" width="150" />
|
||||
|
||||
<el-table-column prop="message" label="消息" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<el-empty description="暂无告警" />
|
||||
</div>
|
||||
</div>
|
||||
<el-tab-pane label="IP属地" name="ip-locations">
|
||||
<PermissionIpLocations ref="ipLocationsRef" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { RefreshRight } from "@element-plus/icons-vue";
|
||||
import { ref, computed, h, onMounted } from "vue";
|
||||
import type {
|
||||
PermissionMetricsResponse,
|
||||
CacheStatsResponse,
|
||||
AlertsResponse,
|
||||
HealthResponse,
|
||||
TopDeniedItem,
|
||||
StatsSummaryResponse,
|
||||
} from "@/types/api";
|
||||
import {
|
||||
fetchPermissionMetrics,
|
||||
fetchPermissionAlerts,
|
||||
fetchPermissionHealth,
|
||||
fetchTopDenied,
|
||||
fetchStatsSummary,
|
||||
} from "@/api/projectPermissions";
|
||||
import PermissionTrendCharts from "./PermissionTrendCharts.vue";
|
||||
import PermissionAccessLogs from "./PermissionAccessLogs.vue";
|
||||
import PermissionIpLocations from "./PermissionIpLocations.vue";
|
||||
|
||||
interface Props {
|
||||
metrics: PermissionMetricsResponse | null;
|
||||
health: HealthResponse | null;
|
||||
alerts: AlertsResponse | null;
|
||||
}
|
||||
const IconCheck = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("polyline", { points: "20 6 9 17 4 12" }),
|
||||
]);
|
||||
const IconActivity = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("polyline", { points: "22 12 18 12 15 21 9 3 6 12 2 12" }),
|
||||
]);
|
||||
const IconPercent = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("line", { x1: "19", y1: "5", x2: "5", y2: "19" }),
|
||||
h("circle", { cx: "6.5", cy: "6.5", r: "2.5" }),
|
||||
h("circle", { cx: "17.5", cy: "17.5", r: "2.5" }),
|
||||
]);
|
||||
const IconClock = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("circle", { cx: "12", cy: "12", r: "10" }),
|
||||
h("polyline", { points: "12 6 12 12 16 14" }),
|
||||
]);
|
||||
const IconDatabase = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
|
||||
h("ellipse", { cx: "12", cy: "5", rx: "9", ry: "3" }),
|
||||
h("path", { d: "M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" }),
|
||||
h("path", { d: "M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" }),
|
||||
]);
|
||||
|
||||
interface Emits {
|
||||
(e: "refresh"): void;
|
||||
}
|
||||
const activeTab = ref("overview");
|
||||
const metrics = ref<PermissionMetricsResponse | null>(null);
|
||||
const health = ref<HealthResponse | null>(null);
|
||||
const alerts = ref<AlertsResponse | null>(null);
|
||||
const topDenied = ref<TopDeniedItem[]>([]);
|
||||
const statsSummary = ref<StatsSummaryResponse | null>(null);
|
||||
|
||||
defineProps<Props>();
|
||||
defineEmits<Emits>();
|
||||
const trendChartsRef = ref<InstanceType<typeof PermissionTrendCharts>>();
|
||||
const accessLogsRef = ref<InstanceType<typeof PermissionAccessLogs>>();
|
||||
const ipLocationsRef = ref<InstanceType<typeof PermissionIpLocations>>();
|
||||
|
||||
const formatTime = (timestamp: number): string => {
|
||||
return new Date(timestamp * 1000).toLocaleString("zh-CN");
|
||||
};
|
||||
|
||||
const getHealthType = (status: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
healthy: "success",
|
||||
degraded: "warning",
|
||||
unhealthy: "danger",
|
||||
};
|
||||
return typeMap[status] || "info";
|
||||
const getHealthType = (status: string) => {
|
||||
const map: Record<string, string> = { healthy: "success", degraded: "warning", unhealthy: "danger" };
|
||||
return map[status] || "info";
|
||||
};
|
||||
|
||||
const getHealthLabel = (status: string): string => {
|
||||
const labelMap: Record<string, string> = {
|
||||
healthy: "健康",
|
||||
degraded: "降级",
|
||||
unhealthy: "不健康",
|
||||
};
|
||||
return labelMap[status] || status;
|
||||
const getHealthLabel = (status: string) => {
|
||||
const map: Record<string, string> = { healthy: "健康", degraded: "降级", unhealthy: "不健康" };
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
const getAlertType = (level: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
info: "info",
|
||||
warning: "warning",
|
||||
error: "danger",
|
||||
};
|
||||
return typeMap[level] || "info";
|
||||
const getAlertType = (level: string) => {
|
||||
const map: Record<string, string> = { info: "info", warning: "warning", error: "danger" };
|
||||
return map[level] || "info";
|
||||
};
|
||||
|
||||
const getProgressColor = (percentage: number): string => {
|
||||
if (percentage >= 80) return "#67c23a";
|
||||
if (percentage >= 50) return "#e6a23c";
|
||||
return "#f56c6c";
|
||||
if (percentage >= 80) return "#10b981";
|
||||
if (percentage >= 50) return "#f59e0b";
|
||||
return "#ef4444";
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number): string => {
|
||||
if (score >= 80) return "#10b981";
|
||||
if (score >= 60) return "#f59e0b";
|
||||
return "#ef4444";
|
||||
};
|
||||
|
||||
const summaryCards = computed(() => {
|
||||
if (!statsSummary.value) return [];
|
||||
return [
|
||||
{
|
||||
label: "今日总检查",
|
||||
value: statsSummary.value.today.total_checks.toLocaleString(),
|
||||
tone: "blue",
|
||||
icon: IconCheck,
|
||||
},
|
||||
{
|
||||
label: "每分钟请求",
|
||||
value: statsSummary.value.last_hour.requests_per_minute,
|
||||
tone: "cyan",
|
||||
icon: IconActivity,
|
||||
},
|
||||
{
|
||||
label: "今日允许率",
|
||||
value: `${statsSummary.value.today.allow_rate}%`,
|
||||
tone: statsSummary.value.today.allow_rate >= 80 ? "green" : "warning",
|
||||
icon: IconPercent,
|
||||
},
|
||||
{
|
||||
label: "平均响应时间",
|
||||
value: `${statsSummary.value.today.avg_elapsed_ms}ms`,
|
||||
tone: statsSummary.value.today.avg_elapsed_ms > 10 ? "danger" : "indigo",
|
||||
icon: IconClock,
|
||||
},
|
||||
{
|
||||
label: "历史总记录",
|
||||
value: statsSummary.value.total_logs.toLocaleString(),
|
||||
tone: "violet",
|
||||
icon: IconDatabase,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const loadOverviewData = async () => {
|
||||
const [metricsRes, healthRes, alertsRes, deniedRes, summaryRes] = await Promise.allSettled([
|
||||
fetchPermissionMetrics(),
|
||||
fetchPermissionHealth(),
|
||||
fetchPermissionAlerts(undefined, 20),
|
||||
fetchTopDenied({ days: 7, limit: 10 }),
|
||||
fetchStatsSummary(),
|
||||
]);
|
||||
if (metricsRes.status === "fulfilled") metrics.value = metricsRes.value.data;
|
||||
if (healthRes.status === "fulfilled") health.value = healthRes.value.data;
|
||||
if (alertsRes.status === "fulfilled") alerts.value = alertsRes.value.data;
|
||||
if (deniedRes.status === "fulfilled") topDenied.value = deniedRes.value.data.items;
|
||||
if (summaryRes.status === "fulfilled") statsSummary.value = summaryRes.value.data;
|
||||
};
|
||||
|
||||
const onTabChange = (tab: string) => {
|
||||
if (tab === "overview") loadOverviewData();
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
if (activeTab.value === "overview") loadOverviewData();
|
||||
else if (activeTab.value === "trends") trendChartsRef.value?.refresh();
|
||||
else if (activeTab.value === "logs") accessLogsRef.value?.refresh();
|
||||
else if (activeTab.value === "ip-locations") ipLocationsRef.value?.refresh();
|
||||
};
|
||||
|
||||
onMounted(loadOverviewData);
|
||||
|
||||
defineExpose({ refresh });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
<style scoped>
|
||||
.permission-monitoring {
|
||||
padding: 20px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
--mon-ink: #1a2332;
|
||||
--mon-muted: #64748b;
|
||||
--mon-border: #e2e8f0;
|
||||
--mon-radius: 16px;
|
||||
--mon-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.health-card {
|
||||
background: #f5f7fa;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
.stats-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
border-radius: var(--mon-radius);
|
||||
background: #fff;
|
||||
border: 1px solid var(--mon-border);
|
||||
box-shadow: var(--mon-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.summary-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.summary-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
border-radius: var(--mon-radius) var(--mon-radius) 0 0;
|
||||
}
|
||||
|
||||
.summary-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
|
||||
.summary-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
|
||||
.summary-card.green::before { background: linear-gradient(90deg, #10b981, #34d399); }
|
||||
.summary-card.warning::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
|
||||
.summary-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
|
||||
.summary-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
|
||||
.summary-card.violet::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
|
||||
|
||||
.summary-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.summary-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.summary-card.blue .summary-icon { background: #eff6ff; color: #3b82f6; }
|
||||
.summary-card.cyan .summary-icon { background: #ecfeff; color: #06b6d4; }
|
||||
.summary-card.green .summary-icon { background: #ecfdf5; color: #10b981; }
|
||||
.summary-card.warning .summary-icon { background: #fffbeb; color: #f59e0b; }
|
||||
.summary-card.indigo .summary-icon { background: #eef2ff; color: #6366f1; }
|
||||
.summary-card.danger .summary-icon { background: #fef2f2; color: #ef4444; }
|
||||
.summary-card.violet .summary-icon { background: #f5f3ff; color: #8b5cf6; }
|
||||
|
||||
.summary-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
color: var(--mon-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
color: var(--mon-ink);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.health-card,
|
||||
.cache-card,
|
||||
.denied-card,
|
||||
.alerts-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--mon-border);
|
||||
border-radius: var(--mon-radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--mon-shadow);
|
||||
}
|
||||
|
||||
.denied-card,
|
||||
.alerts-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--mon-ink);
|
||||
}
|
||||
|
||||
.health-card h3,
|
||||
.cache-card h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--mon-ink);
|
||||
}
|
||||
|
||||
.health-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.health-score {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
.health-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
.health-score-ring {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-top: 10px;
|
||||
.score-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.score-inner strong {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--mon-ink);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.score-inner span {
|
||||
font-size: 12px;
|
||||
color: var(--mon-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.health-issues {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #dcdfe6;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--mon-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.issues-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.health-issues ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
|
||||
li {
|
||||
color: #f56c6c;
|
||||
font-size: 13px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
.issue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-bottom: 10px;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
.issue-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cache-card {
|
||||
background: #f5f7fa;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
.health-ok {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cache-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ok-icon {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cache-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cache-stat {
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
.cache-stat.primary {
|
||||
padding: 14px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
.cache-stat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
margin-top: 10px;
|
||||
.cache-stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-progress) {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.alerts-card {
|
||||
background: #f5f7fa;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.alerts-header {
|
||||
.cache-stat-mini {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
.cache-stat-mini strong {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--mon-ink);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--mon-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.denied-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.denied-row {
|
||||
display: grid;
|
||||
grid-template-columns: 32px minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.denied-row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.denied-rank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.denied-rank.top {
|
||||
background: linear-gradient(135deg, #ef4444, #f87171);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.denied-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.denied-info strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--mon-ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.denied-info small {
|
||||
font-size: 11px;
|
||||
color: var(--mon-muted);
|
||||
}
|
||||
|
||||
.denied-count {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.alerts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.alert-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--mon-border);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.alert-row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.alert-row.error {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.alert-row.warning {
|
||||
border-color: #fed7aa;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.alert-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alert-body strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--mon-ink);
|
||||
}
|
||||
|
||||
.alert-body span {
|
||||
font-size: 12px;
|
||||
color: var(--mon-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.alert-time {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stats-summary {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.alerts-table-wrapper {
|
||||
overflow-x: auto;
|
||||
@media (max-width: 900px) {
|
||||
.stats-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
@media (max-width: 600px) {
|
||||
.stats-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTemplateSelector.vue"), "utf8");
|
||||
|
||||
describe("PermissionTemplateSelector", () => {
|
||||
it("renders compact role permission cards without footer placeholder space", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("角色权限概览");
|
||||
expect(source).toContain("align-items: start");
|
||||
expect(source).toContain("padding: 12px 14px");
|
||||
expect(source).not.toContain("card-footer");
|
||||
expect(source).not.toContain("点击编辑权限");
|
||||
expect(source).not.toContain("inactive-label");
|
||||
expect(source).not.toContain("edit-hint");
|
||||
});
|
||||
});
|
||||
@@ -1,61 +1,62 @@
|
||||
<template>
|
||||
<div class="template-selector">
|
||||
<div class="selector-header">
|
||||
<h3>角色权限概览</h3>
|
||||
<p class="selector-desc">展示当前项目各角色的权限配置状态</p>
|
||||
<div class="selector-title-group">
|
||||
<h3>角色权限概览</h3>
|
||||
<p class="selector-desc">展示当前项目各角色的权限配置状态</p>
|
||||
</div>
|
||||
<el-tag effect="plain" size="small" type="info" round>{{ templates.length }} 个角色</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="template-grid">
|
||||
<el-tooltip
|
||||
<div
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
:content="template.description"
|
||||
placement="top"
|
||||
:disabled="!template.description"
|
||||
>
|
||||
<div
|
||||
class="template-card"
|
||||
:class="{ inactive: template.category && !isRoleActive(template.category) }"
|
||||
@click="template.category && emit('edit-role', template.category!)"
|
||||
>
|
||||
<div class="card-header">
|
||||
<div class="card-top">
|
||||
<span class="card-icon">{{ roleIcon(template.category) }}</span>
|
||||
<div class="card-title-wrap">
|
||||
<span class="card-name">{{ template.category ? (ROLE_LABELS[template.category] || template.name) : template.name }}</span>
|
||||
<el-tag v-if="!template.is_system" size="small" type="success">自定义</el-tag>
|
||||
<span v-if="template.description" class="card-desc">{{ template.description }}</span>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="template.category"
|
||||
size="small"
|
||||
type="primary"
|
||||
link
|
||||
class="edit-role-btn"
|
||||
@click.stop="emit('edit-role', template.category!)"
|
||||
>编辑权限</el-button>
|
||||
<el-tag v-if="!template.is_system" size="small" effect="dark" round type="success" class="card-badge">自定义</el-tag>
|
||||
</div>
|
||||
<div class="card-stats">
|
||||
<template v-if="props.currentPermissions && template.category && props.currentPermissions[template.category]">
|
||||
<span class="stat">
|
||||
<el-icon><Check /></el-icon>
|
||||
{{ countCurrentEnabled(template.category) }} 项已启用
|
||||
</span>
|
||||
<span class="stat disabled">
|
||||
<el-icon><Close /></el-icon>
|
||||
{{ countCurrentDisabled(template.category) }} 项已禁用
|
||||
</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-fill" :style="{ width: enabledPercent(template.category) + '%' }" />
|
||||
</div>
|
||||
<div class="stat-numbers">
|
||||
<span class="stat enabled">
|
||||
<span class="stat-dot green" />
|
||||
{{ countCurrentEnabled(template.category) }} 启用
|
||||
</span>
|
||||
<span class="stat disabled">
|
||||
<span class="stat-dot red" />
|
||||
{{ countCurrentDisabled(template.category) }} 禁用
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="stat">
|
||||
<el-icon><Check /></el-icon>
|
||||
{{ countEnabled(template) }} 项已启用
|
||||
</span>
|
||||
<span class="stat disabled">
|
||||
<el-icon><Close /></el-icon>
|
||||
{{ countDisabled(template) }} 项已禁用
|
||||
</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-fill" :style="{ width: templateEnabledPercent(template) + '%' }" />
|
||||
</div>
|
||||
<div class="stat-numbers">
|
||||
<span class="stat enabled">
|
||||
<span class="stat-dot green" />
|
||||
{{ countEnabled(template) }} 启用
|
||||
</span>
|
||||
<span class="stat disabled">
|
||||
<span class="stat-dot red" />
|
||||
{{ countDisabled(template) }} 禁用
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -63,7 +64,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Check, Close } from "@element-plus/icons-vue";
|
||||
import {
|
||||
fetchPermissionTemplates,
|
||||
type PermissionTemplate,
|
||||
@@ -110,6 +110,12 @@ const countDisabled = (t: PermissionTemplate) => {
|
||||
return n;
|
||||
};
|
||||
|
||||
const templateEnabledPercent = (t: PermissionTemplate) => {
|
||||
const enabled = countEnabled(t);
|
||||
const total = enabled + countDisabled(t);
|
||||
return total > 0 ? Math.round((enabled / total) * 100) : 0;
|
||||
};
|
||||
|
||||
const countCurrentEnabled = (role: string) => {
|
||||
const perms = props.currentPermissions?.[role];
|
||||
if (!perms) return 0;
|
||||
@@ -122,6 +128,12 @@ const countCurrentDisabled = (role: string) => {
|
||||
return Object.values(perms).filter((v) => !v).length;
|
||||
};
|
||||
|
||||
const enabledPercent = (role: string) => {
|
||||
const enabled = countCurrentEnabled(role);
|
||||
const total = enabled + countCurrentDisabled(role);
|
||||
return total > 0 ? Math.round((enabled / total) * 100) : 0;
|
||||
};
|
||||
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
const res = await fetchPermissionTemplates();
|
||||
@@ -136,117 +148,155 @@ onMounted(loadTemplates);
|
||||
|
||||
<style scoped>
|
||||
.template-selector {
|
||||
padding: 16px 0;
|
||||
padding: 8px 0 12px;
|
||||
}
|
||||
|
||||
.selector-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.selector-header h3 {
|
||||
.selector-title-group h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
color: #1a2332;
|
||||
}
|
||||
|
||||
.selector-desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
padding: 14px;
|
||||
border: 1.5px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.template-card.inactive {
|
||||
border-style: dashed;
|
||||
background: #fafafa;
|
||||
opacity: 0.75;
|
||||
border-color: #e2e8f0;
|
||||
background: #fafbfc;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
border-color: #c0c4cc;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
.template-card.inactive:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.template-card:not(.inactive):hover {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-title-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
color: #1a2332;
|
||||
}
|
||||
|
||||
.template-card.inactive .card-name {
|
||||
color: #909399;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.edit-role-btn {
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
.card-desc {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-card:hover .edit-role-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
.card-badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: #67c23a;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.template-card.inactive .card-stats {
|
||||
color: #b0b0b0;
|
||||
.stat-bar {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #f1f5f9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-stats .stat {
|
||||
.stat-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, #10b981, #34d399);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-numbers {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.card-stats .stat.disabled {
|
||||
color: #f56c6c;
|
||||
.stat-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.template-card.inactive .card-stats .stat.disabled {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.stat-dot.green { background: #10b981; }
|
||||
.stat-dot.red { background: #ef4444; }
|
||||
|
||||
.template-card.inactive .stat-dot.green { background: #94a3b8; }
|
||||
.template-card.inactive .stat-dot.red { background: #cbd5e1; }
|
||||
.template-card.inactive .stat-fill { background: #cbd5e1; }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<div class="trend-charts">
|
||||
<div class="trend-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-title">数据趋势</span>
|
||||
<span class="toolbar-desc">查看权限系统各项指标的变化趋势</span>
|
||||
</div>
|
||||
<el-radio-group v-model="period" size="small" @change="loadData">
|
||||
<el-radio-button value="24h">24小时</el-radio-button>
|
||||
<el-radio-button value="7d">7天</el-radio-button>
|
||||
<el-radio-button value="30d">30天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<div class="chart-icon blue">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
</div>
|
||||
<h4>检查量趋势</h4>
|
||||
</div>
|
||||
<v-chart :option="checksChartOption" autoresize class="chart" />
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<div class="chart-icon cyan">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
</div>
|
||||
<h4>响应时间趋势</h4>
|
||||
</div>
|
||||
<v-chart :option="responseTimeOption" autoresize class="chart" />
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<div class="chart-icon green">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
</div>
|
||||
<h4>缓存命中率</h4>
|
||||
</div>
|
||||
<v-chart :option="cacheHitOption" autoresize class="chart" />
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<div class="chart-icon danger">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
|
||||
</div>
|
||||
<h4>拒绝率趋势</h4>
|
||||
</div>
|
||||
<v-chart :option="denyRateOption" autoresize class="chart" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import VChart from "vue-echarts";
|
||||
import { use } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { LineChart, BarChart } from "echarts/charts";
|
||||
import {
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
} from "echarts/components";
|
||||
import { fetchPermissionTrends } from "@/api/projectPermissions";
|
||||
import type { TrendDataPoint } from "@/types/api";
|
||||
|
||||
use([CanvasRenderer, LineChart, BarChart, TitleComponent, TooltipComponent, GridComponent, LegendComponent]);
|
||||
|
||||
const period = ref<"24h" | "7d" | "30d">("24h");
|
||||
const loading = ref(false);
|
||||
const dataPoints = ref<TrendDataPoint[]>([]);
|
||||
|
||||
const formatTime = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
if (period.value === "24h") return `${d.getHours()}:00`;
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
};
|
||||
|
||||
const xLabels = computed(() => dataPoints.value.map((p: TrendDataPoint) => formatTime(p.bucket_time)));
|
||||
|
||||
const baseGridOption = {
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 25 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
};
|
||||
|
||||
const checksChartOption = computed(() => ({
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 50 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
legend: { data: ["允许", "拒绝"], bottom: 0, itemGap: 20 },
|
||||
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
|
||||
yAxis: { type: "value", minInterval: 1, splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
|
||||
series: [
|
||||
{
|
||||
name: "允许",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
data: dataPoints.value.map((p: TrendDataPoint) => p.allowed_checks),
|
||||
itemStyle: { color: "#10b981" },
|
||||
lineStyle: { width: 2.5 },
|
||||
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(16,185,129,0.2)" }, { offset: 1, color: "rgba(16,185,129,0)" }] } },
|
||||
},
|
||||
{
|
||||
name: "拒绝",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
data: dataPoints.value.map((p: TrendDataPoint) => p.denied_checks),
|
||||
itemStyle: { color: "#ef4444" },
|
||||
lineStyle: { width: 2.5 },
|
||||
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(239,68,68,0.15)" }, { offset: 1, color: "rgba(239,68,68,0)" }] } },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const responseTimeOption = computed(() => ({
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 50 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
legend: { data: ["平均响应时间", "最大响应时间"], bottom: 0, itemGap: 20 },
|
||||
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
|
||||
yAxis: { type: "value", name: "ms", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
|
||||
series: [
|
||||
{
|
||||
name: "平均响应时间",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
data: dataPoints.value.map((p: TrendDataPoint) => p.avg_elapsed_ms),
|
||||
itemStyle: { color: "#3b82f6" },
|
||||
lineStyle: { width: 2.5 },
|
||||
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(59,130,246,0.15)" }, { offset: 1, color: "rgba(59,130,246,0)" }] } },
|
||||
},
|
||||
{
|
||||
name: "最大响应时间",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "diamond",
|
||||
symbolSize: 6,
|
||||
data: dataPoints.value.map((p: TrendDataPoint) => p.max_elapsed_ms),
|
||||
itemStyle: { color: "#f59e0b" },
|
||||
lineStyle: { width: 2, type: "dashed" },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const cacheHitOption = computed(() => ({
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 25 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
|
||||
yAxis: { type: "value", max: 100, name: "%", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
|
||||
series: [
|
||||
{
|
||||
name: "缓存命中率",
|
||||
type: "bar",
|
||||
barMaxWidth: 24,
|
||||
itemStyle: {
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
color: (params: any) => {
|
||||
const val = params.value;
|
||||
if (val >= 80) return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#34d399" }, { offset: 1, color: "#10b981" }] };
|
||||
if (val >= 50) return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#fbbf24" }, { offset: 1, color: "#f59e0b" }] };
|
||||
return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#f87171" }, { offset: 1, color: "#ef4444" }] };
|
||||
},
|
||||
},
|
||||
data: dataPoints.value.map((p: TrendDataPoint) => p.cache_hit_rate),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const denyRateOption = computed(() => ({
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 25 },
|
||||
tooltip: { trigger: "axis" as const },
|
||||
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
|
||||
yAxis: { type: "value", max: 100, name: "%", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
|
||||
series: [
|
||||
{
|
||||
name: "拒绝率",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
data: dataPoints.value.map((p: TrendDataPoint) =>
|
||||
p.total_checks > 0 ? Math.round((p.denied_checks / p.total_checks) * 1000) / 10 : 0
|
||||
),
|
||||
itemStyle: { color: "#ef4444" },
|
||||
lineStyle: { width: 2.5 },
|
||||
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(239,68,68,0.2)" }, { offset: 1, color: "rgba(239,68,68,0)" }] } },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetchPermissionTrends(period.value);
|
||||
dataPoints.value = res.data.data_points;
|
||||
} catch {
|
||||
dataPoints.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
defineExpose({ refresh: loadData });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trend-charts {
|
||||
--trend-ink: #1a2332;
|
||||
--trend-muted: #64748b;
|
||||
--trend-border: #e2e8f0;
|
||||
--trend-radius: 16px;
|
||||
--trend-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.trend-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--trend-border);
|
||||
border-radius: var(--trend-radius);
|
||||
box-shadow: var(--trend-shadow);
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--trend-ink);
|
||||
}
|
||||
|
||||
.toolbar-desc {
|
||||
font-size: 12px;
|
||||
color: var(--trend-muted);
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--trend-border);
|
||||
border-radius: var(--trend-radius);
|
||||
padding: 18px;
|
||||
box-shadow: var(--trend-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.chart-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chart-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.chart-icon.blue { background: #eff6ff; color: #3b82f6; }
|
||||
.chart-icon.cyan { background: #ecfeff; color: #06b6d4; }
|
||||
.chart-icon.green { background: #ecfdf5; color: #10b981; }
|
||||
.chart-icon.danger { background: #fef2f2; color: #ef4444; }
|
||||
|
||||
.chart-card h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--trend-ink);
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 240px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.trend-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
// 地图数据来源:https://geojson.cn/api/china/1.6.3/china.json
|
||||
// 组件直接导入本地缓存的 GeoJSON,避免运行时依赖外部网络。
|
||||
|
||||
export const normalizeProvinceName = (value?: string | null) => {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.replace(/特别行政区$/u, "")
|
||||
.replace(/维吾尔自治区$/u, "")
|
||||
.replace(/壮族自治区$/u, "")
|
||||
.replace(/回族自治区$/u, "")
|
||||
.replace(/自治区$/u, "")
|
||||
.replace(/省$/u, "")
|
||||
.replace(/市$/u, "");
|
||||
};
|
||||
@@ -8,13 +8,13 @@ describe("admin project route permissions", () => {
|
||||
it("allows project admin pages through matrix-authorized PMs instead of all project roles", () => {
|
||||
const source = readRouter();
|
||||
|
||||
expect(source).toContain('adminProjectPermission: { module: "project_members", action: "read" }');
|
||||
expect(source).toContain('adminProjectPermission: { module: "project_members", action: "write" }');
|
||||
expect(source).toContain('adminProjectPermission: { module: "sites", action: "read" }');
|
||||
expect(source).toContain('adminProjectPermission: { module: "audit_export", action: "read" }');
|
||||
expect(source).toContain("meta: { title: TEXT.menu.projectManagement }");
|
||||
expect(source).toContain("ADMIN_PROJECT_OPERATION_KEYS");
|
||||
expect(source).toContain('if (role !== "PM") return false;');
|
||||
expect(source).toContain("roles?.[role]?.[permission.module]?.[permission.action]");
|
||||
expect(source).toContain("isApiPermissionAllowed");
|
||||
expect(source).toContain("studyStore.currentPermissions?.[role]?.[operationKey]");
|
||||
});
|
||||
|
||||
it("keeps login landing independent from PM management backend access", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { isSystemAdmin } from "../utils/roles";
|
||||
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
|
||||
import { findFirstAccessibleProjectPath, getProjectRoutePermission, hasProjectPermission } from "../utils/projectRoutePermissions";
|
||||
import { fetchStudyDetail } from "../api/studies";
|
||||
import { fetchApiEndpointPermissions } from "../api/projectPermissions";
|
||||
@@ -489,6 +490,12 @@ const router = createRouter({
|
||||
routes,
|
||||
});
|
||||
|
||||
const ADMIN_PROJECT_OPERATION_KEYS: Record<string, Record<"read" | "write", string>> = {
|
||||
audit_export: { read: "audit_logs:read", write: "audit_logs:read" },
|
||||
project_members: { read: "project_members:list", write: "project_members:update" },
|
||||
sites: { read: "sites:read", write: "sites:update" },
|
||||
};
|
||||
|
||||
const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
|
||||
if (isAdmin) return true;
|
||||
const studyStore = useStudyStore();
|
||||
@@ -509,8 +516,9 @@ const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
|
||||
|
||||
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
|
||||
if (role !== "PM") return false;
|
||||
// 管理员项目权限检查:PM 角色默认有访问权限
|
||||
return true;
|
||||
const operationKey = ADMIN_PROJECT_OPERATION_KEYS[permission.module]?.[permission.action];
|
||||
if (!operationKey) return false;
|
||||
return isApiPermissionAllowed(studyStore.currentPermissions?.[role]?.[operationKey]);
|
||||
};
|
||||
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
@@ -578,7 +586,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
await studyStore.loadCurrentStudyPermissions().catch(() => {});
|
||||
}
|
||||
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
|
||||
const canReadAudit = !!studyStore.currentPermissions?.[role]?.["audit_logs:read"]?.allowed;
|
||||
const canReadAudit = isApiPermissionAllowed(studyStore.currentPermissions?.[role]?.["audit_logs:read"]);
|
||||
if (!(isAdmin || (role === "PM" && canReadAudit))) {
|
||||
next({ path: "/project/overview" });
|
||||
return;
|
||||
|
||||
@@ -117,7 +117,7 @@ describe("study store startup rehydration", () => {
|
||||
status: "ACTIVE",
|
||||
role_in_study: "PM",
|
||||
} as any);
|
||||
study.currentPermissions = { roles: { PM: { project_members: { read: true, write: true } } } };
|
||||
study.currentPermissions = { PM: { "project_members:update": { allowed: true } } };
|
||||
|
||||
study.setCurrentStudy({
|
||||
id: "study-b",
|
||||
|
||||
+154
-3
@@ -131,11 +131,11 @@ export interface Site {
|
||||
}
|
||||
|
||||
// 接口级权限
|
||||
export type ApiPermissionValue = boolean | { allowed: boolean };
|
||||
|
||||
export interface ApiEndpointPermissionsResponse {
|
||||
[role: string]: {
|
||||
[endpoint_key: string]: {
|
||||
allowed: boolean;
|
||||
};
|
||||
[endpoint_key: string]: ApiPermissionValue;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,3 +204,154 @@ export interface HealthResponse {
|
||||
metrics: PermissionMetricsResponse;
|
||||
cache_stats: CacheStatsResponse;
|
||||
}
|
||||
|
||||
// 权限监控 - 访问日志
|
||||
export interface AccessLogItem {
|
||||
id: string;
|
||||
study_id: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
endpoint_key: string;
|
||||
role: string;
|
||||
allowed: boolean;
|
||||
elapsed_ms: number;
|
||||
ip_address: string | null;
|
||||
ip_location: string;
|
||||
ip_country: string;
|
||||
ip_province: string;
|
||||
ip_city: string;
|
||||
ip_isp: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AccessLogsSummary {
|
||||
total_count: number;
|
||||
unique_user_count: number;
|
||||
unique_ip_count: number;
|
||||
denied_count: number;
|
||||
avg_elapsed_ms: number;
|
||||
}
|
||||
|
||||
export interface AccessLogUserStat {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
role: string;
|
||||
total_count: number;
|
||||
denied_count: number;
|
||||
unique_ip_count: number;
|
||||
sample_ip_address: string | null;
|
||||
last_seen_at: string | null;
|
||||
primary_location: string;
|
||||
}
|
||||
|
||||
export interface AccessLogsResponse {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
summary: AccessLogsSummary;
|
||||
user_stats: AccessLogUserStat[];
|
||||
items: AccessLogItem[];
|
||||
}
|
||||
|
||||
export interface SecurityAccessLogItem {
|
||||
id: string;
|
||||
method: string;
|
||||
path: string;
|
||||
status_code: number;
|
||||
elapsed_ms: number;
|
||||
client_ip: string | null;
|
||||
user_agent: string | null;
|
||||
auth_status: "ANONYMOUS" | "INVALID_TOKEN" | "AUTHENTICATED" | string;
|
||||
user_identifier: string | null;
|
||||
account_label: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SecurityAccessLogsResponse {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
summary: {
|
||||
total_count: number;
|
||||
anonymous_count: number;
|
||||
invalid_token_count: number;
|
||||
error_count: number;
|
||||
};
|
||||
items: SecurityAccessLogItem[];
|
||||
}
|
||||
|
||||
// 权限监控 - 趋势数据
|
||||
export interface TrendDataPoint {
|
||||
bucket_time: string;
|
||||
total_checks: number;
|
||||
allowed_checks: number;
|
||||
denied_checks: number;
|
||||
avg_elapsed_ms: number;
|
||||
max_elapsed_ms: number;
|
||||
cache_hits: number;
|
||||
cache_misses: number;
|
||||
cache_hit_rate: number;
|
||||
error_count: number;
|
||||
}
|
||||
|
||||
export interface TrendsResponse {
|
||||
period: string;
|
||||
data_points: TrendDataPoint[];
|
||||
}
|
||||
|
||||
// 权限监控 - 被拒绝最多
|
||||
export interface TopDeniedItem {
|
||||
endpoint_key: string;
|
||||
role: string;
|
||||
denied_count: number;
|
||||
}
|
||||
|
||||
export interface TopDeniedResponse {
|
||||
days: number;
|
||||
items: TopDeniedItem[];
|
||||
}
|
||||
|
||||
export interface IpLocationStatItem {
|
||||
country: string;
|
||||
province: string;
|
||||
city: string;
|
||||
isp: string;
|
||||
location: string;
|
||||
total_count: number;
|
||||
allowed_count: number;
|
||||
denied_count: number;
|
||||
unique_ip_count: number;
|
||||
unique_user_count: number;
|
||||
}
|
||||
|
||||
export interface IpLocationsResponse {
|
||||
days: number;
|
||||
summary: {
|
||||
total_count: number;
|
||||
allowed_count: number;
|
||||
denied_count: number;
|
||||
unique_ip_count: number;
|
||||
unique_user_count: number;
|
||||
};
|
||||
items: IpLocationStatItem[];
|
||||
}
|
||||
|
||||
// 权限监控 - 统计摘要
|
||||
export interface StatsSummaryResponse {
|
||||
total_logs: number;
|
||||
today: {
|
||||
total_checks: number;
|
||||
allowed_checks: number;
|
||||
denied_checks: number;
|
||||
avg_elapsed_ms: number;
|
||||
max_elapsed_ms: number;
|
||||
allow_rate: number;
|
||||
deny_rate: number;
|
||||
};
|
||||
last_hour: {
|
||||
total_checks: number;
|
||||
allowed_checks: number;
|
||||
denied_checks: number;
|
||||
requests_per_minute: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ApiPermissionValue } from "../types/api";
|
||||
|
||||
export const isApiPermissionAllowed = (value: ApiPermissionValue | null | undefined): boolean => {
|
||||
if (typeof value === "boolean") return value;
|
||||
return !!value?.allowed;
|
||||
};
|
||||
@@ -12,8 +12,11 @@ describe("permission project role model", () => {
|
||||
expect(source).toContain("getProjectRole");
|
||||
expect(source).toContain("study.currentStudyRole");
|
||||
expect(source).toContain("study.currentPermissions");
|
||||
expect(source).toContain('"faq.edit": { module: "faq", action: "write" }');
|
||||
expect(source).not.toContain('"faq.edit": { module: "etmf"');
|
||||
expect(source).toContain('"faq.edit": "faq:update"');
|
||||
expect(source).toContain("isApiPermissionAllowed");
|
||||
expect(source).toContain("projectPermissions.value?.[projectRole.value]?.[operationKey]");
|
||||
expect(source).not.toContain("projectPermissions.value?.roles");
|
||||
expect(source).not.toContain('"faq.edit": "etmf');
|
||||
expect(source).not.toContain('"ADMIN", "PM"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { TEXT } from "../locales";
|
||||
import { getProjectRole, isSystemAdmin } from "./roles";
|
||||
import { isApiPermissionAllowed } from "./apiPermissionValue";
|
||||
|
||||
const PERMISSIONS: Record<string, string[]> = {
|
||||
"subject.create": ["ADMIN"],
|
||||
@@ -45,32 +46,31 @@ export const usePermission = () => {
|
||||
const can = (action: string): boolean => {
|
||||
if (systemAdmin.value) return true;
|
||||
if (!projectRole.value) return false;
|
||||
const permissionMap: Record<string, { module: string; action: "read" | "write" }> = {
|
||||
"subject.create": { module: "subjects", action: "write" },
|
||||
"subject.enroll": { module: "subjects", action: "write" },
|
||||
"subject.complete": { module: "subjects", action: "write" },
|
||||
"subject.drop": { module: "subjects", action: "write" },
|
||||
"ae.create": { module: "risk_issues", action: "write" },
|
||||
"ae.close": { module: "risk_issues", action: "write" },
|
||||
"project.overview.read": { module: "project_overview", action: "read" },
|
||||
"faq.edit": { module: "faq", action: "write" },
|
||||
"faq.create": { module: "faq", action: "write" },
|
||||
"faq.reply": { module: "faq", action: "write" },
|
||||
"knowledge.notes.write": { module: "shared_library", action: "write" },
|
||||
"shared.library.read": { module: "shared_library", action: "read" },
|
||||
"shared.library.write": { module: "shared_library", action: "write" },
|
||||
"project.members.manage": { module: "project_members", action: "write" },
|
||||
"site.manage": { module: "sites", action: "write" },
|
||||
"site.cra.bind": { module: "sites", action: "write" },
|
||||
"fees.contract.write": { module: "fees", action: "write" },
|
||||
"fees.attachment.delete": { module: "fees", action: "write" },
|
||||
"file.attachment.delete": { module: "file_versions", action: "write" },
|
||||
"audit.export.read": { module: "audit_export", action: "read" },
|
||||
const permissionMap: Record<string, string> = {
|
||||
"subject.create": "subjects:create",
|
||||
"subject.enroll": "subjects:update",
|
||||
"subject.complete": "subjects:update",
|
||||
"subject.drop": "subjects:update",
|
||||
"ae.create": "risk_issues:create",
|
||||
"ae.close": "risk_issues:update",
|
||||
"project.overview.read": "project_overview:read",
|
||||
"faq.edit": "faq:update",
|
||||
"faq.create": "faq:create",
|
||||
"faq.reply": "faq_reply:create",
|
||||
"knowledge.notes.write": "knowledge_notes:create",
|
||||
"shared.library.read": "knowledge_notes:read",
|
||||
"shared.library.write": "knowledge_notes:create",
|
||||
"project.members.manage": "project_members:update",
|
||||
"site.manage": "sites:update",
|
||||
"site.cra.bind": "sites:update",
|
||||
"fees.contract.write": "fees_contracts:create",
|
||||
"fees.attachment.delete": "fees_attachments:delete",
|
||||
"file.attachment.delete": "attachments:delete",
|
||||
"audit.export.read": "audit_logs:read",
|
||||
};
|
||||
const mapped = permissionMap[action];
|
||||
if (!mapped) return false;
|
||||
const allowed = projectPermissions.value?.roles?.[projectRole.value]?.[mapped.module]?.[mapped.action];
|
||||
return !!allowed;
|
||||
const operationKey = permissionMap[action];
|
||||
if (!operationKey) return false;
|
||||
return isApiPermissionAllowed(projectPermissions.value?.[projectRole.value]?.[operationKey]);
|
||||
};
|
||||
|
||||
const reason = (action: string): string => {
|
||||
|
||||
@@ -2,18 +2,22 @@ import { describe, expect, it } from "vitest";
|
||||
import { findFirstAccessibleProjectPath, getProjectRoutePermission, hasProjectPermission } from "./projectRoutePermissions";
|
||||
|
||||
describe("project route permissions", () => {
|
||||
it("maps project routes to read permission modules", () => {
|
||||
expect(getProjectRoutePermission("/project/overview")).toEqual({ module: "project_overview", action: "read" });
|
||||
expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ module: "faq", action: "read" });
|
||||
expect(getProjectRoutePermission("/knowledge/notes/new")).toEqual({ module: "shared_library", action: "read" });
|
||||
expect(getProjectRoutePermission("/startup/kickoff/new")).toEqual({ module: "startup_auth", action: "read" });
|
||||
it("maps project routes to backend operation keys", () => {
|
||||
expect(getProjectRoutePermission("/project/overview")).toEqual({ operationKey: "project_overview:read" });
|
||||
expect(getProjectRoutePermission("/project/milestones")).toEqual({ operationKey: "project_milestones:read" });
|
||||
expect(getProjectRoutePermission("/finance/contracts")).toEqual({ operationKey: "finance_contracts:read" });
|
||||
expect(getProjectRoutePermission("/drug/shipments")).toEqual({ operationKey: "drug_shipments:read" });
|
||||
expect(getProjectRoutePermission("/startup/feasibility/new")).toEqual({ operationKey: "ethics:read" });
|
||||
expect(getProjectRoutePermission("/startup/kickoff/new")).toEqual({ operationKey: "startup_auth:read" });
|
||||
expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ operationKey: "faq:read" });
|
||||
expect(getProjectRoutePermission("/knowledge/notes/new")).toEqual({ operationKey: "knowledge_notes:read" });
|
||||
});
|
||||
|
||||
it("rejects project routes when the role lacks module read permission", () => {
|
||||
it("rejects project routes when the role lacks operation permission", () => {
|
||||
const matrix = {
|
||||
CRA: {
|
||||
project_overview: { read: false, write: false },
|
||||
shared_library: { read: true, write: true },
|
||||
"project_overview:read": { allowed: false },
|
||||
"knowledge_notes:read": { allowed: true },
|
||||
},
|
||||
} as any;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ApiEndpointPermissionsResponse } from "../types/api";
|
||||
import { isApiPermissionAllowed } from "./apiPermissionValue";
|
||||
|
||||
export type ProjectRoutePermission = {
|
||||
operationKey: string;
|
||||
@@ -7,10 +8,12 @@ export type ProjectRoutePermission = {
|
||||
const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePermission }> = [
|
||||
{ prefixes: ["/project/overview"], permission: { operationKey: "project_overview:read" } },
|
||||
{ prefixes: ["/project/milestones"], permission: { operationKey: "project_milestones:read" } },
|
||||
{ prefixes: ["/fees/contracts", "/finance/contracts"], permission: { operationKey: "fees_contracts:read" } },
|
||||
{ prefixes: ["/drug/shipments", "/materials/equipment"], permission: { operationKey: "material_equipments:read" } },
|
||||
{ prefixes: ["/fees/contracts"], permission: { operationKey: "fees_contracts:read" } },
|
||||
{ prefixes: ["/finance/contracts"], permission: { operationKey: "finance_contracts:read" } },
|
||||
{ prefixes: ["/drug/shipments"], permission: { operationKey: "drug_shipments:read" } },
|
||||
{ prefixes: ["/materials/equipment"], permission: { operationKey: "material_equipments:read" } },
|
||||
{ prefixes: ["/file-versions", "/trial/", "/documents/"], permission: { operationKey: "documents:read" } },
|
||||
{ prefixes: ["/startup/feasibility-ethics", "/startup/feasibility", "/startup/ethics"], permission: { operationKey: "startup_ethics:read" } },
|
||||
{ prefixes: ["/startup/feasibility-ethics", "/startup/feasibility", "/startup/ethics"], permission: { operationKey: "ethics:read" } },
|
||||
{ prefixes: ["/startup/meeting-auth", "/startup/kickoff", "/startup/training"], permission: { operationKey: "startup_auth:read" } },
|
||||
{ prefixes: ["/subjects"], permission: { operationKey: "subjects:read" } },
|
||||
{ prefixes: ["/risk-issues"], permission: { operationKey: "risk_issues:read" } },
|
||||
@@ -57,7 +60,7 @@ export const hasProjectPermission = (
|
||||
) => {
|
||||
if (!permission || isAdmin) return true;
|
||||
if (!role || !permissions) return false;
|
||||
return !!permissions[role]?.[permission.operationKey]?.allowed;
|
||||
return isApiPermissionAllowed(permissions[role]?.[permission.operationKey]);
|
||||
};
|
||||
|
||||
export const findFirstAccessibleProjectPath = (
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import ApiPermissions from "@/views/admin/ApiPermissions.vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
|
||||
vi.mock("vue-router", async () => {
|
||||
const actual = await vi.importActual<typeof import("vue-router")>("vue-router");
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => ({ params: { id: "study-1" } }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/api/projectPermissions", () => ({
|
||||
fetchApiEndpointPermissions: vi.fn().mockResolvedValue({ data: {} }),
|
||||
updateApiEndpointPermissions: vi.fn().mockResolvedValue({ data: {} }),
|
||||
fetchPermissionMetrics: vi.fn().mockResolvedValue({ data: {} }),
|
||||
fetchCacheStats: vi.fn().mockResolvedValue({ data: {} }),
|
||||
fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }),
|
||||
fetchPermissionHealth: vi.fn().mockResolvedValue({ data: {} }),
|
||||
resetPermissionMetrics: vi.fn().mockResolvedValue({ data: undefined }),
|
||||
}));
|
||||
|
||||
describe("ApiPermissions.vue", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
@@ -23,30 +43,15 @@ describe("ApiPermissions.vue", () => {
|
||||
});
|
||||
|
||||
it("renders tabs", () => {
|
||||
const wrapper = mount(ApiPermissions, {
|
||||
global: {
|
||||
stubs: {
|
||||
ApiEndpointPermissions: true,
|
||||
PermissionMonitoring: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const source = readFileSync(resolve(__dirname, "./ApiPermissions.vue"), "utf8");
|
||||
|
||||
const tabs = wrapper.findAll(".el-tabs__nav-item");
|
||||
expect(tabs.length).toBeGreaterThanOrEqual(2);
|
||||
expect(source).toContain('label="角色权限"');
|
||||
expect(source).toContain('label="权限监控"');
|
||||
});
|
||||
|
||||
it("has save button disabled when not dirty", () => {
|
||||
const wrapper = mount(ApiPermissions, {
|
||||
global: {
|
||||
stubs: {
|
||||
ApiEndpointPermissions: true,
|
||||
PermissionMonitoring: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const source = readFileSync(resolve(__dirname, "./ApiPermissions.vue"), "utf8");
|
||||
|
||||
const saveButton = wrapper.findAll("button").find((btn) => btn.text().includes("保存"));
|
||||
expect(saveButton?.attributes("disabled")).toBeDefined();
|
||||
expect(source).toContain(':disabled="!dirty"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,10 +156,10 @@ const onApiMatrixUpdate = (newMatrix: ApiEndpointPermissionsResponse) => {
|
||||
const currentPermissionsForTemplate = computed(() => {
|
||||
if (!apiMatrix.value) return undefined;
|
||||
const result: Record<string, Record<string, boolean>> = {};
|
||||
for (const [role, endpoints] of Object.entries(apiMatrix.value as Record<string, Record<string, { allowed: boolean }>>)) {
|
||||
for (const [role, endpoints] of Object.entries(apiMatrix.value)) {
|
||||
result[role] = {};
|
||||
for (const [key, val] of Object.entries(endpoints)) {
|
||||
result[role][key] = val.allowed;
|
||||
result[role][key] = typeof val === "boolean" ? val : val.allowed;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -171,13 +171,24 @@ const onTemplateApplied = async (permissions: Record<string, Record<string, { al
|
||||
ElMessage.success("权限已更新");
|
||||
};
|
||||
|
||||
const flattenMatrix = (matrix: ApiEndpointPermissionsResponse): Record<string, Record<string, boolean>> => {
|
||||
const result: Record<string, Record<string, boolean>> = {};
|
||||
for (const [role, endpoints] of Object.entries(matrix)) {
|
||||
result[role] = {};
|
||||
for (const [key, val] of Object.entries(endpoints)) {
|
||||
result[role][key] = typeof val === "boolean" ? val : val.allowed;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!studyId.value || !dirty.value) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (apiMatrix.value) {
|
||||
const res = await updateApiEndpointPermissions(studyId.value, apiMatrix.value);
|
||||
const res = await updateApiEndpointPermissions(studyId.value, flattenMatrix(apiMatrix.value));
|
||||
apiMatrix.value = res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ describe("audit logs access", () => {
|
||||
expect(source).toContain("getProjectRole");
|
||||
expect(source).toContain("study.currentStudyRole");
|
||||
expect(source).toContain('projectRole.value === "PM"');
|
||||
expect(source).toContain("roles?.[projectRole.value]?.audit_export?.read");
|
||||
expect(source).not.toContain("roles?.PM?.audit_export?.read");
|
||||
expect(source).toContain("isApiPermissionAllowed");
|
||||
expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]');
|
||||
expect(source).not.toContain('const role = auth.user?.role');
|
||||
expect(source).not.toContain('role !== "ADMIN"');
|
||||
});
|
||||
|
||||
@@ -177,6 +177,7 @@ import { roleDict, getDictLabel } from "../../dictionaries";
|
||||
import { exportAuditCsv } from "../../audit/export/auditExportService";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
|
||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const study = useStudyStore();
|
||||
@@ -216,11 +217,11 @@ const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
|
||||
const canProjectExport = computed(() => {
|
||||
if (isAdmin.value) return true;
|
||||
return projectRole.value === "PM" && !!permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]?.allowed;
|
||||
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]);
|
||||
});
|
||||
const canAccessAuditLogs = computed(() => {
|
||||
if (isAdmin.value) return true;
|
||||
return projectRole.value === "PM" && !!permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]?.allowed;
|
||||
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]);
|
||||
});
|
||||
|
||||
const ensureAccess = () => {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionManagement.vue"), "utf8");
|
||||
|
||||
describe("permission management custom roles", () => {
|
||||
it("uses role management wording in the drawer", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('title="角色管理"');
|
||||
expect(source).toContain('label="角色列表"');
|
||||
expect(source).toContain('placeholder="角色类型"');
|
||||
expect(source).toContain("新增角色");
|
||||
expect(source).toContain('label="角色名称"');
|
||||
expect(source).toContain("roleDisplayName(row)");
|
||||
expect(source).not.toContain('title="权限模板管理"');
|
||||
expect(source).not.toContain('label="模板列表"');
|
||||
expect(source).not.toContain("新增模板");
|
||||
});
|
||||
|
||||
it("keeps custom active roles configurable for permissions and members", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("customRoleInput");
|
||||
expect(source).toContain("addCustomRole");
|
||||
expect(source).toContain("roleOptions");
|
||||
expect(source).toContain("ROLE_LABELS[role] || role");
|
||||
expect(source).toContain("ROLE_LABELS[row.category] || row.name");
|
||||
expect(source).toContain("await loadPermissionData();");
|
||||
expect(source).toContain('activeRolesInStudy.value[0] || ""');
|
||||
expect(source).toContain('role === "ADMIN"');
|
||||
});
|
||||
|
||||
it("does not render the duplicated monitoring header", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("<PermissionMonitoring />");
|
||||
expect(source).not.toContain("实时监控权限使用情况、趋势分析与异常告警");
|
||||
expect(source).not.toContain("重置指标");
|
||||
expect(source).not.toContain("refreshMonitoring");
|
||||
expect(source).not.toContain("doResetMetrics");
|
||||
});
|
||||
|
||||
it("does not render the duplicated system permissions header", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("systemPermissionsByModule");
|
||||
expect(source).not.toContain("<h2 class=\"perm-title\">系统级权限</h2>");
|
||||
expect(source).not.toContain("只读展示,所有系统管理操作仅限 ADMIN 角色执行");
|
||||
});
|
||||
|
||||
it("removes the vertical gap between permission headers and body content", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("padding: 0 0 20px;");
|
||||
expect(source).not.toContain("padding: 20px 0 20px;");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,7 @@ import ProjectForm from "./ProjectForm.vue";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { isSystemAdmin } from "../../utils/roles";
|
||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const projects = ref<Study[]>([]);
|
||||
@@ -127,7 +128,7 @@ const openCreate = () => {
|
||||
};
|
||||
|
||||
const MODULE_TO_OPERATION: Record<string, Record<string, string>> = {
|
||||
project_members: { read: "project_members:read", write: "project_members:update" },
|
||||
project_members: { read: "project_members:list", write: "project_members:update" },
|
||||
sites: { read: "sites:read", write: "sites:update" },
|
||||
audit_export: { read: "audit_logs:read", write: "audit_logs:read" },
|
||||
};
|
||||
@@ -137,7 +138,7 @@ const canProject = (row: Study, module: string, action: "read" | "write") => {
|
||||
const role = row.role_in_study || "";
|
||||
const operationKey = MODULE_TO_OPERATION[module]?.[action];
|
||||
if (!operationKey) return false;
|
||||
return !!permissionsByProject.value[row.id]?.[role]?.[operationKey]?.allowed;
|
||||
return isApiPermissionAllowed(permissionsByProject.value[row.id]?.[role]?.[operationKey]);
|
||||
};
|
||||
|
||||
const goMembers = (row: Study) => {
|
||||
|
||||
@@ -66,7 +66,6 @@ describe("route view overlays are mounted only when visible", () => {
|
||||
'<el-drawer v-if="projectMilestoneEditorVisible"',
|
||||
'<el-drawer v-if="siteMilestoneEditorVisible"',
|
||||
'<el-drawer v-if="siteEnrollmentEditorVisible"',
|
||||
'<el-drawer v-if="monitoringStrategyEditorVisible"',
|
||||
'const getRollbackAxisLeftStyle = (axisX?: number | null) => ({',
|
||||
':style="getRollbackAxisLeftStyle(lane.x)"',
|
||||
':style="getRollbackAxisLeftStyle(row.axisX)"',
|
||||
|
||||
@@ -164,7 +164,7 @@ const roleLabel = computed(() => displayEnum(TEXT.enums.userRole, projectRole.va
|
||||
const emptyText = TEXT.modules.workbench.quickEmpty;
|
||||
const isCraWorkbench = computed(() => projectRole.value === "CRA");
|
||||
const canAccessProjectPath = (path: string) =>
|
||||
hasProjectPermission(study.currentPermissions?.roles, projectRole.value, getProjectRoutePermission(path), isSystemAdmin(auth.user));
|
||||
hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isSystemAdmin(auth.user));
|
||||
const quickActionCandidates = [
|
||||
{ label: TEXT.modules.workbench.feasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer },
|
||||
{ label: TEXT.modules.workbench.aeList, path: "/subjects", icon: Warning },
|
||||
|
||||
Reference in New Issue
Block a user