init: 管理会计OS初始代码
包含前后端完整代码: - 前端:Vue3+Vite+ElementPlus - 后端:FastAPI+SQLAlchemy - 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动 - 当前版本:v1.0.0
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user