ci/woodpecker/push/main Pipeline failed
- 39+ 页删除与布局标题同名/基本同名的页面内 h1-h4(同屏三标题→两处) - 增量副标题弱化为 14px #64748B 小字:收付款计划/战略回顾/地图版本/会计科目打标 - 标题 emoji 清零,保留标题处改 Lucide 内联 SVG(bar-chart-3/video/scale/clipboard-list/alert-circle/map/history) - KPIListView 增加 hideTitle(仅展示),客户/学习看板隐藏组件内重复页头 - 不动 MainLayout、不改接口/业务逻辑、不碰 MapCanvas.vue
365 lines
15 KiB
Vue
365 lines
15 KiB
Vue
<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"> </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>
|