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