包含前后端完整代码: - 前端:Vue3+Vite+ElementPlus - 后端:FastAPI+SQLAlchemy - 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动 - 当前版本:v1.0.0
329 lines
13 KiB
Vue
329 lines
13 KiB
Vue
<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"> </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>
|