Files
ctms/frontend/src/components/PermissionTemplateSelector.vue
T
Cheng Zhou d5279b124f
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
release(main): 同步 dev 最新候选改动
2026-07-16 17:15:50 +08:00

388 lines
9.1 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">
<button
v-for="template in sortedTemplates"
:key="template.id"
type="button"
class="template-card"
:class="{ inactive: template.category && !isRoleActive(template.category) }"
:disabled="!template.category"
:aria-label="template.category ? `配置 ${template.name} 权限` : template.name"
@click="template.category && emit('edit-role', template.category!)"
>
<div class="card-top">
<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>
<span :class="['card-status', isRoleActive(template.category || '') ? 'card-status--active' : 'card-status--inactive']">
{{ isRoleActive(template.category || '') ? '使用中' : '未启用' }}
</span>
</div>
<div class="card-stats">
<template v-if="props.currentPermissions && template.category && props.currentPermissions[template.category]">
<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 class="card-action">配置权限 <span aria-hidden="true">→</span></div>
</button>
</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: "PM", CRA: "CRA", PV: "PV",
QA: "QA", CTA: "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: 4px 0 6px;
}
.selector-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 8px;
}
.selector-title-group h3 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #1a2332;
}
.template-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: start;
gap: 8px;
margin-bottom: 6px;
}
.template-card {
position: relative;
width: 100%;
min-height: 122px;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
cursor: pointer;
color: inherit;
font: inherit;
display: flex;
flex-direction: column;
text-align: left;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
.template-card:hover {
border-color: #93c5fd;
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.1);
transform: translateY(-2px);
}
.template-card:focus-visible {
outline: 3px solid rgba(59, 130, 246, 0.25);
outline-offset: 2px;
}
.template-card:disabled {
cursor: default;
}
.template-card.inactive {
border-style: dashed;
border-color: #e2e8f0;
background: #fafbfc;
}
.template-card.inactive:hover {
border-color: #cbd5e1;
box-shadow: none;
transform: none;
}
.card-top {
display: flex;
align-items: flex-start;
gap: 7px;
margin-bottom: 7px;
}
.card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 7px;
background: #eef5fa;
color: #315f7a;
font-size: 9px;
font-weight: 800;
line-height: 1;
flex-shrink: 0;
}
.card-title-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.card-name {
font-size: 12px;
font-weight: 600;
color: #1a2332;
}
.template-card.inactive .card-name {
color: #94a3b8;
}
.card-desc {
font-size: 10px;
color: #94a3b8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-status {
display: inline-flex;
align-items: center;
flex-shrink: 0;
padding: 2px 5px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 9.5px;
font-weight: 600;
}
.card-status--active {
border-color: #b9ddcc;
background: #edf8f2;
color: #267054;
}
.card-status--inactive {
border-color: #dbe3ea;
background: #f5f7f9;
color: #7a8997;
}
.card-action {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-top: auto;
padding-top: 6px;
border-top: 1px solid #edf1f5;
color: #416b83;
font-size: 10.5px;
font-weight: 600;
}
.card-badge {
flex-shrink: 0;
}
.card-stats {
display: flex;
flex-direction: column;
gap: 5px;
}
.stat-bar {
height: 3px;
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: 8px;
}
.stat {
display: flex;
align-items: center;
gap: 4px;
font-size: 10.5px;
color: #64748b;
}
.stat-dot {
width: 5px;
height: 5px;
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; }
@media (max-width: 1180px) {
.template-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 920px) {
.template-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.template-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>