Files
cma-management/frontend/src/views/RolePermissions.vue
T
wecom-fullstack f51fc72ac7
ci/woodpecker/push/main Pipeline failed
style(page-title): B 全站规范——页面内同名标题清零 + 增量副标题弱化 + 标题 emoji 换 Lucide
- 39+ 页删除与布局标题同名/基本同名的页面内 h1-h4(同屏三标题→两处)
- 增量副标题弱化为 14px #64748B 小字:收付款计划/战略回顾/地图版本/会计科目打标
- 标题 emoji 清零,保留标题处改 Lucide 内联 SVG(bar-chart-3/video/scale/clipboard-list/alert-circle/map/history)
- KPIListView 增加 hideTitle(仅展示),客户/学习看板隐藏组件内重复页头
- 不动 MainLayout、不改接口/业务逻辑、不碰 MapCanvas.vue
2026-10-18 19:33:11 +08:00

365 lines
15 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>
<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>
<!-- Tab 3: 预算模式(系统级) -->
<el-tab-pane label="预算模式" name="budget">
<p style="color:#999;font-size:13px;margin-top:4px;">
系统级设置,影响全部账套。业务页「预算管理」仅只读展示当前模式,切换入口统一在此。
</p>
<div style="margin-top:20px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<el-radio-group v-model="budgetModeSel">
<el-radio-button value="fixed">固定预算</el-radio-button>
<el-radio-button value="rolling">滚动预算</el-radio-button>
</el-radio-group>
<el-button type="primary" :loading="savingBudget"
:disabled="!budgetModeSel || budgetModeSel === budgetModeCur" @click="saveBudgetMode">保存</el-button>
<span style="color:#909399;font-size:13px;">
当前:{{ budgetModeCur === 'rolling' ? '滚动预算(滚动12月)' : '固定预算(固定年度)' }}
</span>
</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, budgetApi } 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
}
// ── 预算模式(系统级;业务页仅只读,切换入口统一在此) ──
const budgetModeCur = ref('fixed')
const budgetModeSel = ref('fixed')
const savingBudget = ref(false)
async function loadBudgetMode() {
try {
const r: any = await budgetApi.getConfig()
budgetModeCur.value = r.budget_mode || r.mode || 'fixed'
budgetModeSel.value = budgetModeCur.value
} catch { budgetModeCur.value = 'fixed'; budgetModeSel.value = 'fixed' }
}
async function saveBudgetMode() {
if (!budgetModeSel.value || budgetModeSel.value === budgetModeCur.value) return
const label = budgetModeSel.value === 'rolling' ? '滚动预算' : '固定预算'
try {
await ElMessageBox.confirm(
`预算模式为系统级设置,切换后将立即作用于全部账套。确认切换为「${label}」?`,
'系统级设置二次确认',
{ type: 'warning', confirmButtonText: '确认切换', cancelButtonText: '取消' }
)
} catch { return }
savingBudget.value = true
try {
await budgetApi.setConfig({ mode: budgetModeSel.value, rolling_months: 12 })
ElMessage.success('预算模式已设置')
await loadBudgetMode()
} catch { ElMessage.error('设置失败') }
savingBudget.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()
loadBudgetMode()
})
</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>