d6452e3f9d
- 以接口权限定义中的 prerequisite_permissions 作为前置依赖单一事实源,生成运行时前置权限映射\n- 保存项目角色权限前预检缺失前置权限与受影响下游权限,未确认时返回 409 调整详情\n- 确认保存时自动合并补齐前置权限与取消下游权限,并提供有效权限矩阵读取接口\n- 前端角色权限编辑改为保存时执行依赖校验,弹窗展示需补充前置权限和受影响下游权限\n- 新增编辑权限取消按钮,清理权限选择即时确认弹窗残留\n- 补充后端接口测试、前端权限编辑测试与优化设计文档
304 lines
7.4 KiB
Vue
304 lines
7.4 KiB
Vue
<template>
|
|
<div class="template-selector">
|
|
<div class="selector-header">
|
|
<div class="selector-title-group">
|
|
<h3>角色权限概览</h3>
|
|
</div>
|
|
<el-tag effect="plain" size="small" type="info" round>{{ templates.length }} 个角色</el-tag>
|
|
</div>
|
|
|
|
<div class="template-grid">
|
|
<div
|
|
v-for="template in sortedTemplates"
|
|
:key="template.id"
|
|
class="template-card"
|
|
:class="{ inactive: template.category && !isRoleActive(template.category) }"
|
|
@click="template.category && emit('edit-role', template.category!)"
|
|
>
|
|
<div class="card-top">
|
|
<span class="card-icon">{{ roleIcon(template.category) }}</span>
|
|
<div class="card-title-wrap">
|
|
<span class="card-name">{{ template.name }}</span>
|
|
<span v-if="template.description" class="card-desc">{{ template.description }}</span>
|
|
</div>
|
|
<el-tag v-if="!template.is_system" size="small" effect="dark" round type="success" class="card-badge">自定义</el-tag>
|
|
</div>
|
|
<div class="card-stats">
|
|
<template v-if="props.currentPermissions && template.category && props.currentPermissions[template.category]">
|
|
<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>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, onMounted, watch } from "vue";
|
|
import { ElMessage } from "element-plus";
|
|
import {
|
|
fetchPermissionTemplates,
|
|
type PermissionTemplate,
|
|
} from "@/api/projectPermissions";
|
|
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
|
|
|
|
const props = defineProps<{
|
|
studyId: string;
|
|
currentPermissions?: Record<string, Record<string, boolean>>;
|
|
activeRoles?: string[];
|
|
refreshKey?: number;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
"edit-role": [role: string];
|
|
}>();
|
|
|
|
const templates = ref<PermissionTemplate[]>([]);
|
|
const { compareRolesByTemplateOrder } = useRoleTemplateMeta();
|
|
|
|
const sortedTemplates = computed(() =>
|
|
[...templates.value].sort((a, b) => {
|
|
if (a.category && b.category) return compareRolesByTemplateOrder(a.category, b.category);
|
|
if (a.category) return -1;
|
|
if (b.category) return 1;
|
|
return 0;
|
|
})
|
|
);
|
|
|
|
const roleIcon = (category: string | null) => {
|
|
const icons: Record<string, string> = {
|
|
PM: "👑", CRA: "📋", PV: "🔍",
|
|
QA: "🏥", CTA: "📦",
|
|
};
|
|
return icons[category ?? ""] ?? "📄";
|
|
};
|
|
|
|
const isRoleActive = (role: string) => props.activeRoles?.includes(role) ?? false;
|
|
|
|
const countEnabled = (t: PermissionTemplate) => {
|
|
let n = 0;
|
|
for (const perms of Object.values(t.permissions) as Record<string, boolean>[])
|
|
n += (Object.values(perms) as boolean[]).filter(Boolean).length;
|
|
return n;
|
|
};
|
|
|
|
const countDisabled = (t: PermissionTemplate) => {
|
|
let n = 0;
|
|
for (const perms of Object.values(t.permissions) as Record<string, boolean>[])
|
|
n += (Object.values(perms) as boolean[]).filter((v) => !v).length;
|
|
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;
|
|
return Object.values(perms).filter(Boolean).length;
|
|
};
|
|
|
|
const countCurrentDisabled = (role: string) => {
|
|
const perms = props.currentPermissions?.[role];
|
|
if (!perms) return 0;
|
|
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();
|
|
templates.value = res.data;
|
|
} catch {
|
|
ElMessage.error("加载模板失败");
|
|
}
|
|
};
|
|
|
|
onMounted(loadTemplates);
|
|
watch(() => props.refreshKey, loadTemplates);
|
|
</script>
|
|
|
|
<style scoped>
|
|
.template-selector {
|
|
padding: 8px 0 12px;
|
|
}
|
|
|
|
.selector-header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.selector-title-group h3 {
|
|
margin: 0 0 4px;
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
color: #1a2332;
|
|
}
|
|
|
|
.template-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
align-items: start;
|
|
gap: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.template-card {
|
|
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;
|
|
border-color: #e2e8f0;
|
|
background: #fafbfc;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.template-card.inactive:hover {
|
|
border-color: #cbd5e1;
|
|
box-shadow: none;
|
|
transform: none;
|
|
}
|
|
|
|
.card-top {
|
|
display: flex;
|
|
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: 2px;
|
|
}
|
|
|
|
.card-name {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: #1a2332;
|
|
}
|
|
|
|
.template-card.inactive .card-name {
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.card-desc {
|
|
font-size: 11px;
|
|
color: #94a3b8;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.card-badge {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.card-stats {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.stat-bar {
|
|
height: 4px;
|
|
border-radius: 2px;
|
|
background: #f1f5f9;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.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: 4px;
|
|
font-size: 12px;
|
|
color: #64748b;
|
|
}
|
|
|
|
.stat-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.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>
|