Files
cma-management/frontend/src/views/RolePermissions.vue
T
Hermes CI Fix 7b74736980 refactor(cma-frontend): route all API calls through shared axios instance (cma-frontend-axios-unify-20260915)
api/index.ts: add permissionApi.getModules/getConfig/saveConfig + alignmentApi.config/tree.

views: replace 13 raw fetch calls and 6 local axios.create instances (each hand-building the Authorization header, without the shared 401 redirect / timeout toast) with the shared instance from api/index.ts.

KPIAlignment: fixes a live bug - the header value was the string literal 'Bearer ' + token, so /alignment/tree always returned 401 (reproduced against the production bundle).

Unwrap semantics preserved: the shared response interceptor returns response.data, so call sites that used res.data were adjusted accordingly.

Verified: scan-frontend-tokenless RAW-TOKENLESS=0; vite build ok; headless A/B (new dist vs production bundle, 8 affected pages, real login) -> identical rendered rows and identical pre-existing error profile, /alignment 401->200.

Push not executed: main is ahead 4 with other tickets' gated commits (see cma-main-ahead3-push-gate-notice-20260915).
2026-09-15 22:21:56 +08:00

315 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'
// 统一走 api/index.ts 的 axios 实例(拦截器自动带 Bearer / 401 跳登录)
import { permissionApi, alignmentApi } from '../api/index'
// ── 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 {
// 统一走 api 实例(Bearer 由拦截器注入,不再手拼 header)
await permissionApi.saveConfig({
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 }
try {
await permissionApi.saveConfig({})
ElMessage.success('已恢复默认')
loadPermData()
} catch { ElMessage.error('恢复失败') }
}
async function loadPermData() {
try {
const [modRes, cfgRes]: any[] = await Promise.all([
permissionApi.getModules(),
permissionApi.getConfig(),
])
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 {
// 统一走 api 实例(Bearer 由拦截器注入)
const r: any = await alignmentApi.getConfig()
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 r: any = await alignmentApi.saveConfig({ mode: selectedMode.value })
ElMessage.success(r.message || '对齐模式已设置')
loadAlignmentConfig()
showSelector.value = false
} catch { ElMessage.error('设置失败') }
savingAlign.value = false
}
async function previewTree() {
loadingTree.value = true
try {
const r: any = await alignmentApi.getTree()
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>