diff --git a/static/super_admin.html b/static/super_admin.html
index cf864d5..e89da87 100644
--- a/static/super_admin.html
+++ b/static/super_admin.html
@@ -89,6 +89,46 @@
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
}
+
+ .tabs-container {
+ margin-top: 16px;
+ }
+
+ .tab-nav {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-bottom: 20px;
+ }
+
+ .tab-button {
+ border: none;
+ background: #e0e7ff;
+ color: #4338ca;
+ padding: 10px 18px;
+ border-radius: 999px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ }
+
+ .tab-button:hover {
+ background: #c7d2fe;
+ }
+
+ .tab-button.active {
+ background: linear-gradient(135deg, #4f46e5, #6366f1);
+ color: #fff;
+ box-shadow: 0 8px 20px rgba(79, 70, 229, 0.25);
+ }
+
+ .tab-content {
+ display: none;
+ }
+
+ .tab-content.active {
+ display: block;
+ }
.card {
background: #fff;
border-radius: 16px;
@@ -158,6 +198,281 @@
color: #4f46e5;
font-size: 12px;
}
+
+ .org-chart-card h2 {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .org-chart-controls {
+ background: #f8f9ff;
+ border-radius: 12px;
+ padding: 20px;
+ margin: 16px 0 24px;
+ }
+
+ .control-group {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-bottom: 12px;
+ }
+
+ .control-group .search-input {
+ flex: 1;
+ min-width: 240px;
+ border: 1px solid #d1d5db;
+ border-radius: 10px;
+ padding: 10px 14px;
+ font-size: 14px;
+ outline: none;
+ transition: box-shadow 0.2s ease, border-color 0.2s ease;
+ }
+
+ .control-group .search-input:focus {
+ border-color: #4f46e5;
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15);
+ }
+
+ .control-group .search-btn,
+ .control-group .reset-btn {
+ padding: 10px 18px;
+ border: none;
+ border-radius: 10px;
+ font-weight: 600;
+ cursor: pointer;
+ }
+
+ .control-group .search-btn {
+ background: linear-gradient(135deg, #4f46e5, #6366f1);
+ color: #fff;
+ box-shadow: 0 6px 16px rgba(79, 70, 229, 0.35);
+ }
+
+ .control-group .reset-btn {
+ background: #e5e7eb;
+ color: #374151;
+ }
+
+ .stats-bar {
+ display: flex;
+ gap: 20px;
+ flex-wrap: wrap;
+ padding-top: 12px;
+ border-top: 1px solid #e5e7eb;
+ color: #6b7280;
+ font-size: 14px;
+ }
+
+ .stats-bar strong {
+ color: #4f46e5;
+ font-size: 16px;
+ }
+
+ .org-chart-container {
+ min-height: 260px;
+ position: relative;
+ padding: 10px 0 30px;
+ }
+
+ .org-tree {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ position: relative;
+ min-height: 200px;
+ }
+
+ .tree-node {
+ display: inline-block;
+ text-align: center;
+ position: relative;
+ margin: 10px 0;
+ padding: 0 20px;
+ }
+
+ .tree-node-box {
+ display: inline-block;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 16px 20px;
+ border-radius: 12px;
+ box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
+ min-width: 200px;
+ max-width: 280px;
+ position: relative;
+ transition: all 0.3s ease;
+ cursor: pointer;
+ }
+
+ .tree-node-box:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 12px 28px rgba(102, 126, 234, 0.4);
+ }
+
+ .tree-node.root-node > .tree-node-box {
+ background: linear-gradient(135deg, #4338ca 0%, #6d28d9 100%);
+ border: 2px solid rgba(255,255,255,0.2);
+ }
+
+ .tree-node-box .node-name {
+ font-weight: 600;
+ font-size: 16px;
+ margin-bottom: 6px;
+ display: block;
+ }
+
+ .tree-node-box .node-code {
+ font-size: 13px;
+ opacity: 0.9;
+ display: block;
+ }
+
+ .tree-node-box .node-region {
+ font-size: 12px;
+ opacity: 0.85;
+ margin-top: 4px;
+ display: block;
+ }
+
+ .tree-level {
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ gap: 28px;
+ position: relative;
+ flex-wrap: wrap;
+ }
+
+ .tree-level.root-level {
+ margin-top: 0;
+ padding-top: 0;
+ }
+
+ .tree-level:not(.root-level) {
+ margin-top: 40px;
+ padding-top: 26px;
+ }
+
+ .tree-level:not(.root-level)::before {
+ content: '';
+ position: absolute;
+ top: 12px;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: #94a3b8;
+ z-index: 0;
+ }
+
+ .tree-level.single-child::before {
+ display: none;
+ }
+
+ .tree-children {
+ display: flex;
+ justify-content: center;
+ gap: 18px;
+ margin-top: 20px;
+ position: relative;
+ z-index: 1;
+ width: 100%;
+ }
+
+ .node-children {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ position: relative;
+ padding: 20px 14px 0;
+ z-index: 1;
+ }
+
+ .node-children::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 50%;
+ width: 2px;
+ height: 20px;
+ background: #94a3b8;
+ z-index: 0;
+ }
+
+ .tree-node.has-children > .tree-node-box::after {
+ content: '';
+ position: absolute;
+ bottom: -20px;
+ left: 50%;
+ width: 2px;
+ height: 20px;
+ background: #94a3b8;
+ transform: translateX(-50%);
+ z-index: 0;
+ }
+
+ .tooltip {
+ position: absolute;
+ background: rgba(17, 24, 39, 0.95);
+ color: white;
+ padding: 10px 14px;
+ border-radius: 8px;
+ font-size: 13px;
+ z-index: 1000;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ white-space: nowrap;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+ }
+
+ .tooltip.show {
+ opacity: 1;
+ }
+
+ .tree-node.hidden {
+ display: none;
+ }
+
+ .highlight {
+ background: #fef08a;
+ padding: 2px 4px;
+ border-radius: 4px;
+ font-weight: 600;
+ }
+
+ .loading {
+ text-align: center;
+ padding: 40px;
+ color: #6b7280;
+ font-size: 15px;
+ }
+
+ .no-results-overlay {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ text-align: center;
+ padding: 28px 36px;
+ background: #fff;
+ border-radius: 16px;
+ box-shadow: 0 8px 36px rgba(0, 0, 0, 0.12);
+ z-index: 10;
+ max-width: 320px;
+ }
+
+ .overlay-close-btn {
+ margin-top: 14px;
+ padding: 8px 16px;
+ border-radius: 10px;
+ border: none;
+ cursor: pointer;
+ background: #4f46e5;
+ color: #fff;
+ font-weight: 600;
+ }
.action {
border: none;
background: none;
@@ -220,135 +535,174 @@
-
@@ -363,6 +717,12 @@ const deptParentSelect = document.getElementById('deptParentSelect');
const deptTableBody = document.getElementById('deptTableBody');
const themeTableBody = document.getElementById('themeTableBody');
const templateMetaBox = document.getElementById('templateMeta');
+const tabButtons = document.querySelectorAll('.tab-button');
+const tabContents = document.querySelectorAll('.tab-content');
+const orgChartContainer = document.getElementById('orgChartContainer');
+const totalDeptsEl = document.getElementById('totalDepts');
+const totalLevelsEl = document.getElementById('totalLevels');
+const rootDeptsEl = document.getElementById('rootDepts');
let state = {
users: [],
departments: [],
@@ -370,6 +730,14 @@ let state = {
templateMeta: {}
};
+let orgChartData = {
+ tree: [],
+ allNodes: [],
+ parentMap: {},
+ nodeMap: {}
+};
+let orgChartLoaded = false;
+
function showMessage(text, type = 'info') {
if (!text) {
messageBar.classList.remove('show');
@@ -667,6 +1035,353 @@ themeTableBody.addEventListener('click', async (evt) => {
}
});
+function switchTab(tabId) {
+ tabContents.forEach(content => {
+ content.classList.toggle('active', content.id === tabId);
+ });
+ tabButtons.forEach(button => {
+ button.classList.toggle('active', button.dataset.tab === tabId);
+ });
+ if (tabId === 'org-chart-tab' && !orgChartLoaded) {
+ loadOrgChart();
+ }
+}
+
+function initTabs() {
+ tabButtons.forEach(button => {
+ button.addEventListener('click', () => {
+ const targetTab = button.dataset.tab;
+ if (targetTab) {
+ switchTab(targetTab);
+ }
+ });
+ });
+}
+
+async function loadOrgChart() {
+ if (!orgChartContainer) {
+ return;
+ }
+ orgChartContainer.innerHTML = '正在加载组织架构...
';
+ try {
+ const response = await fetchJSON(`${API_BASE}/admin/service-departments/tree`);
+ const tree = (response.data && response.data.tree) || [];
+ orgChartData.tree = tree;
+ orgChartData.parentMap = {};
+ orgChartData.nodeMap = {};
+ orgChartData.allNodes = flattenTree(tree);
+ orgChartLoaded = true;
+ updateOrgStats(tree);
+ if (!tree.length) {
+ orgChartContainer.innerHTML = '暂无组织架构数据
';
+ return;
+ }
+ renderOrgTree(tree);
+ } catch (err) {
+ orgChartLoaded = false;
+ orgChartContainer.innerHTML = `加载组织架构失败:${err.message}
`;
+ }
+}
+
+function flattenTree(nodes, level = 0, result = [], parentId = null) {
+ nodes.forEach(node => {
+ if (node.id === undefined || node.id === null) {
+ return;
+ }
+ const nodeId = String(node.id);
+ const flatNode = {
+ id: nodeId,
+ name: node.name || '未知部门',
+ code: node.code || '',
+ region_name: node.region_name || '',
+ level,
+ node
+ };
+ orgChartData.parentMap[nodeId] = parentId;
+ orgChartData.nodeMap[nodeId] = flatNode;
+ result.push(flatNode);
+ if (node.children && node.children.length) {
+ flattenTree(node.children, level + 1, result, nodeId);
+ }
+ });
+ return result;
+}
+
+function updateOrgStats(tree) {
+ if (totalDeptsEl) {
+ totalDeptsEl.textContent = orgChartData.allNodes.length;
+ }
+ if (totalLevelsEl) {
+ totalLevelsEl.textContent = tree.length ? getMaxLevel(tree) + 1 : 0;
+ }
+ if (rootDeptsEl) {
+ rootDeptsEl.textContent = tree.length;
+ }
+}
+
+function getMaxLevel(nodes, level = 0) {
+ let maxLevel = level;
+ nodes.forEach(node => {
+ if (node.children && node.children.length) {
+ const childMax = getMaxLevel(node.children, level + 1);
+ maxLevel = Math.max(maxLevel, childMax);
+ }
+ });
+ return maxLevel;
+}
+
+function renderOrgTree(tree) {
+ if (!orgChartContainer) return;
+ orgChartContainer.innerHTML = '';
+ const wrapper = document.createElement('div');
+ wrapper.className = 'org-tree';
+ orgChartContainer.appendChild(wrapper);
+
+ const rootLevel = document.createElement('div');
+ rootLevel.className = 'tree-level root-level';
+ wrapper.appendChild(rootLevel);
+
+ tree.forEach(node => {
+ rootLevel.appendChild(renderTreeNode(node, true));
+ });
+}
+
+function renderTreeNode(node, isRoot = false) {
+ const nodeDiv = document.createElement('div');
+ nodeDiv.className = isRoot ? 'tree-node root-node' : 'tree-node';
+ nodeDiv.setAttribute('data-node-id', node.id != null ? String(node.id) : '');
+
+ const nodeBox = document.createElement('div');
+ nodeBox.className = 'tree-node-box';
+
+ const nameSpan = document.createElement('span');
+ nameSpan.className = 'node-name';
+ nameSpan.textContent = node.name || '未知部门';
+ nodeBox.appendChild(nameSpan);
+
+ if (node.code) {
+ const codeSpan = document.createElement('span');
+ codeSpan.className = 'node-code';
+ codeSpan.textContent = node.code;
+ nodeBox.appendChild(codeSpan);
+ }
+
+ if (node.region_name) {
+ const regionSpan = document.createElement('span');
+ regionSpan.className = 'node-region';
+ regionSpan.textContent = node.region_name;
+ nodeBox.appendChild(regionSpan);
+ }
+
+ addTooltip(nodeBox, node);
+ nodeDiv.appendChild(nodeBox);
+
+ if (node.children && node.children.length) {
+ nodeDiv.classList.add('has-children');
+ const childrenWrapper = document.createElement('div');
+ childrenWrapper.className = 'tree-children';
+ const nextLevel = document.createElement('div');
+ nextLevel.className = 'tree-level';
+ if (node.children.length === 1) {
+ nextLevel.classList.add('single-child');
+ }
+
+ node.children.forEach(child => {
+ const childHolder = document.createElement('div');
+ childHolder.className = 'node-children';
+ childHolder.appendChild(renderTreeNode(child, false));
+ nextLevel.appendChild(childHolder);
+ });
+
+ childrenWrapper.appendChild(nextLevel);
+ nodeDiv.appendChild(childrenWrapper);
+ }
+
+ return nodeDiv;
+}
+
+function addTooltip(element, node) {
+ const tooltip = document.createElement('div');
+ tooltip.className = 'tooltip';
+ tooltip.innerHTML = `
+ ${node.name || '未知部门'}
+ ${node.code ? `编码: ${node.code}
` : ''}
+ ${node.region_name ? `区域: ${node.region_name}
` : ''}
+ ${node.description ? `${node.description}
` : ''}
+ `;
+ document.body.appendChild(tooltip);
+
+ element.addEventListener('mouseenter', () => {
+ const rect = element.getBoundingClientRect();
+ tooltip.style.left = `${rect.left + rect.width / 2}px`;
+ tooltip.style.top = `${rect.top - 8}px`;
+ tooltip.style.transform = 'translate(-50%, -100%)';
+ tooltip.classList.add('show');
+ });
+
+ element.addEventListener('mouseleave', () => {
+ tooltip.classList.remove('show');
+ });
+}
+
+function searchOrgChart(query) {
+ if (!orgChartData.allNodes.length) {
+ return;
+ }
+ const trimmed = query.trim();
+ if (!trimmed) {
+ resetOrgSearchView(false);
+ return;
+ }
+ resetOrgSearchView(false);
+ const searchTerm = trimmed.toLowerCase();
+ const matchedNodes = new Set();
+
+ orgChartData.allNodes.forEach(node => {
+ const nameMatch = node.name.toLowerCase().includes(searchTerm);
+ const codeMatch = node.code.toLowerCase().includes(searchTerm);
+ const regionMatch = node.region_name.toLowerCase().includes(searchTerm);
+ if (nameMatch || codeMatch || regionMatch) {
+ matchedNodes.add(node.id);
+ includeAncestorNodes(node.id, matchedNodes);
+ includeDescendantNodes(node.node, matchedNodes);
+ if (nameMatch) {
+ highlightText(trimmed, node.id, 'name');
+ }
+ if (codeMatch) {
+ highlightText(trimmed, node.id, 'code');
+ }
+ }
+ });
+
+ document.querySelectorAll('.org-tree .tree-node').forEach(nodeDiv => {
+ const nodeId = nodeDiv.getAttribute('data-node-id');
+ if (nodeId && matchedNodes.has(nodeId)) {
+ nodeDiv.classList.remove('hidden');
+ } else {
+ nodeDiv.classList.add('hidden');
+ }
+ });
+
+ toggleNoResultsOverlay(matchedNodes.size === 0);
+}
+
+function highlightText(searchTerm, nodeId, field) {
+ const nodeDiv = document.querySelector(`.tree-node[data-node-id="${nodeId}"]`);
+ if (!nodeDiv) return;
+ const element = nodeDiv.querySelector(field === 'name' ? '.node-name' : '.node-code');
+ if (!element) return;
+ const nodeData = orgChartData.nodeMap[nodeId];
+ if (!nodeData) return;
+ const originalText = field === 'name' ? nodeData.name : nodeData.code;
+ if (!originalText) return;
+ const regex = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi');
+ element.innerHTML = originalText.replace(regex, '$1');
+}
+
+function includeAncestorNodes(nodeId, matchedNodes) {
+ let parentId = orgChartData.parentMap[nodeId];
+ while (parentId) {
+ matchedNodes.add(parentId);
+ parentId = orgChartData.parentMap[parentId];
+ }
+}
+
+function includeDescendantNodes(nodeData, matchedNodes) {
+ if (!nodeData || !nodeData.children) return;
+ nodeData.children.forEach(child => {
+ if (child.id === undefined || child.id === null) return;
+ const childId = String(child.id);
+ matchedNodes.add(childId);
+ includeDescendantNodes(child, matchedNodes);
+ });
+}
+
+function resetOrgSearchView(clearInput = false) {
+ document.querySelectorAll('.org-tree .tree-node').forEach(nodeDiv => {
+ nodeDiv.classList.remove('hidden');
+ const nodeId = nodeDiv.getAttribute('data-node-id');
+ const nodeData = orgChartData.nodeMap[nodeId];
+ if (!nodeData) return;
+ const nameEl = nodeDiv.querySelector('.node-name');
+ const codeEl = nodeDiv.querySelector('.node-code');
+ if (nameEl) nameEl.textContent = nodeData.name;
+ if (codeEl) codeEl.textContent = nodeData.code;
+ });
+ if (clearInput) {
+ const input = document.getElementById('orgSearchInput');
+ if (input) input.value = '';
+ }
+ toggleNoResultsOverlay(false);
+}
+
+function toggleNoResultsOverlay(show) {
+ if (!orgChartContainer) return;
+ const treeRoot = orgChartContainer.querySelector('.org-tree');
+ if (!treeRoot) return;
+ let overlay = treeRoot.querySelector('.no-results-overlay');
+ if (show) {
+ if (!overlay) {
+ overlay = document.createElement('div');
+ overlay.className = 'no-results-overlay';
+ overlay.innerHTML = `
+ 🔍
+ 未找到匹配的组织部门
+ 尝试其他关键字或点击下方按钮
+ `;
+ const closeBtn = document.createElement('button');
+ closeBtn.className = 'overlay-close-btn';
+ closeBtn.textContent = '返回完整组织架构';
+ closeBtn.addEventListener('click', () => {
+ resetOrgSearchView(true);
+ });
+ overlay.appendChild(closeBtn);
+ treeRoot.appendChild(overlay);
+ }
+ } else if (overlay) {
+ overlay.remove();
+ }
+}
+
+function escapeRegExp(value) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function initOrgSearch() {
+ const searchInput = document.getElementById('orgSearchInput');
+ const searchBtn = document.getElementById('orgSearchBtn');
+ const resetBtn = document.getElementById('orgResetBtn');
+
+ if (searchBtn) {
+ searchBtn.addEventListener('click', () => {
+ searchOrgChart(searchInput ? searchInput.value : '');
+ });
+ }
+ if (resetBtn) {
+ resetBtn.addEventListener('click', () => {
+ resetOrgSearchView(true);
+ });
+ }
+ if (searchInput) {
+ searchInput.addEventListener('keypress', (evt) => {
+ if (evt.key === 'Enter') {
+ evt.preventDefault();
+ searchOrgChart(searchInput.value);
+ }
+ });
+ let timer;
+ searchInput.addEventListener('input', (evt) => {
+ clearTimeout(timer);
+ timer = setTimeout(() => {
+ searchOrgChart(evt.target.value);
+ }, 300);
+ });
+ }
+}
+
+initTabs();
+initOrgSearch();
+
document.getElementById('templateForm').addEventListener('submit', async (evt) => {
evt.preventDefault();
const form = evt.target;