feat(perm/frontend): PM 视角的权限管理界面与个人有效权限接入

- store/study 与 router 改为读取 /api-permissions/me 拉取当前用户在
  当前项目的有效权限,菜单与路由守卫据此判定,并在 PM 项目缺省时
  主动 ensureDefaultPmStudy。
- api/projectPermissions 暴露 fetchMyApiEndpointPermissions,并允许
  调用方关闭统一错误提示,便于在批量项目权限拉取场景下静默失败。
- 项目管理:Projects 页根据每个项目的有效权限决定项目入口的可点击
  状态,缺权限时禁用项目名跳转;ProjectDetail 据 setup_config 写权限
  渲染保存/回填/清空/发布按钮,并在无读取权限时提示并退出页面。
- 权限管理:PermissionManagement 切换为按角色提交,Admin 视角才能
  编辑 PM 行;按系统模块美化系统级权限展示并标注 PM 可访问项;项目
  成员表的角色下拉受 PM 等级约束,自定义角色入口下线。
- 监控:PermissionMonitoring/PermissionAccessLogs 增加 isAdmin 与
  showSecurityLog 入参,仅 ADMIN 才能看到底层安全日志与统计。
- ApiEndpointPermissions 表格根据当前用户角色禁用不可写行,避免
  误操作 PM 行;MODULE_LABELS 增加 setup_config 的中文展示。
- 测试同步覆盖以上行为:新版 router/Layout/Projects/PermissionManagement
  /PermissionAccessLogs/PermissionMonitoring/PermissionIpLocations 等
  组件均补足检测,并按需为相关组件提供 pinia + localStorage stub。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-25 12:38:54 +08:00
parent 747dd55225
commit 934d114a29
22 changed files with 501 additions and 145 deletions
+188 -93
View File
@@ -103,7 +103,12 @@
@change="(val: string) => updateMemberRole(row.id, val)"
:disabled="!canEditMember(row) || !row.is_active || row.effectiveStatus === 'DISABLED_GLOBAL'"
>
<el-option v-for="(label, val) in activeRoleLabels" :key="val" :label="label" :value="val" />
<el-option
v-for="(label, val) in memberRoleLabels(row)"
:key="val"
:label="label"
:value="val"
/>
</el-select>
</template>
</el-table-column>
@@ -166,19 +171,31 @@
<span class="system-stat-item">
<strong>{{ systemPermissions.length }}</strong> 项权限
</span>
<span class="system-stat-divider" />
<span class="system-stat-item">
<strong>{{ systemPermissions.filter(p => p.roles.includes('PM')).length }}</strong> PM 可访问
</span>
</div>
<div v-for="(items, moduleKey) in systemPermissionsByModule" :key="moduleKey" class="system-module-block">
<div class="system-module-header">
<div class="system-module-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
<div class="system-module-icon" :class="`module-icon--${moduleKey}`">
<svg v-if="moduleKey === 'system_users'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
<svg v-else-if="moduleKey === 'system_projects'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<svg v-else-if="moduleKey === 'system_permissions'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<svg v-else-if="moduleKey === 'system_monitoring'" 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>
<svg v-else-if="moduleKey === 'system_audit'" 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"/></svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
</div>
<div class="system-module-info">
<span class="system-module-title">{{ systemModuleLabels[moduleKey] || moduleKey }}</span>
<span class="system-module-count">{{ items.length }} 项操作</span>
</div>
<div class="system-module-roles-summary">
<el-tag v-if="items.some(i => i.roles.includes('PM'))" size="small" type="warning" effect="plain" round> PM 权限</el-tag>
</div>
</div>
<div class="system-perm-list">
<div v-for="row in items" :key="row.permission_key" class="system-perm-row">
<div v-for="(row, idx) in items" :key="row.permission_key" class="system-perm-row" :class="{ 'system-perm-row--alt': idx % 2 === 1 }">
<div class="system-perm-main">
<el-tag :type="opTagType({ operation_key: row.permission_key, action: row.action })" size="small" class="system-perm-action">
{{ opActionLabel({ operation_key: row.permission_key, action: row.action }) }}
@@ -187,7 +204,15 @@
</div>
<code class="perm-key">{{ row.permission_key }}</code>
<div class="system-perm-roles">
<el-tag v-for="r in row.roles" :key="r" size="small" effect="dark" round class="role-chip">{{ ROLE_LABELS[r] || r }}</el-tag>
<el-tag
v-for="r in row.roles"
:key="r"
size="small"
:effect="r === 'ADMIN' ? 'dark' : 'plain'"
:type="r === 'ADMIN' ? '' : 'warning'"
round
class="role-chip"
>{{ roleLabel(r) }}</el-tag>
</div>
</div>
</div>
@@ -202,7 +227,7 @@
<template v-else-if="activeTab === 'monitoring'">
<div class="perm-body">
<div class="perm-body-padded">
<PermissionMonitoring />
<PermissionMonitoring :is-admin="isAdmin" />
</div>
</div>
</template>
@@ -275,10 +300,6 @@
/>
</div>
</div>
<div class="custom-role-entry">
<el-input v-model="customRoleInput" placeholder="输入自定义角色标识,如 DATA_MANAGER" maxlength="20" clearable />
<el-button @click="addCustomRole">新增角色</el-button>
</div>
<div class="drawer-footer">
<el-button type="primary" :loading="activeRolesSaving" @click="saveActiveRoles">保存</el-button>
</div>
@@ -310,16 +331,16 @@
<!-- 右侧权限编辑区 -->
<div class="role-editor-content">
<template v-if="editingRole">
<div v-if="editingRole === 'PM'" class="role-editor-notice">
<el-alert title="项目负责人拥有全部权限不可修改" type="info" :closable="false" show-icon />
<div v-if="!canEditSelectedRole" class="role-editor-notice">
<el-alert :title="editingRole === 'PM' ? '仅系统管理员可修改项目负责人权限' : '该角色权限不可修改'" type="info" :closable="false" show-icon />
</div>
<div class="role-editor-toolbar">
<el-input v-model="roleEditorSearch" placeholder="搜索权限..." clearable style="width: 220px">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<div class="filter-spacer" />
<el-button size="small" :disabled="editingRole === 'PM'" @click="roleEditorSelectAll(true)">全选</el-button>
<el-button size="small" :disabled="editingRole === 'PM'" @click="roleEditorSelectAll(false)">全不选</el-button>
<el-button size="small" :disabled="!canEditSelectedRole" @click="roleEditorSelectAll(true)">全选</el-button>
<el-button size="small" :disabled="!canEditSelectedRole" @click="roleEditorSelectAll(false)">全不选</el-button>
</div>
<div class="role-editor-modules">
<div v-for="(ops, mod) in roleEditorGrouped" :key="mod" class="role-editor-module">
@@ -331,7 +352,7 @@
<div v-for="op in ops" :key="op.operation_key" class="role-editor-op-row">
<el-checkbox
:model-value="roleEditorDraft[op.operation_key] ?? false"
:disabled="editingRole === 'PM'"
:disabled="!canEditSelectedRole"
@change="(v: boolean) => roleEditorDraft[op.operation_key] = v"
/>
<el-tag :type="opTagType(op)" size="small" class="op-action-tag">{{ opActionLabel(op) }}</el-tag>
@@ -341,8 +362,8 @@
</div>
</div>
<div class="drawer-footer">
<el-button type="primary" :loading="roleEditorSaving" :disabled="editingRole === 'PM'" @click="saveRoleEditor">
保存 {{ ROLE_LABELS[editingRole] }} 权限
<el-button type="primary" :loading="roleEditorSaving" :disabled="!canEditSelectedRole" @click="saveRoleEditor">
保存 {{ roleLabel(editingRole) }} 权限
</el-button>
</div>
</template>
@@ -402,7 +423,7 @@
</el-form-item>
<el-form-item label="项目角色" prop="role_in_study">
<el-select v-model="addMemberForm.role_in_study" style="width: 100%">
<el-option v-for="(label, val) in activeRoleLabels" :key="val" :label="label" :value="val" />
<el-option v-for="(label, val) in assignableRoleLabels" :key="val" :label="label" :value="val" />
</el-select>
</el-form-item>
</el-form>
@@ -447,6 +468,7 @@ import { fetchStudies } from "@/api/studies";
import { useAuthStore } from "@/store/auth";
import { displayDateTime } from "@/utils/display";
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
import { isSystemAdmin } from "@/utils/roles";
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
@@ -454,22 +476,43 @@ import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => isSystemAdmin(auth.user));
const selectedProjectRole = computed(() => selectedStudy.value?.role_in_study || "");
const isSelectedProjectPm = computed(() => selectedProjectRole.value === "PM");
const canManageSelectedProject = computed(() => isAdmin.value || isSelectedProjectPm.value);
const ROLE_LABELS: Record<string, string> = {
PM: "项目负责人", CRA: "CRA", PV: "PV",
MEDICAL_REVIEW: "医学审核", IMP: "药品管理员", QA: "QA",
};
const roleLabel = (role: string) => ROLE_LABELS[role] || role;
const activeRoleLabels = computed(() => {
const result: Record<string, string> = {};
for (const role of activeRolesInStudy.value) {
result[role] = ROLE_LABELS[role] || role;
result[role] = roleLabel(role);
}
return result;
});
const ROLE_RANK: Record<string, number> = {
ADMIN: 100, PM: 80, QA: 60, PV: 50, MEDICAL_REVIEW: 50, CRA: 40, IMP: 40,
};
type MemberRoleRow = { user_id: string; role_in_study: string; user?: any };
const canAssignProjectRole = (role: string) => isAdmin.value || (ROLE_RANK[role] ?? 0) < ROLE_RANK.PM;
const assignableRoleLabels = computed(() => {
const result: Record<string, string> = {};
for (const role of activeRolesInStudy.value) {
if (canAssignProjectRole(role)) result[role] = roleLabel(role);
}
return result;
});
const memberRoleLabels = (row: MemberRoleRow) => {
if (canEditMember(row)) return assignableRoleLabels.value;
return {
...assignableRoleLabels.value,
[row.role_in_study]: roleLabel(row.role_in_study),
};
};
// ── 顶层标签 ──
const tabFromPath = (): "project" | "system" | "monitoring" => {
@@ -513,7 +556,8 @@ const currentPermissionsForTemplate = computed(() => {
const loadStudies = async () => {
try {
const res = await fetchStudies();
studies.value = res.data.items ?? [];
const items = res.data.items ?? [];
studies.value = isAdmin.value ? items : items.filter((item: Study) => item.role_in_study === "PM");
} catch {
ElMessage.error("加载项目列表失败");
}
@@ -534,6 +578,12 @@ const loadPermissionData = async () => {
};
const onStudyChange = async (id: string) => {
const study = studies.value.find((s) => s.id === id);
if (!isAdmin.value && study?.role_in_study !== "PM") {
ElMessage.error("仅项目 PM 可配置该项目");
selectedStudyId.value = "";
return;
}
apiMatrix.value = null;
dirty.value = false;
members.value = [];
@@ -549,9 +599,14 @@ const onApiMatrixUpdate = (m: ApiEndpointPermissionsResponse) => { apiMatrix.val
const activeRolesInStudy = computed(() => activeRolesDraft.value);
// 把 apiMatrix(可能混有 { allowed: boolean } 或 boolean)展平为 boolean 格式
const flattenMatrix = (matrix: ApiEndpointPermissionsResponse): Record<string, Record<string, boolean>> => {
const flattenMatrix = (
matrix: ApiEndpointPermissionsResponse,
{ includePm = true }: { includePm?: boolean } = {}
): Record<string, Record<string, boolean>> => {
const result: Record<string, Record<string, boolean>> = {};
for (const [role, endpoints] of Object.entries(matrix as Record<string, Record<string, any>>)) {
if (role === "ADMIN") continue;
if (role === "PM" && !includePm) continue;
result[role] = {};
for (const [key, val] of Object.entries(endpoints)) {
result[role][key] = typeof val === "boolean" ? val : val.allowed;
@@ -560,11 +615,21 @@ const flattenMatrix = (matrix: ApiEndpointPermissionsResponse): Record<string, R
return result;
};
const flattenRolePermissions = (
role: string,
permissions: Record<string, boolean>
): Record<string, Record<string, boolean>> => ({
[role]: { ...permissions },
});
const save = async () => {
if (!selectedStudyId.value || !dirty.value || !apiMatrix.value) return;
saving.value = true;
try {
const res = await updateApiEndpointPermissions(selectedStudyId.value, flattenMatrix(apiMatrix.value));
const res = await updateApiEndpointPermissions(
selectedStudyId.value,
flattenMatrix(apiMatrix.value, { includePm: isAdmin.value })
);
apiMatrix.value = res.data;
dirty.value = false;
ElMessage.success("权限已保存");
@@ -588,10 +653,11 @@ const addMemberRules: FormRules = {
role_in_study: [{ required: true, message: "请选择角色", trigger: "change" }],
};
const canEditMember = (row: { user_id: string; role_in_study: string; user?: any }) => {
if (!auth.user?.is_admin) return false;
const canEditMember = (row: MemberRoleRow) => {
if (!canManageSelectedProject.value) return false;
if (row.user_id === auth.user?.id) return false;
if (row.user?.is_admin) return false;
if (!isAdmin.value && (ROLE_RANK[row.role_in_study] ?? 0) >= ROLE_RANK.PM) return false;
return true;
};
@@ -633,7 +699,7 @@ const loadCandidates = async () => {
const openAddMember = () => {
addMemberForm.user_id = "";
addMemberForm.role_in_study = activeRolesInStudy.value.includes("PM") ? "PM" : activeRolesInStudy.value[0] || "";
addMemberForm.role_in_study = Object.keys(assignableRoleLabels.value)[0] || "";
addMemberVisible.value = true;
};
@@ -718,7 +784,6 @@ const ALL_ROLES = [
const activeRolesDraft = ref<string[]>([]);
const activeRolesLoading = ref(false);
const activeRolesSaving = ref(false);
const customRoleInput = ref("");
const roleOptions = computed(() => {
const options = [...ALL_ROLES];
@@ -759,22 +824,8 @@ const toggleActiveRole = (role: string, active: boolean) => {
}
};
const addCustomRole = () => {
const role = customRoleInput.value.trim();
if (!role) return;
if (role === "ADMIN") {
ElMessage.warning("ADMIN 不能作为项目角色");
return;
}
if (role.length > 20) {
ElMessage.warning("项目角色长度不能超过20个字符");
return;
}
if (!activeRolesDraft.value.includes(role)) activeRolesDraft.value.push(role);
customRoleInput.value = "";
};
const saveActiveRoles = async () => {
if (!canManageSelectedProject.value) return;
activeRolesSaving.value = true;
try {
await updateActiveRoles(selectedStudyId.value, activeRolesDraft.value);
@@ -799,11 +850,18 @@ const templateRules: FormRules = {
const typeLabel = (t: string) => ({ ROLE: "预设角色", SCENARIO: "场景角色", CUSTOM: "自定义角色" }[t] ?? t);
const typeTagType = (t: string) =>
({ ROLE: "primary", SCENARIO: "warning", CUSTOM: "success" } as Record<string, any>)[t] ?? "info";
const roleDisplayName = (row: PermissionTemplate) => (row.category ? (ROLE_LABELS[row.category] || row.name) : row.name);
const templateRoleName = (row: PermissionTemplate) => row.category ? roleLabel(row.category) : row.name;
const roleDisplayName = (row: PermissionTemplate) => templateRoleName(row);
const countEnabledPermissions = (permissions: Record<string, boolean>) =>
Object.values(permissions).filter(Boolean).length;
const countPermissions = (row: PermissionTemplate) => {
const role = row.category;
const currentRolePermissions = role ? currentPermissionsForTemplate.value?.[role] : undefined;
if (currentRolePermissions) return countEnabledPermissions(currentRolePermissions);
let n = 0;
for (const perms of Object.values(row.permissions) as Record<string, boolean>[])
n += Object.values(perms).filter(Boolean).length;
n += countEnabledPermissions(perms);
return n;
};
@@ -828,7 +886,7 @@ const openCreateTemplate = () => {
const openEditTemplate = (row: PermissionTemplate) => {
editingTemplateId.value = row.id;
templateForm.value = {
name: row.name, description: row.description ?? "",
name: templateRoleName(row), description: row.description ?? "",
template_type: row.template_type, category: row.category ?? "",
recommended_roles: row.recommended_roles ?? "", tags: row.tags ?? "",
};
@@ -934,7 +992,7 @@ const ROLE_EDITOR_MODULE_LABELS: Record<string, string> = {
subjects: "参与者管理", sites: "中心管理", risk_issues: "风险问题",
monitoring_audit: "监查稽查", project_members: "项目成员",
project_milestones: "项目里程碑", project_overview: "项目总览",
fees: "合同费用", materials: "物资管理", material_equipments: "物资设备",
setup_config: "立项配置", fees: "合同费用", materials: "物资管理", material_equipments: "物资设备",
documents: "文件版本", startup_ethics: "立项与伦理", startup_auth: "启动与授权",
audit_export: "审计日志", faq: "FAQ", shared_library: "共享库",
attachments: "附件管理", dashboard: "仪表板", subject_histories: "参与者历史",
@@ -978,6 +1036,13 @@ const roleEditorGrouped = computed(() => {
return map;
});
const canEditSelectedRole = computed(() => {
if (editingRole.value === "ADMIN") return false;
if (isAdmin.value) return true;
if (editingRole.value === "PM") return false;
return true;
});
const selectRoleForEdit = (role: string) => {
editingRole.value = role;
roleEditorSearch.value = "";
@@ -1022,19 +1087,19 @@ const roleEditorSelectAll = (val: boolean) => {
const saveRoleEditor = async () => {
if (!selectedStudyId.value || !apiMatrix.value) return;
if (!canEditSelectedRole.value) {
ElMessage.warning("当前角色权限不可修改");
return;
}
roleEditorSaving.value = true;
try {
const payload = flattenMatrix(apiMatrix.value);
if (!payload[editingRole.value]) payload[editingRole.value] = {};
for (const [key, val] of Object.entries(roleEditorDraft.value)) {
payload[editingRole.value][key] = val;
}
const payload = flattenRolePermissions(editingRole.value, roleEditorDraft.value);
const res = await updateApiEndpointPermissions(selectedStudyId.value, payload);
apiMatrix.value = res.data;
dirty.value = false;
const savedRole = editingRole.value;
editingRole.value = "";
ElMessage.success(`${ROLE_LABELS[savedRole] || savedRole} 权限已保存`);
ElMessage.success(`${roleLabel(savedRole)} 权限已保存`);
} catch {
ElMessage.error("保存失败");
} finally {
@@ -1055,8 +1120,9 @@ onMounted(async () => {
const qProjectId = route.query.projectId as string | undefined;
const qSub = route.query.sub as string | undefined;
if (qProjectId) {
selectedStudyId.value = qProjectId;
const initialStudyId = qProjectId || studies.value[0]?.id;
if (initialStudyId) {
selectedStudyId.value = initialStudyId;
if (qSub === "members") projectSubTab.value = "members";
await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]);
loadCandidates();
@@ -1278,15 +1344,15 @@ onMounted(async () => {
padding: 20px 28px;
display: flex;
flex-direction: column;
gap: 16px;
gap: 14px;
}
.system-module-block {
background: #fff;
border-radius: 14px;
padding: 20px 24px;
border-radius: 12px;
padding: 16px 20px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.02);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition: box-shadow 0.2s ease;
}
@@ -1298,8 +1364,8 @@ onMounted(async () => {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 14px;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #f1f5f9;
}
@@ -1307,67 +1373,103 @@ onMounted(async () => {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
width: 34px;
height: 34px;
border-radius: 9px;
background: linear-gradient(135deg, #eff6ff, #e0f2fe);
color: #3b82f6;
flex-shrink: 0;
}
.system-module-icon.module-icon--system_users {
background: linear-gradient(135deg, #fef3c7, #fde68a);
color: #d97706;
}
.system-module-icon.module-icon--system_projects {
background: linear-gradient(135deg, #ede9fe, #ddd6fe);
color: #7c3aed;
}
.system-module-icon.module-icon--system_permissions {
background: linear-gradient(135deg, #ecfdf5, #d1fae5);
color: #059669;
}
.system-module-icon.module-icon--system_monitoring {
background: linear-gradient(135deg, #eff6ff, #dbeafe);
color: #2563eb;
}
.system-module-icon.module-icon--system_audit {
background: linear-gradient(135deg, #fef2f2, #fecaca);
color: #dc2626;
}
.system-module-icon svg {
width: 18px;
height: 18px;
width: 17px;
height: 17px;
}
.system-module-info {
display: flex;
flex-direction: column;
gap: 2px;
gap: 1px;
flex: 1;
}
.system-module-title {
font-size: 15px;
font-size: 14px;
font-weight: 600;
color: #1a2332;
}
.system-module-count {
font-size: 12px;
color: #64748b;
color: #94a3b8;
}
.system-module-roles-summary {
flex-shrink: 0;
}
.system-perm-list {
display: flex;
flex-direction: column;
gap: 4px;
gap: 0;
}
.system-perm-row {
display: grid;
grid-template-columns: minmax(200px, 1fr) minmax(180px, auto) auto;
gap: 16px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
align-items: center;
padding: 10px 12px;
border-radius: 8px;
transition: background 0.15s ease;
padding: 8px 10px;
border-radius: 7px;
transition: background 0.12s ease;
}
.system-perm-row--alt {
background: #fafbfc;
}
.system-perm-row:hover {
background: #f8fafc;
background: #f1f5f9;
}
.system-perm-main {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
min-width: 0;
justify-self: start;
}
.system-perm-action {
flex-shrink: 0;
min-width: 44px;
min-width: 42px;
text-align: center;
font-size: 11px;
}
.system-perm-desc {
@@ -1382,7 +1484,8 @@ onMounted(async () => {
display: flex;
gap: 4px;
flex-wrap: wrap;
justify-content: flex-end;
justify-content: flex-start;
justify-self: start;
}
.role-chip {
@@ -1393,10 +1496,11 @@ onMounted(async () => {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: #f8fafc;
padding: 10px 16px;
background: #fff;
border-radius: 10px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.system-stat-item {
@@ -1419,11 +1523,12 @@ onMounted(async () => {
.system-perm-table { width: 100%; }
.perm-key {
justify-self: start;
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
font-size: 11.5px;
background: linear-gradient(135deg, #f0f4f8, #e8edf5);
padding: 3px 10px;
border-radius: 6px;
font-size: 11px;
background: #f1f5f9;
padding: 2px 8px;
border-radius: 5px;
color: #475569;
border: 1px solid #e2e8f0;
white-space: nowrap;
@@ -1506,16 +1611,6 @@ onMounted(async () => {
margin-top: 2px;
}
.custom-role-entry {
display: flex;
gap: 8px;
margin-top: 16px;
padding: 12px 14px;
background: #f8fafc;
border-radius: 10px;
border: 1px solid #f1f5f9;
}
.template-name { font-weight: 600; color: #1a2332; }
/* ── 角色编辑器 ── */