init: 管理会计OS初始代码

包含前后端完整代码:
- 前端:Vue3+Vite+ElementPlus
- 后端:FastAPI+SQLAlchemy
- 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动
- 当前版本:v1.0.0
This commit is contained in:
Hermes CI Fix
2026-05-28 17:32:22 +08:00
commit 3dddd36866
142 changed files with 18533 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>
+128
View File
@@ -0,0 +1,128 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api/cma',
timeout: 15000,
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('cma_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
api.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('cma_token')
localStorage.removeItem('cma_user')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export const authApi = {
login: (data: any) => api.post('/auth/login', data),
register: (data: any) => api.post('/auth/register', data),
}
export const kpiApi = {
list: (params?: any) => api.get('/kpis', { params }),
get: (id: number) => api.get(`/kpis/${id}`),
create: (data: any) => api.post('/kpis', data),
update: (id: number, data: any) => api.put(`/kpis/${id}`, data),
delete: (id: number) => api.delete(`/kpis/${id}`),
listCategories: () => api.get('/kpis/categories'),
}
export const mapApi = {
list: () => api.get('/maps'),
create: (data: any) => api.post('/maps', data),
update: (id: number, data: any) => api.put(`/maps/${id}`, data),
createWithTemplate: (data: any) => api.post('/maps/create-with-template', data),
}
export const dashboardApi = {
summary: (role: string) => api.get('/dashboard/summary', { params: { role } }),
kpis: (params: any) => api.get('/dashboard/kpis', { params }),
myKpis: (params?: any) => api.get('/dashboard/my-kpis', { params }),
financeAnalysis: (params?: any) => api.get('/dashboard/finance-analysis', { params }),
predict: (params?: any) => api.get('/dashboard/predict', { params }),
}
export const dataApi = {
importExcel: (file: File) => {
const form = new FormData()
form.append('file', file)
return api.post('/data/import-excel', form)
},
listSources: () => api.get('/data/sources'),
createSource: (data: any) => api.post('/data/sources', data),
updateSource: (id: number, data: any) => api.put(`/data/sources/${id}`, data),
deleteSource: (id: number) => api.delete(`/data/sources/${id}`),
}
export const alertApi = {
list: (params?: any) => api.get('/alerts', { params }),
resolve: (id: number, data: any) => api.post(`/alerts/${id}/resolve`, data),
}
export const userApi = {
list: () => api.get('/users'),
create: (data: any) => api.post('/users', data),
update: (id: number, data: any) => api.put(`/users/${id}`, data),
delete: (id: number) => api.delete(`/users/${id}`),
}
export const notificationApi = {
list: () => api.get('/notifications/channels'),
create: (data: any) => api.post('/notifications/channels', data),
update: (id: number, data: any) => api.put(`/notifications/channels/${id}`, data),
delete: (id: number) => api.delete(`/notifications/channels/${id}`),
test: (id: number) => api.post(`/notifications/channels/${id}/test`),
logs: (params?: any) => api.get('/notifications/logs', { params }),
}
export const actionPlanApi = {
list: (params?: any) => api.get('/action-plans', { params }),
create: (data: any) => api.post('/action-plans', data),
update: (id: number, data: any) => api.put(`/action-plans/${id}`, data),
delete: (id: number) => api.delete(`/action-plans/${id}`),
}
export const budgetApi = {
list: (params?: any) => api.get('/budget/plans', { params }),
create: (data: any) => api.post('/budget/plans', data),
update: (id: number, data: any) => api.put(`/budget/plans/${id}`, data),
delete: (id: number) => api.delete(`/budget/plans/${id}`),
autoDecompose: (data: any) => api.post('/budget/auto-decompose', data),
deviationReport: (params?: any) => api.get('/budget/deviation-report', { params }),
}
export const costApi = {
dashboard: (params?: any) => api.get('/cost/dashboard', { params }),
overview: (params?: any) => api.get('/cost/overview', { params }),
variance: (params?: any) => api.get('/cost/variance', { params }),
breakdown: (params?: any) => api.get('/cost/breakdown', { params }),
listStandardCosts: (params?: any) => api.get('/cost/standard-costs', { params }),
createStandardCost: (data: any) => api.post('/cost/standard-costs', data),
updateStandardCost: (id: number, data: any) => api.put(`/cost/standard-costs/${id}`, data),
deleteStandardCost: (id: number) => api.delete(`/cost/standard-costs/${id}`),
listActualCosts: (params?: any) => api.get('/cost/actual-costs', { params }),
createActualCost: (data: any) => api.post('/cost/actual-costs', data),
listAbcActivities: () => api.get('/cost/abc/activities'),
createAbcActivity: (data: any) => api.post('/cost/abc/activities', data),
doAbcAllocate: (data: any) => api.post('/cost/abc/allocate', data),
listAbcAllocations: (params?: any) => api.get('/cost/abc/allocations', { params }),
}
export const predictApi = {
cvp: (data: any) => api.post('/predict/cvp', data),
investment: (data: any) => api.post('/predict/investment', data),
sensitivity: (data: any) => api.post('/predict/sensitivity', data),
scenario: (data: any) => api.post('/predict/scenario', data),
}
export default api
+10
View File
@@ -0,0 +1,10 @@
import api from "./index"
export const orgApi = {
tree: () => api.get("/org/tree"),
list: () => api.get("/org/nodes"),
create: (data: any) => api.post("/org/nodes", data),
update: (id: number, data: any) => api.put(`/org/nodes/${id}`, data),
delete: (id: number) => api.delete(`/org/nodes/${id}`),
toggle: (id: number) => api.put(`/org/nodes/${id}/toggle`),
}
+91
View File
@@ -0,0 +1,91 @@
<template>
<teleport to="body">
<div v-if="modelValue" class="my-dialog-overlay" @click.self="close">
<div class="my-dialog" :style="{ width: width + 'px' }">
<div class="my-dialog-header">
<span class="my-dialog-title">{{ title }}</span>
<button class="my-dialog-close" @click="close">×</button>
</div>
<div class="my-dialog-body">
<slot />
</div>
<div v-if="$slots.footer" class="my-dialog-footer">
<slot name="footer" />
</div>
</div>
</div>
</teleport>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
title?: string
width?: number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
}>()
function close() {
emit('update:modelValue', false)
}
</script>
<style scoped>
.my-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.my-dialog {
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
max-width: 90vw;
max-height: 85vh;
display: flex;
flex-direction: column;
}
.my-dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e4e7ed;
}
.my-dialog-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.my-dialog-close {
background: none;
border: none;
font-size: 20px;
color: #909399;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.my-dialog-close:hover {
color: #409eff;
}
.my-dialog-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.my-dialog-footer {
padding: 12px 20px;
border-top: 1px solid #e4e7ed;
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module 'markdown-it' {
const content: any
export default content
}
+166
View File
@@ -0,0 +1,166 @@
<template>
<el-container>
<el-aside :width="isCollapse ? '64px' : '220px'" :class="{ 'mobile-show': mobileMenuOpen }">
<div class="aside-mask" v-if="mobileMenuOpen" @click="mobileMenuOpen = false"></div>
<div class="logo">
<span v-show="!isCollapse">管理会计OS</span>
</div>
<el-menu :default-active="activeMenu" :collapse="isCollapse" router background-color="#304156" text-color="#bfcbd9" active-text-color="#409EFF" @select="onMenuSelect">
<template v-for="group in menuGroups" :key="group.label">
<el-sub-menu :index="'group-' + group.label" v-if="group.items.length > 0">
<template #title>
<el-icon><component :is="groupIcon(group.label)" /></el-icon>
<span>{{ group.label }}</span>
</template>
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
<el-icon><component :is="iconMap[item.icon]" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
</el-sub-menu>
</template>
</el-menu>
</el-aside>
<el-container>
<el-header height="60px">
<div class="header-left">
<el-button :icon="isCollapse ? 'Expand' : 'Fold'" text @click="isCollapse = !isCollapse" class="hide-mobile" />
<el-button text class="show-mobile" @click="mobileMenuOpen = !mobileMenuOpen"><el-icon><Menu /></el-icon></el-button>
<span class="page-title">{{ pageTitle }}</span>
</div>
<div class="header-right">
<el-tag size="small" :type="roleTagType">{{ roleLabel }}</el-tag>
<el-dropdown trigger="click">
<span class="user-info"><el-icon><User /></el-icon>{{ userName }}</span>
<template #dropdown>
<el-dropdown-item @click="logout">退出登录</el-dropdown-item>
</template>
</el-dropdown>
</div>
</el-header>
<el-main><router-view /></el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
User, Monitor, Document, TrendCharts, WarningFilled, Connection,
Menu, Bell, Setting, Coin, DataAnalysis, Money, DataLine,
DataBoard, Tools, Collection, Edit,
} from '@element-plus/icons-vue'
import { MENU_ITEMS, ROLE_LABELS } from '../permission'
const route = useRoute()
const router = useRouter()
const isCollapse = ref(false)
const mobileMenuOpen = ref(false)
// 图标名称 → 组件实例映射
const iconMap: Record<string, any> = {
Monitor, Document, TrendCharts, WarningFilled, Connection,
User, Bell, Setting, Coin, DataAnalysis, Money, DataLine,
DataBoard, Collection, Edit,
}
// 分组图标映射
const groupIcons: Record<string, any> = {
'数据基础': DataBoard,
'分析决策': TrendCharts,
'行动管理': Edit,
'系统管理': Tools,
'帮助支持': Document,
}
function groupIcon(label: string) {
return groupIcons[label] || Collection
}
// 从 localStorage 获取用户信息
const userStr = localStorage.getItem('cma_user') || '{}'
const user = JSON.parse(userStr)
const userRole: string = user.role || ''
// 按角色过滤菜单
const filteredMenus = computed(() => {
return MENU_ITEMS.filter(item => item.roles.includes(userRole))
})
// 按分组组织菜单
const menuGroups = computed(() => {
const groups: { label: string; items: typeof MENU_ITEMS }[] = []
let currentLabel = ''
let currentItems: typeof MENU_ITEMS = []
for (const item of filteredMenus.value) {
if (item.group) {
if (currentItems.length > 0) {
groups.push({ label: currentLabel, items: currentItems })
}
currentLabel = item.group as string
currentItems = [item]
} else {
currentItems.push(item)
}
}
if (currentItems.length > 0) {
groups.push({ label: currentLabel, items: currentItems })
}
return groups
})
const activeMenu = computed(() => route.path)
const pageTitle = computed(() => (route.meta?.title as string) || '管理会计OS')
const roleLabel = computed(() => ROLE_LABELS[userRole] || userRole)
const roleTagType = computed(() => {
const types: Record<string, string> = { ceo: 'danger', finance: 'warning', business: 'primary', it: 'info' }
return types[userRole] || ''
})
const userName = computed(() => user.name || user.username || '')
function logout() {
localStorage.removeItem('cma_token')
localStorage.removeItem('cma_user')
router.push('/login')
}
function onMenuSelect() {
mobileMenuOpen.value = false
}
</script>
<style scoped>
.logo { height: 60px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 16px; font-weight: bold; background: #2b3a4a; }
.el-aside { background: #304156; }
.el-header { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e6e6e6; background: #fff; padding: 0 20px; }
.header-left { display: flex; align-items: center; gap: 12px; }
.header-right { display: flex; align-items: center; gap: 16px; }
.user-info { cursor: pointer; display: flex; align-items: center; gap: 4px; }
.el-main { background: #f0f2f5; min-height: calc(100vh - 60px); }
/* 折叠菜单样式覆盖:子菜单项缩进 */
.el-menu :deep(.el-sub-menu .el-menu-item) { padding-left: 54px !important; }
/* 移动端 */
@media screen and (max-width: 768px) {
.hide-mobile { display: none !important; }
.show-mobile { display: inline-flex !important; }
.el-aside {
position: fixed; top: 0; bottom: 0; left: 0; z-index: 1001;
transform: translateX(-100%);
transition: transform .3s ease;
}
.el-aside.mobile-show { transform: translateX(0); }
.aside-mask {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
z-index: -1;
}
.el-header { padding: 0 12px; }
.header-right .el-tag { display: none; }
.el-main { min-height: calc(100vh - 60px); padding: 10px; }
}
.show-mobile { display: none; }
</style>
+26
View File
@@ -0,0 +1,26 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
const usedIcons = ['Monitor', 'Document', 'TrendCharts', 'WarningFilled', 'Connection', 'User', 'Bell', 'Menu', 'Setting', 'Expand', 'Fold', 'UploadFilled']
for (const key of usedIcons) {
const component = (ElementPlusIconsVue as any)[key]
if (component) app.component(key, component)
}
app.use(ElementPlus, { locale: zhCn })
app.use(createPinia())
app.use(router)
// 应用挂载后立即清理可能残留的弹窗遮罩
app.mount('#app')
setTimeout(() => {
document.querySelectorAll('.el-overlay').forEach(el => el.remove())
}, 200)
+87
View File
@@ -0,0 +1,87 @@
/**
* 角色权限配置 — 管理会计OS
* 4角色: ceo(总经理), finance(财务), business(业务), it(IT运维)
*/
// 各角色可访问的路由列表
export const ROLE_ROUTES: Record<string, string[]> = {
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide'],
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide'],
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/deviations', '/budget', '/action-plans', '/knowledge', '/guide'],
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide'],
}
// 可操作的CRUD权限
export const ROLE_ACTIONS: Record<string, string[]> = {
ceo: ['read'],
finance: ['read', 'write', 'import', 'export'],
business: ['read', 'write'],
it: ['read', 'write', 'delete', 'admin'],
}
// 菜单项定义(带角色限制)
export interface MenuItem {
path: string
label: string
icon: string
roles: string[]
group?: string // 分组标识,仅首位项标记
}
export const MENU_ITEMS: MenuItem[] = [
// ── 个人视角 ──
{ path: '/my-dashboard', label: '我的工作台', icon: 'Monitor', roles: ['ceo', 'finance', 'business', 'it'], group: '数据基础' },
// ── 数据基础 ──
{ path: '/kpis', label: 'KPI字典', icon: 'Document', roles: ['ceo', 'finance', 'business', 'it'], group: '数据基础' },
{ path: '/data', label: '数据管理', icon: 'Connection', roles: ['ceo', 'finance', 'it'] },
// ── 分析决策 ──
{ path: '/dashboard', label: '驾驶舱', icon: 'DataBoard', roles: ['ceo', 'finance', 'business', 'it'], group: '分析决策' },
{ path: '/maps', label: '战略地图', icon: 'DataBoard', roles: ['ceo', 'finance'] },
{ path: '/maps-review', label: '战略回顾会', icon: 'TrendCharts', roles: ['ceo', 'finance'] },
{ path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'] },
{ path: '/cost', label: '成本分析', icon: 'Money', roles: ['ceo', 'finance', 'it'] },
{ path: '/predict', label: '预测模拟', icon: 'DataLine', roles: ['ceo', 'finance', 'it'] },
{ path: '/budget', label: '预算管理', icon: 'Coin', roles: ['ceo', 'finance', 'business', 'it'] },
// ── 行动管理 ──
{ path: '/alerts', label: '预警中心', icon: 'WarningFilled', roles: ['ceo', 'finance', 'business', 'it'], group: '行动管理' },
{ path: '/action-plans', label: '改善行动', icon: 'Edit', roles: ['ceo', 'finance', 'business', 'it'] },
// ── 系统管理 ──
{ path: '/org', label: '组织管理', icon: 'Collection', roles: ['ceo', 'it'], group: '系统管理' },
{ path: '/users', label: '用户管理', icon: 'User', roles: ['ceo', 'it'] },
{ path: '/notifications', label: '通知配置', icon: 'Bell', roles: ['ceo', 'it'] },
{ path: '/permissions', label: '系统设置', icon: 'Setting', roles: ['ceo', 'it'] },
// ── 帮助支持 ──
{ path: '/knowledge', label: 'CMA知识库', icon: 'Document', roles: ['ceo', 'finance', 'business', 'it'], group: '帮助支持' },
{ path: '/guide', label: '新手引导', icon: 'Edit', roles: ['ceo', 'finance', 'business', 'it'] },
]
// 角色名称映射
export const ROLE_LABELS: Record<string, string> = {
ceo: '总经理',
finance: '财务部',
business: '业务部',
it: 'IT部',
}
/**
* 检查用户是否有权访问某路由
*/
export function hasRouteAccess(role: string, path: string): boolean {
const allowed = ROLE_ROUTES[role]
if (!allowed) return false
// 精确匹配或前缀匹配(如 /kpis/123 匹配 /kpis
return allowed.some(r => path === r || path.startsWith(r + '/'))
}
/**
* 检查用户是否有指定操作权限
*/
export function hasAction(role: string, action: string): boolean {
const actions = ROLE_ACTIONS[role]
return actions ? actions.includes(action) : false
}
+95
View File
@@ -0,0 +1,95 @@
import { createRouter, createWebHistory } from 'vue-router'
import { hasRouteAccess } from '../permission'
const routes = [
{ path: '/login', name: 'Login', component: () => import('@/views/Login.vue'), meta: { noAuth: true } },
{ path: '/', component: () => import('@/layouts/MainLayout.vue'), redirect: '/dashboard',
children: [
{ path: 'dashboard', name: 'Dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '驾驶舱', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'kpis', name: 'KPIs', component: () => import('@/views/KPIList.vue'), meta: { title: 'KPI字典', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'kpis/:id', name: 'KPIDetail', component: () => import('@/views/KPIDetail.vue'), meta: { title: 'KPI详情', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'maps', name: 'Maps', component: () => import('@/views/MapList.vue'), meta: { title: '战略地图', roles: ['ceo', 'finance'] } },
{ path: 'maps-review', name: 'MapReviewList', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'] } },
{ path: 'maps/canvas/:id', name: 'MapCanvas', component: () => import('@/views/MapCanvas.vue'), meta: { title: '战略地图画布', roles: ['ceo', 'finance'] } },
{ path: 'maps/review/:id', name: 'MapReview', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'] } },
{ path: 'my-dashboard', name: 'MyDashboard', component: () => import('@/views/MyDashboard.vue'), meta: { title: '我的工作台', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'knowledge', name: 'CMAKnowledge', component: () => import('@/views/CMAKnowledge.vue'), meta: { title: 'CMA知识库', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'guide', name: 'NewUserGuide', component: () => import('@/views/NewUserGuide.vue'), meta: { title: '新手引导', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'alerts', name: 'Alerts', component: () => import('@/views/AlertList.vue'), meta: { title: '预警中心', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'data', name: 'Data', component: () => import('@/views/DataManage.vue'), meta: { title: '数据管理', roles: ['ceo', 'finance', 'it'] } },
{ path: 'users', name: 'Users', component: () => import('@/views/UserManage.vue'), meta: { title: '用户管理', roles: ['ceo', 'it'] } },
{ path: 'notifications', name: 'Notifications', component: () => import('@/views/NotificationManage.vue'), meta: { title: '通知配置', roles: ['ceo', 'it'] } },
{ path: 'permissions', name: 'RolePermissions', component: () => import('@/views/RolePermissions.vue'), meta: { title: '系统设置', roles: ['ceo', 'it'] } },
{ path: 'budget', name: 'BudgetManagement', component: () => import('@/views/BudgetManagement.vue'), meta: { title: '预算管理', roles: ['ceo', 'finance', 'it'] } },
{ path: 'org', name: 'OrgManage', component: () => import('@/views/OrgManage.vue'), meta: { title: '组织管理', roles: ['ceo', 'it'] } },
{ path: 'deviations', name: 'DeviationDashboard', component: () => import('@/views/DeviationDashboard.vue'), meta: { title: '差异分析', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'cost', name: 'CostDashboard', component: () => import('@/views/CostDashboard.vue'), meta: { title: '成本分析', roles: ['ceo', 'finance', 'it'] } },
{ path: 'predict', name: 'PredictDashboard', component: () => import('@/views/PredictDashboard.vue'), meta: { title: '预测模拟', roles: ['ceo', 'finance', 'it'] } },
{ path: 'action-plans', name: 'ActionPlans', component: () => import('@/views/AlertList.vue'), meta: { title: '改善行动', roles: ['ceo', 'finance', 'business', 'it'], tab: 'plans' } },
]
},
]
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach((to, _from, next) => {
// 路由切换时清理所有残留的弹窗遮罩
document.querySelectorAll('.el-overlay').forEach(el => el.remove())
const token = localStorage.getItem('cma_token')
const userStr = localStorage.getItem('cma_user')
// 不需要登录的页面(如登录页)
if (to.meta.noAuth) {
if (token && to.path === '/login') {
next('/dashboard')
} else {
next()
}
return
}
// 检查登录
if (!token || !userStr) {
next('/login')
return
}
// 检查角色权限
try {
const user = JSON.parse(userStr)
const role = user.role || ''
const routeRoles = to.meta.roles as string[] | undefined
if (routeRoles && !routeRoles.includes(role)) {
// 权限不足,跳转到驾驶舱或401页
next('/dashboard')
return
}
} catch {
next('/login')
return
}
next()
})
router.afterEach(() => {
// 只清理残留的孤立遮罩(没有对应dialog的),不干扰正常弹窗
setTimeout(() => {
document.querySelectorAll('.el-overlay').forEach(el => {
// 如果这个遮罩没有对应的dialog,说明是残留的,才删除
const dialogId = el.getAttribute('aria-describedby')
if (!dialogId || !document.querySelector(`#${dialogId}`)) {
// 等待一帧确保 dialog 已渲染
requestAnimationFrame(() => {
if (!el.parentElement?.querySelector('.el-dialog')) {
el.remove()
}
})
}
})
}, 100)
})
export default router
+356
View File
@@ -0,0 +1,356 @@
/* ===== 博海网络科技 · 全栈学习平台 — 全局响应式样式 ===== */
/* === Reset === */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* === CSS 自定义属性(设计令牌) === */
:root {
/* 颜色系统 */
--bh-primary: #409eff;
--bh-primary-dark: #337ecc;
--bh-primary-light: #a0cfff;
--bh-success: #67c23a;
--bh-warning: #e6a23c;
--bh-danger: #f56c6c;
--bh-info: #909399;
--bh-bg-main: #f0f2f5;
--bh-bg-card: #ffffff;
--bh-bg-dark: #304156;
--bh-bg-dark-light: #3a4a5e;
--bh-text-primary: #303133;
--bh-text-regular: #606266;
--bh-text-secondary: #909399;
--bh-text-placeholder: #c0c4cc;
--bh-border: #e4e7ed;
--bh-border-light: #ebeef5;
/* 侧边栏暗色(永恒不变,无论明暗模式) */
--sidebar-bg: #304156;
--sidebar-bg-hover: #263445;
--sidebar-text: #bfcbd9;
--sidebar-text-active: #409eff;
--sidebar-logo-bg: #2b3a4a;
/* 间距系统 */
--bh-space-xs: 4px;
--bh-space-sm: 8px;
--bh-space-md: 12px;
--bh-space-lg: 16px;
--bh-space-xl: 20px;
--bh-space-2xl: 24px;
/* 圆角 */
--bh-radius-sm: 4px;
--bh-radius-md: 8px;
--bh-radius-lg: 12px;
/* 阴影 */
--bh-shadow-sm: 0 1px 2px rgba(0,0,0,0.06);
--bh-shadow-md: 0 2px 8px rgba(0,0,0,0.08);
/* 侧边栏宽度 */
--bh-sidebar-width: 220px;
--bh-sidebar-collapsed: 64px;
/* 顶栏高度 */
--bh-header-height: 60px;
/* 移动端底部导航高度 */
--bh-mobile-nav-height: 56px;
/* 断点变量(用于 JS */
--bh-breakpoint-sm: 768px;
--bh-breakpoint-md: 992px;
--bh-breakpoint-lg: 1200px;
--bh-breakpoint-xl: 1920px;
}
/* 暗黑模式覆盖 — 只影响内容区,侧边栏保持不变 */
html.dark {
--bh-bg-main: #121212;
--bh-bg-card: #1e1e1e;
--bh-text-primary: #e0e0e0;
--bh-text-regular: #b0b0b0;
--bh-text-secondary: #808080;
--bh-border: #333333;
--bh-border-light: #2a2a2a;
/* Element Plus 暗黑变量覆盖 — 让 el-card 等卡片用我们的色值 */
--el-bg-color: #1e1e1e;
--el-bg-color-overlay: #1e1e1e;
--el-bg-color-page: #121212;
--el-text-color-primary: #e0e0e0;
--el-text-color-regular: #b0b0b0;
--el-text-color-secondary: #808080;
--el-border-color: #333333;
--el-border-color-light: #2a2a2a;
--el-fill-color: #252525;
--el-fill-color-light: #2a2a2a;
--el-fill-color-lighter: #303030;
--el-mask-color: rgba(0, 0, 0, 0.6);
}
/* === 侧边栏全局样式(不受暗黑影响) === */
.sidebar-dark {
background-color: var(--sidebar-bg) !important;
}
.sidebar-dark .el-menu {
background-color: var(--sidebar-bg) !important;
border-right: none;
}
.sidebar-dark .el-menu-item {
color: var(--sidebar-text) !important;
background-color: transparent !important;
}
.sidebar-dark .el-menu-item:hover {
background-color: var(--sidebar-bg-hover) !important;
}
.sidebar-dark .el-menu-item.is-active {
color: var(--sidebar-text-active) !important;
background-color: var(--sidebar-bg-hover) !important;
}
.sidebar-dark .el-menu-item * {
color: inherit !important;
}
/* === 响应式工具类 === */
.d-only { display: revert; }
.m-only { display: none !important; }
@media (max-width: 768px) {
.d-only { display: none !important; }
.m-only { display: revert; }
:root {
--bh-sidebar-width: 0px;
--bh-sidebar-collapsed: 0px;
--bh-header-height: 52px;
}
}
@media (max-width: 480px) {
:root {
--bh-header-height: 48px;
}
}
/* === 通用响应式容器 === */
.bh-container {
width: 100%;
max-width: 1400px;
margin: 0 auto;
padding-left: var(--bh-space-lg);
padding-right: var(--bh-space-lg);
}
@media (max-width: 768px) {
.bh-container {
padding-left: var(--bh-space-md);
padding-right: var(--bh-space-md);
}
}
/* === 滚动条美化 === */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #c0c4cc; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #909399; }
/* 暗黑模式滚动条 */
html.dark ::-webkit-scrollbar-thumb { background: #555; }
html.dark ::-webkit-scrollbar-thumb:hover { background: #777; }
/* === Element Plus 全局覆盖 === */
.el-main {
background: var(--bh-bg-main);
min-height: calc(100vh - var(--bh-header-height));
}
/* el-card 暗黑适配 */
html.dark .el-card {
background-color: var(--bh-bg-card);
border-color: var(--bh-border);
}
html.dark .el-card__header {
border-bottom-color: var(--bh-border);
}
/* el-table 暗黑适配 */
html.dark .el-table {
--el-table-bg-color: var(--bh-bg-card);
--el-table-tr-bg-color: var(--bh-bg-card);
--el-table-header-bg-color: var(--bh-bg-dark);
--el-table-row-hover-bg-color: #2a2a2a;
border-color: var(--bh-border);
}
html.dark .el-table th.el-table__cell {
background-color: var(--bh-bg-dark);
color: var(--sidebar-text);
}
html.dark .el-table__inner-wrapper::before,
html.dark .el-table__border-left-patch {
background-color: var(--bh-border);
}
html.dark .el-table__body-wrapper::-webkit-scrollbar-thumb {
background: #555;
}
/* el-tabs 暗黑适配 */
html.dark .el-tabs__header {
border-bottom-color: var(--bh-border);
}
html.dark .el-tabs__item {
color: var(--bh-text-regular);
}
html.dark .el-tabs__item.is-active {
color: var(--bh-primary);
}
/* el-progress 暗黑适配 */
html.dark .el-progress__text {
color: var(--bh-text-secondary);
}
/* el-collapse 暗黑适配 */
html.dark .el-collapse {
border-top-color: var(--bh-border);
border-bottom-color: var(--bh-border);
}
html.dark .el-collapse-item__header {
background-color: var(--bh-bg-card);
color: var(--bh-text-primary);
border-bottom-color: var(--bh-border);
}
html.dark .el-collapse-item__wrap {
background-color: var(--bh-bg-card);
}
html.dark .el-collapse-item__content {
color: var(--bh-text-regular);
}
/* el-dialog 暗黑适配 */
html.dark .el-dialog {
background-color: var(--bh-bg-card);
}
html.dark .el-dialog__title {
color: var(--bh-text-primary);
}
html.dark .el-dialog__body {
color: var(--bh-text-regular);
}
/* 自定义弹窗内下拉选择框层级修复 */
.dialog-select-popper {
z-index: 10001 !important;
}
/* el-empty 暗黑适配 */
html.dark .el-empty__description p {
color: var(--bh-text-secondary);
}
/* el-alert 暗黑适配(info 类型保持原样) */
html.dark .el-alert--info {
background-color: #1a1a2e;
}
/* el-tag 暗黑适配 */
html.dark .el-tag.el-tag--success {
--el-tag-bg-color: #1a3a1a;
--el-tag-text-color: #67c23a;
--el-tag-border-color: #2a5a2a;
}
html.dark .el-tag.el-tag--danger {
--el-tag-bg-color: #3a1a1a;
--el-tag-text-color: #f56c6c;
--el-tag-border-color: #5a2a2a;
}
/* el-form 暗黑适配 */
html.dark .el-form-item__label {
color: var(--bh-text-regular);
}
/* el-timeline 暗黑适配 */
html.dark .el-timeline-item__timestamp {
color: var(--bh-text-secondary);
}
/* el-dropdown 暗黑适配 */
html.dark .el-dropdown-menu {
background-color: var(--bh-bg-card);
border-color: var(--bh-border);
}
html.dark .el-dropdown-menu__item {
color: var(--bh-text-regular);
}
html.dark .el-dropdown-menu__item:hover {
background-color: var(--bh-fill-color, #252525);
}
/* el-drawer 暗黑适配 */
html.dark .el-drawer {
background-color: var(--bh-bg-card);
}
html.dark .el-drawer__header {
color: var(--bh-text-primary);
}
/* 表格在小屏横向滚动 */
.el-table {
@media (max-width: 768px) {
width: 100% !important;
overflow-x: auto;
display: block;
}
}
/* el-card 间距在手机上缩小 */
@media (max-width: 768px) {
.el-card { margin-bottom: 12px !important; }
.el-card__body { padding: 14px !important; }
}
/* 表单在小屏上 label 置顶 */
@media (max-width: 576px) {
.el-form--label-top .el-form-item__label {
padding-bottom: 0;
}
}
/* el-message-box / el-dialog 弹窗全局居中修复 —
注意:不移除 .el-overlay 的 display:flex 会导致遮罩层布局问题,
Element Plus 默认的 el-overlay 已经有 position:fixed + inset:0
居中应该用 center 属性而非覆盖样式 */
.el-message-box {
margin: 0 !important;
}
.el-dialog {
--el-dialog-margin-top: 1vh !important;
}
/* 过渡动画 — 明暗切换平滑过渡 */
html.dark,
html,
html *,
html *::before,
html *::after {
transition: background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease,
box-shadow 0.3s ease;
}
+292
View File
@@ -0,0 +1,292 @@
<template>
<div>
<h3>预警中心</h3>
<div style="margin:12px 0;display:flex;gap:12px;">
<el-radio-group v-model="activeTab" size="small">
<el-radio-button value="alerts">待处理预警</el-radio-button>
<el-radio-button value="plans">改善行动</el-radio-button>
</el-radio-group>
</div>
<!-- ===== 预警列表 ===== -->
<template v-if="activeTab === 'alerts'">
<el-table :data="alerts" v-loading="loading" style="width:100%" border stripe>
<el-table-column prop="alert_message" label="预警信息" min-width="300" />
<el-table-column prop="alert_level" label="级别" width="70">
<template #default="{ row }">
<el-tag :type="row.alert_level==='red'?'danger':'warning'" size="small">
{{ row.alert_level==='red'?'🔴紧急':'🟡警告' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">{{ {pending:'待处理',processing:'处理中',resolved:'已处理'}[row.status] || row.status }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status==='pending'" size="small" type="primary" @click="openResolveDialog(row)">处理</el-button>
<el-button v-if="row.status==='pending'" size="small" @click="openPlanDialog(row)">创建改善计划</el-button>
</template>
</el-table-column>
</el-table>
</template>
<!-- ===== 改善行动计划 ===== -->
<template v-if="activeTab === 'plans'">
<div style="margin-bottom:12px;">
<el-button type="primary" size="small" @click="showPlanForm = true; planForm = { title: '', kpi_id: null, assignee: '', priority: 'medium', description: '' }">新建改善计划</el-button>
</div>
<el-table :data="plans" v-loading="loadingPlans" style="width:100%" border stripe>
<el-table-column prop="title" label="计划名称" min-width="200" />
<el-table-column prop="kpi_name" label="关联KPI" width="140" />
<el-table-column prop="assignee" label="负责人" width="100" />
<el-table-column prop="priority" label="优先级" width="80">
<template #default="{ row }">
<el-tag :type="row.priority==='high'?'danger':row.priority==='medium'?'warning':'info'" size="small">
{{ {high:'高',medium:'中',low:'低'}[row.priority] || row.priority }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status==='completed'?'success':row.status==='in_progress'?'warning':'info'" size="small">
{{ {pending:'待开始',in_progress:'进行中',completed:'已完成',cancelled:'已取消'}[row.status] || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="progress" label="进度" width="120">
<template #default="{ row }">
<el-progress :percentage="row.progress || 0" :status="row.progress >= 100 ? 'success' : ''" :stroke-width="12" />
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="editPlan(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deletePlan(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<!-- 处理预警弹窗 -->
<el-dialog v-model="showDialog" title="处理预警" width="500px">
<el-form :model="resolveForm" label-width="100px">
<el-form-item label="处理人">
<el-select v-model="resolveForm.assignee" filterable style="width:100%;">
<el-option v-for="u in users" :key="u.id" :label="u.name" :value="u.name" />
</el-select>
</el-form-item>
<el-form-item label="处理结果">
<el-input v-model="resolveForm.resolution" type="textarea" :rows="3" placeholder="描述处理结果" />
</el-form-item>
<el-form-item label="创建改善计划">
<el-checkbox v-model="resolveForm.createPlan">同时创建改善行动计划</el-checkbox>
</el-form-item>
<template v-if="resolveForm.createPlan">
<el-form-item label="计划名称">
<el-input v-model="resolveForm.planTitle" placeholder="如:制定客户拓展方案" />
</el-form-item>
<el-form-item label="截止日期">
<el-date-picker v-model="resolveForm.planDueDate" type="date" placeholder="选择截止日期" style="width:100%;" />
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="showDialog=false">取消</el-button>
<el-button type="primary" @click="doResolve" :loading="resolving">确认处理</el-button>
</template>
</el-dialog>
<!-- 新建/编辑改善计划弹窗 -->
<el-dialog v-model="showPlanForm" :title="editingPlan ? '编辑改善计划' : '新建改善计划'" width="550px">
<el-form :model="planForm" label-width="100px">
<el-form-item label="计划名称"><el-input v-model="planForm.title" /></el-form-item>
<el-form-item label="关联KPI">
<el-select v-model="planForm.kpi_id" filterable style="width:100%;">
<el-option v-for="k in kpiOptions" :key="k.id" :label="k.kpi_name" :value="k.id" />
</el-select>
</el-form-item>
<el-form-item label="负责人">
<el-select v-model="planForm.assignee" filterable style="width:100%;">
<el-option v-for="u in users" :key="u.id" :label="u.name" :value="u.name" />
</el-select>
</el-form-item>
<el-form-item label="优先级">
<el-select v-model="planForm.priority">
<el-option label="高" value="high" />
<el-option label="中" value="medium" />
<el-option label="低" value="low" />
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="planForm.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="截止日期">
<el-date-picker v-model="planForm.due_date" type="date" placeholder="选择截止日期" style="width:100%;" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showPlanForm=false">取消</el-button>
<el-button type="primary" @click="savePlan" :loading="savingPlan">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { alertApi, userApi, actionPlanApi, kpiApi } from '../api/index'
const route = useRoute()
const activeTab = ref((route.meta?.tab as string) || 'alerts')
// 预警
const alerts = ref<any[]>([])
const loading = ref(false)
const showDialog = ref(false)
const currentAlert = ref<any>(null)
const resolving = ref(false)
const resolveForm = ref({
assignee: '', resolution: '',
createPlan: false, planTitle: '', planDueDate: null,
})
const users = ref<any[]>([])
// 改善计划
const plans = ref<any[]>([])
const loadingPlans = ref(false)
const showPlanForm = ref(false)
const editingPlan = ref<any>(null)
const savingPlan = ref(false)
const kpiOptions = ref<any[]>([])
const planForm = ref<any>({
title: '', kpi_id: null, assignee: '', priority: 'medium',
description: '', due_date: null,
})
function openResolveDialog(row: any) {
currentAlert.value = row
resolveForm.value = {
assignee: '', resolution: '',
createPlan: false, planTitle: `${row.alert_message?.slice(0, 20) || '改善'}方案`,
planDueDate: null,
}
showDialog.value = true
}
function openPlanDialog(row: any) {
// 从预警创建改善计划
const kpiId = row.kpi_id || null
showPlanForm.value = true
editingPlan.value = null
planForm.value = {
title: `${row.alert_message?.slice(0, 30) || '改善'}方案`,
kpi_id: kpiId,
assignee: '',
priority: 'high',
description: row.alert_message || '',
due_date: null,
}
}
async function doResolve() {
if (!resolveForm.value.assignee) { ElMessage.warning('请选择处理人'); return }
resolving.value = true
try {
await alertApi.resolve(currentAlert.value.id, {
assignee: resolveForm.value.assignee,
resolution: resolveForm.value.resolution,
})
// 如果勾选了创建改善计划
if (resolveForm.value.createPlan && resolveForm.value.planTitle) {
await actionPlanApi.create({
title: resolveForm.value.planTitle,
kpi_id: currentAlert.value.kpi_id || 1,
alert_id: currentAlert.value.id,
description: resolveForm.value.resolution || '',
assignee: resolveForm.value.assignee,
priority: 'high',
due_date: resolveForm.value.planDueDate || null,
})
}
ElMessage.success('已处理')
showDialog.value = false
load()
} catch (e) { ElMessage.error('操作失败') }
resolving.value = false
}
async function savePlan() {
if (!planForm.value.title || !planForm.value.kpi_id) {
ElMessage.warning('请填写计划名称并选择关联KPI')
return
}
savingPlan.value = true
try {
const data = { ...planForm.value }
if (data.due_date) data.due_date = new Date(data.due_date).toISOString()
if (editingPlan.value) {
await actionPlanApi.update(editingPlan.value.id, data)
ElMessage.success('已更新')
} else {
await actionPlanApi.create(data)
ElMessage.success('已创建')
}
showPlanForm.value = false
loadPlans()
} catch (e) { ElMessage.error('保存失败') }
savingPlan.value = false
}
function editPlan(row: any) {
editingPlan.value = row
planForm.value = {
title: row.title,
kpi_id: row.kpi_id,
assignee: row.assignee,
priority: row.priority,
description: row.description,
due_date: row.due_date || null,
}
showPlanForm.value = true
}
async function deletePlan(id: number) {
try {
await ElMessageBox.confirm('确定删除此改善计划?')
await actionPlanApi.delete(id)
ElMessage.success('已删除')
loadPlans()
} catch (e) {
if (e !== 'cancel') ElMessage.error('删除失败')
}
}
async function load() {
loading.value = true
try { const r: any = await alertApi.list(); alerts.value = r.data || [] } catch (e) {}
try { const u: any = await userApi.list(); users.value = u.data || [] } catch (e) {}
loading.value = false
}
async function loadPlans() {
loadingPlans.value = true
try { const r: any = await actionPlanApi.list(); plans.value = r.data || [] } catch (e) {}
loadingPlans.value = false
}
async function loadKpis() {
try { const r: any = await kpiApi.list(); kpiOptions.value = r.data || [] } catch (e) {}
}
onMounted(() => {
load()
loadPlans()
loadKpis()
})
</script>
+311
View File
@@ -0,0 +1,311 @@
<template>
<div>
<h3>预算管理</h3>
<el-tabs v-model="activeTab" style="margin-top:16px;">
<!-- Tab 1: 预算录入 -->
<el-tab-pane label="预算录入" name="input">
<!-- 筛选栏 -->
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="filterYear" placeholder="年份" style="width:100px;" @change="loadBudget">
<el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" />
</el-select>
<el-select v-model="filterMonth" placeholder="月份" style="width:90px;" @change="loadBudget">
<el-option label="全年" :value="0" />
<el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" />
</el-select>
<el-input v-model="searchKpi" placeholder="搜索KPI名称" clearable style="width:200px;" @clear="loadBudget" @keyup.enter="loadBudget" />
<el-button type="primary" @click="loadBudget">查询</el-button>
<el-button @click="showBatchForm = true">批量录入</el-button>
<el-button @click="showDecompose = true" :disabled="!filterYear">年度分解</el-button>
</div>
<!-- 预算表格 -->
<el-table :data="budgetList" v-loading="loading" border stripe size="small" style="width:100%;">
<el-table-column type="index" label="#" width="40" />
<el-table-column prop="kpi_code" label="KPI编码" width="120" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="160" />
<el-table-column prop="dimension" label="维度" width="80">
<template #default="{ row }">
<el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="budget_value" label="预算值" width="140">
<template #default="{ row }">
<el-input-number v-if="row.editing" v-model="row.editValue" :min="0" :precision="row.precision || 0" :step="row.step || 1" controls-position="right" style="width:130px;" />
<span v-else>{{ formatNumber(row.budget_value) }}</span>
</template>
</el-table-column>
<el-table-column prop="version" label="版本" width="70" />
<el-table-column prop="status" label="状态" width="70">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button v-if="!row.editing" size="small" @click="startEdit(row)">编辑</el-button>
<template v-else>
<el-button size="small" type="primary" @click="saveEdit(row)" :loading="row.saving">保存</el-button>
<el-button size="small" @click="cancelEdit(row)">取消</el-button>
</template>
<el-button size="small" type="danger" @click="doDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div style="margin-top:16px;display:flex;justify-content:flex-end;">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next, total"
@current-change="loadBudget"
/>
</div>
</el-tab-pane>
<!-- Tab 2: 年度自动分解 -->
<el-tab-pane label="年度分解" name="decompose">
<el-card>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span>年度预算 月度自动分解</span>
<el-button type="primary" @click="doDecompose" :loading="decomposing">执行分解</el-button>
</div>
</template>
<el-form :model="decomposeForm" label-width="120px" style="max-width:500px;">
<el-form-item label="年份">
<el-select v-model="decomposeForm.year" style="width:150px;">
<el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" />
</el-select>
</el-form-item>
<el-form-item label="分解方式">
<el-radio-group v-model="decomposeForm.method">
<el-radio value="equal">均分1/12</el-radio>
<el-radio value="weighted">按历史权重</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<el-alert v-if="decomposeResult" :title="decomposeResult" type="success" show-icon :closable="false" style="margin-top:16px;" />
</el-card>
</el-tab-pane>
</el-tabs>
<!-- 批量录入弹窗 -->
<MyDialog v-model="showBatchForm" title="批量录入预算" :width="600">
<div style="margin-bottom:12px;">
<p style="color:#666;font-size:13px;">从KPI字典中选择需要设置预算的KPI统一填写预算值</p>
<el-select v-model="batchYear" placeholder="年份" style="width:100px;margin-right:8px;">
<el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" />
</el-select>
<el-select v-model="batchMonth" placeholder="月份" style="width:90px;">
<el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" />
</el-select>
</div>
<el-table :data="batchKpis" v-loading="batchLoading" border size="small" max-height="400" style="width:100%;" @selection-change="onBatchSelect">
<el-table-column type="selection" width="40" />
<el-table-column prop="kpi_code" label="编码" width="110" />
<el-table-column prop="kpi_name" label="名称" min-width="150" />
<el-table-column prop="dimension" label="维度" width="70">
<template #default="{ row }">
<el-tag size="small" :type="dimTag(row.dimension)" style="font-size:11px;">{{ dimLabel(row.dimension) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="预算值" width="130">
<template #default="{ row }">
<el-input-number v-model="row.batchValue" :min="0" :precision="0" controls-position="right" style="width:110px;" />
</template>
</el-table-column>
</el-table>
<div style="margin-top:12px;display:flex;gap:12px;align-items:center;">
<span style="font-size:13px;color:#666;">已选 {{ batchSelected.length }} </span>
<el-button size="small" @click="batchSetSame">设为相同值</el-button>
<el-input-number v-model="batchSameValue" :min="0" controls-position="right" style="width:130px;" />
</div>
<template #footer>
<el-button @click="showBatchForm = false">取消</el-button>
<el-button type="primary" @click="doBatchCreate" :loading="batchSaving">批量创建</el-button>
</template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { budgetApi, kpiApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
const activeTab = ref('input')
// ── 年份/月份选项 ──
const currentYear = new Date().getFullYear()
const yearOptions = computed(() => {
const years: number[] = []
for (let y = currentYear - 2; y <= currentYear + 2; y++) years.push(y)
return years
})
const filterYear = ref(currentYear)
const filterMonth = ref(0)
const searchKpi = ref('')
const loading = ref(false)
const budgetList = ref<any[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
// ── 维度标签 ──
const dimMap: Record<string, string> = {
finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长'
}
const dimTagMap: Record<string, string> = {
finance: 'danger', customer: 'warning', process: 'primary', learning: 'success'
}
function dimLabel(d: string) { return dimMap[d] || d }
function dimTag(d: string) { return dimTagMap[d] || 'info' }
function formatNumber(v: any) {
if (v === null || v === undefined) return '--'
return Number(v).toLocaleString('zh-CN')
}
// ── 加载预算列表 ──
async function loadBudget() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize.value }
if (filterYear.value) params.budget_year = filterYear.value
if (filterMonth.value > 0) params.budget_month = filterMonth.value
if (searchKpi.value) params.keyword = searchKpi.value
const r: any = await budgetApi.list(params)
const data = r.data || r || []
// 如果后端返回分页格式
if (Array.isArray(data)) {
budgetList.value = data.map((item: any) => ({ ...item, editing: false, editValue: item.budget_value, saving: false }))
total.value = data.length
} else if (data.items) {
budgetList.value = (data.items || []).map((item: any) => ({ ...item, editing: false, editValue: item.budget_value, saving: false }))
total.value = data.total || data.items.length
} else {
budgetList.value = []
total.value = 0
}
} catch (e) { ElMessage.error('加载预算数据失败') }
loading.value = false
}
// ── 编辑操作 ──
function startEdit(row: any) {
row.editing = true
row.editValue = row.budget_value
}
function cancelEdit(row: any) {
row.editing = false
row.editValue = row.budget_value
}
async function saveEdit(row: any) {
row.saving = true
try {
await budgetApi.update(row.id, { budget_value: row.editValue })
ElMessage.success('已更新')
row.budget_value = row.editValue
row.editing = false
} catch (e) { ElMessage.error('更新失败') }
row.saving = false
}
async function doDelete(row: any) {
try {
await ElMessageBox.confirm(`确认删除「${row.kpi_name || row.kpi_code}」的预算?`, '确认')
await budgetApi.delete(row.id)
ElMessage.success('已删除')
loadBudget()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('删除失败')
}
}
// ── 批量录入 ──
const showBatchForm = ref(false)
const batchYear = ref(currentYear)
const batchMonth = ref(new Date().getMonth() + 1)
const batchLoading = ref(false)
const batchKpis = ref<any[]>([])
const batchSelected = ref<any[]>([])
const batchSameValue = ref(0)
const batchSaving = ref(false)
async function loadBatchKpis() {
batchLoading.value = true
try {
const r: any = await kpiApi.list({ page_size: 500 })
const data = r.data || r || []
const items = Array.isArray(data) ? data : (data.items || [])
batchKpis.value = items.map((k: any) => ({ ...k, batchValue: 0 }))
} catch (e) { ElMessage.error('加载KPI列表失败') }
batchLoading.value = false
}
function onBatchSelect(sel: any[]) {
batchSelected.value = sel
}
function batchSetSame() {
batchSelected.value.forEach(item => { item.batchValue = batchSameValue.value })
}
async function doBatchCreate() {
if (batchSelected.value.length === 0) { ElMessage.warning('请先选择KPI'); return }
batchSaving.value = true
let success = 0
for (const kpi of batchSelected.value) {
try {
await budgetApi.create({
kpi_id: kpi.id,
period: `${batchYear.value}-${String(batchMonth.value).padStart(2, '0')}`,
budget_value: kpi.batchValue,
budget_year: batchYear.value,
budget_month: batchMonth.value,
})
success++
} catch (e) { /* skip */ }
}
ElMessage.success(`批量创建完成:${success}/${batchSelected.value.length} 条成功`)
batchSaving.value = false
showBatchForm.value = false
loadBudget()
}
// ── 年度分解 ──
const showDecompose = ref(false)
const decomposeForm = ref({ year: currentYear, method: 'equal' })
const decomposing = ref(false)
const decomposeResult = ref('')
async function doDecompose() {
decomposing.value = true
decomposeResult.value = ''
try {
const r: any = await budgetApi.autoDecompose({
year: decomposeForm.value.year,
method: decomposeForm.value.method,
})
decomposeResult.value = r.message || '分解成功'
ElMessage.success('年度预算分解完成')
loadBudget()
} catch (e: any) {
ElMessage.error(e.message || '分解失败')
}
decomposing.value = false
}
onMounted(() => {
loadBudget()
})
</script>
+357
View File
@@ -0,0 +1,357 @@
<template>
<div class="kb-page">
<div class="kb-header">
<h3>📖 CMA知识库</h3>
<el-input v-model="search" placeholder="搜索术语、计算公式、最佳实践..." prefix-icon="Search" clearable style="width:360px;" />
</div>
<!-- 搜索结果 -->
<div v-if="search" class="search-results">
<h4>搜索结果{{ searchResults.length }}</h4>
<div v-for="item in searchResults" :key="item.title" class="search-item" @click="openItem(item)">
<span class="search-cat-tag">{{ catLabel(item.category) }}</span>
<span class="search-title">{{ item.title }}</span>
<span class="search-summary">{{ item.summary }}</span>
</div>
<div v-if="searchResults.length === 0" class="empty-search">未找到相关内容</div>
</div>
<!-- 分类展示 -->
<div v-else class="kb-grid">
<div v-for="(cat, ci) in categories" :key="ci" class="kb-category">
<div class="cat-head">
<span class="cat-icon">{{ cat.icon }}</span>
<span class="cat-title">{{ cat.name }}</span>
<span class="cat-count">{{ cat.items.length }}</span>
</div>
<div class="cat-body">
<div v-for="(item, ii) in cat.items" :key="ii" class="kb-item" @click="openItem(item)">
<span class="kb-item-title">{{ item.title }}</span>
<span class="kb-item-summary">{{ item.summary }}</span>
</div>
</div>
</div>
</div>
<!-- 详情弹窗 -->
<el-dialog v-model="showDetail" :title="detailItem?.title" width="700" top="5vh">
<div v-if="detailItem" class="detail-content" v-html="detailItem.content"></div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue"
const search = ref("")
const showDetail = ref(false)
const detailItem = ref<any>(null)
// ── 分类配置 ──
const categories = ref([
{
name: "术语解释", icon: "📖", key: "term",
items: [
{
title: "KPI(关键绩效指标)",
summary: "衡量战略目标达成情况的量化指标",
content: `<h4>KPIKey Performance Indicator</h4>
<p>关键绩效指标,是衡量企业战略目标达成情况的量化指标。在管理会计OS中,KPI贯穿战略地图、驾驶舱、预警中心等所有模块。</p>
<h5>特征</h5>
<ul>
<li><b>可量化</b>:每个KPI有明确的计算公式和数值</li>
<li><b>有时效</b>:按日/周/月/季/年采集和考核</li>
<li><b>有标准</b>:每个KPI设定了目标值和红黄绿灯阈值</li>
</ul>
<h5>编码规则</h5>
<p>KPI编码按 BSC维度_类别_序号 命名,如 <code>F_REVENUE_001</code></p>
<ul>
<li>F = 财务维度 (Finance)</li>
<li>C = 客户维度 (Customer)</li>
<li>P = 内部流程 (Process)</li>
<li>L = 学习成长 (Learning)</li>
</ul>`,
},
{
title: "BSC(平衡计分卡)",
summary: "从财务、客户、内部流程、学习成长四个维度衡量企业绩效",
content: `<h4>BSCBalanced Scorecard</h4>
<p>平衡计分卡,由Kaplan和Norton提出,从四个维度衡量企业绩效:</p>
<ul>
<li><b>财务维度</b>:最终结果指标,如收入、利润、成本</li>
<li><b>客户维度</b>:市场反馈指标,如客户数量、满意度</li>
<li><b>内部流程</b>:运营效率指标,如交付及时率、库存周转</li>
<li><b>学习成长</b>:驱动因素指标,如培训完成率、人才梯队</li>
</ul>
<p>四个维度形成因果链:<b>学习成长 → 内部流程 → 客户 → 财务</b></p>`,
},
{
title: "红黄绿灯预警",
summary: "KPI达成情况的颜色标识:绿=正常 黄=预警 红=异常",
content: `<h4>红黄绿灯预警机制</h4>
<p>系统自动根据KPI实际值与目标值的比率,生成三种预警等级:</p>
<table border="1" cellpadding="8" cellspacing="0" style="border-collapse:collapse;width:100%;margin:12px 0;">
<tr style="background:#f5f5f5;"><th>颜色</th><th>等级</th><th>判定标准</th><th>建议动作</th></tr>
<tr><td style="color:#67c23a;font-weight:bold;">🟢 绿色</td><td>正常</td><td>达成率 ≥ 90%</td><td>保持现状</td></tr>
<tr><td style="color:#e6a23c;font-weight:bold;">🟡 黄色</td><td>预警</td><td>70% ≤ 达成率 < 90%</td><td>关注趋势,制定改善计划</td></tr>
<tr><td style="color:#f56c6c;font-weight:bold;">🔴 红色</td><td>异常</td><td>达成率 < 70%</td><td>立即分析原因,启动改善行动</td></tr>
</table>`,
},
{
title: "战略地图",
summary: "用BSC四维框架可视化展示战略目标与因果关系的工具",
content: `<h4>战略地图</h4>
<p>战略地图是平衡计分卡的图形化表达,将战略目标按BSC四个维度排列,并用箭头展示因果驱动关系。</p>
<h5>使用方法</h5>
<ol>
<li>在「战略地图」页面创建一张新地图</li>
<li>在每个维度下添加战略目标</li>
<li>给目标关联KPI,用于量化衡量</li>
<li>从低维度向高维度画因果连线(如 人才培养→流程优化→客户满意→收入增长)</li>
<li>发布后可在「战略回顾会」中查看执行情况</li>
</ol>`,
},
{
title: "改善行动计划(CAP",
summary: "针对预警问题制定的改进措施,跟踪执行和效果",
content: `<h4>改善行动计划</h4>
<p>当KPI出现黄色或红色预警时,需要制定改善行动计划来解决问题。</p>
<h5>流程</h5>
<ol>
<li>在预警中心识别异常KPI</li>
<li>创建改善行动计划:指定标题、负责人、截止日期、优先级</li>
<li>执行过程中更新进度(0-100%</li>
<li>完成后填写改善结果</li>
<li>系统会持续跟踪逾期和进度</li>
</ol>`,
},
],
},
{
name: "计算公式", icon: "📐", key: "formula",
items: [
{
title: "达成率",
summary: "实际值 ÷ 目标值 × 100%",
content: `<h4>达成率</h4>
<p><b>公式</b>:达成率 = 实际值 / 目标值 × 100%</p>
<h5>示例</h5>
<p>销售总额目标2000万,实际1640万:1640/2000 = <b>82%</b></p>
<h5>说明</h5>
<ul>
<li>达成率 ≥ 90% 为绿灯</li>
<li>70% ≤ 达成率 < 90% 为黄灯</li>
<li>达成率 < 70% 为红灯</li>
<li>部分反向指标(如成本控制率)需取相反判断</li>
</ul>`,
},
{
title: "偏差率",
summary: "(实际值 - 目标值)÷ 目标值 × 100%",
content: `<h4>偏差率</h4>
<p><b>公式</b>:偏差率 = (实际值 - 目标值) / 目标值 × 100%</p>
<h5>示例</h5>
<p>预算100万,实际支出120万:(120-100)/100 = <b>+20%</b>(超支)</p>
<h5>说明</h5>
<ul>
<li>正偏差 = 超过目标(可能好也可能坏,取决于指标性质)</li>
<li>负偏差 = 低于目标</li>
</ul>`,
},
{
title: "销售毛利率",
summary: "(销售收入 - 销售成本)÷ 销售收入 × 100%",
content: `<h4>销售毛利率</h4>
<p><b>公式</b>:毛利率 = (销售收入 - 销售成本) / 销售收入 × 100%</p>
<h5>示例</h5>
<p>收入1000万,成本700万:(1000-700)/1000 = <b>30%</b></p>
<h5>说明</h5>
<p>毛利率是衡量企业盈利能力的核心指标,越高代表产品附加值越大。</p>`,
},
{
title: "库存周转率",
summary: "销售成本 ÷ 平均库存金额",
content: `<h4>库存周转率</h4>
<p><b>公式</b>:库存周转率 = 销售成本 / 平均库存金额</p>
<h5>示例</h5>
<p>年销售成本1.2亿,平均库存1500万:1.2亿/1500万 = <b>8次/年</b></p>
<h5>说明</h5>
<p>库存周转率反映企业存货管理效率,越高说明资金占用越少。</p>`,
},
{
title: "达成率 vs 偏差率 对照",
summary: "两个指标的区别与使用场景",
content: `<h4>达成率 vs 偏差率</h4>
<table border="1" cellpadding="8" cellspacing="0" style="border-collapse:collapse;width:100%;margin:12px 0;">
<tr style="background:#f5f5f5;"><th></th><th>达成率</th><th>偏差率</th></tr>
<tr><td><b>公式</b></td><td>实际/目标 × 100%</td><td>(实际-目标)/目标 × 100%</td></tr>
<tr><td><b>理想值</b></td><td>≥ 100%</td><td>接近0%</td></tr>
<tr><td><b>适用场景</b></td><td>正向指标(收入、满意度)</td><td>预算控制、成本差异</td></tr>
<tr><td><b>红黄绿灯</b></td><td>≥90%绿,≥70%黄,<70%红</td><td>按偏差幅度自定义</td></tr>
</table>`,
},
],
},
{
name: "最佳实践", icon: "⭐", key: "practice",
items: [
{
title: "如何绘制一张好的战略地图",
summary: "从设定目标到画因果链的实操指南",
content: `<h4>绘制战略地图的5个步骤</h4>
<ol>
<li><b>明确战略主题</b>:确定本年度最重要的3-5个战略方向</li>
<li><b>从财务开始</b>:先在财务维度设定最终目标(如"提升销售总额30%"</li>
<li><b>追溯驱动因素</b>:思考\"要达成财务目标,客户需要什么?流程需要怎么优化?员工需要什么能力?\"</li>
<li><b>关联KPI</b>:每个目标绑定1-2个可量化的KPI</li>
<li><b>画因果连线</b>:从下到上连接各维度目标,形成因果链</li>
</ol>
<h5>常见错误</h5>
<ul>
<li>❌ 每个维度设太多目标(建议不超过4个)</li>
<li>❌ 目标不关联KPI(无法量化跟踪)</li>
<li>❌ 因果链混乱(学习→流程→客户→财务,不要跳层)</li>
</ul>`,
},
{
title: "预算编制的10个原则",
summary: "预算管理模块使用的最佳实践",
content: `<h4>预算编制最佳实践</h4>
<ol>
<li><b>自上而下 vs 自下而上</b>:先定总盘子,再分解到各部门</li>
<li><b>零基预算</b>:每年从零开始编制,不以上年基数为准</li>
<li><b>弹性预算</b>:按业务量设定不同层级的预算标准</li>
<li><b>滚动预算</b>:每季度更新一次全年预算</li>
<li><b>差异分析</b>:每月对比实际与预算,找出偏差原因</li>
</ol>`,
},
{
title: "预警阈值设置的技巧",
summary: "如何设置合理的红黄绿灯阈值",
content: `<h4>阈值设置建议</h4>
<p>合理的红黄绿灯阈值应该:</p>
<ul>
<li><b>不要过严</b>:绿灯设90%而不是95%,否则大部分KPI都是黄色</li>
<li><b>区分指标性质</b>:正向指标(收入)和反向指标(成本)的绿/红判断逻辑不同</li>
<li><b>结合历史数据</b>:参考过去6个月的实际值来设定合理目标</li>
<li><b>动态调整</b>:每季度review一次阈值,随业务发展调整</li>
</ul>`,
},
{
title: "每日工作台的使用方法",
summary: "如何高效使用我的工作台进行日常管理",
content: `<h4>工作台使用三部曲</h4>
<ol>
<li><b>早上看KPI</b>:登录先看一眼我的KPI红黄绿灯,了解整体状态</li>
<li><b>处理待办</b>:查看逾期改善行动和待审批事项</li>
<li><b>跟踪异常</b>:红色预警的KPI立即查看详情,安排改善行动</li>
</ol>`,
},
],
},
{
name: "常见问题", icon: "❓", key: "faq",
items: [
{
title: "数据不同步怎么办?",
summary: "ERP数据同步失败的排查步骤",
content: `<h4>数据同步问题排查</h4>
<ol>
<li>检查ERP API网关是否正常运行(端口8300</li>
<li>在「数据管理」页面查看最近一次同步时间</li>
<li>手动触发同步:数据管理 → 点击「手动同步」</li>
<li>如仍失败,联系IT管理员查看后端日志</li>
</ol>`,
},
{
title: "为什么看不到某些KPI",
summary: "角色权限和KPI可见性的说明",
content: `<h4>KPI可见性规则</h4>
<ul>
<li><b>CEO</b>:全量KPI可见</li>
<li><b>财务</b>:财务维度KPI + 通用KPI</li>
<li><b>业务</b>:客户、流程、学习维度KPI</li>
<li><b>IT</b>:全量KPI可见(运维用途)</li>
</ul>
<p>如需调整可见范围,请联系管理员修改权限配置。</p>`,
},
{
title: "如何删除一个战略目标?",
summary: "画布上目标的编辑和删除操作",
content: `<h4>删除战略目标</h4>
<ol>
<li>进入战略地图画布页面</li>
<li>点击要删除的目标卡片(会弹出编辑弹窗)</li>
<li>点击弹窗底部的「删除」按钮</li>
<li>确认删除</li>
<li>点击「保存」使改动生效</li>
</ol>`,
},
],
},
])
// ── 计算属性 ──
const searchResults = computed(() => {
if (!search.value.trim()) return []
const q = search.value.toLowerCase()
const results: any[] = []
for (const cat of categories.value) {
for (const item of cat.items) {
if (
item.title.toLowerCase().includes(q) ||
item.summary.toLowerCase().includes(q) ||
item.content.toLowerCase().includes(q)
) {
results.push({ ...item, category: cat.key })
}
}
}
return results
})
// ── 方法 ──
function catLabel(key: string) {
const map: Record<string, string> = { term: "术语", formula: "公式", practice: "实践", faq: "FAQ" }
return map[key] || key
}
function openItem(item: any) {
detailItem.value = item
showDetail.value = true
}
</script>
<style scoped>
.kb-page { padding: 20px; height: calc(100vh - 60px); overflow-y: auto; }
.kb-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.kb-header h3 { margin: 0; font-size: 20px; }
.kb-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.kb-category { background: #fff; border-radius: 10px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.cat-head { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #f0f0f0; }
.cat-icon { font-size: 20px; }
.cat-title { font-size: 16px; font-weight: 600; flex: 1; }
.cat-count { font-size: 12px; color: #999; background: #f5f5f5; padding: 2px 8px; border-radius: 10px; }
.cat-body { display: flex; flex-direction: column; gap: 6px; }
.kb-item { padding: 10px; border-radius: 8px; cursor: pointer; transition: background 0.2s; }
.kb-item:hover { background: #f5f7fa; }
.kb-item-title { display: block; font-size: 14px; font-weight: 500; margin-bottom: 2px; }
.kb-item-summary { display: block; font-size: 12px; color: #999; }
.search-results { margin-bottom: 20px; }
.search-results h4 { margin: 0 0 12px 0; font-size: 15px; color: #666; }
.search-item { display: flex; align-items: center; gap: 8px; padding: 12px; border-radius: 8px; cursor: pointer; background: #fff; margin-bottom: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
.search-item:hover { background: #f5f7fa; }
.search-cat-tag { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: #ecf5ff; color: #409eff; flex-shrink: 0; }
.search-title { font-weight: 500; font-size: 14px; flex-shrink: 0; }
.search-summary { font-size: 12px; color: #999; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty-search { text-align: center; color: #999; padding: 40px; }
.detail-content { line-height: 1.8; font-size: 14px; }
.detail-content h4 { margin: 16px 0 8px; }
.detail-content h5 { margin: 12px 0 6px; color: #666; }
.detail-content ul, .detail-content ol { padding-left: 20px; }
.detail-content li { margin-bottom: 4px; }
.detail-content code { background: #f5f5f5; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
.detail-content table { width: 100%; border-collapse: collapse; margin: 12px 0; }
.detail-content th, .detail-content td { border: 1px solid #e0e0e0; padding: 8px; text-align: left; }
.detail-content th { background: #f5f5f5; font-weight: 600; }
</style>
+523
View File
@@ -0,0 +1,523 @@
<template>
<div>
<h3>成本分析</h3>
<el-tabs v-model="activeTab" style="margin-top:16px;">
<!-- Tab 1: 成本总览 -->
<el-tab-pane label="成本总览" name="overview">
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="overviewPeriod" placeholder="期间" style="width:110px;" @change="loadOverview">
<el-option v-for="p in periodOptions" :key="p" :label="p" :value="p" />
</el-select>
<el-button type="primary" @click="loadOverview">刷新</el-button>
</div>
<el-row :gutter="16" style="margin-bottom:16px;">
<el-col :span="6">
<el-card shadow="hover"><div class="stat-card"><div class="stat-label">总成本</div><div class="stat-value">{{ formatMoney(overviewData.total_cost || 0) }}</div></div></el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover"><div class="stat-card"><div class="stat-label">ERP同步成本</div><div class="stat-value" style="color:#409eff;">{{ formatMoney(overviewData.erp_cost || 0) }}</div></div></el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover"><div class="stat-card"><div class="stat-label">手工录入成本</div><div class="stat-value" style="color:#e6a23c;">{{ formatMoney(overviewData.manual_cost || 0) }}</div></div></el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover"><div class="stat-card"><div class="stat-label">产品数</div><div class="stat-value" style="color:#67c23a;">{{ (overviewData.products || []).length }}</div></div></el-card>
</el-col>
</el-row>
<!-- 成本结构 -->
<el-row :gutter="16">
<el-col :span="12">
<el-card>
<template #header>成本结构按类型</template>
<div v-if="(overviewData.structure || []).length > 0">
<div v-for="s in overviewData.structure" :key="s.cost_type" style="margin-bottom:12px;">
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:4px;">
<span>{{ costTypeLabel(s.cost_type) }}</span>
<span>{{ formatMoney(s.amount) }}{{ s.ratio }}%</span>
</div>
<el-progress :percentage="s.ratio" :stroke-width="14" :color="costTypeColor(s.cost_type)" />
</div>
</div>
<div v-else style="text-align:center;color:#999;padding:20px;">暂无成本数据请先在成本录入中添加</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>各产品成本</template>
<div v-if="(overviewData.products || []).length > 0">
<div v-for="p in overviewData.products" :key="p.product_code" style="display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid #f5f5f5;font-size:13px;">
<span>{{ p.product_name || p.product_code }}</span>
<span style="font-weight:500;">{{ formatMoney(p.total_cost) }}</span>
</div>
</div>
<div v-else style="text-align:center;color:#999;padding:20px;">暂无产品数据</div>
</el-card>
</el-col>
</el-row>
<!-- 成本趋势 -->
<el-card style="margin-top:16px;">
<template #header>成本趋势近6个月</template>
<div v-if="(overviewData.trend || []).length > 0" style="display:flex;align-items:flex-end;gap:12px;height:180px;padding:20px 0;overflow-x:auto;">
<div v-for="(t, idx) in overviewData.trend" :key="idx" style="display:flex;flex-direction:column;align-items:center;min-width:80px;">
<div class="trend-bar" :style="{ height: trendHeight(t.total_cost, maxTrend) + 'px' }" :title="formatMoney(t.total_cost)"></div>
<span style="font-size:11px;color:#909399;margin-top:6px;">{{ t.period }}</span>
<span style="font-size:10px;">{{ formatMoney(t.total_cost) }}</span>
</div>
</div>
<div v-else style="text-align:center;color:#999;padding:20px;">暂无趋势数据</div>
</el-card>
</el-tab-pane>
<!-- Tab 2: 标准成本管理 -->
<el-tab-pane label="标准成本" name="standard">
<div style="margin-bottom:12px;">
<el-button type="primary" @click="showStdForm = true; stdForm = {}">+ 新建标准成本</el-button>
<el-select v-model="stdProductFilter" placeholder="产品" clearable style="width:150px;margin-left:8px;" @change="loadStandardCosts">
<el-option v-for="p in productOptions" :key="p" :label="p" :value="p" />
</el-select>
</div>
<el-table :data="standardCosts" v-loading="stdLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="product_code" label="产品编码" width="100" />
<el-table-column prop="product_name" label="产品名称" width="120" />
<el-table-column prop="cost_type" label="成本类型" width="90">
<template #default="{ row }"><el-tag size="small" :type="costTypeTag(row.cost_type)">{{ costTypeLabel(row.cost_type) }}</el-tag></template>
</el-table-column>
<el-table-column prop="item_name" label="项目名称" min-width="140" />
<el-table-column prop="standard_quantity" label="标准用量" width="90" align="right" />
<el-table-column prop="unit" label="单位" width="60" />
<el-table-column prop="standard_price" label="标准单价" width="90" align="right">
<template #default="{ row }">{{ formatMoney(row.standard_price) }}</template>
</el-table-column>
<el-table-column prop="standard_cost" label="标准成本" width="100" align="right">
<template #default="{ row }"><span style="font-weight:500;">{{ formatMoney(row.standard_cost) }}</span></template>
</el-table-column>
<el-table-column prop="version" label="版本" width="60" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="editStd(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteStd(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- Tab 3: 成本录入 -->
<el-tab-pane label="成本录入" name="entry">
<div style="margin-bottom:12px;">
<el-button type="primary" @click="showActualForm = true; actualForm = {}">+ 录入实际成本</el-button>
<el-select v-model="entryPeriod" placeholder="期间" style="width:110px;margin-left:8px;" @change="loadActualCosts">
<el-option v-for="p in periodOptions" :key="p" :label="p" :value="p" />
</el-select>
</div>
<el-table :data="actualCosts" v-loading="actualLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="period" label="期间" width="80" />
<el-table-column prop="product_code" label="产品编码" width="90" />
<el-table-column prop="product_name" label="产品名称" width="120" />
<el-table-column prop="cost_type" label="成本类型" width="90">
<template #default="{ row }"><el-tag size="small" :type="costTypeTag(row.cost_type)">{{ costTypeLabel(row.cost_type) }}</el-tag></template>
</el-table-column>
<el-table-column prop="item_name" label="项目" min-width="120" />
<el-table-column prop="actual_quantity" label="实际用量" width="90" align="right" />
<el-table-column prop="actual_price" label="实际单价" width="90" align="right">
<template #default="{ row }">{{ formatMoney(row.actual_price) }}</template>
</el-table-column>
<el-table-column prop="actual_cost" label="实际成本" width="100" align="right">
<template #default="{ row }"><span style="font-weight:500;">{{ formatMoney(row.actual_cost) }}</span></template>
</el-table-column>
<el-table-column prop="source" label="来源" width="70" />
</el-table>
</el-tab-pane>
<!-- Tab 4: 量差价差分析 -->
<el-tab-pane label="量差价差" name="variance">
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;">
<el-select v-model="varProduct" placeholder="选择产品" style="width:200px;">
<el-option v-for="p in productOptions" :key="p" :label="p" :value="p" />
</el-select>
<el-select v-model="varPeriod" placeholder="期间" style="width:110px;">
<el-option v-for="p in periodOptions" :key="p" :label="p" :value="p" />
</el-select>
<el-button type="primary" @click="loadVariance" :loading="varLoading">分析</el-button>
</div>
<div v-if="varianceData.summary">
<el-row :gutter="16" style="margin-bottom:16px;">
<el-col :span="6"><el-card shadow="hover"><div class="stat-card"><div class="stat-label">标准成本</div><div class="stat-value" style="color:#409eff;">{{ formatMoney(varianceData.summary.total_standard_cost) }}</div></div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><div class="stat-card"><div class="stat-label">实际成本</div><div class="stat-value" style="color:#e6a23c;">{{ formatMoney(varianceData.summary.total_actual_cost) }}</div></div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><div class="stat-card"><div class="stat-label">总差异</div><div class="stat-value" :style="{ color: varianceData.summary.total_variance > 0 ? '#f56c6c' : '#67c23a' }">{{ formatMoney(varianceData.summary.total_variance) }}</div></div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><div class="stat-card"><div class="stat-label">差异率</div><div class="stat-value" :style="{ color: varianceData.summary.variance_rate > 0 ? '#f56c6c' : '#67c23a' }">{{ varianceData.summary.variance_rate }}%</div></div></el-card></el-col>
</el-row>
<el-table :data="varianceData.items" border stripe size="small" style="width:100%;">
<el-table-column prop="cost_type" label="类型" width="80">
<template #default="{ row }"><el-tag size="small" :type="costTypeTag(row.cost_type)">{{ costTypeLabel(row.cost_type) }}</el-tag></template>
</el-table-column>
<el-table-column prop="item_name" label="项目" width="140" />
<el-table-column prop="standard_cost" label="标准" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.standard_cost) }}</template></el-table-column>
<el-table-column prop="actual_cost" label="实际" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.actual_cost) }}</template></el-table-column>
<el-table-column prop="qty_variance" label="量差" width="100" align="right">
<template #default="{ row }"><span :style="{ color: row.qty_variance > 0 ? '#f56c6c' : '#67c23a' }">{{ formatMoney(row.qty_variance) }}</span></template>
</el-table-column>
<el-table-column prop="price_variance" label="价差" width="100" align="right">
<template #default="{ row }"><span :style="{ color: row.price_variance > 0 ? '#f56c6c' : '#67c23a' }">{{ formatMoney(row.price_variance) }}</span></template>
</el-table-column>
<el-table-column prop="total_variance" label="总差异" width="100" align="right">
<template #default="{ row }"><span :style="{ color: row.total_variance > 0 ? '#f56c6c' : '#67c23a', fontWeight:500 }">{{ formatMoney(row.total_variance) }}</span></template>
</el-table-column>
</el-table>
</div>
<el-empty v-else-if="!varLoading" description="请选择产品后点击「分析」" style="padding:40px 0;" />
</el-tab-pane>
<!-- Tab 5: ABC作业成本 -->
<el-tab-pane label="ABC作业成本" name="abc">
<el-tabs v-model="abcTab">
<el-tab-pane label="作业中心" name="activities">
<div style="margin-bottom:12px;"><el-button type="primary" @click="showAbcForm = true; abcForm = {}">+ 新建作业中心</el-button></div>
<el-table :data="abcActivities" v-loading="abcLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="activity_code" label="编码" width="100" />
<el-table-column prop="activity_name" label="作业名称" width="160" />
<el-table-column prop="cost_driver" label="成本动因" width="120" />
<el-table-column prop="driver_unit" label="动因单位" width="70" />
<el-table-column prop="total_cost" label="总成本" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.total_cost) }}</template></el-table-column>
<el-table-column prop="driver_volume" label="动因总量" width="90" align="right" />
<el-table-column prop="driver_rate" label="分配率" width="90" align="right">{{ formatMoney(row.driver_rate) }}</el-table-column>
<el-table-column label="操作" width="80"><template #default="{ row }"><el-button size="small" @click="editAbc(row)">编辑</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="成本分配" name="allocations">
<div style="margin-bottom:12px;display:flex;gap:8px;">
<el-button type="primary" @click="showAllocForm = true; allocForm = {}">+ 执行分配</el-button>
<el-select v-model="allocPeriod" placeholder="期间" style="width:110px;" @change="loadAllocations">
<el-option v-for="p in periodOptions" :key="p" :label="p" :value="p" />
</el-select>
</div>
<el-table :data="abcAllocations" v-loading="allocLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="period" label="期间" width="80" />
<el-table-column prop="activity_id" label="作业ID" width="70" />
<el-table-column prop="product_code" label="产品" width="90" />
<el-table-column prop="product_name" label="产品名" width="120" />
<el-table-column prop="driver_consumed" label="动因消耗" width="90" align="right" />
<el-table-column prop="allocated_cost" label="分配成本" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.allocated_cost) }}</template></el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
</el-tabs>
<!-- 标准成本表单弹窗 -->
<MyDialog v-model="showStdForm" :title="stdForm.id ? '编辑标准成本' : '新建标准成本'" :width="550">
<el-form :model="stdForm" label-width="110px">
<el-form-item label="产品编码"><el-input v-model="stdForm.product_code" /></el-form-item>
<el-form-item label="产品名称"><el-input v-model="stdForm.product_name" /></el-form-item>
<el-form-item label="成本类型">
<el-select v-model="stdForm.cost_type"><el-option label="材料" value="material" /><el-option label="人工" value="labor" /><el-option label="制造费用" value="overhead" /></el-select>
</el-form-item>
<el-form-item label="项目名称"><el-input v-model="stdForm.item_name" /></el-form-item>
<el-form-item label="标准用量"><el-input-number v-model="stdForm.standard_quantity" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="单位"><el-input v-model="stdForm.unit" style="width:150px;" /></el-form-item>
<el-form-item label="标准单价"><el-input-number v-model="stdForm.standard_price" :min="0" :precision="2" style="width:200px;" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showStdForm = false">取消</el-button>
<el-button type="primary" @click="saveStd" :loading="stdSaving">保存</el-button>
</template>
</MyDialog>
<!-- 实际成本录入弹窗 -->
<MyDialog v-model="showActualForm" title="录入实际成本" :width="550">
<el-form :model="actualForm" label-width="110px">
<el-form-item label="期间"><el-input v-model="actualForm.period" placeholder="2026-05" /></el-form-item>
<el-form-item label="产品编码"><el-input v-model="actualForm.product_code" /></el-form-item>
<el-form-item label="产品名称"><el-input v-model="actualForm.product_name" /></el-form-item>
<el-form-item label="成本类型"><el-select v-model="actualForm.cost_type"><el-option label="材料" value="material" /><el-option label="人工" value="labor" /><el-option label="制造费用" value="overhead" /></el-select></el-form-item>
<el-form-item label="项目名称"><el-input v-model="actualForm.item_name" /></el-form-item>
<el-form-item label="实际用量"><el-input-number v-model="actualForm.actual_quantity" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="实际单价"><el-input-number v-model="actualForm.actual_price" :min="0" :precision="2" style="width:200px;" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showActualForm = false">取消</el-button>
<el-button type="primary" @click="saveActual" :loading="actualSaving">保存</el-button>
</template>
</MyDialog>
<!-- ABC作业中心弹窗 -->
<MyDialog v-model="showAbcForm" :title="abcForm.id ? '编辑作业中心' : '新建作业中心'" :width="500">
<el-form :model="abcForm" label-width="110px">
<el-form-item label="作业编码"><el-input v-model="abcForm.activity_code" /></el-form-item>
<el-form-item label="作业名称"><el-input v-model="abcForm.activity_name" /></el-form-item>
<el-form-item label="成本动因"><el-input v-model="abcForm.cost_driver" /></el-form-item>
<el-form-item label="动因单位"><el-input v-model="abcForm.driver_unit" /></el-form-item>
<el-form-item label="总成本"><el-input-number v-model="abcForm.total_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="动因总量"><el-input-number v-model="abcForm.driver_volume" :min="0" style="width:200px;" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showAbcForm = false">取消</el-button>
<el-button type="primary" @click="saveAbc" :loading="abcSaving">保存</el-button>
</template>
</MyDialog>
<!-- ABC分配弹窗 -->
<MyDialog v-model="showAllocForm" title="执行ABC成本分配" :width="450">
<el-form :model="allocForm" label-width="110px">
<el-form-item label="作业中心">
<el-select v-model="allocForm.activity_id" style="width:250px;">
<el-option v-for="a in abcActivities" :key="a.id" :label="a.activity_name" :value="a.id" />
</el-select>
</el-form-item>
<el-form-item label="期间"><el-input v-model="allocForm.period" placeholder="2026-05" /></el-form-item>
<el-form-item label="产品编码"><el-input v-model="allocForm.product_code" /></el-form-item>
<el-form-item label="产品名称"><el-input v-model="allocForm.product_name" /></el-form-item>
<el-form-item label="动因消耗量"><el-input-number v-model="allocForm.driver_consumed" :min="0" :precision="2" style="width:200px;" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showAllocForm = false">取消</el-button>
<el-button type="primary" @click="doAllocate" :loading="allocSaving">执行分配</el-button>
</template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { costApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
const activeTab = ref('overview')
const abcTab = ref('activities')
// ── 期间选项 ──
const now = new Date()
const periodOptions = computed(() => {
const opts: string[] = []
for (let i = 0; i < 12; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
opts.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
}
return opts
})
// ── 工具函数 ──
const costTypeMap: Record<string, string> = { material: '材料', labor: '人工', overhead: '制造费用' }
const costTypeTagMap: Record<string, string> = { material: 'primary', labor: 'success', overhead: 'warning' }
function costTypeLabel(t: string) { return costTypeMap[t] || t }
function costTypeTag(t: string) { return costTypeTagMap[t] || 'info' }
function formatMoney(v: any) {
if (v === null || v === undefined) return '--'
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
// ============================
// 成本总览
// ============================
const overviewPeriod = ref(periodOptions.value[0] || now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0'))
const overviewData = ref<any>({})
const productOptions = ref<string[]>([])
const maxTrend = ref(0)
async function loadOverview() {
try {
const r: any = await costApi.dashboard({ period: overviewPeriod.value })
overviewData.value = r.data || r
// 提取产品列表
const products = overviewData.value.products || []
productOptions.value = [...new Set(products.map((p: any) => p.product_code))]
// 计算趋势最高值
const trend = overviewData.value.overview?.trend || []
if (trend.length > 0) {
maxTrend.value = Math.max(...trend.map((t: any) => t.total_cost), 1)
}
} catch (e) { ElMessage.error('加载成本总览失败') }
}
function trendHeight(val: number, max: number) {
if (!max) return 0
return Math.max(4, (val / max) * 140)
}
// ============================
// 标准成本管理
// ============================
const stdLoading = ref(false)
const standardCosts = ref<any[]>([])
const showStdForm = ref(false)
const stdForm = ref<any>({})
const stdSaving = ref(false)
const stdProductFilter = ref('')
async function loadStandardCosts() {
stdLoading.value = true
try {
const params: any = {}
if (stdProductFilter.value) params.product_code = stdProductFilter.value
const r: any = await costApi.listStandardCosts(params)
standardCosts.value = r.data || []
} catch (e) { ElMessage.error('加载标准成本失败') }
stdLoading.value = false
}
function editStd(row: any) {
stdForm.value = { ...row }
showStdForm.value = true
}
async function saveStd() {
stdSaving.value = true
try {
if (stdForm.value.id) {
await costApi.updateStandardCost(stdForm.value.id, stdForm.value)
ElMessage.success('已更新')
} else {
await costApi.createStandardCost(stdForm.value)
ElMessage.success('已创建')
}
showStdForm.value = false
loadStandardCosts()
} catch (e) { ElMessage.error('保存失败') }
stdSaving.value = false
}
async function deleteStd(row: any) {
try {
await ElMessageBox.confirm(`确认删除「${row.item_name}」的标准成本?`, '确认')
await costApi.deleteStandardCost(row.id)
ElMessage.success('已删除')
loadStandardCosts()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
// ============================
// 成本录入
// ============================
const actualLoading = ref(false)
const actualCosts = ref<any[]>([])
const entryPeriod = ref(overviewPeriod.value)
const showActualForm = ref(false)
const actualForm = ref<any>({})
const actualSaving = ref(false)
async function loadActualCosts() {
actualLoading.value = true
try {
const params: any = { period: entryPeriod.value }
const r: any = await costApi.listActualCosts(params)
actualCosts.value = r.data || []
} catch (e) { ElMessage.error('加载实际成本失败') }
actualLoading.value = false
}
async function saveActual() {
actualSaving.value = true
try {
await costApi.createActualCost(actualForm.value)
ElMessage.success('已录入')
showActualForm.value = false
loadActualCosts()
} catch (e) { ElMessage.error('保存失败') }
actualSaving.value = false
}
// ============================
// 量差价差分析
// ============================
const varProduct = ref('')
const varPeriod = ref(overviewPeriod.value)
const varLoading = ref(false)
const varianceData = ref<any>({})
async function loadVariance() {
if (!varProduct.value) { ElMessage.warning('请选择产品'); return }
varLoading.value = true
try {
const r: any = await costApi.variance({ product_code: varProduct.value, period: varPeriod.value })
varianceData.value = r
} catch (e) { ElMessage.error('加载差异分析失败') }
varLoading.value = false
}
// ============================
// ABC作业成本
// ============================
const abcLoading = ref(false)
const abcActivities = ref<any[]>([])
const showAbcForm = ref(false)
const abcForm = ref<any>({})
const abcSaving = ref(false)
async function loadAbcActivities() {
abcLoading.value = true
try {
const r: any = await costApi.listAbcActivities()
abcActivities.value = r.data || []
} catch (e) { /* ignore */ }
abcLoading.value = false
}
function editAbc(row: any) {
abcForm.value = { ...row }
showAbcForm.value = true
}
async function saveAbc() {
abcSaving.value = true
try {
await costApi.createAbcActivity(abcForm.value)
ElMessage.success('已保存')
showAbcForm.value = false
loadAbcActivities()
} catch (e) { ElMessage.error('保存失败') }
abcSaving.value = false
}
// ABC分配
const showAllocForm = ref(false)
const allocForm = ref<any>({})
const allocPeriod = ref(overviewPeriod.value)
const allocLoading = ref(false)
const abcAllocations = ref<any[]>([])
const allocSaving = ref(false)
async function loadAllocations() {
allocLoading.value = true
try {
const r: any = await costApi.listAbcAllocations({ period: allocPeriod.value })
abcAllocations.value = r.data || []
} catch (e) { /* ignore */ }
allocLoading.value = false
}
async function doAllocate() {
if (!allocForm.value.activity_id || !allocForm.value.product_code) {
ElMessage.warning('请填写必要信息')
return
}
allocSaving.value = true
try {
const r: any = await costApi.doAbcAllocate(allocForm.value)
ElMessage.success(`分配完成:${r.allocated_cost || ''}`)
showAllocForm.value = false
loadAllocations()
} catch (e) { ElMessage.error('分配失败') }
allocSaving.value = false
}
onMounted(() => {
loadOverview()
loadStandardCosts()
loadActualCosts()
loadAbcActivities()
loadAllocations()
})
</script>
<style scoped>
.stat-card { text-align: center; padding: 8px 0; }
.stat-label { font-size: 13px; color: #909399; margin-bottom: 8px; }
.stat-value { font-size: 24px; font-weight: bold; color: #303133; }
.trend-bar { width: 40px; border-radius: 4px 4px 0 0; background: linear-gradient(to top, #409eff, #79bbff); transition: height 0.3s; }
</style>
+575
View File
@@ -0,0 +1,575 @@
<template>
<div class="dashboard-page">
<!-- 顶部导航条 -->
<div class="dash-header">
<div class="header-left">
<el-tag size="large" :type="roleTagType">{{ roleLabel }}驾驶舱</el-tag>
<el-radio-group v-model="periodType" size="small" @change="onPeriodChange">
<el-radio-button value="month">本月</el-radio-button>
<el-radio-button value="quarter">本季</el-radio-button>
<el-radio-button value="year">本年</el-radio-button>
<el-radio-button value="custom">自定义</el-radio-button>
</el-radio-group>
<el-date-picker
v-if="periodType === 'custom'"
v-model="customStart"
type="date"
placeholder="开始日期"
value-format="YYYY-MM-DD"
size="small"
style="width:140px"
@change="onCustomRangeChange"
/>
<span v-if="periodType === 'custom'" style="color:#999;"></span>
<el-date-picker
v-if="periodType === 'custom'"
v-model="customEnd"
type="date"
placeholder="结束日期"
value-format="YYYY-MM-DD"
size="small"
style="width:140px"
@change="onCustomRangeChange"
/>
</div>
<div class="header-right">
<span v-if="dataRange" class="data-range">📅 {{ dataRange }}</span>
<el-button text size="small" @click="showSidebar = !showSidebar; if(showSidebar) refreshAnalysis()">
{{ showSidebar ? '收起AI' : '🤖 AI分析' }}
</el-button>
</div>
</div>
<!-- ====== CEO视图 ====== -->
<template v-if="userRole === 'ceo'">
<!-- 摘要卡片行 -->
<div class="summary-row">
<div class="stat-card blue" @click="$router.push('/kpis')" style="cursor:pointer;"><div class="stat-val">{{ summary.kpi_total }}</div><div class="stat-label">KPI总数</div><div class="stat-sub click-hint">点击查看详情</div></div>
<div class="stat-card red" @click="$router.push('/alerts')" style="cursor:pointer;"><div class="stat-val">{{ redAlerts }}</div><div class="stat-label">紧急异常</div><div class="stat-sub">待处理 {{ alerts.length }} </div></div>
<div class="stat-card green" @click="$router.push('/kpis')" style="cursor:pointer;"><div class="stat-val">{{ greenKpis }}</div><div class="stat-label">正常</div></div>
<div class="stat-card yellow" @click="$router.push('/alerts')" style="cursor:pointer;"><div class="stat-val">{{ yellowKpis }}</div><div class="stat-label">预警中</div></div>
<div class="stat-card" :class="syncCardClass" @click="$router.push('/data')" style="cursor:pointer;">
<div class="stat-val" style="font-size:14px;line-height:1.4;">{{ syncStatusText }}</div>
<div class="stat-label">数据同步</div>
<div class="stat-sub">{{ syncTimeText }}</div>
</div>
</div>
<!-- 主内容区双栏 -->
<div class="ceo-main">
<!-- 左栏 -->
<div class="ceo-left">
<!-- 异常高亮区 -->
<div v-if="alerts.length > 0" class="alert-spotlight">
<div class="spotlight-title">🔴 需要关注的异常</div>
<div class="spotlight-list">
<div v-for="a in alerts" :key="a.id" class="spotlight-item"
:class="'level-' + a.alert_level"
@click="$router.push('/alerts')">
<span class="spotlight-badge">{{ a.alert_level === 'red' ? '紧急' : '预警' }}</span>
<span class="spotlight-msg">{{ a.alert_message }}</span>
<el-icon><ArrowRight /></el-icon>
</div>
</div>
</div>
<!-- KPI矩阵 四维度网格 -->
<div class="matrix-grid">
<div v-for="dim in kpiByDim" :key="dim.name" class="dim-card">
<div class="dim-head" :style="{ background: dim.color }">
<span class="dim-icon">{{ dim.icon }}</span>
<span class="dim-name">{{ dim.name }}</span>
<span class="dim-count">{{ dim.kpis.length }}</span>
</div>
<div class="dim-body">
<div v-for="k in dim.kpis" :key="k.id" class="kpi-row" @click="showKPIDetail(k)">
<div class="kpi-row-top">
<span class="kpi-row-name">{{ k.kpi_name }}</span>
<span class="kpi-row-badge" :style="{ color: alertColor(k.alert_level) }">{{ alertIcon(k.alert_level) }}</span>
</div>
<div class="kpi-row-btm">
<span class="kpi-row-val">{{ fmtValue(k.actual_value, k.kpi_name) }}</span>
<span class="kpi-row-target">/ {{ k.target_value ?? '-' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 右栏 -->
<div class="ceo-right">
<!-- 预测卡片 -->
<div v-if="predictions.length > 0" class="right-card">
<div class="right-card-title">🔮 下月预测</div>
<div class="pred-list">
<div v-for="p in predictions" :key="p.kpi_id" class="pred-row" :class="'trend-' + p.trend"
@click="$router.push('/predict')" style="cursor:pointer;">
<div class="pred-row-top">
<span class="pred-name">{{ p.kpi_name }}</span>
<span class="pred-arrow">{{ {up:'↑',down:'↓',stable:'→'}[p.trend] }}</span>
</div>
<div class="pred-row-val">
<span class="pred-cur">{{ fmtValue(p.last_value, p.kpi_code) }}</span>
<span class="pred-sep"></span>
<span class="pred-fut" :style="{ color: p.trend === 'down' ? '#f56c6c' : '#67c23a' }">
{{ fmtValue(p.predicted_value, p.kpi_code) }}
</span>
</div>
<div class="pred-row-info">
<span class="pred-conf" :class="p.confidence">{{ {high:'高',medium:'中',low:'低'}[p.confidence] }}置信</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- ====== 业务视图 ====== -->
<template v-if="userRole === 'business'">
<div class="summary-row">
<div class="stat-card blue"><div class="stat-val">{{ myKpis.length }}</div><div class="stat-label">我的KPI</div></div>
<div class="stat-card red"><div class="stat-val">{{ myKpis.filter((k:any)=>k.alert_level==='red').length }}</div><div class="stat-label">需关注</div></div>
</div>
<div v-if="myKpis.length === 0" class="empty-state">暂无关联到你的KPI</div>
<div v-else class="kpi-card-grid">
<div v-for="k in myKpis" :key="k.id" class="kpi-card-item" @click="showKPIDetail(k)">
<div class="card-item-head">
<span class="item-name">{{ k.kpi_name }}</span>
<el-tag size="small" :type="alertType(k.alert_level)">{{ alertIcon(k.alert_level) }}</el-tag>
</div>
<div class="card-item-val" :style="{ color: alertColor(k.alert_level) }">
{{ fmtValue(k.actual_value, k.kpi_name) }}
<span class="item-unit">{{ k.unit }}</span>
</div>
<div class="card-item-target">目标 {{ k.target_value ?? '-' }}</div>
<div class="card-item-trend" v-if="k.trend && k.trend.length >= 2">
<span v-for="(t, i) in k.trend.slice(-3)" :key="i" class="tdot" :style="{ background: trendColor(t.value, k) }"></span>
<span class="trend-hint">近3月</span>
</div>
</div>
</div>
</template>
<!-- ====== 财务视图 ====== -->
<template v-if="userRole === 'finance'">
<div class="summary-row">
<div class="stat-card blue" @click="$router.push('/deviations')" style="cursor:pointer;"><div class="stat-val">{{ finSummary.total_sales ? fmtValue(finSummary.total_sales, 'SALES_TOTAL') : '-' }}</div><div class="stat-label">本月销售额</div></div>
<div class="stat-card" :class="(finSummary.gross_profit_rate ?? 0) < 0 ? 'red' : 'green'" @click="$router.push('/deviations')" style="cursor:pointer;">
<div class="stat-val">{{ finSummary.gross_profit_rate != null ? finSummary.gross_profit_rate.toFixed(1) + '%' : '-' }}</div>
<div class="stat-label">毛利率</div>
</div>
<div class="stat-card yellow" @click="$router.push('/deviations')" style="cursor:pointer;"><div class="stat-val">{{ finSummary.receivable_turnover != null ? finSummary.receivable_turnover.toFixed(2) : '-' }}</div><div class="stat-label">应收周转率</div></div>
</div>
<div class="kpi-card-grid">
<div v-for="k in finKpis" :key="k.id" class="kpi-card-item wide" @click="showKPIDetail(k)">
<div class="card-item-head">
<span class="item-name">{{ k.kpi_name }}</span>
<el-tag size="small" :type="alertType(k.alert_level)">{{ alertIcon(k.alert_level) }}</el-tag>
</div>
<div class="card-item-val" :style="{ color: alertColor(k.alert_level) }">
{{ k.actual_value != null ? fmtValue(k.actual_value, k.kpi_code) : '-' }}
<span class="item-unit">{{ k.unit }}</span>
</div>
<div class="trend-bars" v-if="k.trend && k.trend.length >= 2">
<div v-for="(t, i) in k.trend" :key="i" class="tbar-wrap" :title="t.period + ': ' + (t.value ?? '-')">
<div class="tbar" :style="{ height: barHeight(t.value, k.trend) + '%' }"></div>
<div class="tbar-label">{{ t.period.slice(-2) }}</div>
</div>
</div>
</div>
</div>
</template>
<!-- ====== IT视图 ====== -->
<template v-if="userRole === 'it'">
<div class="summary-row">
<div class="stat-card blue"><div class="stat-val">{{ summary.kpi_total }}</div><div class="stat-label">活跃KPI</div></div>
<div class="stat-card green"><div class="stat-val">{{ enabledChannels }}</div><div class="stat-label">通知渠道</div></div>
<div class="stat-card gray"><div class="stat-val">{{ erpStatus }}</div><div class="stat-label">ERP同步</div></div>
</div>
<el-card shadow="never" class="sys-card">
<template #header><span> 系统概览</span></template>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="后端服务">{{ backendStatus }}</el-descriptions-item>
<el-descriptions-item label="数据库">{{ dbStatus }}</el-descriptions-item>
<el-descriptions-item label="ERP网关">{{ erpGatewayStatus }}</el-descriptions-item>
<el-descriptions-item label="定时任务">每日1:00</el-descriptions-item>
<el-descriptions-item label="预警推送">企微应用</el-descriptions-item>
<el-descriptions-item label="Redis">{{ redisStatus }}</el-descriptions-item>
</el-descriptions>
</el-card>
</template>
<!-- AI分析浮窗 -->
<div v-if="showSidebar" class="ai-panel">
<div class="ai-head"><span>🤖 AI 分析</span><el-button text size="small" @click="showSidebar = false"></el-button></div>
<div class="ai-body">
<div v-if="aiLoading" class="ai-loading"><el-icon class="is-loading" :size="20"><Loading /></el-icon><p>分析中...</p></div>
<div v-else-if="aiAnalysis" class="ai-content" v-html="renderMd(aiAnalysis)"></div>
<el-empty v-else description="暂无分析" />
</div>
<div class="ai-foot"><el-button size="small" type="primary" @click="refreshAnalysis" :loading="aiLoading">刷新</el-button></div>
</div>
<!-- KPI详情弹窗 -->
<el-dialog v-model="showDetail" :title="selectedKPI?.kpi_name" width="500px">
<div v-if="selectedKPI">
<div class="dlg-summary">
<el-tag :type="alertType(selectedKPI.alert_level)" size="large">
{{ periodType === 'month' ? '本月' : periodType }}: {{ fmtValue(selectedKPI.actual_value, selectedKPI.kpi_name) }}
</el-tag>
<span>目标: {{ selectedKPI.target_value }} | {{ selectedKPI.responsible_dept || '' }}</span>
</div>
<div class="kpi-bar large" v-if="selectedKPI.target_value && selectedKPI.actual_value">
<div class="bar-fill" :style="{ width: Math.min(100, selectedKPI.actual_value/selectedKPI.target_value*100)+'%', background: alertColor(selectedKPI.alert_level) }"></div>
</div>
<el-divider />
<div v-if="selectedKPIDetail" class="analysis-content" v-html="renderMd(selectedKPIDetail)"></div>
<el-button v-else type="primary" plain @click="loadKPIAnalysis" :loading="loadingDetail">AI分析此KPI</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Loading, ArrowRight } from '@element-plus/icons-vue'
import { dashboardApi, alertApi } from '../api/index'
import axios from 'axios'
const router = useRouter()
const userStr = localStorage.getItem('cma_user') || '{}'
const user = JSON.parse(userStr)
const userRole = ref(user.role || 'ceo')
const roleTagType = computed(() => ({ ceo: 'danger', finance: 'warning', business: 'primary', it: 'info' }[userRole.value] || ''))
const roleLabel = computed(() => ({ ceo: '总经理', finance: '财务部', business: '业务部', it: 'IT部' }[userRole.value] || userRole.value))
const periodType = ref('month')
const customStart = ref('')
const customEnd = ref('')
const dataRange = ref('')
const showSidebar = ref(false)
const showDetail = ref(false)
const selectedKPI = ref<any>(null)
const selectedKPIDetail = ref('')
const loadingDetail = ref(false)
const predictions = ref<any[]>([])
const kpis = ref<any[]>([])
const alerts = ref<any[]>([])
const summary = ref({ kpi_total: 0, alert_count: 0, sync_status: { status: 'unknown', last_sync: null, detail: '' } })
// 同步状态卡片样式和文字
const syncCardClass = computed(() => {
const s = summary.value.sync_status?.status
if (s === 'success') return 'green'
if (s === 'failed') return 'red'
return 'blue'
})
const syncStatusText = computed(() => {
const s = summary.value.sync_status?.status
if (s === 'success') return '✅ 同步正常'
if (s === 'failed') return '❌ 同步失败'
return '⏳ 待同步'
})
const syncTimeText = computed(() => {
const raw = summary.value.sync_status?.last_sync || ''
// 从日志行提取时间 2026-05-27 01:00:02
const m = raw.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})/)
if (m) return m[1]
return raw.substring(0, 20) || '--'
})
const redAlerts = computed(() => alerts.value.filter((a: any) => a.alert_level === 'red').length)
const greenKpis = computed(() => kpis.value.filter((k: any) => k.alert_level === 'green' || k.alert_level === 'none').length)
const yellowKpis = computed(() => kpis.value.filter((k: any) => k.alert_level === 'yellow' || k.alert_level === 'red').length)
const myKpis = ref<any[]>([])
const finKpis = ref<any[]>([])
const finSummary = ref<any>({})
const enabledChannels = ref(0)
const aiAnalysis = ref('')
const aiLoading = ref(false)
const aiApi = axios.create({ baseURL: '/api/cma', timeout: 30000 })
aiApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('cma_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
const dimConfig: Record<string, { icon: string; color: string; name: string }> = {
finance: { icon: '💰', color: '#409eff', name: '财务维度' },
customer: { icon: '👥', color: '#67c23a', name: '客户维度' },
process: { icon: '⚙️', color: '#e6a23c', name: '内部流程' },
learning: { icon: '📚', color: '#f56c6c', name: '学习成长' },
}
const kpiByDim = computed(() => {
const dims: Record<string, any[]> = {}
kpis.value.forEach((k: any) => {
const d = k.dimension || 'other'
if (!dims[d]) dims[d] = []
dims[d].push(k)
})
return Object.entries(dims).map(([key, kpis]) => ({
name: dimConfig[key]?.name || key,
icon: dimConfig[key]?.icon || '📊',
color: dimConfig[key]?.color || '#909399',
kpis,
}))
})
function fmtValue(v: any, tag?: string): string {
if (v == null) return '-'
const n = Number(v)
if (isNaN(n)) return String(v)
if (tag === 'SALES_TOTAL') return '¥' + (n / 10000).toFixed(0) + '万'
if (tag && (tag.includes('率') || tag.includes('RATE') || tag.includes('ratio') || tag.includes('RATIO'))) return n.toFixed(1) + '%'
if (Math.abs(n) >= 10000) return (n / 10000).toFixed(1) + '万'
if (Number.isInteger(n)) return n.toLocaleString()
return n.toFixed(2)
}
function alertType(level: string): string {
return level === 'red' ? 'danger' : level === 'yellow' ? 'warning' : level === 'green' ? 'success' : 'info'
}
function alertIcon(level: string): string {
return level === 'red' ? '🔴' : level === 'yellow' ? '🟡' : level === 'green' ? '🟢' : '⚪'
}
function alertColor(level: string): string {
return level === 'red' ? '#f56c6c' : level === 'yellow' ? '#e6a23c' : level === 'green' ? '#67c23a' : '#909399'
}
function trendColor(v: any, kpi: any): string {
if (v == null) return '#ddd'
if (kpi.threshold_red) {
const threshold = parseFloat(kpi.threshold_red.replace(/[<>=]/g, ''))
return v < threshold ? '#f56c6c' : '#67c23a'
}
return '#909399'
}
function barHeight(v: any, trend: any[]): number {
if (v == null || trend.length === 0) return 0
const values = trend.filter(t => t.value != null).map(t => Math.abs(t.value))
if (values.length === 0) return 0
return Math.max(...values) > 0 ? (Math.abs(v) / Math.max(...values)) * 80 : 0
}
function renderMd(text: string): string {
if (!text) return ''
return text.replace(/### (.+)/g, '<h4 style="margin:12px 0 6px">$1</h4>').replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br>')
}
function showKPIDetail(k: any) {
selectedKPI.value = k
selectedKPIDetail.value = ''
showDetail.value = true
}
async function loadKPIAnalysis() {
if (!selectedKPI.value?.id) return
loadingDetail.value = true
try {
const r: any = await aiApi.post('/ai/kpi-analysis', { kpi_id: selectedKPI.value.id, period: periodType.value })
selectedKPIDetail.value = r.data || r.analysis || '暂无分析结果'
} catch { selectedKPIDetail.value = 'AI分析请求失败' }
loadingDetail.value = false
}
async function refreshAnalysis() {
aiLoading.value = true
try {
const r: any = await aiApi.post('/ai/dashboard-analysis', { role: userRole.value, period: periodType.value })
aiAnalysis.value = r.data || r.analysis || '暂无分析'
} catch { aiAnalysis.value = 'AI分析暂时不可用' }
aiLoading.value = false
}
// 构建请求参数
function getPeriodParams(): any {
if (periodType.value === 'custom' && customStart.value && customEnd.value) {
return { period: 'custom', start_date: customStart.value, end_date: customEnd.value }
}
return { period: periodType.value }
}
function onPeriodChange(val: string) {
if (val !== 'custom') loadData()
}
function onCustomRangeChange(val: any) {
if (customStart.value && customEnd.value) loadData()
}
async function loadData() {
try {
const params = getPeriodParams()
if (userRole.value === 'ceo') {
const [kpiRes, alertRes, sumRes, predRes] = await Promise.all([
dashboardApi.kpis({ role: 'ceo', ...params }),
alertApi.list({ limit: 10 }),
dashboardApi.summary({ role: 'ceo', ...params }),
dashboardApi.predict({}).catch(() => ({ data: [] })),
])
kpis.value = (kpiRes as any).data || []
alerts.value = (alertRes as any).data?.filter((a: any) => a.status === 'pending') || []
summary.value = { kpi_total: (sumRes as any).kpi_total || 0, alert_count: (sumRes as any).alert_count || 0 }
predictions.value = (predRes as any)?.predictions || []
dataRange.value = (kpiRes as any).range?.start + ' ~ ' + (kpiRes as any).range?.end
} else if (userRole.value === 'business') {
const r: any = await dashboardApi.myKpis?.(params)
myKpis.value = (r as any)?.data || []
dataRange.value = (r as any)?.period || ''
} else if (userRole.value === 'finance') {
const r: any = await dashboardApi.financeAnalysis?.(params)
finKpis.value = (r as any)?.kpis || []
finSummary.value = (r as any)?.summary || {}
dataRange.value = (r as any)?.period || ''
} else if (userRole.value === 'it') {
const channelRes = await axios.get('/api/cma/notifications/channels').then(r => r.data)
enabledChannels.value = channelRes.data?.filter((c: any) => c.enabled).length || 0
}
} catch (e) { console.error('加载数据失败:', e) }
}
onMounted(() => { loadData() })
</script>
<style scoped>
/* ========== 布局 ========== */
.dashboard-page { max-width: 1400px; margin: 0 auto; position: relative; }
/* 顶部导航 */
.dash-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; padding:12px 20px; background:#fff; border-radius:10px; box-shadow:0 1px 3px rgba(0,0,0,0.05); }
.header-left, .header-right { display:flex; align-items:center; gap:12px; }
.data-range { color:#999; font-size:13px; }
/* ========== 摘要卡片行 ========== */
.summary-row { display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:14px; margin-bottom:20px; }
.stat-card { background:#fff; border-radius:10px; padding:16px 20px; box-shadow:0 1px 3px rgba(0,0,0,0.05); border-top:3px solid #ccc; transition:transform .15s; cursor:default; }
.stat-card:hover { transform:translateY(-1px); box-shadow:0 3px 10px rgba(0,0,0,0.08); }
.stat-card.blue { border-top-color:#409eff; }
.stat-card.red { border-top-color:#f56c6c; }
.stat-card.green { border-top-color:#67c23a; }
.stat-card.yellow { border-top-color:#e6a23c; }
.stat-card.gray { border-top-color:#909399; }
.stat-val { font-size:28px; font-weight:700; color:#1a1a2e; letter-spacing:-1px; }
.stat-label { font-size:13px; color:#888; margin-top:2px; }
.stat-sub { font-size:11px; color:#bbb; margin-top:1px; }
.click-hint { font-size:10px; color:#d0d0d0; }
/* ========== CEO双栏 ========== */
.ceo-main { display:flex; gap:20px; align-items:flex-start; }
.ceo-left { flex:2; min-width:0; }
.ceo-right { flex:1; min-width:260px; max-width:340px; position:sticky; top:80px; }
/* 异常高亮区 */
.alert-spotlight { background:linear-gradient(135deg, #fff5f5 0%, #fff 100%); border:1px solid #fde2e2; border-radius:10px; padding:16px; margin-bottom:20px; }
.spotlight-title { font-size:15px; font-weight:600; margin-bottom:10px; color:#c0392b; }
.spotlight-list { display:flex; flex-direction:column; gap:6px; }
.spotlight-item { display:flex; align-items:center; gap:10px; padding:10px 12px; border-radius:8px; cursor:pointer; transition:background .15s; font-size:13px; }
.spotlight-item:hover { background:rgba(245,108,108,0.08); }
.spotlight-item.level-red { border-left:3px solid #f56c6c; }
.spotlight-item.level-yellow { border-left:3px solid #e6a23c; }
.spotlight-badge { font-size:11px; font-weight:600; padding:2px 8px; border-radius:4px; white-space:nowrap; }
.spotlight-item.level-red .spotlight-badge { background:#fef0f0; color:#f56c6c; }
.spotlight-item.level-yellow .spotlight-badge { background:#fdf6ec; color:#e6a23c; }
.spotlight-msg { flex:1; color:#333; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.spotlight-item .el-icon { color:#ccc; flex-shrink:0; }
/* KPI矩阵 */
.matrix-grid { display:grid; grid-template-columns:repeat(2, 1fr); gap:14px; }
.dim-card { background:#fff; border-radius:10px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.05); }
.dim-head { display:flex; align-items:center; gap:6px; padding:10px 14px; color:#fff; font-weight:600; font-size:13px; }
.dim-icon { font-size:16px; }
.dim-count { margin-left:auto; font-size:11px; opacity:.7; }
.dim-body { padding:6px 0; }
.kpi-row { padding:10px 14px; cursor:pointer; transition:background .12s; border-bottom:1px solid #f8f8f8; }
.kpi-row:last-child { border-bottom:none; }
.kpi-row:hover { background:#f8faff; }
.kpi-row-top { display:flex; justify-content:space-between; align-items:center; margin-bottom:4px; }
.kpi-row-name { font-size:13px; color:#333; }
.kpi-row-badge { font-size:12px; }
.kpi-row-btm { display:flex; align-items:baseline; gap:4px; }
.kpi-row-val { font-size:18px; font-weight:700; color:#1a1a2e; }
.kpi-row-target { font-size:12px; color:#aaa; }
/* 右侧预测 */
.right-card { background:#fff; border-radius:10px; padding:16px; box-shadow:0 1px 3px rgba(0,0,0,0.05); border:1px solid #f0f0f0; }
.right-card-title { font-size:15px; font-weight:600; margin-bottom:14px; }
.pred-list { display:flex; flex-direction:column; gap:12px; }
.pred-row { padding:12px; border-radius:8px; border:1px solid #f0f0f0; transition:border-color .15s; }
.pred-row:hover { border-color:#d0d0d0; }
.pred-row.trend-down { border-left:3px solid #f56c6c; }
.pred-row.trend-up { border-left:3px solid #67c23a; }
.pred-row.trend-stable { border-left:3px solid #909399; }
.pred-row-top { display:flex; justify-content:space-between; margin-bottom:6px; }
.pred-name { font-size:13px; font-weight:500; color:#333; }
.pred-arrow { font-size:14px; font-weight:700; color:#999; }
.pred-row.trend-down .pred-arrow { color:#f56c6c; }
.pred-row.trend-up .pred-arrow { color:#67c23a; }
.pred-row-val { display:flex; align-items:center; gap:6px; margin-bottom:4px; }
.pred-cur { font-size:16px; font-weight:600; color:#555; }
.pred-sep { color:#ccc; font-size:12px; }
.pred-fut { font-size:18px; font-weight:700; }
.pred-row-info { font-size:11px; }
.pred-conf { padding:1px 6px; border-radius:3px; }
.pred-conf.high { background:#f0f9eb; color:#67c23a; }
.pred-conf.medium { background:#fdf6ec; color:#e6a23c; }
.pred-conf.low { background:#f4f4f5; color:#909399; }
/* ========== 业务/财务KPI卡片 ========== */
.kpi-card-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(240px, 1fr)); gap:14px; }
.kpi-card-item { background:#fff; border-radius:10px; padding:16px; box-shadow:0 1px 3px rgba(0,0,0,0.05); cursor:pointer; transition:all .15s; border:1px solid transparent; }
.kpi-card-item:hover { border-color:#409eff; box-shadow:0 3px 12px rgba(64,158,255,0.12); }
.kpi-card-item.wide { grid-column:span 2; }
.card-item-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
.item-name { font-size:14px; font-weight:500; color:#333; }
.card-item-val { font-size:26px; font-weight:700; margin-bottom:4px; }
.item-unit { font-size:13px; color:#999; font-weight:400; margin-left:4px; }
.card-item-target { font-size:12px; color:#aaa; margin-bottom:6px; }
.card-item-trend { display:flex; align-items:center; gap:4px; }
.tdot { width:8px; height:8px; border-radius:50%; display:inline-block; }
.trend-hint { font-size:10px; color:#ccc; margin-left:2px; }
/* 财务趋势柱 */
.trend-bars { display:flex; gap:6px; align-items:flex-end; height:40px; margin-top:8px; }
.tbar-wrap { display:flex; flex-direction:column; align-items:center; flex:1; }
.tbar { width:100%; background:#409eff; border-radius:2px 2px 0 0; min-height:3px; transition:height .3s; }
.tbar-label { font-size:9px; color:#bbb; margin-top:3px; }
/* 系统卡片 */
.sys-card { margin-top:16px; }
/* AI面板 */
.ai-panel { position:fixed; top:80px; right:20px; width:340px; max-height:calc(100vh - 100px); background:#fff; border-radius:10px; box-shadow:0 4px 20px rgba(0,0,0,0.12); display:flex; flex-direction:column; z-index:200; }
.ai-head { display:flex; justify-content:space-between; align-items:center; padding:14px 16px; border-bottom:1px solid #f0f0f0; font-weight:600; }
.ai-body { flex:1; overflow-y:auto; padding:14px 16px; font-size:13px; line-height:1.6; }
.ai-foot { padding:10px 16px; border-top:1px solid #f0f0f0; }
.ai-loading { text-align:center; padding:30px 0; color:#999; }
/* KPI详情弹窗 */
.dlg-summary { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
.kpi-bar.large { height:10px; background:#f0f0f0; border-radius:5px; overflow:hidden; }
.bar-fill { height:100%; border-radius:5px; transition:width .5s; }
.analysis-content { font-size:13px; line-height:1.7; }
.analysis-content h4 { margin:12px 0 6px; color:#409eff; }
/* 空状态 */
.empty-state { text-align:center; padding:60px 0; color:#aaa; font-size:14px; }
/* 响应式 */
@media screen and (max-width: 1024px) {
.ceo-main { flex-direction:column; }
.ceo-right { max-width:100%; position:static; }
.matrix-grid { grid-template-columns:1fr; }
}
@media screen and (max-width: 768px) {
.summary-row { grid-template-columns:repeat(2, 1fr); }
.kpi-card-grid { grid-template-columns:1fr; }
.kpi-card-item.wide { grid-column:span 1; }
.dash-header { flex-direction:column; align-items:flex-start; gap:8px; }
.ai-panel { width:calc(100vw - 40px); right:20px; }
}
</style>
+169
View File
@@ -0,0 +1,169 @@
<template>
<div>
<h3>数据管理</h3>
<el-tabs v-model="activeTab" style="margin-top:16px;">
<el-tab-pane label="Excel导入" name="import">
<el-card>
<template #header>Excel导入</template>
<el-upload drag :auto-upload="false" :on-change="handleFile" accept=".xlsx,.xls" :limit="1">
<el-icon :size="32"><UploadFilled /></el-icon>
<div>拖拽或点击上传Excel文件</div>
<div style="color:#999;font-size:12px;">支持 .xlsx .xls需包含 kpi_code, period, actual_value 三列</div>
</el-upload>
<el-button v-if="file" type="primary" style="margin-top:12px;" :loading="uploading" @click="doImport">导入数据</el-button>
<div v-if="result" style="margin-top:12px;"><el-alert :title="result" type="success" show-icon /></div>
</el-card>
</el-tab-pane>
<el-tab-pane label="数据源管理" name="sources">
<el-card>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span>数据源配置</span>
<el-button type="primary" size="small" @click="openSourceForm()">新增数据源</el-button>
</div>
</template>
<el-table :data="sources" v-loading="sourcesLoading" style="width:100%">
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="source_type" label="类型" width="80">
<template #default="{ row }">
<el-tag :type="sourceTypeTag(row.source_type)" size="small">{{ row.source_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="api_endpoint" label="API地址" min-width="200" show-overflow-tooltip />
<el-table-column prop="sync_type" label="同步方式" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.sync_type==='realtime'?'success':'info'">{{ row.sync_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="70">
<template #default="{ row }">
<el-tag :type="row.status==='active'?'success':'danger'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="last_sync_at" label="上次同步" width="160" />
<el-table-column label="操作" width="150">
<template #default="{ row }">
<el-button size="small" @click="openSourceForm(row)">编辑</el-button>
<el-button size="small" type="danger" @click="doDeleteSource(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
<MyDialog v-model="showSourceForm" :title="sourceForm.id ? '编辑数据源' : '新增数据源'" :width="550">
<el-form :model="sourceForm" label-width="120px">
<el-form-item label="名称"><el-input v-model="sourceForm.name" /></el-form-item>
<el-form-item label="类型">
<el-select v-model="sourceForm.source_type">
<el-option label="ERP" value="erp" />
<el-option label="业务系统" value="business" />
<el-option label="Excel" value="excel" />
<el-option label="手工" value="manual" />
</el-select>
</el-form-item>
<el-form-item label="API地址"><el-input v-model="sourceForm.api_endpoint" placeholder="https://..." /></el-form-item>
<el-form-item label="API Key"><el-input v-model="sourceForm.api_key" type="password" show-password /></el-form-item>
<el-form-item label="SQL查询"><el-input v-model="sourceForm.query_sql" type="textarea" :rows="3" placeholder="可选,用于查询型数据源" /></el-form-item>
<el-form-item label="同步方式">
<el-select v-model="sourceForm.sync_type">
<el-option label="实时" value="realtime" />
<el-option label="定时批量" value="batch" />
<el-option label="手动" value="manual" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showSourceForm=false">取消</el-button>
<el-button type="primary" @click="saveSource" :loading="sourceSaving">保存</el-button>
</template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import { dataApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
const activeTab = ref('import')
// === Excel ===
const file = ref<any>(null)
const uploading = ref(false)
const result = ref('')
function handleFile(f: any) { file.value = f.raw }
async function doImport() {
if (!file.value) return
uploading.value = true
try {
const r: any = await dataApi.importExcel(file.value)
result.value = r.message || '导入成功'
file.value = null
} catch (e) { ElMessage.error('导入失败') }
uploading.value = false
}
// === ===
const sources = ref<any[]>([])
const sourcesLoading = ref(false)
const showSourceForm = ref(false)
const sourceSaving = ref(false)
const sourceForm = ref<any>({ source_type: 'manual', sync_type: 'manual' })
function sourceTypeTag(t: string) {
return ({ erp: 'primary', business: 'warning', excel: 'success', manual: 'info' } as any)[t] || 'info'
}
async function loadSources() {
sourcesLoading.value = true
try {
const r: any = await dataApi.listSources()
sources.value = r.data || []
} catch (e) { ElMessage.error('加载数据源失败') }
sourcesLoading.value = false
}
function openSourceForm(row?: any) {
if (row) {
sourceForm.value = { ...row }
} else {
sourceForm.value = { source_type: 'manual', sync_type: 'manual' }
}
showSourceForm.value = true
}
async function saveSource() {
sourceSaving.value = true
try {
if (sourceForm.value.id) {
await dataApi.updateSource(sourceForm.value.id, sourceForm.value)
ElMessage.success('已更新')
} else {
await dataApi.createSource(sourceForm.value)
ElMessage.success('已创建')
}
showSourceForm.value = false
loadSources()
} catch (e) { ElMessage.error('保存失败') }
sourceSaving.value = false
}
async function doDeleteSource(row: any) {
try {
await ElMessageBox.confirm(`确认删除数据源「${row.name}」?`, '确认')
await dataApi.deleteSource(row.id)
ElMessage.success('已删除')
loadSources()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('删除失败')
}
}
onMounted(loadSources)
</script>
+317
View File
@@ -0,0 +1,317 @@
<template>
<div>
<h3>差异分析看板</h3>
<!-- 筛选栏 -->
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="filterYear" placeholder="年份" style="width:100px;" @change="loadReport">
<el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" />
</el-select>
<el-select v-model="filterMonth" placeholder="月份" style="width:90px;" @change="loadReport">
<el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" />
</el-select>
<el-select v-model="filterDimension" placeholder="维度" clearable style="width:110px;" @change="loadReport">
<el-option label="财务" value="finance" />
<el-option label="客户" value="customer" />
<el-option label="内部流程" value="process" />
<el-option label="学习成长" value="learning" />
</el-select>
<el-select v-model="alertFilter" placeholder="预警级别" clearable style="width:120px;" @change="loadReport">
<el-option label="全部" value="" />
<el-option label="红色预警(超20%" value="red" />
<el-option label="黄色预警(超10%" value="yellow" />
<el-option label="正常(10%以内)" value="normal" />
</el-select>
<el-button type="primary" @click="loadReport">刷新</el-button>
<el-button @click="exportReport">导出CSV</el-button>
</div>
<!-- 汇总统计卡片 -->
<el-row :gutter="16" style="margin-bottom:16px;">
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">已录预算KPI</div>
<div class="stat-value">{{ stats.total_budget || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">超预算红色</div>
<div class="stat-value red-text">{{ stats.red_count || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">接近阈值黄色</div>
<div class="stat-value yellow-text">{{ stats.yellow_count || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">正常达标</div>
<div class="stat-value green-text">{{ stats.normal_count || 0 }}</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 差异趋势图简易柱状图 -->
<el-card style="margin-bottom:16px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span>差异趋势各月实际 vs 预算</span>
<el-select v-model="trendKpi" placeholder="选择KPI" style="width:220px;" @change="loadTrend">
<el-option v-for="k in kpiOptions" :key="k.id" :label="`${k.kpi_code} ${k.kpi_name}`" :value="k.id" />
</el-select>
</div>
</template>
<div v-if="trendData.length > 0" style="display:flex;align-items:flex-end;gap:8px;height:200px;padding:20px 0;overflow-x:auto;">
<div v-for="(item, idx) in trendData" :key="idx" style="display:flex;flex-direction:column;align-items:center;min-width:60px;">
<div :title="`实际: ${item.actual}, 预算: ${item.budget}`" class="trend-bar-group" style="display:flex;gap:3px;height:160px;align-items:flex-end;">
<div
class="trend-bar actual-bar"
:style="{ height: barHeight(item.actual, maxTrend) + 'px' }"
:title="`实际 ${item.actual}`"
></div>
<div
class="trend-bar budget-bar"
:style="{ height: barHeight(item.budget, maxTrend) + 'px' }"
:title="`预算 ${item.budget}`"
></div>
</div>
<span style="font-size:11px;color:#909399;margin-top:4px;">{{ item.period }}</span>
<span v-if="item.deviation !== undefined" :style="{ fontSize:'10px', color: item.deviation > 0 ? '#f56c6c' : '#67c23a' }">
{{ item.deviation > 0 ? '+' : '' }}{{ item.deviation.toFixed(1) }}%
</span>
</div>
</div>
<div v-else style="text-align:center;color:#999;padding:40px;">
{{ trendLoading ? '加载中...' : '选择一个KPI查看趋势' }}
</div>
</el-card>
<!-- 差异明细表格 -->
<el-table :data="reportData" v-loading="loading" border stripe size="small" style="width:100%;" default-expand-all row-key="kpi_id" :tree-props="{ children: 'children' }">
<el-table-column type="index" label="#" width="35" />
<el-table-column prop="kpi_code" label="KPI编码" width="110" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="150" />
<el-table-column prop="dimension" label="维度" width="70">
<template #default="{ row }">
<el-tag size="small" :type="dimTag(row.dimension)" style="font-size:11px;">{{ dimLabel(row.dimension) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="period" label="期间" width="80" />
<el-table-column prop="budget_value" label="预算值" width="120" align="right">
<template #default="{ row }">{{ formatValue(row.budget_value) }}</template>
</el-table-column>
<el-table-column prop="actual_value" label="实际值" width="120" align="right">
<template #default="{ row }">{{ formatValue(row.actual_value) }}</template>
</el-table-column>
<el-table-column prop="deviation_amount" label="差异额" width="120" align="right">
<template #default="{ row }">
<span :style="{ color: (row.deviation_amount || 0) > 0 ? '#f56c6c' : '#67c23a' }">
{{ row.deviation_amount !== undefined ? formatValue(row.deviation_amount) : '--' }}
</span>
</template>
</el-table-column>
<el-table-column prop="deviation_rate" label="差异率" width="100" align="right">
<template #default="{ row }">
<el-tag v-if="row.deviation_rate !== undefined" :type="deviationTag(row.deviation_rate)" size="small">
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate.toFixed(2) }}%
</el-tag>
<span v-else>--</span>
</template>
</el-table-column>
<el-table-column prop="yoy_rate" label="同比" width="85" align="right">
<template #default="{ row }">
<span v-if="row.yoy_rate !== undefined" :style="{ color: (row.yoy_rate || 0) > 0 ? '#f56c6c' : '#67c23a', fontSize:'12px' }">
{{ row.yoy_rate > 0 ? '+' : '' }}{{ row.yoy_rate.toFixed(1) }}%
</span>
<span v-else style="color:#999;">--</span>
</template>
</el-table-column>
<el-table-column prop="mom_rate" label="环比" width="85" align="right">
<template #default="{ row }">
<span v-if="row.mom_rate !== undefined" :style="{ color: (row.mom_rate || 0) > 0 ? '#f56c6c' : '#67c23a', fontSize:'12px' }">
{{ row.mom_rate > 0 ? '+' : '' }}{{ row.mom_rate.toFixed(1) }}%
</span>
<span v-else style="color:#999;">--</span>
</template>
</el-table-column>
<el-table-column prop="alert_level" label="预警" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.alert_level === 'red'" type="danger" size="small">红色</el-tag>
<el-tag v-else-if="row.alert_level === 'yellow'" type="warning" size="small">黄色</el-tag>
<el-tag v-else-if="row.deviation_rate !== undefined" type="success" size="small">正常</el-tag>
<span v-else style="color:#999;">--</span>
</template>
</el-table-column>
</el-table>
<!-- 无数据提示 -->
<el-empty v-if="!loading && reportData.length === 0" description="暂无差异分析数据,请先在预算管理中录入预算" style="padding:40px 0;" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { budgetApi, kpiApi } from '../api/index'
const currentYear = new Date().getFullYear()
const yearOptions = computed(() => {
const years: number[] = []
for (let y = currentYear - 2; y <= currentYear + 2; y++) years.push(y)
return years
})
const filterYear = ref(currentYear)
const filterMonth = ref(new Date().getMonth() + 1)
const filterDimension = ref('')
const alertFilter = ref('')
const loading = ref(false)
const reportData = ref<any[]>([])
const stats = ref<any>({})
//
const dimMap: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
const dimTagMap: Record<string, string> = { finance: 'danger', customer: 'warning', process: 'primary', learning: 'success' }
function dimLabel(d: string) { return dimMap[d] || d }
function dimTag(d: string) { return dimTagMap[d] || 'info' }
function deviationTag(rate: number) {
if (rate > 20) return 'danger'
if (rate > 10) return 'warning'
return 'success'
}
function formatValue(v: any) {
if (v === null || v === undefined) return '--'
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
//
async function loadReport() {
loading.value = true
try {
const params: any = {}
if (filterYear.value) params.year = filterYear.value
if (filterMonth.value) params.month = filterMonth.value
if (filterDimension.value) params.dimension = filterDimension.value
if (alertFilter.value) params.alert_level = alertFilter.value
const r: any = await budgetApi.deviationReport(params)
const data = r.data || r || []
if (Array.isArray(data)) {
reportData.value = data
} else if (data.items) {
reportData.value = data.items
} else {
reportData.value = []
}
//
stats.value = r.stats || r.statistics || {}
//
if (!stats.value.total_budget && reportData.value.length > 0) {
const items = reportData.value
stats.value.total_budget = items.length
stats.value.red_count = items.filter((i: any) => (i.deviation_rate || 0) > 20).length
stats.value.yellow_count = items.filter((i: any) => (i.deviation_rate || 0) > 10 && (i.deviation_rate || 0) <= 20).length
stats.value.normal_count = items.filter((i: any) => (i.deviation_rate || 0) <= 10).length
}
} catch (e) { ElMessage.error('加载差异报告失败') }
loading.value = false
}
//
const trendKpi = ref<number | null>(null)
const kpiOptions = ref<any[]>([])
const trendData = ref<any[]>([])
const trendLoading = ref(false)
const maxTrend = ref(0)
async function loadKpiOptions() {
try {
const r: any = await kpiApi.list({ page_size: 200 })
const data = r.data || r || []
kpiOptions.value = Array.isArray(data) ? data : (data.items || [])
} catch (e) { /* ignore */ }
}
async function loadTrend() {
if (!trendKpi.value) { trendData.value = []; return }
trendLoading.value = true
try {
const r: any = await budgetApi.deviationReport({ kpi_id: trendKpi.value })
const data = r.data || r || []
const items = Array.isArray(data) ? data : (data.items || [])
//
const monthly = items.filter((i: any) => i.budget_value !== null)
.sort((a: any, b: any) => {
const keyA = a.period || ''
const keyB = b.period || ''
return keyA.localeCompare(keyB)
})
trendData.value = monthly.map((i: any) => ({
period: i.period,
actual: i.actual_value || 0,
budget: i.budget_value || 0,
deviation: i.deviation_rate,
}))
maxTrend.value = Math.max(...trendData.value.flatMap((d: any) => [d.actual, d.budget]), 1)
} catch (e) { /* ignore */ }
trendLoading.value = false
}
function barHeight(val: number, max: number) {
if (!max) return 0
return Math.max(4, (val / max) * 150)
}
// CSV
function exportReport() {
const rows = reportData.value
if (rows.length === 0) { ElMessage.warning('无数据可导出'); return }
const headers = ['KPI编码', 'KPI名称', '维度', '期间', '预算值', '实际值', '差异额', '差异率(%)', '同比(%)', '环比(%)', '预警']
const csvRows = [headers.join(',')]
for (const r of rows) {
csvRows.push([
r.kpi_code || '', `"${r.kpi_name || ''}"`, dimLabel(r.dimension || ''),
r.period || '', r.budget_value ?? '', r.actual_value ?? '',
r.deviation_amount ?? '', r.deviation_rate?.toFixed(2) ?? '',
r.yoy_rate?.toFixed(1) ?? '', r.mom_rate?.toFixed(1) ?? '',
r.alert_level || ''
].join(','))
}
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `偏差分析报告_${filterYear.value}${filterMonth.value}月.csv`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
}
onMounted(() => {
loadReport()
loadKpiOptions()
})
</script>
<style scoped>
.stat-card { text-align: center; padding: 8px 0; }
.stat-label { font-size: 13px; color: #909399; margin-bottom: 8px; }
.stat-value { font-size: 28px; font-weight: bold; color: #303133; }
.red-text { color: #f56c6c; }
.yellow-text { color: #e6a23c; }
.green-text { color: #67c23a; }
.trend-bar { width: 18px; border-radius: 3px 3px 0 0; transition: height 0.3s; }
.actual-bar { background: #409eff; }
.budget-bar { background: #e6a23c; }
</style>
+164
View File
@@ -0,0 +1,164 @@
<template>
<div>
<el-button text @click="$router.back()">< 返回KPI字典</el-button>
<el-card v-if="kpi" style="margin-top:16px;">
<template #header>
<span style="font-size:16px;font-weight:600;">{{ kpi.kpi_name }}</span>
<el-tag size="small" style="margin-left:8px;">{{ kpi.kpi_code }}</el-tag>
<el-tag v-if="kpi.dimension" :type="dimTagType(kpi.dimension)" size="small" style="margin-left:4px;">{{ dimLabel(kpi.dimension) }}</el-tag>
<el-tag v-if="kpi.category" type="info" size="small" style="margin-left:4px;">{{ catLabel(kpi.category) }}</el-tag>
</template>
<el-form :model="kpi" label-width="120px" size="small">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="KPI编码">{{ kpi.kpi_code }}</el-form-item>
<el-form-item label="KPI名称"><el-input v-model="kpi.kpi_name" /></el-form-item>
<el-form-item label="维度">
<el-select v-model="kpi.dimension" style="width:100%">
<el-option label="财务" value="finance" />
<el-option label="客户" value="customer" />
<el-option label="内部流程" value="process" />
<el-option label="学习成长" value="learning" />
</el-select>
</el-form-item>
<el-form-item label="二级类别">
<el-select v-model="kpi.category" style="width:100%" :disabled="!kpi.dimension">
<el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
</el-select>
</el-form-item>
<el-form-item label="目标值"><el-input-number v-model="kpi.target_value" :min="0" style="width:100%" /></el-form-item>
<el-form-item label="单位"><el-input v-model="kpi.unit" /></el-form-item>
<el-form-item label="频率">
<el-select v-model="kpi.frequency" style="width:100%">
<el-option label="月度" value="monthly" />
<el-option label="季度" value="quarterly" />
<el-option label="年度" value="yearly" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="负责部门"><el-input v-model="kpi.responsible_dept" /></el-form-item>
<el-form-item label="负责人"><el-input v-model="kpi.responsible_user" /></el-form-item>
<el-form-item label="数据源">
<el-select v-model="kpi.data_source_type" style="width:100%">
<el-option label="ERP" value="erp" />
<el-option label="Excel" value="excel" />
<el-option label="手工" value="manual" />
</el-select>
</el-form-item>
<el-form-item label="所属Epic">
<el-select v-model="kpi.epic" style="width:100%">
<el-option v-for="i in 10" :key="i" :label="'Epic ' + i" :value="'Epic' + i" />
</el-select>
</el-form-item>
<el-form-item label="绿灯阈值">
<div style="display:flex;gap:8px;"><el-input v-model="kpi.threshold_green" /><el-button size="small" type="primary" plain @click="suggestThreshold">自动建议</el-button></div>
</el-form-item>
<el-form-item label="黄灯阈值"><el-input v-model="kpi.threshold_yellow" /></el-form-item>
<el-form-item label="红灯阈值"><el-input v-model="kpi.threshold_red" /></el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="计算公式"><el-input v-model="kpi.formula" type="textarea" :rows="2" /></el-form-item>
</el-col>
</el-row>
<el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
</el-form>
</el-card>
<el-card style="margin-top:16px;">
<template #header>趋势图</template>
<v-chart :option="chartOption" autoresize style="height:300px;width:100%;" />
</el-card>
<el-card style="margin-top:16px;">
<template #header>历史数据</template>
<el-table :data="values" size="small" style="width:100%">
<el-table-column prop="period" label="期间" width="120" />
<el-table-column prop="actual_value" label="实际值" width="120" />
<el-table-column prop="source_type" label="来源" width="80" />
<el-table-column prop="data_status" label="状态" width="80">
<template #default="{ row }"><el-tag :type="row.data_status==='verified'?'success':'warning'" size="small">{{ row.data_status }}</el-tag></template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import VChart from 'vue-echarts'
import 'echarts'
import { kpiApi } from '../api/index'
import api from '../api/index'
const route = useRoute()
const kpi = ref<any>(null)
const values = ref<any[]>([])
const CAT_MAP: Record<string, string> = {
revenue_growth: '收入增长', profitability: '盈利水平', cost_control: '成本费用',
asset_efficiency: '资产效率', cash_risk: '现金流风控',
customer_scale: '客户规模', customer_concentration: '客户集中度', customer_satisfaction: '客户满意',
supply_chain: '供应链效率', delivery_quality: '交付质量',
talent_pipeline: '人才梯队', employee_engagement: '员工敬业', innovation: '创新改善',
}
const categoryOptions = computed(() => {
const dim = kpi.value?.dimension
if (!dim) return []
return Object.entries(CAT_MAP)
.filter(([key]) => {
if (dim === 'finance') return ['revenue_growth','profitability','cost_control','asset_efficiency','cash_risk'].includes(key)
if (dim === 'customer') return ['customer_scale','customer_concentration','customer_satisfaction'].includes(key)
if (dim === 'process') return ['supply_chain','delivery_quality'].includes(key)
if (dim === 'learning') return ['talent_pipeline','employee_engagement','innovation'].includes(key)
return false
})
.map(([value, label]) => ({ value, label }))
})
function dimLabel(d: string) { return ({ finance: '财务', customer: '客户', process: '流程', learning: '学习' } as any)[d] || d }
function catLabel(c: string) { return CAT_MAP[c] || c }
function dimTagType(d: string) { return ({ finance: '', customer: 'success', process: 'warning', learning: 'info' } as any)[d] || '' }
const chartOption = computed(() => ({
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: values.value.map(v => v.period) },
yAxis: { type: 'value' },
series: [{ type: 'line', data: values.value.map(v => v.actual_value), smooth: true, lineStyle: { width: 2 }, itemStyle: { color: '#409eff' }, areaStyle: { color: 'rgba(64,158,255,0.1)' } }],
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
}))
async function save() {
try { await kpiApi.update(kpi.value.id, kpi.value); ElMessage.success('保存成功') }
catch (e) { ElMessage.error('保存失败') }
}
async function suggestThreshold() {
try {
const r: any = await api.get('/thresholds/suggest/' + kpi.value.id)
const s = r.suggestion
if (!s) { ElMessage.warning('暂无建议数据'); return }
kpi.value.threshold_green = s.green?.label || ''
kpi.value.threshold_yellow = s.yellow?.label || ''
kpi.value.threshold_red = s.red?.label || ''
ElMessage.success('已自动填入推荐阈值')
} catch (e) {
ElMessage.error('获取阈值建议失败')
}
}
onMounted(async () => {
const r: any = await kpiApi.get(Number(route.params.id))
kpi.value = r.data || r
//
try {
const kv: any = await api.get('/kpis/' + kpi.value.id)
values.value = kv.values || []
} catch(e) {}
})
</script>
+565
View File
@@ -0,0 +1,565 @@
<template>
<div class="kpi-dict-page">
<!-- 页头 -->
<div class="page-header">
<h3>KPI字典</h3>
<el-button type="primary" @click="showForm=true; form={}; editMode=false">+ 新建KPI</el-button>
</div>
<div class="main-layout">
<!-- 左侧BSC分类导航树 -->
<div class="left-tree">
<div class="tree-header">BSC分类导航</div>
<el-tree
ref="treeRef"
:data="categoryTree"
node-key="key"
:props="{ label: 'label', children: 'children' }"
:default-expanded-keys="defaultExpanded"
:show-checkbox="true"
:check-strictly="true"
@check="onTreeCheck"
highlight-current
>
<template #default="{ node, data }">
<span class="tree-node">
<span class="tree-node-label">{{ data.label }}</span>
<el-tag size="small" :type="data.children && data.children.length > 0 ? '' : 'info'" class="tree-count">{{ data.count }}</el-tag>
</span>
</template>
</el-tree>
</div>
<!-- 右侧搜索+表格 -->
<div class="right-content">
<!-- 搜索栏 -->
<div class="search-bar">
<el-input v-model="searchKeyword" placeholder="搜索KPI名称/编码" clearable style="width:220px" @clear="loadKpis" @keyup.enter="loadKpis" />
<el-select v-model="searchDimension" placeholder="维度" clearable style="width:110px" @change="loadKpis">
<el-option label="财务" value="finance" />
<el-option label="客户" value="customer" />
<el-option label="内部流程" value="process" />
<el-option label="学习成长" value="learning" />
</el-select>
<el-select v-model="searchCategory" placeholder="二级类别" clearable style="width:140px" @change="loadKpis">
<el-option v-for="c in allCategories" :key="c.value" :label="c.label" :value="c.value" />
</el-select>
<el-button type="primary" @click="loadKpis">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<!-- 批量操作栏 -->
<div class="batch-bar" v-if="selectedIds.length > 0">
<span class="selected-info">已选 {{ selectedIds.length }} </span>
<el-button size="small" @click="batchDelete">批量删除</el-button>
<el-button size="small" @click="batchExport">导出CSV</el-button>
<el-button size="small" @click="selectedIds=[]">取消选择</el-button>
</div>
<!-- 表格 -->
<el-table
:data="kpis"
v-loading="loading"
style="width:100%"
@selection-change="onSelectionChange"
@row-click="(row) => $router.push('/kpis/' + row.id)"
border
stripe
size="small"
>
<el-table-column type="selection" width="40" />
<el-table-column prop="kpi_code" label="编码" width="120">
<template #default="{ row }">
<span class="kpi-code">{{ row.kpi_code }}</span>
</template>
</el-table-column>
<el-table-column prop="kpi_name" label="名称" min-width="170" />
<el-table-column prop="dimension" label="维度" width="70">
<template #default="{ row }">
<el-tag :type="dimTagType(row.dimension)" size="small">{{ dimLabel(row.dimension) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="二级类别" width="90">
<template #default="{ row }">{{ catLabel(row.category) }}</template>
</el-table-column>
<el-table-column prop="target_value" label="目标值" width="90">
<template #default="{ row }">{{ formatTarget(row.target_value, row.unit) }}</template>
</el-table-column>
<el-table-column prop="frequency" label="频率" width="70">
<template #default="{ row }">{{ freqLabel(row.frequency) }}</template>
</el-table-column>
<el-table-column prop="data_source_type" label="数据源" width="70">
<template #default="{ row }">{{ sourceLabel(row.data_source_type) }}</template>
</el-table-column>
<el-table-column label="当前值" width="100">
<template #default="{ row }">
<span v-if="row.actual_value !== null" :class="'status-' + (row.alert_level || 'none')">
{{ row.actual_value }}{{ row.unit }}
</span>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column label="预警" width="70">
<template #default="{ row }">
<el-tag v-if="row.alert_level && row.alert_level !== 'none'" :type="row.alert_level === 'red' ? 'danger' : row.alert_level === 'yellow' ? 'warning' : 'success'" size="small">
{{ row.alert_level === 'red' ? '红灯' : row.alert_level === 'yellow' ? '黄灯' : '绿灯' }}
</el-tag>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button size="small" text type="primary" @click.stop="$router.push('/kpis/' + row.id)">编辑</el-button>
<el-button size="small" text type="danger" @click.stop="doDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-bar">
<span class="total-info"> {{ total }} </span>
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
@current-change="loadKpis"
/>
</div>
</div>
</div>
<!-- 新建/编辑对话框 -->
<MyDialog v-model="showForm" :title="editMode ? '编辑KPI' : '新建KPI'" :width="640">
<el-form :model="form" label-width="110px" size="small">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="KPI编码"><el-input v-model="form.kpi_code" :disabled="editMode" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="KPI名称"><el-input v-model="form.kpi_name" /></el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="维度">
<el-select v-model="form.dimension" style="width:100%" @change="onDimensionChange">
<el-option label="财务" value="finance" />
<el-option label="客户" value="customer" />
<el-option label="内部流程" value="process" />
<el-option label="学习成长" value="learning" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="二级类别">
<el-select v-model="form.category" style="width:100%" :disabled="!form.dimension">
<el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="目标值"><el-input-number v-model="form.target_value" :min="0" style="width:100%" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="单位"><el-input v-model="form.unit" placeholder="%, 元, 次, 个..." /></el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="频率">
<el-select v-model="form.frequency" style="width:100%">
<el-option label="月度" value="monthly" />
<el-option label="季度" value="quarterly" />
<el-option label="年度" value="yearly" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="数据源">
<el-select v-model="form.data_source_type" style="width:100%">
<el-option label="ERP自动" value="erp" />
<el-option label="Excel导入" value="excel" />
<el-option label="手工填报" value="manual" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="负责部门"><el-input v-model="form.responsible_dept" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="负责人"><el-input v-model="form.responsible_user" /></el-form-item>
</el-col>
</el-row>
<el-form-item label="计算公式"><el-input v-model="form.formula" type="textarea" :rows="2" /></el-form-item>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="绿灯阈值"><el-input v-model="form.threshold_green" /></el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="黄灯阈值"><el-input v-model="form.threshold_yellow" /></el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="红灯阈值"><el-input v-model="form.threshold_red" /></el-form-item>
</el-col>
</el-row>
<el-form-item label="所属Epic">
<el-select v-model="form.epic" style="width:100%">
<el-option v-for="i in 10" :key="i" :label="'Epic ' + i" :value="'Epic' + i" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showForm=false">取消</el-button>
<el-button type="primary" @click="saveKPI">{{ editMode ? '保存' : '创建' }}</el-button>
</template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { kpiApi, dashboardApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
//
const kpis = ref<any[]>([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const pageSize = 20
//
const searchKeyword = ref('')
const searchDimension = ref('')
const searchCategory = ref('')
//
const categoryTree = ref<any[]>([])
const defaultExpanded = ref<string[]>([])
const treeRef = ref<any>(null)
const treeFilterDims = ref<string[]>([])
const treeFilterCats = ref<string[]>([])
//
const selectedIds = ref<number[]>([])
// /
const showForm = ref(false)
const editMode = ref(false)
const form = ref<any>({})
//
const DIM_MAP: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
const CAT_MAP: Record<string, string> = {
revenue_growth: '收入增长', profitability: '盈利水平', cost_control: '成本费用',
asset_efficiency: '资产效率', cash_risk: '现金流风控',
customer_scale: '客户规模', customer_concentration: '客户集中度', customer_satisfaction: '客户满意',
supply_chain: '供应链效率', delivery_quality: '交付质量',
talent_pipeline: '人才梯队', employee_engagement: '员工敬业', innovation: '创新改善',
}
const FREQ_MAP: Record<string, string> = { daily: '日', weekly: '周', monthly: '月', quarterly: '季', yearly: '年' }
const SOURCE_MAP: Record<string, string> = { erp: 'ERP', excel: 'Excel', manual: '手工' }
//
const DIM_CAT_MAP: Record<string, string[]> = {
finance: ['revenue_growth', 'profitability', 'cost_control', 'asset_efficiency', 'cash_risk'],
customer: ['customer_scale', 'customer_concentration', 'customer_satisfaction'],
process: ['supply_chain', 'delivery_quality'],
learning: ['talent_pipeline', 'employee_engagement', 'innovation'],
}
//
const categoryOptions = computed(() => {
const dim = form.value?.dimension
if (!dim) return []
const catKeys = DIM_CAT_MAP[dim] || []
return catKeys.map(key => ({ value: key, label: CAT_MAP[key] || key }))
})
//
const allCategories = computed(() => {
const seen = new Set<string>()
const result: { value: string; label: string }[] = []
for (const key in DIM_CAT_MAP) {
for (const cat of DIM_CAT_MAP[key]) {
if (!seen.has(cat)) {
seen.add(cat)
result.push({ value: cat, label: CAT_MAP[cat] || cat })
}
}
}
return result
})
function dimLabel(d: string) { return DIM_MAP[d] || d }
function catLabel(c: string) { return CAT_MAP[c] || c }
function freqLabel(f: string) { return FREQ_MAP[f] || f }
function sourceLabel(s: string) { return SOURCE_MAP[s] || s }
function dimTagType(d: string) { return ({ finance: '', customer: 'success', process: 'warning', learning: 'info' } as any)[d] || '' }
function formatTarget(val: number, unit: string) {
if (val === null || val === undefined) return '-'
if (val >= 10000) return (val / 10000).toFixed(0) + '万' + (unit || '')
return val + (unit || '')
}
//
async function loadCategories() {
try {
const r: any = await kpiApi.listCategories()
categoryTree.value = [
{ key: '_all', label: '全部', count: r.total, children: r.tree || [] }
]
defaultExpanded.value = ['_all', ...(r.tree || []).map((n: any) => n.key)]
} catch (e) {
console.error('加载分类树失败', e)
}
}
//
function onTreeCheck(data: any, { checkedKeys }: any) {
//
if (data.key === '_all') {
// =
treeFilterDims.value = []
treeFilterCats.value = []
} else if (data.children) {
//
treeFilterDims.value = checkedKeys.includes(data.key) ? [data.key] : []
treeFilterCats.value = []
} else {
//
treeFilterCats.value = checkedKeys.includes(data.key) ? [data.key] : []
treeFilterDims.value = []
}
loadKpis()
}
// KPI
async function loadKpis() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize }
if (searchKeyword.value) params.keyword = searchKeyword.value
if (searchDimension.value) params.dimension = searchDimension.value
if (searchCategory.value) params.category = searchCategory.value
if (treeFilterDims.value.length > 0 && !params.dimension) params.dimension = treeFilterDims.value[0]
if (treeFilterCats.value.length > 0 && !params.category) params.category = treeFilterCats.value[0]
const r: any = await kpiApi.list(params)
kpis.value = r.data || []
total.value = r.total || 0
} catch (e) {
console.error('加载KPI失败', e)
}
loading.value = false
}
//
async function loadActualValues() {
try {
const r: any = await dashboardApi.kpis({ role: 'ceo', period: 'month' })
const vals = r.data || []
const valMap: Record<number, any> = {}
for (const v of vals) {
valMap[v.id] = v
}
for (const kpi of kpis.value) {
if (valMap[kpi.id]) {
kpi.actual_value = valMap[kpi.id].actual_value
kpi.alert_level = valMap[kpi.id].alert_level
}
}
} catch (e) {
//
}
}
function resetSearch() {
searchKeyword.value = ''
searchDimension.value = ''
searchCategory.value = ''
treeFilterDims.value = []
treeFilterCats.value = []
if (treeRef.value) treeRef.value.setCheckedKeys([])
page.value = 1
loadKpis()
}
//
function onSelectionChange(rows: any[]) {
selectedIds.value = rows.map((r: any) => r.id)
}
function batchDelete() {
ElMessageBox.confirm(`确定删除选中的 ${selectedIds.value.length} 条KPI`, '提示').then(async () => {
for (const id of selectedIds.value) {
try { await kpiApi.delete(id) } catch (e) {}
}
ElMessage.success('批量删除完成')
selectedIds.value = []
loadKpis()
loadCategories()
}).catch(() => {})
}
function batchExport() {
const header = '编码,名称,维度,二级类别,目标值,频率,数据源,负责人,计算公式'
const rows = kpis.value.map((k: any) =>
[k.kpi_code, k.kpi_name, dimLabel(k.dimension), catLabel(k.category), k.target_value, freqLabel(k.frequency), sourceLabel(k.data_source_type), k.responsible_user, (k.formula || '')].join(',')
)
const bom = '\uFEFF' // Excel UTF-8 BOM
const blob = new Blob([bom + header + '\n' + rows.join('\n')], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = 'KPI字典_' + new Date().toISOString().slice(0, 10) + '.csv'
a.click()
URL.revokeObjectURL(url)
}
// /
function onDimensionChange(val: string) {
form.value.category = ''
}
async function saveKPI() {
try {
if (editMode.value) {
await kpiApi.update(form.value.id, form.value)
ElMessage.success('保存成功')
} else {
await kpiApi.create(form.value)
ElMessage.success('创建成功')
}
showForm.value = false
loadKpis()
loadCategories()
} catch (e) {
ElMessage.error(editMode.value ? '保存失败' : '创建失败')
}
}
function doDelete(id: number) {
ElMessageBox.confirm('确定删除此KPI', '提示').then(async () => {
try { await kpiApi.delete(id); ElMessage.success('已删除'); loadKpis(); loadCategories() }
catch (e) { ElMessage.error('删除失败') }
}).catch(() => {})
}
onMounted(() => {
loadCategories()
loadKpis()
})
</script>
<style scoped>
.kpi-dict-page {
height: 100%;
display: flex;
flex-direction: column;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.page-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.main-layout {
display: flex;
gap: 12px;
flex: 1;
overflow: hidden;
}
.left-tree {
width: 260px;
min-width: 260px;
background: #fff;
border-radius: 4px;
border: 1px solid #e4e7ed;
padding: 8px 0;
overflow-y: auto;
}
.tree-header {
padding: 8px 16px;
font-size: 14px;
font-weight: 600;
color: #303133;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 4px;
}
.tree-node {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
padding-right: 8px;
}
.tree-node-label {
font-size: 13px;
}
.tree-count {
font-size: 11px;
min-width: 20px;
text-align: center;
}
.right-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.search-bar {
display: flex;
gap: 8px;
margin-bottom: 12px;
align-items: center;
flex-wrap: wrap;
}
.batch-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #ecf5ff;
border-radius: 4px;
margin-bottom: 8px;
}
.selected-info {
font-size: 13px;
color: #409eff;
font-weight: 500;
}
.kpi-code {
font-family: 'Courier New', monospace;
font-size: 12px;
color: #606266;
}
.status-red { color: #f56c6c; font-weight: 500; }
.status-yellow { color: #e6a23c; font-weight: 500; }
.status-green { color: #67c23a; font-weight: 500; }
.status-none { color: #606266; }
.pagination-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
}
.total-info {
font-size: 13px;
color: #909399;
}
:deep(.el-tree-node__content) {
height: 32px;
}
</style>
+48
View File
@@ -0,0 +1,48 @@
<template>
<div class="login-page">
<div class="login-card">
<h1>管理会计OS</h1>
<p class="subtitle">博海网络科技</p>
<el-form ref="formRef" :model="form" label-width="0" @keyup.enter="handleLogin">
<el-form-item><el-input v-model="form.username" placeholder="用户名" size="large" /></el-form-item>
<el-form-item><el-input v-model="form.password" type="password" placeholder="密码" size="large" show-password /></el-form-item>
<el-form-item><el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleLogin"> </el-button></el-form-item>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { authApi } from '../api/index'
const router = useRouter()
const loading = ref(false)
const form = reactive({ username: 'admin', password: 'admin123' })
async function handleLogin() {
loading.value = true
try {
const res: any = await authApi.login(form)
localStorage.setItem('cma_token', res.token)
localStorage.setItem('cma_user', JSON.stringify(res.user))
router.push('/dashboard')
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '登录失败')
}
loading.value = false
}
</script>
<style scoped>
.login-page { display: flex; align-items: center; justify-content: center; height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
.login-card { background: #fff; padding: 40px; border-radius: 12px; width: 380px; text-align: center; }
.login-card h1 { margin: 0 0 4px; font-size: 24px; }
.subtitle { color: #999; margin-bottom: 24px; }
@media screen and (max-width: 768px) {
.login-page { padding: 20px; }
.login-card { width: 100%; max-width: 380px; padding: 30px 24px; }
}
</style>
+732
View File
@@ -0,0 +1,732 @@
<template>
<div class="map-canvas-page" ref="canvasRef">
<!-- 工具栏 -->
<div class="toolbar">
<h3>战略地图画布</h3>
<div class="toolbar-right">
<el-select v-model="selectedMap" placeholder="选择地图" style="width:200px;margin-right:8px;" @change="loadCanvas">
<el-option v-for="m in maps" :key="m.id" :label="m.title" :value="m.id" />
</el-select>
<el-button type="primary" @click="saveCanvas">保存</el-button>
<el-button type="success" @click="publishMap" v-if="currentMap?.status === 'draft'">发布</el-button>
<el-button @click="showVersions = true; loadVersions()">版本历史</el-button>
</div>
</div>
<!-- 连线模式提示 -->
<div v-if="linkingFrom" class="linking-bar">
<span>🔗 {{ linkingFrom.obj.name }}连线 点击另一个目标完成连线</span>
<el-button size="small" @click="cancelLink">取消</el-button>
</div>
<!-- 画布主体 -->
<div class="canvas-body" ref="bodyRef">
<!-- SVG 连线层 -->
<svg class="connection-svg" ref="svgRef">
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#409eff" />
</marker>
<marker id="arrowhead-warn" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#e6a23c" />
</marker>
</defs>
<line v-for="(line, idx) in connectionLines" :key="idx"
:x1="line.x1" :y1="line.y1" :x2="line.x2" :y2="line.y2"
:class="['conn-line', { 'conn-selected': selectedConnIdx === idx }]"
:style="{ '--from-color': line.fromColor, '--to-color': line.toColor }"
marker-end="url(#arrowhead)"
:stroke="selectedConnIdx === idx ? '#f56c6c' : '#409eff'"
:stroke-width="selectedConnIdx === idx ? 3 : 2"
@click.stop="selectConnection(idx)"
/>
<!-- 删除按钮选中连线时 -->
<g v-if="selectedConnIdx !== null && connectionLines[selectedConnIdx]">
<circle
:cx="connectionLines[selectedConnIdx].mx"
:cy="connectionLines[selectedConnIdx].my"
r="10" fill="#f56c6c" class="del-btn-circle"
@click.stop="deleteConnection(selectedConnIdx)"
/>
<text
:x="connectionLines[selectedConnIdx].mx"
:y="connectionLines[selectedConnIdx].my + 4"
text-anchor="middle" fill="#fff" font-size="12" font-weight="bold"
class="del-btn-text"
@click.stop="deleteConnection(selectedConnIdx)"
></text>
</g>
<!-- 绘制中的临时线 -->
<line v-if="tempLine"
:x1="tempLine.x1" :y1="tempLine.y1"
:x2="tempLine.x2" :y2="tempLine.y2"
stroke="#e6a23c" stroke-width="2" stroke-dasharray="6,3"
marker-end="url(#arrowhead-warn)"
/>
</svg>
<!-- 四维度列 -->
<div class="dimension-col" v-for="(dim, di) in dimensions" :key="dim.key"
:ref="el => setDimRef(dim.key, el)"
:style="{ borderColor: dim.color }">
<div class="dim-header" :style="{ background: dim.color }"
@dblclick="startEditDim(di)"
:title="'双击编辑维度'">
<span class="dim-icon">{{ dim.icon }}</span>
<span v-if="editingDimIdx !== di" class="dim-name">{{ dim.label }}</span>
<el-input v-else v-model="dim.label" size="small" style="width:100px;"
@blur="editingDimIdx = -1"
@keyup.enter="editingDimIdx = -1" />
<!-- 维度操作按钮 -->
<el-dropdown trigger="click" @command="(cmd:string) => dimAction(cmd, di)">
<el-button class="dim-more-btn" size="small" text>
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-item command="rename">重命名</el-dropdown-item>
<el-dropdown-item command="color">更换颜色</el-dropdown-item>
<el-dropdown-item command="icon">更换图标</el-dropdown-item>
<el-dropdown-item v-if="dimensions.length > 1" command="delete" divided>删除此维度</el-dropdown-item>
<el-dropdown-item command="add">左侧添加维度</el-dropdown-item>
<el-dropdown-item command="addRight">右侧添加维度</el-dropdown-item>
</template>
</el-dropdown>
</div>
<div class="dim-body">
<div
v-for="(obj, oi) in dim.objectives" :key="oi"
:ref="el => setObjRef(`${dim.key}-${oi}`, el)"
class="objective-card"
:class="{
'linking-source': linkingFrom?.key === `${dim.key}-${oi}`,
'linking-target': linkingFrom && linkingFrom?.key !== `${dim.key}-${oi}`,
'obj-level-red': objectiveLevels[`${dim.key}-${oi}`] === 'red',
'obj-level-yellow': objectiveLevels[`${dim.key}-${oi}`] === 'yellow',
'obj-level-green': objectiveLevels[`${dim.key}-${oi}`] === 'green',
}"
@click="onObjClick(dim.key, oi, obj)"
>
<div class="obj-title">
<span class="obj-level-dot" :class="'dot-' + (objectiveLevels[`${dim.key}-${oi}`] || 'gray')"></span>
<el-icon v-if="obj.icon" style="margin-right:4px;"><component :is="iconMap[obj.icon]" /></el-icon>
{{ obj.name }}
</div>
<div v-if="obj.description" class="obj-desc">{{ obj.description }}</div>
<div class="obj-kpis">
<el-tag v-for="kpi in (obj.kpis||[])" :key="kpi" size="small"
style="margin:2px;cursor:pointer;"
@click.stop="goKPI(kpi)">
{{ kpi }}
</el-tag>
</div>
<!-- 操作按钮组 -->
<div class="obj-actions">
<el-button text size="small" @click.stop="editObjective(dim.key, oi, obj)" title="编辑目标"></el-button>
<el-button text size="small" @click.stop="deleteObjective(dim.key, oi)" title="删除目标" style="color:#f56c6c;">🗑</el-button>
<span class="link-btn" @click.stop="startLink(dim.key, oi, obj)" title="从此目标创建因果连线"></span>
</div>
</div>
<el-button text type="primary" size="small" style="margin-top:8px;width:100%;" @click="addObjective(dim.key)">
+ 添加目标
</el-button>
</div>
</div>
</div>
<!-- 添加/编辑目标弹窗 -->
<el-dialog v-model="showDialog" :title="editingIndex >= 0 ? '编辑目标' : '添加目标'" width="500">
<el-form :model="editForm" label-width="100px">
<el-form-item label="目标名称"><el-input v-model="editForm.name" /></el-form-item>
<el-form-item label="描述"><el-input v-model="editForm.description" type="textarea" rows="3" /></el-form-item>
<el-form-item label="图标">
<el-select v-model="editForm.icon" style="width:100%;">
<el-option v-for="(ico, key) in iconOptions" :key="key" :label="ico" :value="key">
<el-icon style="margin-right:6px;"><component :is="iconMap[key]" /></el-icon>{{ ico }}
</el-option>
</el-select>
</el-form-item>
<el-form-item label="关联KPI">
<div class="kpi-select-wrap">
<el-select v-model="editForm.kpis" multiple filterable style="flex:1;" :max="3"
popper-class="dialog-select-popper">
<el-option v-for="k in allKpis" :key="k.id" :label="k.kpi_name" :value="k.kpi_code" />
</el-select>
<el-button size="small" @click="$router.push('/kpis')" style="margin-left:4px;">管理KPI</el-button>
</div>
<span v-if="editForm.kpis.length >= 3" style="color:#e6a23c;font-size:12px;">每个目标最多绑定3个KPI</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDialog=false">取消</el-button>
<el-button type="primary" @click="saveObjective">保存</el-button>
</template>
</el-dialog>
<!-- 版本历史 -->
<el-dialog v-model="showVersions" title="版本历史" width="600">
<el-table :data="versions" style="width:100%">
<el-table-column prop="comment" label="说明" min-width="200" />
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button size="small" type="warning" @click="rollback(row.id)">回滚</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick, onUnmounted } from "vue"
import { ElMessage, ElMessageBox } from "element-plus"
import { MoreFilled } from "@element-plus/icons-vue"
import { mapApi, kpiApi } from "../api/index"
import api from "../api/index"
//
const DEFAULT_DIMENSIONS = [
{ key: "finance", label: "财务维度", icon: "💰", color: "#409eff", objectives: [] as any[] },
{ key: "customer", label: "客户维度", icon: "🤝", color: "#67c23a", objectives: [] as any[] },
{ key: "process", label: "内部流程", icon: "⚙️", color: "#e6a23c", objectives: [] as any[] },
{ key: "learning", label: "学习成长", icon: "📚", color: "#f56c6c", objectives: [] as any[] },
]
const dimensions = reactive<any[]>([])
const editingDimIdx = ref(-1)
//
DEFAULT_DIMENSIONS.forEach(d => dimensions.push({ ...d, objectives: [] }))
//
function startEditDim(idx: number) {
editingDimIdx.value = idx
}
function dimAction(cmd: string, idx: number) {
switch (cmd) {
case "rename":
editingDimIdx.value = idx
break
case "color": {
const colors = ["#409eff", "#67c23a", "#e6a23c", "#f56c6c", "#909399", "#9b59b6", "#1abc9c", "#e74c3c"]
const cur = dimensions[idx].color
const nextIdx = (colors.indexOf(cur) + 1) % colors.length
dimensions[idx].color = colors[nextIdx]
break
}
case "icon": {
const icons = ["💰", "🤝", "⚙️", "📚", "📊", "🎯", "🛡️", "⭐", "📈", "🔬"]
const cur = dimensions[idx].icon
const nextIdx = (icons.indexOf(cur) + 1) % icons.length
dimensions[idx].icon = icons[nextIdx]
break
}
case "delete":
dimensions.splice(idx, 1)
break
case "add": {
const key = "dim_" + Date.now()
dimensions.splice(idx, 0, {
key, label: "新维度", icon: "📊", color: "#909399", objectives: [],
})
editingDimIdx.value = idx
break
}
case "addRight": {
const key = "dim_" + Date.now()
dimensions.splice(idx + 1, 0, {
key, label: "新维度", icon: "📊", color: "#909399", objectives: [],
})
editingDimIdx.value = idx + 1
break
}
}
}
const iconOptions: Record<string, string> = {
target: "目标", star: "星级", rocket: "火箭", chart: "图表", team: "团队",
light: "灯泡", shield: "盾牌", gear: "齿轮", handshake: "握手", medal: "奖牌",
}
const iconMap: Record<string, string> = {
target: "Star", star: "StarFilled", rocket: "Top", chart: "DataLine",
team: "UserFilled", light: "Lightning", shield: "Shield", gear: "Setting",
handshake: "Handshake", medal: "Trophy",
}
//
const maps = ref<any[]>([])
const selectedMap = ref<number | null>(null)
const currentMap = ref<any>(null)
const allKpis = ref<any[]>([])
const showDialog = ref(false)
const editingDim = ref("")
const editingIndex = ref(-1)
const editForm = reactive({ name: "", description: "", icon: "target", kpis: [] as string[] })
const showVersions = ref(false)
const versions = ref<any[]>([])
const connections = ref<any[]>([]) // [{from, to, style}]
// 绿
const objectiveLevels = reactive<Record<string, string>>({})
// 线
const linkingFrom = ref<{ key: string; obj: any } | null>(null)
const selectedConnIdx = ref<number | null>(null)
const tempLine = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(null)
// DOM
const bodyRef = ref<HTMLElement | null>(null)
const svgRef = ref<SVGSVGElement | null>(null)
const dimRefs: Record<string, HTMLElement> = {}
const objRefs: Record<string, HTMLElement> = {}
function setDimRef(key: string, el: any) { if (el) dimRefs[key] = el }
function setObjRef(key: string, el: any) { if (el) objRefs[key] = el }
// 线 SVG
const connectionLines = ref<any[]>([])
function recalcLines() {
if (!svgRef.value || !bodyRef.value) { connectionLines.value = []; return }
const svgRect = svgRef.value.getBoundingClientRect()
const bodyRect = bodyRef.value.getBoundingClientRect()
connectionLines.value = connections.value.map(c => {
const fromEl = objRefs[c.from]
const toEl = objRefs[c.to]
if (!fromEl || !toEl) return { x1: 0, y1: 0, x2: 0, y2: 0, mx: 0, my: 0, fromColor: '#999', toColor: '#999' }
const fr = fromEl.getBoundingClientRect()
const tr = toEl.getBoundingClientRect()
const dimKey = c.from.split('-')[0]
const toDimKey = c.to.split('-')[0]
const fromDim = dimensions.find(d => d.key === dimKey)
const toDim = dimensions.find(d => d.key === toDimKey)
//
const x1 = fr.right - bodyRect.left
const y1 = fr.top + fr.height / 2 - bodyRect.top
const x2 = tr.left - bodyRect.left
const y2 = tr.top + tr.height / 2 - bodyRect.top
return {
x1, y1, x2, y2,
mx: (x1 + x2) / 2,
my: (y1 + y2) / 2,
fromColor: fromDim?.color || '#999',
toColor: toDim?.color || '#999',
}
})
}
let resizeObserver: ResizeObserver | null = null
//
function loadCanvas() {
if (!selectedMap.value) return
for (const dim of dimensions) { dim.objectives = [] }
connections.value = []
linkingFrom.value = null
selectedConnIdx.value = null
api.get("/maps").then((r: any) => {
const data = r.data || []
const map = data.find((m: any) => m.id === selectedMap.value)
currentMap.value = map
if (map?.dimensions) {
for (const dim of map.dimensions) {
const target = dimensions.find((d: any) => d.key === dim.key || d.label === dim.name)
if (target) {
target.objectives = dim.objectives || []
target.icon = dim.icon || target.icon
target.color = dim.color || target.color
}
}
}
connections.value = map?.canvas_data?.connections || []
nextTick(recalcLines)
// 绿
loadObjectiveLevels()
})
}
// 线
function startLink(dimKey: string, oi: number, obj: any) {
selectedConnIdx.value = null
linkingFrom.value = { key: `${dimKey}-${oi}`, obj }
}
function cancelLink() {
linkingFrom.value = null
tempLine.value = null
}
function onObjClick(dimKey: string, oi: number, obj: any) {
const key = `${dimKey}-${oi}`
// 线
if (linkingFrom.value) {
if (linkingFrom.value.key === key) { cancelLink(); return }
//
const fromDim = linkingFrom.value.key.split('-')[0]
const toDim = key.split('-')[0]
if (fromDim === toDim) { ElMessage.warning("同维度内不能连线"); return }
//
if (connections.value.some((c: any) => c.from === linkingFrom.value.key && c.to === key)) {
ElMessage.warning("已存在相同连线"); cancelLink(); return
}
// API
api.post(`/maps/${selectedMap.value}/connections`, {
from: linkingFrom.value.key,
to: key,
}).then(() => {
ElMessage.success("连线已添加")
//
connections.value.push({ from: linkingFrom.value!.key, to: key, style: "solid" })
cancelLink()
nextTick(() => { recalcLines(); saveConnections() })
}).catch((e: any) => {
//
if (e?.response?.status !== 400) {
connections.value.push({ from: linkingFrom.value!.key, to: key, style: "solid" })
ElMessage.success("连线已添加(本地)")
} else {
ElMessage.warning(e?.response?.data?.detail || "连线失败")
}
cancelLink()
nextTick(recalcLines)
})
return
}
// 线
editObjective(dimKey, oi, obj)
}
// KPI
function goKPI(kpiCode: string) {
const found = allKpis.value.find((k: any) => k.kpi_code === kpiCode)
if (found) {
window.location.href = '/kpis/' + found.id
} else {
window.location.href = '/kpis'
}
}
//
async function deleteObjective(dimKey: string, oi: number) {
try {
await ElMessageBox.confirm("确定要删除这个目标吗?", "确认删除")
const dim = dimensions.find((d: any) => d.key === dimKey)
if (dim) {
dim.objectives.splice(oi, 1)
ElMessage.success("目标已删除")
}
} catch { /* cancel */ }
}
function selectConnection(idx: number) {
selectedConnIdx.value = selectedConnIdx.value === idx ? null : idx
}
function deleteConnection(idx: number) {
if (selectedConnIdx.value !== idx) return
const conn = connections.value[idx]
if (!conn) return
// from/to
api.delete(`/maps/${selectedMap.value}/connections`, {
data: { from: conn.from, to: conn.to },
}).then(() => {
ElMessage.success("连线已删除")
connections.value.splice(idx, 1)
selectedConnIdx.value = null
nextTick(recalcLines)
}).catch(() => {
//
connections.value.splice(idx, 1)
selectedConnIdx.value = null
nextTick(recalcLines)
})
}
// 线
function onMouseMove(e: MouseEvent) {
if (!linkingFrom.value || !bodyRef.value) { tempLine.value = null; return }
const fromEl = objRefs[linkingFrom.value.key]
if (!fromEl) return
const bodyRect = bodyRef.value.getBoundingClientRect()
const fr = fromEl.getBoundingClientRect()
tempLine.value = {
x1: fr.right - bodyRect.left,
y1: fr.top + fr.height / 2 - bodyRect.top,
x2: e.clientX - bodyRect.left,
y2: e.clientY - bodyRect.top,
}
}
// CRUD
function addObjective(dimKey: string) {
editingDim.value = dimKey
editingIndex.value = -1
editForm.name = ""
editForm.description = ""
editForm.icon = "target"
editForm.kpis = []
showDialog.value = true
}
function editObjective(dimKey: string, idx: number, obj: any) {
editingDim.value = dimKey
editingIndex.value = idx
editForm.name = obj.name
editForm.description = obj.description || ""
editForm.icon = obj.icon || "target"
editForm.kpis = obj.kpis || []
showDialog.value = true
}
function saveObjective() {
if (!editForm.name) { ElMessage.warning("请输入目标名称"); return }
const dim = dimensions.find((d: any) => d.key === editingDim.value)
if (!dim) return
if (editingIndex.value >= 0) {
dim.objectives[editingIndex.value] = { name: editForm.name, description: editForm.description, icon: editForm.icon, kpis: [...editForm.kpis] }
} else {
dim.objectives.push({ name: editForm.name, description: editForm.description, icon: editForm.icon, kpis: [...editForm.kpis] })
}
showDialog.value = false
saveCanvas()
// DOM
nextTick(() => {
recalcLines()
})
}
async function saveConnections() {
if (!selectedMap.value) return
try {
await mapApi.update(selectedMap.value, {
canvas_data: { connections: connections.value },
})
} catch { /* silent */ }
}
async function saveCanvas() {
if (!selectedMap.value) return
const mapData = dimensions.map((d: any) => ({
key: d.key, name: d.label, icon: d.icon, color: d.color, objectives: d.objectives,
}))
try {
await mapApi.update(selectedMap.value, {
dimensions: mapData,
canvas_data: { connections: connections.value },
})
// objRefs
nextTick(() => {
for (const dim of dimensions) {
for (let i = 0; i < dim.objectives.length; i++) {
const key = `${dim.key}-${i}`
if (!objRefs[key]) {
// ref
setTimeout(() => { recalcLines() }, 100)
return
}
}
}
recalcLines()
})
} catch (e: any) {
console.error("saveCanvas error:", e)
ElMessage.error("保存失败")
}
}
// /
async function publishMap() {
if (!selectedMap.value) return
try {
await mapApi.update(selectedMap.value, { status: "published" })
ElMessage.success("已发布(自动创建版本快照)")
currentMap.value.status = "published"
loadCanvas()
} catch (e) {
ElMessage.error("发布失败")
}
}
async function loadVersions() {
if (!selectedMap.value) return
try {
const r: any = await api.get(`/maps/${selectedMap.value}/versions`)
versions.value = r.data || []
} catch (e) {
versions.value = []
}
}
async function rollback(verId: number) {
try {
await ElMessageBox.confirm("确定回滚到此版本?当前内容将被覆盖。")
await api.post(`/maps/${selectedMap.value}/versions/${verId}/rollback`)
ElMessage.success("回滚成功,刷新页面")
window.location.reload()
} catch (e: any) {
if (e !== "cancel") ElMessage.error("回滚失败")
}
}
// 绿
async function loadObjectiveLevels() {
if (!selectedMap.value) return
try {
const r: any = await api.get(`/maps/${selectedMap.value}/review`)
//
for (const key of Object.keys(objectiveLevels)) {
delete objectiveLevels[key]
}
// review
const dims = r.dimensions || []
for (const dim of dims) {
const dimKey = dim.key
dim.objectives.forEach((obj: any, idx: number) => {
const key = `${dimKey}-${idx}`
objectiveLevels[key] = obj.level || 'gray'
})
}
} catch (e) {
//
}
}
onMounted(async () => {
const r: any = await mapApi.list()
maps.value = r.data || []
if (maps.value.length > 0) {
selectedMap.value = maps.value[0].id
loadCanvas()
}
const k: any = await kpiApi.list()
allKpis.value = k.data || []
// 线
document.addEventListener("mousemove", onMouseMove)
// /线
resizeObserver = new ResizeObserver(() => nextTick(recalcLines))
if (bodyRef.value) resizeObserver.observe(bodyRef.value)
})
onUnmounted(() => {
document.removeEventListener("mousemove", onMouseMove)
if (resizeObserver) resizeObserver.disconnect()
})
</script>
<style scoped>
.map-canvas-page { padding: 16px; height: 100vh; display: flex; flex-direction: column; position: relative; overflow: hidden; }
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-shrink: 0; }
.toolbar h3 { margin: 0; }
.linking-bar {
display: flex; align-items: center; gap: 12px;
padding: 8px 16px; background: #fdf6ec; border: 1px solid #f5dab1;
border-radius: 8px; margin-bottom: 8px; font-size: 14px; flex-shrink: 0;
}
.canvas-body {
display: flex; gap: 12px; flex: 1; min-height: 0;
position: relative;
}
/* SVG 连线层 */
.connection-svg {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
z-index: 5; pointer-events: none;
}
.conn-line {
cursor: pointer; transition: stroke .2s;
pointer-events: stroke;
}
.conn-line:hover { stroke: #e6a23c !important; stroke-width: 3; }
.conn-selected { stroke: #f56c6c !important; stroke-width: 3; }
.del-btn-circle { cursor: pointer; pointer-events: all; }
.del-btn-text { cursor: pointer; pointer-events: all; user-select: none; }
/* 四列 */
.dimension-col {
flex: 1; min-width: 0;
border: 2px solid #e8e8e8; border-radius: 8px;
display: flex; flex-direction: column; background: #fafafa;
overflow-y: auto; position: relative; z-index: 1;
}
.dim-header {
padding: 10px; color: #fff; border-radius: 6px 6px 0 0;
text-align: center; font-weight: bold; display: flex;
align-items: center; justify-content: center; gap: 6px; font-size: 14px;
flex-shrink: 0;
}
.dim-body { padding: 10px; flex: 1; }
.objective-card {
background: #fff; border: 1px solid #e0e0e0; border-radius: 8px;
padding: 10px; margin-bottom: 8px; cursor: pointer;
transition: all 0.2s; position: relative;
}
.objective-card:hover { box-shadow: 0 2px 10px rgba(0,0,0,0.08); }
.objective-card.linking-source {
border-color: #e6a23c; box-shadow: 0 0 0 2px rgba(230,162,60,0.3);
}
.objective-card.linking-target:hover {
border-color: #67c23a; box-shadow: 0 0 0 2px rgba(103,194,58,0.3);
}
.obj-title { font-size: 13px; font-weight: 500; margin-bottom: 4px; display: flex; align-items: center; }
.obj-desc { font-size: 12px; color: #888; margin-bottom: 4px; }
.obj-kpis { display: flex; flex-wrap: wrap; gap: 2px; }
/* 操作按钮组 */
.obj-actions { display: flex; align-items: center; gap: 2px; margin-top: 6px; justify-content: flex-end; }
.obj-actions .el-button { padding: 2px 4px !important; font-size: 12px; min-height: auto; }
.obj-actions .link-btn {
width: 22px; height: 22px; display: flex; align-items: center; justify-content: center;
background: #ecf5ff; color: #409eff; border-radius: 50%;
font-size: 14px; font-weight: bold; cursor: pointer; transition: opacity .2s, transform .2s;
}
.obj-actions .link-btn:hover { transform: scale(1.2); background: #409eff; color: #fff; }
/* 连线入口按钮(旧,保留兼容) */
.link-btn {
position: absolute; bottom: 4px; right: 6px;
width: 22px; height: 22px; display: flex; align-items: center; justify-content: center;
background: #ecf5ff; color: #409eff; border-radius: 50%;
font-size: 14px; font-weight: bold; cursor: pointer; opacity: 0;
transition: opacity .2s, transform .2s;
}
.objective-card:hover .link-btn { opacity: 1; }
.link-btn:hover { transform: scale(1.2); background: #409eff; color: #fff; }
/* 目标红黄绿灯指示器 */
.obj-level-dot {
display: inline-block; width: 10px; height: 10px; border-radius: 50%;
margin-right: 4px; flex-shrink: 0;
}
.dot-green { background: #67c23a; }
.dot-yellow { background: #e6a23c; }
.dot-red { background: #f56c6c; }
.dot-gray { background: #dcdfe6; }
.obj-level-red { border-left: 3px solid #f56c6c !important; background: #fef0f0; }
.obj-level-yellow { border-left: 3px solid #e6a23c !important; background: #fdf6ec; }
.obj-level-green { border-left: 3px solid #67c23a !important; background: #f0f9eb; }
/* 维度列头部操作按钮 */
.dim-more-btn { color: rgba(255,255,255,0.7) !important; margin-left: auto; padding: 2px !important; }
.dim-more-btn:hover { color: #fff !important; }
.dim-header { position: relative; }
/* KPI选择区 */
.kpi-select-wrap { display: flex; align-items: center; width: 100%; }
</style>
+103
View File
@@ -0,0 +1,103 @@
<template>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:16px;">
<h3>战略地图</h3>
<el-button type="primary" @click="showForm=true;form={}">新建地图</el-button>
</div>
<el-table :data="maps" style="width:100%">
<el-table-column prop="title" label="地图名称" min-width="200" />
<el-table-column prop="version" label="版本" width="80" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">{{ row.status === "published" ? "已发布" : "草稿" }}</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="250">
<template #default="{ row }">
<el-button size="small" @click='goCanvas(row.id)'>画布</el-button>
<el-button size="small" @click='editMap(row)'>编辑</el-button>
<el-button size="small" @click='goVersion(row.id)'>版本</el-button>
<el-button size="small" type="danger" @click="deleteMap(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新建/编辑弹窗 -->
<el-dialog v-model="showForm" :title="editingMap ? '编辑地图' : '新建战略地图'" width="500">
<el-form :model="form" label-width="100px">
<el-form-item label="地图名称"><el-input v-model="form.title" /></el-form-item>
<el-form-item label="版本号"><el-input v-model="form.version" placeholder="v1.0" /></el-form-item>
<el-form-item label="创建方式" v-if="!editingMap">
<el-radio-group v-model="createMode">
<el-radio value="blank">空白地图</el-radio>
<el-radio value="template">快速模板4维度+预设目标</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showForm=false">取消</el-button>
<el-button type="primary" @click="saveMap">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue"
import { useRouter } from "vue-router"
import { ElMessage, ElMessageBox } from "element-plus"
import { mapApi } from "../api/index"
const router = useRouter()
const maps = ref<any[]>([])
const showForm = ref(false)
const editingMap = ref<any>(null)
const form = ref<any>({})
const createMode = ref("blank")
async function load() {
try { const r: any = await mapApi.list(); maps.value = r.data || [] } catch (e) {}
}
function goCanvas(id: number) { router.push('/maps/canvas/' + id) }
function goVersion(id: number) { router.push('/maps/versions/' + id) }
async function saveMap() {
try {
if (editingMap.value) {
await mapApi.update(editingMap.value.id, form.value)
ElMessage.success("已更新")
} else if (createMode.value === "template") {
await mapApi.createWithTemplate ? await mapApi.createWithTemplate(form.value) : await mapApi.create(form.value)
ElMessage.success("模板地图已创建")
} else {
await mapApi.create(form.value)
ElMessage.success("创建成功")
}
showForm.value = false
editingMap.value = null
load()
} catch (e) {
ElMessage.error("操作失败")
}
}
function editMap(map: any) {
editingMap.value = map
form.value = { title: map.title, version: map.version }
showForm.value = true
}
function handleClick(path: string) { window.location.href = path }
async function deleteMap(map: any) {
try {
await ElMessageBox.confirm(`确定删除「${map.title}」?`)
await mapApi.delete ? await mapApi.delete(map.id) : ElMessage.warning("当前API不支持删除")
ElMessage.success("已删除")
load()
} catch (e: any) {
if (e !== "cancel") ElMessage.error("删除失败")
}
}
onMounted(load)
</script>
+343
View File
@@ -0,0 +1,343 @@
<template>
<div class="review-page">
<!-- 顶部 -->
<div class="review-header">
<div class="header-left">
<el-button text @click="$router.push('/maps')"> 返回战略地图</el-button>
<h3>{{ mapTitle }} · 战略回顾</h3>
</div>
<div class="header-right">
<el-select v-model="selectedMap" placeholder="选择地图" style="width:200px;margin-right:8px;" @change="loadReview">
<el-option v-for="m in maps" :key="m.id" :label="m.title" :value="m.id" />
</el-select>
<el-radio-group v-model="period" size="small">
<el-radio-button value="month">本月</el-radio-button>
<el-radio-button value="quarter">本季</el-radio-button>
<el-radio-button value="year">本年</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 加载中 -->
<div v-if="loading" class="loading-wrap">
<el-skeleton :rows="6" animated />
</div>
<template v-if="!loading && summary">
<!-- 总体得分卡片 -->
<div class="summary-cards">
<div class="s-card overall">
<div class="s-card-top">
<span class="score">{{ summary.health_score }}%</span>
<span class="score-label">整体健康度</span>
</div>
<div class="s-card-btm">
<span class="stat-item green">🟢 {{ summary.green }}个达成</span>
<span class="stat-item yellow">🟡 {{ summary.yellow }}个预警</span>
<span class="stat-item red">🔴 {{ summary.red }}个异常</span>
</div>
</div>
</div>
<!-- 四维度矩阵 -->
<div class="dim-matrix">
<div v-for="dim in dimensions" :key="dim.key" class="dim-col" :style="{ borderLeft: '4px solid ' + dim.color }">
<div class="dim-head">
<span class="dim-icon">{{ dim.icon }}</span>
<span class="dim-name">{{ dim.name }}</span>
</div>
<div class="dim-body">
<div v-for="obj in dim.objectives" :key="obj.name"
class="obj-row"
:class="'obj-' + obj.level"
@click="showObjKpis(obj)">
<span class="obj-badge">{{ badgeIcon(obj.level) }}</span>
<div class="obj-info">
<span class="obj-name">{{ obj.name }}</span>
<span v-if="obj.has_data" class="obj-ratio">
<template v-for="k in obj.kpis.slice(0,2)" :key="k.kpi_code">
{{ fmtValue(k.actual_value, k.kpi_code) }}/{{ k.target_value ?? '-' }}
</template>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 🔴 需重点关注 -->
<div v-if="focusItems.length > 0" class="focus-section">
<h4 class="focus-title">🔴 需重点关注</h4>
<div v-for="item in focusItems" :key="item.name" class="focus-card" :class="'focus-' + item.level" @click="showObjKpis(item)">
<div class="focus-head">
<span class="focus-level">{{ item.level === 'red' ? '紧急' : '预警' }}</span>
<span class="focus-name">{{ item.name }}</span>
</div>
<div class="focus-kpis">
<div v-for="k in item.kpis" :key="k.kpi_code" class="fkpi-row">
<span class="fkpi-name">{{ k.kpi_name }}</span>
<span class="fkpi-val" :style="{ color: k.level === 'red' ? '#f56c6c' : '#e6a23c' }">
{{ fmtValue(k.actual_value, k.kpi_code) }} / {{ k.target_value ?? '-' }}
</span>
</div>
</div>
<!-- 关联的改善行动 -->
<div v-if="getPlansByKpi(item.kpis)" class="focus-plans">
<div v-for="p in getPlansByKpi(item.kpis)" :key="p.id" class="fplan-row" :class="'plan-' + planStatus(p)">
<span class="fplan-status">{{ planIcon(p) }}</span>
<span class="fplan-title">{{ p.title }}</span>
<span class="fplan-assignee">{{ p.assignee }}</span>
<span v-if="p.due_date && p.status !== 'completed'" class="fplan-due">{{ dueText(p) }}</span>
</div>
</div>
</div>
</div>
<!-- 📋 改善行动进度 -->
<div v-if="actionPlans.length > 0" class="plans-section">
<h4 class="plans-title">📋 改善行动进度</h4>
<div class="plans-stats">
<el-tag>{{ pendingCount }} 待处理</el-tag>
<el-tag type="warning">{{ inProgressCount }} 进行中</el-tag>
<el-tag type="success">{{ doneCount }} 已完成</el-tag>
<el-tag type="danger">{{ overdueCount }} 逾期</el-tag>
</div>
<el-table :data="actionPlans" size="small" style="width:100%" @row-click="(r:any)=>$router.push('/action-plans?kpi='+r.kpi_id)">
<el-table-column prop="title" label="行动标题" min-width="200" />
<el-table-column prop="assignee" label="负责人" width="120" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="planTagType(row)" size="small">{{ planLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" width="120">
<template #default="{ row }">
<el-progress :percentage="row.progress || 0" :status="row.status==='completed'?'success':undefined" :stroke-width="12" />
</template>
</el-table-column>
<el-table-column label="截止" width="120">
<template #default="{ row }">
<span :style="{ color: isOverdue(row) ? '#f56c6c' : '#666' }">{{ row.due_date ? row.due_date.slice(0,10) : '-' }}</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<!-- KPI详情弹出 -->
<el-dialog v-model="showKpiDialog" :title="selectedObjName" width="600">
<div v-for="k in selectedKpis" :key="k.kpi_code" class="kpi-detail-row" :class="'kpi-' + k.level">
<div class="kpi-detail-head">
<span class="kpi-detail-badge">{{ badgeIcon(k.level) }}</span>
<span class="kpi-detail-name">{{ k.kpi_name }} ({{ k.kpi_code }})</span>
</div>
<div class="kpi-detail-body">
<div class="kpi-detail-item">
<span class="label">实际值</span>
<span class="val">{{ fmtValue(k.actual_value, k.kpi_code) }}</span>
</div>
<div class="kpi-detail-item">
<span class="label">目标值</span>
<span class="val">{{ k.target_value ?? '-' }} {{ k.unit }}</span>
</div>
<div class="kpi-detail-item" v-if="k.actual_value != null && k.target_value">
<span class="label">达成率</span>
<span class="val" :style="{ color: k.level === 'red' ? '#f56c6c' : 'inherit' }">
{{ (k.actual_value / k.target_value * 100).toFixed(1) }}%
</span>
</div>
<el-button size="small" @click="$router.push('/kpis/'+k.kpi_id)" style="margin-top:8px;">查看KPI详情 </el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue"
import { useRoute } from "vue-router"
import { ElMessage } from "element-plus"
import api from "../api/index"
const route = useRoute()
//
const maps = ref<any[]>([])
const selectedMap = ref<number | null>(null)
const mapTitle = ref("")
const loading = ref(false)
const period = ref("month")
const summary = ref<any>(null)
const dimensions = ref<any[]>([])
const focusItems = ref<any[]>([])
const actionPlans = ref<any[]>([])
const showKpiDialog = ref(false)
const selectedObjName = ref("")
const selectedKpis = ref<any[]>([])
//
const pendingCount = () => actionPlans.value.filter((p:any) => p.status === 'pending').length
const inProgressCount = () => actionPlans.value.filter((p:any) => p.status === 'in_progress').length
const doneCount = () => actionPlans.value.filter((p:any) => p.status === 'completed').length
const overdueCount = () => actionPlans.value.filter((p:any) => p.status !== 'completed' && isOverdue(p)).length
//
async function loadReview() {
if (!selectedMap.value) return
loading.value = true
try {
const r: any = await api.get(`/maps/${selectedMap.value}/review`)
mapTitle.value = r.title
summary.value = r.summary
dimensions.value = r.dimensions || []
focusItems.value = r.focus_items || []
actionPlans.value = r.action_plans || []
} catch (e: any) {
ElMessage.error("加载战略回顾数据失败")
} finally {
loading.value = false
}
}
//
function badgeIcon(level: string) {
return level === 'green' ? '🟢' : level === 'yellow' ? '🟡' : level === 'red' ? '🔴' : '⚪'
}
function fmtValue(val: any, code: string) {
if (val == null) return '-'
if (typeof val === 'number') {
if (Math.abs(val) >= 10000) return (val / 10000).toFixed(1) + '万'
return val.toLocaleString()
}
return val
}
function showObjKpis(obj: any) {
selectedObjName.value = obj.name
selectedKpis.value = obj.kpis || []
showKpiDialog.value = true
}
function getPlansByKpi(kpis: any[]) {
if (!kpis || !actionPlans.value.length) return null
const kpiIds = kpis.map((k:any) => k.kpi_id)
return actionPlans.value.filter((p:any) => kpiIds.includes(p.kpi_id))
}
function planStatus(p: any) {
if (p.status === 'completed') return 'done'
if (isOverdue(p)) return 'overdue'
if (p.status === 'in_progress') return 'active'
return 'pending'
}
function planIcon(p: any) {
if (p.status === 'completed') return '✅'
if (isOverdue(p)) return '🔴'
if (p.status === 'in_progress') return '🔄'
return '⏳'
}
function planLabel(p: any) {
const labels: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
return labels[p.status] || p.status
}
function planTagType(p: any) {
if (p.status === 'completed') return 'success'
if (isOverdue(p)) return 'danger'
if (p.status === 'in_progress') return 'warning'
return 'info'
}
function isOverdue(p: any) {
if (p.status === 'completed' || !p.due_date) return false
return new Date(p.due_date) < new Date()
}
function dueText(p: any) {
if (!p.due_date) return ''
const due = new Date(p.due_date)
const now = new Date()
const diff = Math.ceil((due.getTime() - now.getTime()) / 86400000)
if (diff < 0) return `逾期${Math.abs(diff)}`
if (diff === 0) return '今日截止'
return `剩余${diff}`
}
onMounted(async () => {
const r: any = await api.get('/maps')
maps.value = r.data || []
const id = route.params.id ? Number(route.params.id) : (maps.value[0]?.id || null)
if (id) {
selectedMap.value = id
await loadReview()
}
})
</script>
<style scoped>
.review-page { padding: 20px; height: calc(100vh - 60px); overflow-y: auto; }
.review-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-shrink: 0; }
.review-header .header-left { display: flex; align-items: center; gap: 12px; }
.review-header h3 { margin: 0; font-size: 18px; }
.review-header .header-right { display: flex; align-items: center; }
.loading-wrap { padding: 40px; }
.summary-cards { display: flex; gap: 16px; margin-bottom: 20px; }
.s-card { padding: 20px; border-radius: 12px; flex: 1; }
.s-card.overall { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
.s-card-top { display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }
.score { font-size: 36px; font-weight: 700; }
.score-label { font-size: 14px; opacity: 0.8; }
.s-card-btm { display: flex; gap: 16px; font-size: 13px; }
.stat-item { opacity: 0.9; }
.dim-matrix { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px; }
.dim-col { background: #fff; border-radius: 8px; padding: 12px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.dim-head { font-size: 14px; font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.dim-body { display: flex; flex-direction: column; gap: 6px; }
.obj-row { display: flex; align-items: center; gap: 6px; padding: 8px; border-radius: 6px; cursor: pointer; transition: background 0.2s; font-size: 13px; }
.obj-row:hover { background: #f5f7fa; }
.obj-red { background: #fef0f0; }
.obj-yellow { background: #fdf6ec; }
.obj-green { background: #f0f9eb; }
.obj-gray { background: #fafafa; }
.obj-badge { font-size: 12px; }
.obj-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.obj-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.obj-ratio { font-size: 11px; color: #999; }
.focus-section { margin-bottom: 24px; }
.focus-title { font-size: 16px; margin: 0 0 12px 0; }
.focus-card { background: #fff; border-radius: 8px; padding: 12px; margin-bottom: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); cursor: pointer; }
.focus-red { border-left: 4px solid #f56c6c; }
.focus-yellow { border-left: 4px solid #e6a23c; }
.focus-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.focus-level { font-size: 11px; padding: 2px 6px; border-radius: 4px; font-weight: 600; }
.focus-red .focus-level { background: #fef0f0; color: #f56c6c; }
.focus-yellow .focus-level { background: #fdf6ec; color: #e6a23c; }
.focus-name { font-weight: 600; font-size: 14px; }
.focus-kpis { padding-left: 20px; margin-bottom: 6px; }
.fkpi-row { display: flex; justify-content: space-between; font-size: 13px; padding: 4px 0; }
.fkpi-name { color: #666; }
.fkpi-val { font-weight: 500; }
.focus-plans { padding-left: 20px; margin-top: 4px; border-top: 1px solid #f0f0f0; padding-top: 6px; }
.fplan-row { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 4px 0; }
.fplan-status { flex-shrink: 0; }
.fplan-title { flex: 1; }
.fplan-assignee { color: #999; }
.fplan-due { color: #f56c6c; font-size: 11px; }
.plans-section { margin-bottom: 24px; }
.plans-title { font-size: 16px; margin: 0 0 12px 0; }
.plans-stats { display: flex; gap: 8px; margin-bottom: 12px; }
.kpi-detail-row { border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-bottom: 8px; }
.kpi-red { border-left: 4px solid #f56c6c; }
.kpi-yellow { border-left: 4px solid #e6a23c; }
.kpi-green { border-left: 4px solid #67c23a; }
.kpi-gray { border-left: 4px solid #ccc; }
.kpi-detail-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.kpi-detail-name { font-weight: 600; font-size: 14px; }
.kpi-detail-body { padding-left: 24px; }
.kpi-detail-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; }
.kpi-detail-item .label { color: #999; width: 60px; }
.kpi-detail-item .val { font-weight: 500; }
</style>
+205
View File
@@ -0,0 +1,205 @@
<template>
<div class="my-dashboard-page">
<!-- 顶部问候 -->
<div class="dash-header">
<div class="header-left">
<h3>你好{{ userName }}</h3>
<span class="header-role">{{ roleLabel }}</span>
</div>
<div class="header-right">
<span class="date-text">📅 {{ todayStr }}</span>
</div>
</div>
<div v-if="loading" class="loading-wrap">
<el-skeleton :rows="5" animated />
</div>
<template v-if="!loading">
<!-- 待办提醒 -->
<div v-if="reminders.length > 0" class="reminder-section">
<h4 class="section-title">📌 待办提醒</h4>
<div v-for="r in reminders" :key="r.type + r.related_id" class="reminder-item" :class="'sev-' + r.severity">
<span class="reminder-icon">{{ r.severity === 'danger' ? '🔴' : '🟡' }}</span>
<span class="reminder-msg">{{ r.message }}</span>
<el-button size="small" text @click="goToDetail(r)">查看详情 </el-button>
</div>
</div>
<!-- 我的KPI -->
<div class="kpi-section">
<h4 class="section-title">📊 我的KPI</h4>
<div v-if="kpis.length === 0" class="empty-state">暂无关联到你的KPI请管理员在KPI字典中设置负责人</div>
<div v-else class="kpi-list">
<div v-for="k in kpis" :key="k.id" class="kpi-card" :class="'kpi-' + k.level" @click="$router.push('/kpis/' + k.id)">
<div class="kpi-card-left">
<span class="kpi-dot" :class="'dot-' + k.level"></span>
</div>
<div class="kpi-card-body">
<div class="kpi-card-top">
<span class="kpi-name">{{ k.kpi_name }}</span>
<span class="kpi-code">{{ k.kpi_code }}</span>
</div>
<div class="kpi-card-bar">
<el-progress
:percentage="k.target_value ? Math.min(100, Math.round((k.actual_value || 0) / k.target_value * 100)) : 0"
:status="k.level === 'green' ? 'success' : k.level === 'red' ? 'exception' : undefined"
:stroke-width="16"
:text-inside="true"
:format="() => fmtValue(k.actual_value, k.kpi_code) + ' / ' + (k.target_value ?? '-')"
/>
</div>
</div>
</div>
</div>
</div>
<!-- 我的改善行动 -->
<div class="plans-section">
<h4 class="section-title">📋 我的改善行动</h4>
<div v-if="actionPlans.length === 0" class="empty-state">暂无分配给你的改善行动计划</div>
<div v-else class="plans-list">
<div v-for="p in actionPlans" :key="p.id" class="plan-card" :class="{ 'plan-overdue': p.overdue }">
<div class="plan-card-left">
<span class="plan-status-icon">{{ planIcon(p) }}</span>
</div>
<div class="plan-card-body">
<div class="plan-title">{{ p.title }}</div>
<div class="plan-meta">
<span class="plan-kpi" v-if="p.kpi_name">{{ p.kpi_name }}</span>
<el-tag size="small" :type="planTagType(p)" class="plan-status-tag">{{ planLabel(p) }}</el-tag>
<span class="plan-due" v-if="p.due_date" :class="{ 'overdue-text': p.overdue }">
{{ p.overdue ? '逾期' : '截止' }} {{ p.due_date.slice(0,10) }}
</span>
</div>
<el-progress v-if="p.status !== 'completed'" :percentage="p.progress || 0" :stroke-width="8" />
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue"
import { useRouter } from "vue-router"
import api from "../api/index"
const router = useRouter()
//
const userStr = localStorage.getItem('cma_user') || '{}'
const user = JSON.parse(userStr)
const userName = ref(user.name || user.username || '用户')
const roleLabel = ref({
ceo: '总经理', finance: '财务部', business: '业务部', it: 'IT部'
}[user.role] || user.role)
//
const loading = ref(true)
const kpis = ref<any[]>([])
const actionPlans = ref<any[]>([])
const reminders = ref<any[]>([])
const todayStr = new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })
//
function fmtValue(val: any, code: string) {
if (val == null) return '-'
if (typeof val === 'number') {
if (Math.abs(val) >= 10000) return (val / 10000).toFixed(1) + '万'
return val.toLocaleString()
}
return val
}
function planIcon(p: any) {
const overdue = p.overdue
if (p.status === 'completed') return '✅'
if (overdue) return '🔴'
if (p.status === 'in_progress') return '🔄'
return '⏳'
}
function planLabel(p: any) {
if (p.overdue) return '逾期'
const labels: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
return labels[p.status] || p.status
}
function planTagType(p: any) {
if (p.overdue) return 'danger'
if (p.status === 'completed') return 'success'
if (p.status === 'in_progress') return 'warning'
return 'info'
}
function goToDetail(r: any) {
if (r.related_type === 'kpi') router.push('/kpis/' + r.related_id)
else if (r.related_type === 'action_plan') router.push('/action-plans')
}
//
onMounted(async () => {
try {
const r: any = await api.get('/dashboard/my-dashboard')
kpis.value = r.kpis || []
actionPlans.value = r.action_plans || []
reminders.value = r.reminders || []
} catch (e: any) {
console.error('Failed to load my dashboard', e)
} finally {
loading.value = false
}
})
</script>
<style scoped>
.my-dashboard-page { padding: 20px; height: calc(100vh - 60px); overflow-y: auto; }
.dash-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.header-left { display: flex; align-items: center; gap: 12px; }
.header-left h3 { margin: 0; font-size: 20px; }
.header-role { color: #999; font-size: 14px; }
.header-right .date-text { color: #666; font-size: 14px; }
.loading-wrap { padding: 40px; }
.section-title { font-size: 16px; margin: 0 0 12px 0; padding-bottom: 8px; border-bottom: 1px solid #eee; }
.empty-state { text-align: center; color: #999; padding: 30px; }
.reminder-section { margin-bottom: 24px; }
.reminder-item { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-radius: 8px; margin-bottom: 6px; font-size: 14px; }
.reminder-item.sev-danger { background: #fef0f0; border: 1px solid #fde2e2; }
.reminder-item.sev-warning { background: #fdf6ec; border: 1px solid #f5dab1; }
.reminder-icon { flex-shrink: 0; }
.reminder-msg { flex: 1; }
.kpi-section { margin-bottom: 24px; }
.kpi-list { display: flex; flex-direction: column; gap: 8px; }
.kpi-card { display: flex; align-items: center; gap: 12px; padding: 14px; border-radius: 10px; cursor: pointer; transition: box-shadow 0.2s; }
.kpi-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,0.08); }
.kpi-green { background: #f0f9eb; border: 1px solid #e1f3d8; }
.kpi-yellow { background: #fdf6ec; border: 1px solid #f5dab1; }
.kpi-red { background: #fef0f0; border: 1px solid #fde2e2; }
.kpi-gray { background: #fafafa; border: 1px solid #eee; }
.kpi-card-left { flex-shrink: 0; }
.kpi-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; }
.dot-green { background: #67c23a; }
.dot-yellow { background: #e6a23c; }
.dot-red { background: #f56c6c; }
.dot-gray { background: #dcdfe6; }
.kpi-card-body { flex: 1; min-width: 0; }
.kpi-card-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.kpi-name { font-weight: 600; font-size: 14px; }
.kpi-code { font-size: 11px; color: #999; }
.kpi-card-bar { margin-top: 4px; }
.plans-section { margin-bottom: 24px; }
.plans-list { display: flex; flex-direction: column; gap: 8px; }
.plan-card { display: flex; gap: 12px; padding: 14px; border-radius: 10px; background: #fff; border: 1px solid #eee; }
.plan-card.plan-overdue { border-left: 4px solid #f56c6c; background: #fef0f0; }
.plan-card-left { flex-shrink: 0; font-size: 18px; padding-top: 2px; }
.plan-card-body { flex: 1; min-width: 0; }
.plan-title { font-weight: 500; font-size: 14px; margin-bottom: 6px; }
.plan-meta { display: flex; align-items: center; gap: 8px; font-size: 12px; margin-bottom: 6px; }
.plan-kpi { color: #409eff; }
.plan-status-tag { font-size: 11px; }
.plan-due { color: #999; }
.plan-due.overdue-text { color: #f56c6c; font-weight: 500; }
</style>
+244
View File
@@ -0,0 +1,244 @@
<template>
<div class="guide-page">
<div class="guide-header">
<h3>🚀 新手引导</h3>
<p class="guide-subtitle">5分钟快速上手管理会计OS开始你的数据驱动管理之旅</p>
</div>
<!-- 快速开始引导 -->
<div class="quick-start-card" @click="startOnboarding">
<div class="qs-icon">🚀</div>
<div class="qs-body">
<div class="qs-title">快速开始引导</div>
<div class="qs-desc">按步骤带你完成系统配置和首次使用</div>
<el-tag size="small" type="danger">推荐新手</el-tag>
</div>
<el-button type="primary" size="large" class="qs-btn">开始引导</el-button>
</div>
<!-- 流程概览 -->
<div class="flow-section">
<h4>使用流程</h4>
<div class="flow-steps">
<div v-for="(step, i) in steps" :key="i" class="flow-step">
<div class="flow-num">{{ i + 1 }}</div>
<div class="flow-icon">{{ step.icon }}</div>
<div class="flow-title">{{ step.title }}</div>
<div class="flow-desc">{{ step.desc }}</div>
</div>
</div>
</div>
<!-- 视频教程 -->
<div class="video-section">
<h4>📹 视频教程</h4>
<div class="video-grid">
<div v-for="(v, i) in videos" :key="i" class="video-card" @click="openVideo(v)">
<div class="video-thumb">
<span class="play-icon"></span>
<span class="video-duration">{{ v.duration }}</span>
</div>
<div class="video-title">{{ v.title }}</div>
<div class="video-desc">{{ v.desc }}</div>
</div>
</div>
</div>
<!-- 引导遮罩弹窗 -->
<teleport to="body">
<div v-if="showWalkthrough" class="walkthrough-overlay" @click="skipWalkthrough">
<div class="walkthrough-box" @click.stop>
<div class="wt-header">
<span class="wt-step">{{ currentStep + 1 }} / {{ walkthroughSteps.length }}</span>
<el-button text size="small" @click="skipWalkthrough" style="color:#999;">跳过</el-button>
</div>
<div class="wt-body">
<div class="wt-icon">{{ walkthroughSteps[currentStep].icon }}</div>
<div class="wt-title">{{ walkthroughSteps[currentStep].title }}</div>
<div class="wt-desc">{{ walkthroughSteps[currentStep].desc }}</div>
<div class="wt-action" v-if="walkthroughSteps[currentStep].action">
<el-button type="primary" @click="goAction(walkthroughSteps[currentStep].action!)">前往{{ walkthroughSteps[currentStep].actionLabel }}</el-button>
</div>
</div>
<div class="wt-footer">
<div class="wt-dots">
<span v-for="(_, i) in walkthroughSteps" :key="i" class="wt-dot" :class="{ active: i === currentStep }"></span>
</div>
<div class="wt-btns">
<el-button v-if="currentStep > 0" @click="prevStep">上一步</el-button>
<el-button v-if="currentStep < walkthroughSteps.length - 1" type="primary" @click="nextStep">下一步</el-button>
<el-button v-else type="success" @click="finishWalkthrough">🎉 完成引导</el-button>
</div>
</div>
</div>
</div>
</teleport>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue"
import { useRouter } from "vue-router"
import { ElMessage } from "element-plus"
const router = useRouter()
//
const steps = [
{ icon: "📊", title: "查看驾驶舱", desc: "总览企业KPI整体状况" },
{ icon: "🎯", title: "配置KPI", desc: "在KPI字典中设置关键指标" },
{ icon: "🗺️", title: "绘制战略地图", desc: "把你的战略目标画出来" },
{ icon: "⚡", title: "设定预警", desc: "配置红黄绿灯阈值" },
{ icon: "📋", title: "日常管理", desc: "通过工作台持续跟进" },
]
//
const videos = [
{ title: "如何设置KPI", desc: "KPI字典的创建、编辑和配置", duration: "3:20", url: "" },
{ title: "如何绘制战略地图", desc: "创建目标、关联KPI、画因果连线", duration: "4:15", url: "" },
{ title: "如何配置预警", desc: "设置红黄绿灯阈值和通知渠道", duration: "2:50", url: "" },
{ title: "如何进行差异分析", desc: "对比实际与预算,找出偏差原因", duration: "3:45", url: "" },
{ title: "如何使用战略回顾会", desc: "周会月会的复盘操作方法", duration: "3:00", url: "" },
{ title: "如何提交改善行动", desc: "从预警到行动计划的全流程", duration: "2:30", url: "" },
]
//
const walkthroughSteps = [
{
icon: "👋",
title: "欢迎使用管理会计OS",
desc: "这是你的企业管理驾驶舱。我们将带你快速完成首次配置,让你在5分钟内上手。",
},
{
icon: "📊",
title: "Step 1: 先看驾驶舱",
desc: "驾驶舱是你了解企业整体状况的第一站。这里展示了所有KPI的红黄绿灯状态,以及你的个人KPI。",
action: "/dashboard",
actionLabel: "驾驶舱",
},
{
icon: "🎯",
title: "Step 2: 配置你的KPI",
desc: "在KPI字典中管理和配置你的关键绩效指标。你可以新建KPI、设置目标值和预警阈值。",
action: "/kpis",
actionLabel: "KPI字典",
},
{
icon: "🗺️",
title: "Step 3: 绘制战略地图",
desc: "使用BSC四维框架,把你的战略目标可视化。在画布上添加目标、关联KPI、画因果连线。",
action: "/maps",
actionLabel: "战略地图",
},
{
icon: "📋",
title: "Step 4: 开始日常管理",
desc: "通过「我的工作台」查看个人KPI和改善行动,通过「战略回顾会」进行周月复盘。",
action: "/my-dashboard",
actionLabel: "我的工作台",
},
]
const showWalkthrough = ref(false)
const currentStep = ref(0)
const FIRST_LOGIN_KEY = "cma_first_login_done"
//
function startOnboarding() {
currentStep.value = 0
showWalkthrough.value = true
}
function nextStep() {
if (currentStep.value < walkthroughSteps.length - 1) {
currentStep.value++
}
}
function prevStep() {
if (currentStep.value > 0) {
currentStep.value--
}
}
function skipWalkthrough() {
showWalkthrough.value = false
}
function finishWalkthrough() {
localStorage.setItem(FIRST_LOGIN_KEY, "true")
showWalkthrough.value = false
ElMessage.success("🎉 引导完成,开始你的管理会计之旅吧!")
}
function goAction(path: string) {
router.push(path)
showWalkthrough.value = false
}
function openVideo(v: any) {
if (v.url) {
window.open(v.url, "_blank")
} else {
ElMessage.info("视频教程正在制作中,敬请期待")
}
}
onMounted(() => {
//
const done = localStorage.getItem(FIRST_LOGIN_KEY)
if (!done) {
// 1
setTimeout(() => {
showWalkthrough.value = true
}, 1000)
}
})
</script>
<style scoped>
.guide-page { padding: 20px; height: calc(100vh - 60px); overflow-y: auto; }
.guide-header { margin-bottom: 24px; }
.guide-header h3 { margin: 0; font-size: 20px; }
.guide-subtitle { color: #999; margin: 4px 0 0; font-size: 14px; }
.quick-start-card { display: flex; align-items: center; gap: 16px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; border-radius: 12px; padding: 20px; margin-bottom: 24px; cursor: pointer; }
.qs-icon { font-size: 40px; }
.qs-body { flex: 1; }
.qs-title { font-size: 18px; font-weight: 600; margin-bottom: 4px; }
.qs-desc { font-size: 13px; opacity: 0.8; margin-bottom: 6px; }
.qs-btn { flex-shrink: 0; }
.flow-section { margin-bottom: 24px; }
.flow-section h4 { margin: 0 0 16px; font-size: 16px; }
.flow-steps { display: flex; gap: 12px; }
.flow-step { flex: 1; text-align: center; padding: 16px; background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.flow-num { width: 28px; height: 28px; border-radius: 50%; background: #409eff; color: #fff; display: flex; align-items: center; justify-content: center; margin: 0 auto 8px; font-size: 13px; font-weight: 600; }
.flow-icon { font-size: 28px; margin-bottom: 6px; }
.flow-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.flow-desc { font-size: 12px; color: #999; }
.video-section { margin-bottom: 24px; }
.video-section h4 { margin: 0 0 16px; font-size: 16px; }
.video-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.video-card { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,0.06); cursor: pointer; transition: transform 0.2s; }
.video-card:hover { transform: translateY(-2px); }
.video-thumb { position: relative; background: #304156; height: 120px; display: flex; align-items: center; justify-content: center; }
.play-icon { font-size: 40px; color: #fff; opacity: 0.8; }
.video-duration { position: absolute; bottom: 6px; right: 8px; background: rgba(0,0,0,0.6); color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
.video-title { padding: 10px 12px 2px; font-size: 14px; font-weight: 500; }
.video-desc { padding: 0 12px 10px; font-size: 12px; color: #999; }
/* 引导遮罩 */
.walkthrough-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 9999; display: flex; align-items: center; justify-content: center; }
.walkthrough-box { background: #fff; border-radius: 16px; width: 480px; max-width: 90vw; box-shadow: 0 8px 32px rgba(0,0,0,0.2); overflow: hidden; }
.wt-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; border-bottom: 1px solid #f0f0f0; }
.wt-step { font-size: 12px; color: #999; }
.wt-body { padding: 32px 20px; text-align: center; }
.wt-icon { font-size: 56px; margin-bottom: 16px; }
.wt-title { font-size: 20px; font-weight: 600; margin-bottom: 12px; }
.wt-desc { font-size: 14px; color: #666; line-height: 1.6; margin-bottom: 20px; }
.wt-action { margin-top: 8px; }
.wt-footer { padding: 12px 20px; border-top: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center; }
.wt-dots { display: flex; gap: 6px; }
.wt-dot { width: 8px; height: 8px; border-radius: 50%; background: #ddd; transition: background 0.3s; }
.wt-dot.active { background: #409eff; }
.wt-btns { display: flex; gap: 8px; }
</style>
+201
View File
@@ -0,0 +1,201 @@
<template>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<h3>通知配置</h3>
<el-button type="primary" @click="showAddDialog = true">添加渠道</el-button>
</div>
<!-- 渠道列表 -->
<el-table :data="channels" v-loading="loading" style="width:100%;margin-top:16px;">
<el-table-column prop="name" label="名称" width="150" />
<el-table-column prop="channel_type" label="类型" width="120">
<template #default="{ row }">
<el-tag :type="row.channel_type === 'wecom' ? 'success' : row.channel_type === 'wecom_app' ? 'warning' : 'primary'">
{{ row.channel_type === 'wecom' ? '企业微信' : row.channel_type === 'wecom_app' ? '企微应用' : '邮件' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="配置摘要" min-width="200">
<template #default="{ row }">
<span v-if="row.channel_type === 'wecom'" style="font-size:12px;color:#999;">
Webhook: {{ (row.config?.webhook_url || '').substring(0, 40) }}...
</span>
<span v-else-if="row.channel_type === 'mail'" style="font-size:12px;color:#999;">
收件人: {{ (row.config?.to || []).join(', ') }}
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-switch :model-value="row.enabled" @change="toggleChannel(row)" size="small" />
</template>
</el-table-column>
<el-table-column label="操作" width="160">
<template #default="{ row }">
<el-button size="small" @click="testChannel(row)">测试</el-button>
<el-button size="small" type="danger" plain @click="deleteChannel(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && channels.length === 0" description="暂无通知渠道,请添加" />
<!-- 通知日志 -->
<h3 style="margin-top:30px;">发送记录</h3>
<el-table :data="logs" style="width:100%;margin-top:8px;" size="small">
<el-table-column prop="created_at" label="时间" width="160" />
<el-table-column prop="title" label="标题" min-width="200" />
<el-table-column prop="channel" label="渠道" width="80" />
<el-table-column prop="recipient" label="接收方" width="120" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'sent' ? 'success' : 'danger'" size="small">{{ row.status === 'sent' ? '成功' : '失败' }}</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 添加渠道对话框 -->
<MyDialog v-model="showAddDialog" title="添加通知渠道" :width="500">
<el-form :model="form" label-width="100px">
<el-form-item label="名称"><el-input v-model="form.name" placeholder="如:管理群、财务部邮箱" /></el-form-item>
<el-form-item label="类型">
<el-select v-model="form.channel_type" style="width:100%;">
<el-option value="wecom" label="企业微信群机器人" />
<el-option value="wecom_app" label="企微应用消息" />
<el-option value="mail" label="邮件" />
</el-select>
</el-form-item>
<el-form-item v-if="form.channel_type === 'wecom'" label="Webhook地址">
<el-input v-model="form.webhook_url" type="textarea" rows="2" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx" />
</el-form-item>
<template v-if="form.channel_type === 'wecom_app'">
<el-form-item label="CorpID"><el-input v-model="form.corp_id" placeholder="企业ID,如 wx..." /></el-form-item>
<el-form-item label="CorpSecret"><el-input v-model="form.corp_secret" type="password" show-password /></el-form-item>
<el-form-item label="AgentID"><el-input v-model="form.agent_id" placeholder="应用AgentID,如 1000020" /></el-form-item>
<el-form-item label="接收人UserID"><el-input v-model="form.touser" placeholder="企微成员UserID,多个用 | 分隔" /></el-form-item>
</template>
<template v-if="form.channel_type === 'mail'">
<el-form-item label="SMTP地址"><el-input v-model="form.smtp_host" placeholder="smtp.qq.com" /></el-form-item>
<el-form-item label="端口"><el-input v-model="form.smtp_port" placeholder="465" /></el-form-item>
<el-form-item label="账号"><el-input v-model="form.smtp_user" placeholder="xxx@qq.com" /></el-form-item>
<el-form-item label="密码"><el-input v-model="form.smtp_password" type="password" show-password /></el-form-item>
<el-form-item label="收件人"><el-input v-model="form.mail_to" placeholder="用逗号分隔多个邮箱" /></el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="saveChannel" :loading="saving">保存</el-button>
</template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { notificationApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
const loading = ref(false)
const saving = ref(false)
const channels = ref<any[]>([])
const logs = ref<any[]>([])
const showAddDialog = ref(false)
const form = reactive({
name: '',
channel_type: 'wecom',
webhook_url: '',
corp_id: '',
corp_secret: '',
agent_id: '',
touser: '',
smtp_host: 'smtp.qq.com',
smtp_port: '465',
smtp_user: '',
smtp_password: '',
mail_to: '',
})
async function loadChannels() {
loading.value = true
try {
const r: any = await notificationApi.list()
channels.value = r.data || []
} catch (e: any) {
ElMessage.error('加载失败')
}
loading.value = false
}
async function loadLogs() {
try {
const r: any = await notificationApi.logs()
logs.value = r.data || []
} catch (_) {}
}
async function saveChannel() {
saving.value = true
try {
const config: any = {}
if (form.channel_type === 'wecom') {
config.webhook_url = form.webhook_url
} else if (form.channel_type === 'wecom_app') {
config.corp_id = form.corp_id
config.corp_secret = form.corp_secret
config.agent_id = form.agent_id
config.touser = form.touser
} else {
config.host = form.smtp_host
config.port = form.smtp_port
config.user = form.smtp_user
config.password = form.smtp_password
config.from_addr = form.smtp_user
config.to = form.mail_to.split(',').map((s: string) => s.trim()).filter(Boolean)
}
await notificationApi.create({
name: form.name,
channel_type: form.channel_type,
config,
})
ElMessage.success('添加成功')
showAddDialog.value = false
loadChannels()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '保存失败')
}
saving.value = false
}
async function toggleChannel(row: any) {
try {
await notificationApi.update(row.id, { enabled: !row.enabled })
row.enabled = !row.enabled
} catch (_) {}
}
async function testChannel(row: any) {
try {
const r: any = await notificationApi.test(row.id)
const result = r.results?.[0]
if (result?.success) {
ElMessage.success(result.message || '测试推送成功')
} else {
ElMessage.warning(result?.message || '推送失败')
}
} catch (e: any) {
ElMessage.error('测试异常')
}
}
async function deleteChannel(row: any) {
try {
await ElMessageBox.confirm('确定删除此通知渠道?', '确认')
await notificationApi.delete(row.id)
ElMessage.success('已删除')
loadChannels()
} catch (_) {}
}
onMounted(() => { loadChannels(); loadLogs() })
</script>
+324
View File
@@ -0,0 +1,324 @@
<template>
<div class="org-page">
<!-- 顶部统计栏 -->
<div class="stats-row">
<div class="stat-card"><el-icon><Collection /></el-icon><div><span class="num">{{ stats.total }}</span><span class="lbl">总节点</span></div></div>
<div class="stat-card"><el-icon><TrendCharts /></el-icon><div><span class="num">{{ stats.levels }}</span><span class="lbl">层级深度</span></div></div>
<div class="stat-card" style="color:#67c23a;"><el-icon><CircleCheckFilled /></el-icon><div><span class="num">{{ stats.enabled }}</span><span class="lbl">已启用</span></div></div>
<div class="stat-card" style="color:#909399;"><el-icon><RemoveFilled /></el-icon><div><span class="num">{{ stats.disabled }}</span><span class="lbl">已禁用</span></div></div>
</div>
<div class="org-body">
<!-- 左侧树 -->
<div class="tree-panel">
<div class="panel-head">
<span>组织架构树</span>
<el-button size="small" type="primary" @click="addRoot">+ 根节点</el-button>
</div>
<el-input v-model="searchText" placeholder="搜索节点..." prefix-icon="Search" size="small" class="search-box" clearable />
<div class="tree-wrap">
<el-tree
ref="treeRef"
:data="treeData"
:props="{ label: 'label', children: 'children' }"
node-key="id"
default-expand-all
:highlight-current="true"
:filter-node-method="filterNode"
draggable
@node-click="onNodeClick"
@node-drag-end="onDragEnd"
>
<template #default="{ node, data }">
<span class="custom-tree-node">
<el-icon v-if="data.level === 1" :size="16" color="#e6a23c"><HomeFilled /></el-icon>
<el-icon v-else-if="data.level === 2" :size="16" color="#409eff"><Connection /></el-icon>
<el-icon v-else :size="16" color="#67c23a"><FolderOpened /></el-icon>
<span class="node-label">{{ data.label }}</span>
<el-tag v-if="!data.enabled" size="small" type="info" effect="dark" class="node-tag">停用</el-tag>
<span class="level-tag">L{{ data.level }}</span>
</span>
</template>
</el-tree>
</div>
</div>
<!-- 右侧详情 -->
<div class="detail-panel">
<template v-if="selectedNode">
<div class="detail-head">
<h3>
<el-icon v-if="selectedNode.level === 1" color="#e6a23c"><HomeFilled /></el-icon>
<el-icon v-else-if="selectedNode.level === 2" color="#409eff"><Connection /></el-icon>
<el-icon v-else color="#67c23a"><FolderOpened /></el-icon>
{{ selectedNode.label }}
</h3>
<el-tag :type="selectedNode.enabled ? 'success' : 'info'" effect="dark" size="small">
{{ selectedNode.enabled ? '已启用' : '已禁用' }}
</el-tag>
</div>
<el-descriptions :column="1" border size="small" class="node-info">
<el-descriptions-item label="层级">
<el-tag size="small" round>{{ levelLabels[selectedNode.level] || '未知' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="编码">{{ selectedNode.code || '—' }}</el-descriptions-item>
<el-descriptions-item label="排序">{{ selectedNode.sort_order || 0 }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ selectedNode.remark || '—' }}</el-descriptions-item>
<el-descriptions-item label="子节点数">
<el-tag size="small" type="primary" round>{{ childCount }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="detail-actions">
<el-button type="primary" @click="addChild(selectedNode)"><el-icon><Plus /></el-icon>新增子节点</el-button>
<el-button @click="editNode(selectedNode)"><el-icon><Edit /></el-icon>编辑</el-button>
<el-button :type="selectedNode.enabled ? 'warning' : 'success'" @click="toggleNode(selectedNode)">
<el-icon>{{ selectedNode.enabled ? 'VideoPause' : 'VideoPlay' }}</el-icon>
{{ selectedNode.enabled ? '禁用' : '启用' }}
</el-button>
<el-button type="danger" :disabled="hasChildren" @click="deleteNode(selectedNode)">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</template>
<el-empty v-else description="请选择左侧组织节点" :image-size="120" />
</div>
</div>
<!-- 编辑弹窗 -->
<el-dialog v-model="showDialog" :title="isEdit ? '编辑节点' : '新增节点'" width="480" destroy-on-close>
<el-form :model="form" label-width="90px" size="small">
<el-form-item label="节点名称">
<el-input v-model="form.name" placeholder="输入节点名称" />
</el-form-item>
<el-form-item label="编码">
<el-input v-model="form.code" placeholder="可选编码,如 HQ-FIN" />
</el-form-item>
<el-form-item label="层级" v-if="!isEdit">
<el-select v-model="form.level" :disabled="!!form.parent_id" style="width:100%">
<el-option :value="1" label="1-集团" />
<el-option :value="2" label="2-事业部" />
<el-option :value="3" label="3-区域" />
<el-option :value="4" label="4-部门" />
<el-option :value="5" label="5-班组" />
</el-select>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort_order" :min="0" style="width:100%" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDialog=false">取消</el-button>
<el-button type="primary" @click="saveNode" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue"
import { ElMessage, ElMessageBox } from "element-plus"
import {
Collection, TrendCharts, CircleCheckFilled, RemoveFilled,
HomeFilled, Connection, FolderOpened,
Search, Plus, Edit, Delete,
} from "@element-plus/icons-vue"
import api from "../api/index"
const treeData = ref<any[]>([])
const treeRef = ref<any>(null)
const searchText = ref("")
const selectedNode = ref<any>(null)
const showDialog = ref(false)
const isEdit = ref(false)
const editingId = ref<number | null>(null)
const saving = ref(false)
const form = ref<any>({ name: "", code: "", level: 1, sort_order: 0, parent_id: null, remark: "" })
const levelLabels: Record<number, string> = { 1: "集团", 2: "事业部", 3: "区域", 4: "部门", 5: "班组" }
//
const stats = computed(() => {
let total = 0, levels = 0, enabled = 0, disabled = 0
function walk(nodes: any[]) {
for (const n of nodes) {
total++
levels = Math.max(levels, n.level || 1)
if (n.enabled) enabled++; else disabled++
if (n.children) walk(n.children)
}
}
walk(treeData.value)
return { total, levels, enabled, disabled }
})
const childCount = computed(() => {
if (!selectedNode.value) return 0
return selectedNode.value.children?.length || 0
})
const hasChildren = computed(() => childCount.value > 0)
//
watch(searchText, (val) => {
treeRef.value?.filter(val)
})
function filterNode(value: string, data: any) {
if (!value) return true
return data.label.toLowerCase().includes(value.toLowerCase())
}
// API
async function loadTree() {
try {
const r: any = await api.get("/org/tree")
treeData.value = r.data || []
} catch { ElMessage.error("加载组织树失败") }
}
function onNodeClick(data: any) {
selectedNode.value = data
}
function addRoot() {
isEdit.value = false
editingId.value = null
form.value = { name: "", code: "", level: 1, sort_order: 0, parent_id: null, remark: "" }
showDialog.value = true
}
function addChild(data: any) {
isEdit.value = false
editingId.value = null
const newLevel = Math.min(data.level + 1, 5)
form.value = { name: "", code: "", level: newLevel, sort_order: 0, parent_id: data.id, remark: "" }
showDialog.value = true
}
function editNode(data: any) {
isEdit.value = true
editingId.value = data.id
form.value = {
name: data.label,
code: data.code || "",
level: data.level,
sort_order: data.sort_order || 0,
parent_id: null,
remark: data.remark || "",
}
showDialog.value = true
}
async function saveNode() {
if (!form.value.name) { ElMessage.warning("请输入名称"); return }
saving.value = true
try {
if (isEdit.value && editingId.value) {
await api.put(`/org/nodes/${editingId.value}`, form.value)
ElMessage.success("已更新")
} else {
await api.post("/org/nodes", form.value)
ElMessage.success("已创建")
}
showDialog.value = false
await loadTree()
//
if (isEdit.value && editingId.value === selectedNode.value?.id) {
const r: any = await api.get("/org/nodes")
const found = r.data?.find((n: any) => n.id === editingId.value)
if (found) selectedNode.value = { ...found, label: found.name, children: selectedNode.value?.children || [] }
}
} catch { ElMessage.error("操作失败") }
finally { saving.value = false }
}
async function toggleNode(data: any) {
try {
await api.put(`/org/nodes/${data.id}/toggle`)
ElMessage.success(data.enabled ? "已禁用" : "已启用")
await loadTree()
//
if (selectedNode.value?.id === data.id) {
selectedNode.value.enabled = !data.enabled
}
} catch { ElMessage.error("操作失败") }
}
async function deleteNode(data: any) {
if (hasChildren.value) { ElMessage.warning("请先删除子节点"); return }
try {
await ElMessageBox.confirm(`确定删除「${data.label}」?`)
await api.delete(`/org/nodes/${data.id}`)
ElMessage.success("已删除")
selectedNode.value = null
await loadTree()
} catch (e: any) {
if (e !== "cancel") ElMessage.error("删除失败")
}
}
function onDragEnd() { loadTree() }
onMounted(loadTree)
</script>
<style scoped>
.org-page { padding: 16px; height: calc(100vh - 80px); display: flex; flex-direction: column; gap: 12px; }
/* 统计栏 */
.stats-row { display: flex; gap: 12px; }
.stat-card {
flex: 1; display: flex; align-items: center; gap: 8px;
background: #fff; border-radius: 8px; padding: 14px 16px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.stat-card .el-icon { font-size: 28px; }
.stat-card .num { display: block; font-size: 22px; font-weight: 700; line-height: 1.2; }
.stat-card .lbl { font-size: 12px; color: #909399; }
/* 主体 */
.org-body { display: flex; gap: 12px; flex: 1; min-height: 0; }
.tree-panel {
width: 320px; background: #fff; border-radius: 8px;
display: flex; flex-direction: column;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
overflow: hidden;
}
.panel-head {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 14px; border-bottom: 1px solid #f0f0f0;
font-weight: 600; font-size: 14px;
}
.search-box { margin: 8px 10px; }
.tree-wrap { flex: 1; overflow-y: auto; padding: 4px 0; }
.detail-panel {
flex: 1; background: #fff; border-radius: 8px;
padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.06);
display: flex; flex-direction: column;
}
.detail-head { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.detail-head h3 { margin: 0; display: flex; align-items: center; gap: 6px; font-size: 16px; }
.detail-head h3 .el-icon { font-size: 20px; }
.node-info { margin-bottom: 16px; }
.detail-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: auto; padding-top: 16px; }
/* 树节点样式 */
.custom-tree-node { flex: 1; display: flex; align-items: center; gap: 4px; font-size: 13px; overflow: hidden; }
.custom-tree-node .node-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.custom-tree-node .node-tag { transform: scale(0.85); margin-left: 4px; }
.level-tag {
font-size: 10px; color: #909399; background: #f5f7fa;
border-radius: 4px; padding: 0 4px; margin-left: 4px;
line-height: 16px;
}
</style>
+287
View File
@@ -0,0 +1,287 @@
<template>
<div>
<h3>预测模拟</h3>
<el-tabs v-model="activeTab" style="margin-top:16px;">
<!-- Tab 1: CVP本量利分析 -->
<el-tab-pane label="本量利分析" name="cvp">
<el-row :gutter="16">
<el-col :span="10">
<el-card>
<template #header>输入参数</template>
<el-form :model="cvpForm" label-width="140px">
<el-form-item label="产品单价"><el-input-number v-model="cvpForm.unit_price" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="单位变动成本"><el-input-number v-model="cvpForm.unit_variable_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="固定成本"><el-input-number v-model="cvpForm.fixed_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="实际销量(可选)"><el-input-number v-model="cvpForm.actual_volume" :min="0" style="width:200px;" /></el-form-item>
<el-form-item label="目标利润(可选)"><el-input-number v-model="cvpForm.target_profit" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item>
<el-button type="primary" @click="doCvp" :loading="cvpLoading">计算</el-button>
<el-button @click="cvpForm = { unit_price: 100, unit_variable_cost: 60, fixed_cost: 50000, actual_volume: 2000, target_profit: 20000 }">填充示例</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<el-col :span="14">
<el-card v-if="cvpResult.bep_units">
<template #header>分析结果</template>
<el-row :gutter="12">
<el-col :span="8"><div class="result-card"><div class="r-label">保本销量</div><div class="r-value">{{ cvpResult.bep_units }} </div></div></el-col>
<el-col :span="8"><div class="result-card"><div class="r-label">保本销售额</div><div class="r-value">{{ formatMoney(cvpResult.bep_revenue) }}</div></div></el-col>
<el-col :span="8"><div class="result-card"><div class="r-label">边际贡献率</div><div class="r-value blue">{{ cvpResult.contribution_ratio }}%</div></div></el-col>
</el-row>
<el-row :gutter="12" style="margin-top:12px;">
<el-col :span="8" v-if="cvpResult.safety_margin_units !== undefined"><div class="result-card"><div class="r-label">安全边际(量)</div><div class="r-value green">{{ cvpResult.safety_margin_units }} </div></div></el-col>
<el-col :span="8" v-if="cvpResult.safety_margin_ratio !== undefined"><div class="result-card"><div class="r-label">安全边际率</div><div class="r-value" :class="safetyColor(cvpResult.safety_margin_ratio)">{{ cvpResult.safety_margin_ratio }}%</div></div></el-col>
<el-col :span="8" v-if="cvpResult.actual_profit !== undefined"><div class="result-card"><div class="r-label">当前利润</div><div class="r-value orange">{{ formatMoney(cvpResult.actual_profit) }}</div></div></el-col>
</el-row>
<el-row :gutter="12" style="margin-top:12px;" v-if="cvpResult.target_units">
<el-col :span="12"><div class="result-card"><div class="r-label">目标利润所需销量</div><div class="r-value purple">{{ cvpResult.target_units }} </div></div></el-col>
<el-col :span="12"><div class="result-card"><div class="r-label">目标利润所需收入</div><div class="r-value purple">{{ formatMoney(cvpResult.target_revenue) }}</div></div></el-col>
</el-row>
</el-card>
<el-empty v-else-if="!cvpLoading" description="输入参数后点击「计算」" style="padding:40px 0;" />
</el-col>
</el-row>
</el-tab-pane>
<!-- Tab 2: 投资决策 -->
<el-tab-pane label="投资决策" name="investment">
<el-row :gutter="16">
<el-col :span="10">
<el-card>
<template #header>输入参数</template>
<el-form :model="invForm" label-width="140px">
<el-form-item label="初始投资"><el-input-number v-model="invForm.initial_investment" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="折现率(%)"><el-input-number v-model="invForm.discount_rate" :min="0" :max="100" :precision="1" style="width:200px;" /></el-form-item>
<el-form-item label="现金流(每年)">
<div v-for="(cf, idx) in invForm.cash_flows" :key="idx" style="display:flex;gap:8px;margin-bottom:4px;">
<span style="line-height:32px;width:80px;">{{ idx+1 }}</span>
<el-input-number v-model="invForm.cash_flows[idx]" :min="0" :precision="2" style="width:150px;" />
<el-button v-if="invForm.cash_flows.length > 1" size="small" type="danger" @click="invForm.cash_flows.splice(idx, 1)">×</el-button>
</div>
<el-button size="small" @click="invForm.cash_flows.push(0)">+ 添加年份</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="doInvestment" :loading="invLoading">计算</el-button>
<el-button @click="fillInvExample">填充示例</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<el-col :span="14">
<el-card v-if="invResult.npv_analysis">
<template #header>分析结果</template>
<el-row :gutter="12">
<el-col :span="8"><div class="result-card"><div class="r-label">净现值 (NPV)</div><div class="r-value" :class="invResult.npv_analysis.npv > 0 ? 'green' : 'red'">{{ formatMoney(invResult.npv_analysis.npv) }}</div></div></el-col>
<el-col :span="8"><div class="result-card"><div class="r-label">内部收益率 (IRR)</div><div class="r-value blue">{{ invResult.irr_analysis.irr }}%</div></div></el-col>
<el-col :span="8"><div class="result-card"><div class="r-label">盈利指数 (PI)</div><div class="r-value purple">{{ invResult.npv_analysis.profitability_index }}</div></div></el-col>
</el-row>
<el-row :gutter="12" style="margin-top:12px;">
<el-col :span="8"><div class="result-card"><div class="r-label">静态回收期</div><div class="r-value">{{ invResult.irr_analysis.payback_period || '--' }} </div></div></el-col>
<el-col :span="8"><div class="result-card"><div class="r-label">动态回收期</div><div class="r-value">{{ invResult.irr_analysis.discounted_payback_period || '>N期' }} </div></div></el-col>
<el-col :span="8"><div class="result-card"><div class="r-label">可行性</div><div class="r-value" :class="invResult.npv_analysis.is_viable ? 'green' : 'red'">{{ invResult.npv_analysis.is_viable ? '✅ 可行' : '❌ 不可行' }}</div></div></el-col>
</el-row>
</el-card>
<el-empty v-else-if="!invLoading" description="输入参数后点击「计算」" style="padding:40px 0;" />
</el-col>
</el-row>
</el-tab-pane>
<!-- Tab 3: 敏感性分析 -->
<el-tab-pane label="敏感性分析" name="sensitivity">
<el-row :gutter="16">
<el-col :span="10">
<el-card>
<template #header>输入参数</template>
<el-form :model="sensForm" label-width="140px">
<el-form-item label="基准收入"><el-input-number v-model="sensForm.base_revenue" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="基准成本"><el-input-number v-model="sensForm.base_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="变动步长(%)"><el-input-number v-model="sensForm.step" :min="1" :max="50" style="width:200px;" /></el-form-item>
<el-form-item>
<el-button type="primary" @click="doSensitivity" :loading="sensLoading">计算</el-button>
<el-button @click="sensForm = { base_revenue: 1000000, base_cost: 700000, step: 5 }">填充示例</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<el-col :span="14">
<el-card v-if="sensResult.base_revenue">
<template #header>
敏感性分析结果 基准利润{{ formatMoney(sensResult.base_profit) }}
</template>
<el-table :data="sensResult.factors" border stripe size="small" style="width:100%;" max-height="400">
<el-table-column prop="change_pct" label="变动%" width="70" align="center">
<template #default="{ row }"><span :style="{ color: row.change_pct > 0 ? '#f56c6c' : '#67c23a', fontWeight:600 }">{{ row.change_pct > 0 ? '+' : '' }}{{ row.change_pct }}%</span></template>
</el-table-column>
<el-table-column prop="revenue_change_profit" label="收入变动→利润" width="130" align="right"><template #default="{ row }">{{ formatMoney(row.revenue_change_profit) }}</template></el-table-column>
<el-table-column prop="revenue_sensitivity" label="收入敏感度" width="100" align="right"><template #default="{ row }"><span :style="{ color: row.revenue_sensitivity < 0 ? '#f56c6c' : '#67c23a' }">{{ row.revenue_sensitivity > 0 ? '+' : '' }}{{ row.revenue_sensitivity }}%</span></template></el-table-column>
<el-table-column prop="cost_change_profit" label="成本变动→利润" width="130" align="right"><template #default="{ row }">{{ formatMoney(row.cost_change_profit) }}</template></el-table-column>
<el-table-column prop="cost_sensitivity" label="成本敏感度" width="100" align="right"><template #default="{ row }"><span :style="{ color: row.cost_sensitivity < 0 ? '#f56c6c' : '#67c23a' }">{{ row.cost_sensitivity > 0 ? '+' : '' }}{{ row.cost_sensitivity }}%</span></template></el-table-column>
</el-table>
</el-card>
<el-empty v-else-if="!sensLoading" description="输入参数后点击「计算」" style="padding:40px 0;" />
</el-col>
</el-row>
</el-tab-pane>
<!-- Tab 4: 情景模拟 -->
<el-tab-pane label="情景模拟" name="scenario">
<el-row :gutter="16">
<el-col :span="10">
<el-card>
<template #header>输入三个情景</template>
<el-form label-width="120px">
<div style="margin-bottom:12px;">
<el-tag type="success" size="small">乐观情景</el-tag>
<div style="display:flex;gap:8px;margin-top:4px;">
<el-input-number v-model="scenarioForm.optimistic.revenue" placeholder="收入" :min="0" :precision="2" style="width:150px;" />
<el-input-number v-model="scenarioForm.optimistic.cost" placeholder="成本" :min="0" :precision="2" style="width:150px;" />
</div>
</div>
<div style="margin-bottom:12px;">
<el-tag type="warning" size="small">中性情景</el-tag>
<div style="display:flex;gap:8px;margin-top:4px;">
<el-input-number v-model="scenarioForm.base.revenue" :min="0" :precision="2" style="width:150px;" />
<el-input-number v-model="scenarioForm.base.cost" :min="0" :precision="2" style="width:150px;" />
</div>
</div>
<div style="margin-bottom:12px;">
<el-tag type="danger" size="small">悲观情景</el-tag>
<div style="display:flex;gap:8px;margin-top:4px;">
<el-input-number v-model="scenarioForm.pessimistic.revenue" :min="0" :precision="2" style="width:150px;" />
<el-input-number v-model="scenarioForm.pessimistic.cost" :min="0" :precision="2" style="width:150px;" />
</div>
</div>
<el-button type="primary" @click="doScenario" :loading="scenarioLoading">模拟</el-button>
<el-button @click="fillScenarioExample">填充示例</el-button>
</el-form>
</el-card>
</el-col>
<el-col :span="14">
<el-card v-if="scenarioResult.scenarios">
<template #header>
模拟结果
<span style="margin-left:12px;font-size:12px;color:#999;">
期望利润{{ formatMoney(scenarioResult.expected_profit) }}
| 标准差{{ formatMoney(scenarioResult.std_deviation) }}
</span>
</template>
<el-table :data="scenarioResult.scenarios" border stripe size="small" style="width:100%;">
<el-table-column prop="scenario" label="情景" width="80">
<template #default="{ row }">
<el-tag :type="row.scenario === '乐观' ? 'success' : row.scenario === '中性' ? 'warning' : 'danger'" size="small">{{ row.scenario }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="revenue" label="收入" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.revenue) }}</template></el-table-column>
<el-table-column prop="cost" label="成本" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.cost) }}</template></el-table-column>
<el-table-column prop="profit" label="利润" width="100" align="right">
<template #default="{ row }"><span :style="{ color: row.profit >= 0 ? '#67c23a' : '#f56c6c', fontWeight:600 }">{{ formatMoney(row.profit) }}</span></template>
</el-table-column>
<el-table-column prop="profit_margin" label="利润率" width="80" align="right"><template #default="{ row }">{{ row.profit_margin }}%</template></el-table-column>
<el-table-column prop="deviation_from_base" label="偏差" width="100" align="right">
<template #default="{ row }"><span :style="{ color: row.deviation_from_base > 0 ? '#67c23a' : '#f56c6c' }">{{ row.deviation_from_base > 0 ? '+' : '' }}{{ formatMoney(row.deviation_from_base) }}</span></template>
</el-table-column>
<el-table-column prop="deviation_pct" label="偏差%" width="80" align="right"><template #default="{ row }">{{ row.deviation_pct }}%</template></el-table-column>
</el-table>
</el-card>
<el-empty v-else-if="!scenarioLoading" description="输入三个情景后点击「模拟」" style="padding:40px 0;" />
</el-col>
</el-row>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { predictApi } from '../api/index'
const activeTab = ref('cvp')
function formatMoney(v: any) {
if (v === null || v === undefined) return '--'
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function safetyColor(r: number) { return r > 25 ? 'green' : r > 10 ? 'orange' : 'red' }
// CVP
const cvpLoading = ref(false)
const cvpForm = ref({ unit_price: 100, unit_variable_cost: 60, fixed_cost: 50000, actual_volume: 2000, target_profit: 20000 })
const cvpResult = ref<any>({})
async function doCvp() {
cvpLoading.value = true
try {
cvpResult.value = await predictApi.cvp(cvpForm.value) as any
} catch { ElMessage.error('CVP计算失败') }
cvpLoading.value = false
}
//
const invLoading = ref(false)
const invForm = ref({ initial_investment: 100000, discount_rate: 10, cash_flows: [30000, 40000, 50000, 40000] })
const invResult = ref<any>({})
function fillInvExample() {
invForm.value = { initial_investment: 100000, discount_rate: 10, cash_flows: [30000, 40000, 50000, 40000] }
}
async function doInvestment() {
invLoading.value = true
try {
invResult.value = await predictApi.investment(invForm.value) as any
} catch { ElMessage.error('投资决策计算失败') }
invLoading.value = false
}
//
const sensLoading = ref(false)
const sensForm = ref({ base_revenue: 1000000, base_cost: 700000, step: 5 })
const sensResult = ref<any>({})
async function doSensitivity() {
sensLoading.value = true
try {
sensResult.value = await predictApi.sensitivity(sensForm.value) as any
} catch { ElMessage.error('敏感性分析失败') }
sensLoading.value = false
}
//
const scenarioLoading = ref(false)
const scenarioForm = ref({
optimistic: { revenue: 130, cost: 90 },
base: { revenue: 100, cost: 100 },
pessimistic: { revenue: 80, cost: 110 },
})
const scenarioResult = ref<any>({})
function fillScenarioExample() {
scenarioForm.value = {
optimistic: { revenue: 130, cost: 90 },
base: { revenue: 100, cost: 100 },
pessimistic: { revenue: 80, cost: 110 },
}
}
async function doScenario() {
scenarioLoading.value = true
try {
scenarioResult.value = await predictApi.scenario(scenarioForm.value) as any
} catch { ElMessage.error('情景模拟失败') }
scenarioLoading.value = false
}
</script>
<style scoped>
.result-card { background: #f5f7fa; border-radius: 8px; padding: 16px; text-align: center; }
.r-label { font-size: 12px; color: #909399; margin-bottom: 6px; }
.r-value { font-size: 22px; font-weight: bold; color: #303133; }
.r-value.green { color: #67c23a; }
.r-value.blue { color: #409eff; }
.r-value.orange { color: #e6a23c; }
.r-value.red { color: #f56c6c; }
.r-value.purple { color: #8b5cf6; }
</style>
+328
View File
@@ -0,0 +1,328 @@
<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>
+131
View File
@@ -0,0 +1,131 @@
<template>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:16px;">
<h3>用户管理</h3>
<el-button type="primary" @click="openForm(null)">新建用户</el-button>
</div>
<el-table :data="users" v-loading="loading" style="width:100%" border stripe>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="role" label="角色" width="120">
<template #default="{ row }">
<el-tag :type="roleType(row.role)" size="small">{{ roleLabel(row.role) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="phone" label="手机号" width="140" />
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openForm(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<MyDialog v-model="showForm" :title="isEdit ? '编辑用户' : '新建用户'" :width="480">
<el-form :model="form" label-width="80px">
<el-form-item label="用户名">
<el-input v-model="form.username" :disabled="isEdit" />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item :label="isEdit ? '新密码' : '密码'">
<el-input v-model="form.password" type="password" show-password
:placeholder="isEdit ? '留空则不修改' : '请输入密码'" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="form.role" style="width:100%">
<el-option label="CEO" value="ceo" />
<el-option label="财务" value="finance" />
<el-option label="业务" value="business" />
<el-option label="IT" value="it" />
</el-select>
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="form.phone" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showForm=false">取消</el-button>
<el-button type="primary" @click="saveUser">保存</el-button>
</template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { userApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
const users = ref<any[]>([])
const loading = ref(false)
const showForm = ref(false)
const isEdit = ref(false)
const form = ref<any>({})
function roleLabel(role: string) {
const map: Record<string, string> = { ceo: 'CEO', finance: '财务', business: '业务', it: 'IT管理' }
return map[role] || role
}
function roleType(role: string) {
const map: Record<string, string> = { ceo: 'danger', finance: 'success', business: 'warning', it: 'info' }
return map[role] || ''
}
async function load() {
loading.value = true
try { const r: any = await userApi.list(); users.value = r.data || [] } catch (e) {}
loading.value = false
}
function openForm(row: any) {
if (row) {
isEdit.value = true
form.value = { ...row, password: '' }
} else {
isEdit.value = false
form.value = { username: '', name: '', password: '', role: 'business', phone: '' }
}
showForm.value = true
}
async function saveUser() {
try {
if (isEdit.value) {
const payload: any = {}
if (form.value.name) payload.name = form.value.name
if (form.value.password) payload.password = form.value.password
if (form.value.role) payload.role = form.value.role
if (form.value.phone !== undefined) payload.phone = form.value.phone
await userApi.update(form.value.id, payload)
ElMessage.success('已更新')
} else {
if (!form.value.username || !form.value.password) {
ElMessage.warning('用户名和密码必填')
return
}
await userApi.create(form.value)
ElMessage.success('创建成功')
}
showForm.value = false
load()
} catch (e) {
ElMessage.error('操作失败')
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(`确定删除用户"${row.name}"`, '确认')
await userApi.delete(row.id)
ElMessage.success('已删除')
load()
} catch (e) {}
}
onMounted(load)
</script>