Files
cma-management/frontend/src/views/RolePermissions.vue
T
Hermes CI Fix 3dddd36866 init: 管理会计OS初始代码
包含前后端完整代码:
- 前端:Vue3+Vite+ElementPlus
- 后端:FastAPI+SQLAlchemy
- 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动
- 当前版本:v1.0.0
2026-05-28 17:32:22 +08:00

329 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<h3>系统设置</h3>
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<!-- Tab 1: 角色权限 -->
<el-tab-pane label="角色权限" name="permissions">
<p style="color:#999;font-size:13px;margin-top:4px;">勾选每个角色可访问的模块和可执行的操作修改后实时生效</p>
<el-tabs v-model="permTab">
<el-tab-pane label="模块权限" name="routes">
<el-table :data="routeTable" border style="width:100%;margin-top:12px;">
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="moduleName" label="模块" width="150" />
<el-table-column v-for="role in roles" :key="role.code" :label="role.name" width="120">
<template #default="{ row }">
<el-checkbox :model-value="row[role.code]" @change="(v: boolean) => toggleRoute(role.code, row.moduleKey, v)" />
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="操作权限" name="actions">
<el-table :data="actionTable" border style="width:100%;margin-top:12px;">
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="actionName" label="操作" width="150" />
<el-table-column v-for="role in roles" :key="role.code" :label="role.name" width="120">
<template #default="{ row }">
<el-checkbox :model-value="row[role.code]" @change="(v: boolean) => toggleAction(role.code, row.actionKey, v)" />
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<div style="margin-top:20px;display:flex;gap:12px;">
<el-button type="primary" @click="savePermConfig" :loading="saving">保存配置</el-button>
<el-button @click="resetPermConfig">恢复默认</el-button>
<span v-if="permSaved" style="color:#67c23a;font-size:13px;line-height:32px;"> 已保存</span>
</div>
</el-tab-pane>
<!-- Tab 2: KPI目标对齐 -->
<el-tab-pane label="目标对齐" name="alignment">
<p style="color:#999;font-size:13px;margin-top:4px;">
{{ showSelector ? '选择一个对齐模式' : '当前模式:' + (currentMode?.name || '') }}
<el-button v-if="!showSelector" text type="primary" size="small" @click="showSelector = true" style="margin-left:12px;">[ 重新选择 ]</el-button>
</p>
<!-- 选择器卡片 -->
<div v-if="showSelector" style="margin-top:16px;">
<div v-if="!alignConfigured" style="margin-bottom:16px;">
<el-alert title="系统尚未配置KPI目标对齐模式" type="warning" :closable="false" show-icon />
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;">
<el-card v-for="mode in alignmentModes" :key="mode.key"
:class="{ 'mode-card': true, 'selected-mode': selectedMode === mode.key }"
shadow="hover"
@click="selectedMode = mode.key"
>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-weight:600;">{{ mode.name }}</span>
<el-radio :model-value="selectedMode === mode.key" :label="mode.key">&nbsp;</el-radio>
</div>
</template>
<p style="font-size:13px;color:#666;line-height:1.6;">{{ mode.description }}</p>
<el-tag type="info" size="small" style="margin-top:8px;">示例</el-tag>
<p style="font-size:12px;color:#999;margin-top:4px;background:#f5f7fa;padding:8px;border-radius:4px;">{{ mode.example }}</p>
</el-card>
</div>
<div style="margin-top:20px;display:flex;gap:12px;">
<el-button type="primary" size="large" @click="saveAlignmentConfig" :loading="savingAlign" :disabled="!selectedMode">
确定并启用{{ selectedModeName }}模式
</el-button>
<el-button v-if="alignConfigured" @click="showSelector = false; selectedMode = ''">取消</el-button>
</div>
</div>
<!-- 已配置详情 -->
<div v-else style="margin-top:20px;">
<el-alert
:title="'当前模式:' + (currentMode?.name || '')"
type="success"
:closable="false"
show-icon
style="margin-bottom:20px;"
/>
<div style="background:#f0f9eb;border-radius:8px;padding:20px;margin-bottom:20px;">
<h4 style="margin:0 0 12px 0;">模式说明</h4>
<p style="font-size:14px;color:#555;line-height:1.8;">{{ currentMode?.description || '' }}</p>
</div>
<el-button @click="previewTree" :loading="loadingTree" style="margin-bottom:12px;">
预览KPI对齐关系图
</el-button>
<div v-if="treeData" style="border:1px solid #e4e7ed;border-radius:8px;padding:16px;max-height:500px;overflow-y:auto;">
<h4 style="margin:0 0 12px 0;">KPI对齐关系{{ treeData.mode_name }}</h4>
<el-tree
:data="treeData.tree || []"
:props="{ label: 'kpi_name', children: 'children' }"
node-key="id"
default-expand-all
:expand-on-click-node="false"
>
<template #default="{ node, data }">
<span style="display:flex;align-items:center;gap:6px;">
<template v-if="data.is_dimension_group">
<el-tag type="primary" size="small">{{ data.kpi_name }}</el-tag>
<span style="font-size:12px;color:#999;">{{ data.child_count }}个KPI</span>
<span v-if="data.description" style="font-size:11px;color:#909399;"> {{ data.description }}</span>
</template>
<template v-else-if="data.is_category_group">
<el-tag type="success" size="small">{{ data.category_label }}</el-tag>
<span style="font-size:12px;color:#999;">{{ data.child_count }}个KPI</span>
<span v-if="data.feeds" style="font-size:11px;color:#909399;"> 支撑 {{ feedsLabel(data.feeds) }}</span>
</template>
<template v-else>
<span style="font-size:13px;">{{ data.kpi_code }}</span>
<span>{{ data.kpi_name }}</span>
<span v-if="data.alignment_type === 'vertical_split'" style="font-size:11px;color:#e6a23c;">
承接自 {{ data.parent_code }}
</span>
<span v-if="data.drives" style="font-size:11px;color:#909399;">
驱动: {{ data.drives === 'internal_process' ? '内部流程' : data.drives === 'customer' ? '客户' : data.drives === 'finance' ? '财务' : data.drives }}
</span>
</template>
</span>
</template>
</el-tree>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
// ── Tab 控制 ──
const activeTab = ref('permissions')
const permTab = ref('routes')
// Tab 切换时主动关闭可能残留的弹窗遮罩
function onTabChange() {
// 关闭所有弹窗遮罩
document.querySelectorAll('.el-overlay').forEach(el => {
if (el && el.parentNode) {
el.parentNode.removeChild(el)
}
})
}
// ── 权限配置 ──
const roles = ref<any[]>([])
const modules = ref<any[]>([])
const actions = ref<any[]>([])
const routePerms = ref<Record<string, string[]>>({})
const actionPerms = ref<Record<string, string[]>>({})
const saving = ref(false)
const permSaved = ref(false)
const routeTable = ref<any[]>([])
const actionTable = ref<any[]>([])
function buildTables() {
routeTable.value = modules.value.map(mod => {
const row: any = { moduleKey: mod.key, moduleName: mod.name }
roles.value.forEach(r => { row[r.code] = (routePerms.value[r.code] || []).includes(mod.key) })
return row
})
actionTable.value = actions.value.map(act => {
const row: any = { actionKey: act.key, actionName: act.name }
roles.value.forEach(r => { row[r.code] = (actionPerms.value[r.code] || []).includes(act.key) })
return row
})
}
function toggleRoute(role: string, moduleKey: string, value: boolean) {
const list = routePerms.value[role] || []
if (value) { if (!list.includes(moduleKey)) list.push(moduleKey) }
else { routePerms.value[role] = list.filter(k => k !== moduleKey) }
permSaved.value = false
buildTables()
}
function toggleAction(role: string, actionKey: string, value: boolean) {
const list = actionPerms.value[role] || []
if (value) { if (!list.includes(actionKey)) list.push(actionKey) }
else { actionPerms.value[role] = list.filter(k => k !== actionKey) }
permSaved.value = false
buildTables()
}
async function savePermConfig() {
saving.value = true
try {
const token = localStorage.getItem('cma_token')
await fetch('/api/cma/permissions/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({
route_permissions: routePerms.value,
action_permissions: actionPerms.value,
}),
})
ElMessage.success('权限配置已保存')
permSaved.value = true
} catch { ElMessage.error('保存失败') }
saving.value = false
}
async function resetPermConfig() {
try { await ElMessageBox.confirm('确定恢复默认权限配置?', '确认', { center: true }) } catch { return }
const token = localStorage.getItem('cma_token')
try {
await fetch('/api/cma/permissions/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({})
})
ElMessage.success('已恢复默认')
loadPermData()
} catch { ElMessage.error('恢复失败') }
}
async function loadPermData() {
try {
const [modRes, cfgRes] = await Promise.all([
fetch('/api/cma/permissions/modules').then(r => r.json()),
fetch('/api/cma/permissions/config').then(r => r.json()),
])
modules.value = modRes.modules || []
actions.value = modRes.actions || []
roles.value = modRes.roles || []
routePerms.value = cfgRes.route_permissions || {}
actionPerms.value = cfgRes.action_permissions || {}
buildTables()
} catch { ElMessage.error('加载权限配置失败') }
}
// ── KPI目标对齐 ──
const alignmentModes = ref<any[]>([])
const alignConfigured = ref(false)
const currentMode = ref<any>(null)
const selectedMode = ref('')
const savingAlign = ref(false)
const loadingTree = ref(false)
const treeData = ref<any>(null)
const showSelector = ref(false) // 是否显示选择器卡片
const selectedModeName = computed(() => {
const m = alignmentModes.value.find(m => m.key === selectedMode.value)
return m ? m.name : ''
})
function feedsLabel(feeds: string[] | null) {
if (!feeds) return '最终结果'
const map: Record<string, string> = {
'supply_chain': '供应链效率', 'delivery_quality': '交付质量',
'customer_scale': '客户规模', 'customer_concentration': '客户集中度',
'customer_satisfaction': '客户满意', 'revenue_growth': '收入增长',
'profitability': '盈利水平', 'cost_control': '成本费用', 'asset_efficiency': '资产效率',
}
return feeds.map(f => map[f] || f).join('、')
}
async function loadAlignmentConfig() {
try {
const r = await fetch('/api/cma/alignment/config').then(r => r.json())
alignmentModes.value = r.modes || []
alignConfigured.value = r.configured || false
currentMode.value = null
if (r.configured && r.mode) {
currentMode.value = alignmentModes.value.find(m => m.key === r.mode.mode) || null
}
// 未配置时,直接显示选择器
showSelector.value = !r.configured
} catch { }
}
async function saveAlignmentConfig() {
if (!selectedMode.value) return
savingAlign.value = true
try {
const token = localStorage.getItem('cma_token')
const r = await fetch('/api/cma/alignment/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ mode: selectedMode.value }),
}).then(r => r.json())
ElMessage.success(r.message || '对齐模式已设置')
loadAlignmentConfig()
showSelector.value = false
} catch { ElMessage.error('设置失败') }
savingAlign.value = false
}
async function previewTree() {
loadingTree.value = true
try {
const token = localStorage.getItem('cma_token')
const r = await fetch('/api/cma/alignment/tree', {
headers: { 'Authorization': `Bearer ${token}` },
}).then(r => r.json())
treeData.value = r
} catch (e: any) {
ElMessage.error(e.message || '加载失败')
}
loadingTree.value = false
}
onMounted(() => {
loadPermData()
loadAlignmentConfig()
})
</script>
<style scoped>
.mode-card { cursor: pointer; transition: all 0.2s; border: 2px solid transparent; }
.mode-card:hover { border-color: #409eff; }
.selected-mode { border-color: #409eff; background: #f0f7ff; }
</style>