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

用户管理

-
-

用户添加

-
-
+ +
-
-

服务部门管理

-
- - - - - -
-
- - - - - - - - - - -
名称上级电话操作
-
-
+
+
+

主题列表管理

+
+ + +
+
+ + + + + + + + + + +
主题关联许可覆盖区划操作
+
+
+
-
-

主题列表管理

-
- - -
-
- - - - - - - - - - -
主题关联许可覆盖区划操作
-
-
+
+
+

模板管理

+
+ 模板未加载 +
+
+ + +
+ +
+
-
-

模板管理

-
- 模板未加载 -
-
- - -
- -
+
+
+

🌳 组织架构

+
+
+ + + +
+
+ 📊 总部门: 0 + 🌿 层级: 0 + 🪴 根部门: 0 +
+
+
+
+
正在加载组织架构...
+
+
+
+
@@ -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;