Files
cma-management/frontend/src/views/OrgManage.vue
T
Hermes CI Fix 3dddd36866 init: 管理会计OS初始代码
包含前后端完整代码:
- 前端:Vue3+Vite+ElementPlus
- 后端:FastAPI+SQLAlchemy
- 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动
- 当前版本:v1.0.0
2026-05-28 17:32:22 +08:00

325 lines
12 KiB
Vue

<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>