Files
ctms/frontend/src/components/PermissionTemplateSelector.vue
T
Cheng Zhou d6452e3f9d 完善项目权限前置依赖保存校验
- 以接口权限定义中的 prerequisite_permissions 作为前置依赖单一事实源,生成运行时前置权限映射\n- 保存项目角色权限前预检缺失前置权限与受影响下游权限,未确认时返回 409 调整详情\n- 确认保存时自动合并补齐前置权限与取消下游权限,并提供有效权限矩阵读取接口\n- 前端角色权限编辑改为保存时执行依赖校验,弹窗展示需补充前置权限和受影响下游权限\n- 新增编辑权限取消按钮,清理权限选择即时确认弹窗残留\n- 补充后端接口测试、前端权限编辑测试与优化设计文档
2026-06-04 16:29:44 +08:00

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>