2379 lines
73 KiB
HTML
2379 lines
73 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>LawRisk 超级管理员控制台</title>
|
||
<style>
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
body {
|
||
margin: 0;
|
||
padding: 0;
|
||
font-family: "Microsoft YaHei", "PingFang SC", -apple-system, BlinkMacSystemFont, sans-serif;
|
||
background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
|
||
min-height: 100vh;
|
||
color: #1f2937;
|
||
}
|
||
.page {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
padding: 32px 20px 60px;
|
||
}
|
||
.header {
|
||
background: #fff;
|
||
border-radius: 18px;
|
||
padding: 28px;
|
||
box-shadow: 0 20px 60px rgba(79, 70, 229, 0.12);
|
||
margin-bottom: 24px;
|
||
position: relative;
|
||
}
|
||
.title {
|
||
text-align: center;
|
||
}
|
||
.title h1 {
|
||
font-size: 30px;
|
||
margin: 0;
|
||
color: #111827;
|
||
}
|
||
.title p {
|
||
margin: 6px 0 0;
|
||
color: #4b5563;
|
||
font-size: 15px;
|
||
}
|
||
.user-chip {
|
||
position: absolute;
|
||
top: 28px;
|
||
right: 28px;
|
||
padding: 16px 20px;
|
||
border-radius: 16px;
|
||
border: 1px solid #e5e7eb;
|
||
background: rgba(255,255,255,0.9);
|
||
box-shadow: 0 10px 30px rgba(17, 24, 39, 0.08);
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 220px;
|
||
gap: 6px;
|
||
}
|
||
.user-name {
|
||
font-weight: 600;
|
||
font-size: 16px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.user-tag {
|
||
font-size: 12px;
|
||
padding: 2px 10px;
|
||
border-radius: 999px;
|
||
background: #eef2ff;
|
||
color: #4f46e5;
|
||
}
|
||
.user-meta {
|
||
font-size: 12px;
|
||
color: #6b7280;
|
||
}
|
||
.logout-btn {
|
||
align-self: flex-start;
|
||
background: #ef4444;
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 999px;
|
||
padding: 6px 14px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
}
|
||
.grid {
|
||
display: grid;
|
||
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;
|
||
padding: 20px;
|
||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
h2 {
|
||
margin: 0;
|
||
font-size: 20px;
|
||
color: #111827;
|
||
}
|
||
h3 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
color: #374151;
|
||
}
|
||
label {
|
||
font-size: 13px;
|
||
color: #6b7280;
|
||
}
|
||
input, select, textarea {
|
||
width: 100%;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 10px;
|
||
padding: 10px 12px;
|
||
font-size: 14px;
|
||
margin-top: 4px;
|
||
}
|
||
textarea {
|
||
resize: vertical;
|
||
}
|
||
button.primary {
|
||
background: linear-gradient(135deg, #4f46e5, #6366f1);
|
||
border: none;
|
||
color: #fff;
|
||
padding: 10px 18px;
|
||
border-radius: 999px;
|
||
cursor: pointer;
|
||
font-weight: 600;
|
||
margin-top: 8px;
|
||
}
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
th, td {
|
||
padding: 8px 6px;
|
||
border-bottom: 1px solid #f3f4f6;
|
||
text-align: left;
|
||
}
|
||
th {
|
||
font-weight: 600;
|
||
color: #4b5563;
|
||
}
|
||
.table-wrap {
|
||
max-height: 260px;
|
||
overflow: auto;
|
||
}
|
||
.pill {
|
||
padding: 2px 8px;
|
||
border-radius: 999px;
|
||
background: #eef2ff;
|
||
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;
|
||
}
|
||
|
||
#orgChartContainer {
|
||
position: relative;
|
||
min-height: 200px;
|
||
}
|
||
|
||
.org-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.org-node {
|
||
--indent: 0px;
|
||
border-radius: 12px;
|
||
background: #f9fafb;
|
||
border-left: 4px solid #cbd5e1;
|
||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.org-node:hover {
|
||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.org-node.hidden {
|
||
display: none;
|
||
}
|
||
|
||
/* 层级 0 - 根节点 */
|
||
.org-node[data-level="0"] {
|
||
background: linear-gradient(135deg, #e0e7ff 0%, #f0f4ff 100%);
|
||
border-left-color: #818cf8;
|
||
border: 2px solid #818cf8;
|
||
}
|
||
|
||
.org-node[data-level="0"] .node-name {
|
||
color: #312e81;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.org-node[data-level="0"] .org-node-meta .node-code {
|
||
color: #4338ca;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.org-node[data-level="0"] .org-node-meta .node-region {
|
||
background: #e0e7ff;
|
||
color: #4338ca;
|
||
border: 1px solid #c7d2fe;
|
||
}
|
||
|
||
.org-node[data-level="0"] .toggle-btn {
|
||
background: #e0e7ff;
|
||
border-color: #818cf8;
|
||
color: #4338ca;
|
||
}
|
||
|
||
.org-node[data-level="0"] .toggle-btn:hover {
|
||
background: #c7d2fe;
|
||
}
|
||
|
||
/* 层级 1 - 二级节点 */
|
||
.org-node[data-level="1"] {
|
||
background: linear-gradient(135deg, #ede9fe 0%, #f3e8ff 100%);
|
||
border-left-color: #a78bfa;
|
||
border: 2px solid #a78bfa;
|
||
margin-left: 20px;
|
||
}
|
||
|
||
.org-node[data-level="1"] .node-name {
|
||
color: #5b21b6;
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.org-node[data-level="1"] .org-node-meta .node-code {
|
||
color: #6d28d9;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.org-node[data-level="1"] .org-node-meta .node-region {
|
||
background: #ede9fe;
|
||
color: #6d28d9;
|
||
border: 1px solid #ddd6fe;
|
||
}
|
||
|
||
.org-node[data-level="1"] .toggle-btn {
|
||
background: #ede9fe;
|
||
border-color: #a78bfa;
|
||
color: #6d28d9;
|
||
}
|
||
|
||
.org-node[data-level="1"] .toggle-btn:hover {
|
||
background: #ddd6fe;
|
||
}
|
||
|
||
/* 层级 2+ - 三级及以下节点 */
|
||
.org-node[data-level="2"],
|
||
.org-node[data-level="3"],
|
||
.org-node[data-level="4"],
|
||
.org-node[data-level="5"] {
|
||
background: linear-gradient(135deg, #f5f3ff 0%, #faf5ff 100%);
|
||
border-left-color: #c4b5fd;
|
||
border: 2px solid #c4b5fd;
|
||
margin-left: 40px;
|
||
}
|
||
|
||
.org-node[data-level="2"] .node-name,
|
||
.org-node[data-level="3"] .node-name,
|
||
.org-node[data-level="4"] .node-name,
|
||
.org-node[data-level="5"] .node-name {
|
||
color: #4c1d95;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.org-node[data-level="2"] .org-node-meta .node-code,
|
||
.org-node[data-level="3"] .org-node-meta .node-code,
|
||
.org-node[data-level="4"] .org-node-meta .node-code,
|
||
.org-node[data-level="5"] .org-node-meta .node-code {
|
||
color: #6d28d9;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.org-node[data-level="2"] .org-node-meta .node-region,
|
||
.org-node[data-level="3"] .org-node-meta .node-region,
|
||
.org-node[data-level="4"] .org-node-meta .node-region,
|
||
.org-node[data-level="5"] .org-node-meta .node-region {
|
||
background: #f5f3ff;
|
||
color: #6d28d9;
|
||
border: 1px solid #e9d5ff;
|
||
}
|
||
|
||
.org-node[data-level="2"] .toggle-btn,
|
||
.org-node[data-level="3"] .toggle-btn,
|
||
.org-node[data-level="4"] .toggle-btn,
|
||
.org-node[data-level="5"] .toggle-btn {
|
||
background: #f5f3ff;
|
||
border-color: #c4b5fd;
|
||
color: #6d28d9;
|
||
}
|
||
|
||
.org-node[data-level="2"] .toggle-btn:hover,
|
||
.org-node[data-level="3"] .toggle-btn:hover,
|
||
.org-node[data-level="4"] .toggle-btn:hover,
|
||
.org-node[data-level="5"] .toggle-btn:hover {
|
||
background: #e9d5ff;
|
||
}
|
||
|
||
.org-node-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 16px 20px;
|
||
padding-left: calc(var(--indent) + 20px);
|
||
}
|
||
|
||
.org-node[data-level="0"] .org-node-header {
|
||
padding: 20px 24px;
|
||
}
|
||
|
||
.org-node[data-level="1"] .org-node-header {
|
||
padding: 18px 22px;
|
||
}
|
||
|
||
.org-node-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.org-node .node-name {
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.org-node-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.org-node-meta .node-code {
|
||
font-weight: 500;
|
||
}
|
||
|
||
.org-node-meta .node-region {
|
||
padding: 2px 10px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.org-node-actions {
|
||
color: #6b7280;
|
||
font-size: 13px;
|
||
min-width: 160px;
|
||
text-align: right;
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.action-btn {
|
||
border: none;
|
||
background: #f3f4f6;
|
||
color: #4b5563;
|
||
padding: 4px 10px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
transition: all 0.15s ease;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.action-btn:hover {
|
||
background: #e5e7eb;
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.action-btn.add-child-btn {
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
}
|
||
|
||
.action-btn.add-child-btn:hover {
|
||
background: #bbf7d0;
|
||
}
|
||
|
||
.action-btn.edit-btn {
|
||
background: #dbeafe;
|
||
color: #1e40af;
|
||
}
|
||
|
||
.action-btn.edit-btn:hover {
|
||
background: #bfdbfe;
|
||
}
|
||
|
||
.action-btn.delete-btn {
|
||
background: #fee2e2;
|
||
color: #991b1b;
|
||
}
|
||
|
||
.action-btn.delete-btn:hover {
|
||
background: #fecaca;
|
||
}
|
||
|
||
.toggle-btn {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 8px;
|
||
border: 1px solid #c7d2fe;
|
||
background: #eef2ff;
|
||
color: #4338ca;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
font-weight: 700;
|
||
transition: all 0.2s ease;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.toggle-btn:hover {
|
||
transform: scale(1.05);
|
||
box-shadow: 0 4px 12px rgba(67, 56, 202, 0.2);
|
||
}
|
||
|
||
.toggle-btn::before {
|
||
content: '▼';
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.toggle-btn.collapsed::before {
|
||
content: '▶';
|
||
}
|
||
|
||
.toggle-placeholder {
|
||
width: 32px;
|
||
height: 32px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.org-children {
|
||
padding-bottom: 8px;
|
||
position: relative;
|
||
}
|
||
|
||
.org-children::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 15px;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 2px;
|
||
background: linear-gradient(to bottom, rgba(99, 102, 241, 0.3), rgba(99, 102, 241, 0.1));
|
||
}
|
||
|
||
.org-node.has-children.collapsed > .org-children {
|
||
display: none;
|
||
}
|
||
|
||
/* 叶子节点样式(无子节点) */
|
||
.org-node:not(.has-children) {
|
||
margin-left: calc(var(--indent) + 20px);
|
||
}
|
||
|
||
.org-node:not(.has-children) .org-node-header {
|
||
padding-left: 20px;
|
||
}
|
||
|
||
/* 增强的hover效果 */
|
||
.org-node[data-level="0"]:hover {
|
||
box-shadow: 0 12px 28px rgba(129, 140, 248, 0.25);
|
||
}
|
||
|
||
.org-node[data-level="1"]:hover {
|
||
box-shadow: 0 12px 28px rgba(167, 139, 250, 0.25);
|
||
}
|
||
|
||
.org-node[data-level="2"]:hover,
|
||
.org-node[data-level="3"]:hover,
|
||
.org-node[data-level="4"]:hover,
|
||
.org-node[data-level="5"]:hover {
|
||
box-shadow: 0 12px 28px rgba(196, 181, 253, 0.25);
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* Drag and Drop Styles - 修改从属关系 */
|
||
.org-node.dragging {
|
||
opacity: 0.6;
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||
transform: rotate(2deg);
|
||
z-index: 1000;
|
||
}
|
||
|
||
.org-node.drop-target {
|
||
border: 2px dashed #4f46e5;
|
||
background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
|
||
}
|
||
|
||
.org-node.drop-target::after {
|
||
content: '↳ 拖放到此处作为下级';
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background: rgba(79, 70, 229, 0.95);
|
||
color: white;
|
||
padding: 8px 16px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
pointer-events: none;
|
||
z-index: 1001;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.drag-handle {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 10px;
|
||
border: 1px dashed #d1d5db;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 14px;
|
||
color: #6b7280;
|
||
cursor: grab;
|
||
background: #fff;
|
||
margin-right: 8px;
|
||
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||
}
|
||
|
||
.drag-handle:hover {
|
||
background: #eef2ff;
|
||
color: #4338ca;
|
||
border-color: #c7d2fe;
|
||
}
|
||
|
||
.drag-handle:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
/* 权限等级显示标签(保留) */
|
||
.node-grade {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 3px 8px;
|
||
background: #fef3c7;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
color: #92400e;
|
||
border: 1px solid #fcd34d;
|
||
}
|
||
|
||
.node-grade strong {
|
||
font-weight: 700;
|
||
}
|
||
|
||
/* Modal Styles */
|
||
.modal-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 2000;
|
||
}
|
||
|
||
.modal-overlay.show {
|
||
display: flex;
|
||
}
|
||
|
||
.modal {
|
||
background: white;
|
||
border-radius: 16px;
|
||
padding: 24px;
|
||
max-width: 480px;
|
||
width: 90%;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.modal h3 {
|
||
margin: 0 0 16px;
|
||
font-size: 20px;
|
||
color: #111827;
|
||
}
|
||
|
||
.modal-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.modal-form label {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
color: #4b5563;
|
||
}
|
||
|
||
.modal-form input,
|
||
.modal-form select,
|
||
.modal-form textarea {
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
transition: border-color 0.2s ease;
|
||
}
|
||
|
||
.modal-form input:focus,
|
||
.modal-form select:focus,
|
||
.modal-form textarea:focus {
|
||
outline: none;
|
||
border-color: #4f46e5;
|
||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||
}
|
||
|
||
.modal-actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
justify-content: flex-end;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.modal-actions button {
|
||
padding: 10px 20px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.modal-actions .cancel-btn {
|
||
background: #f3f4f6;
|
||
color: #4b5563;
|
||
}
|
||
|
||
.modal-actions .cancel-btn:hover {
|
||
background: #e5e7eb;
|
||
}
|
||
|
||
.modal-actions .submit-btn {
|
||
background: linear-gradient(135deg, #4f46e5, #6366f1);
|
||
color: white;
|
||
}
|
||
|
||
.modal-actions .submit-btn:hover {
|
||
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.35);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.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;
|
||
color: #2563eb;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
font-size: 13px;
|
||
}
|
||
.danger {
|
||
color: #dc2626;
|
||
}
|
||
.message {
|
||
margin-bottom: 16px;
|
||
padding: 12px 16px;
|
||
border-radius: 12px;
|
||
background: #eef2ff;
|
||
color: #312e81;
|
||
display: none;
|
||
}
|
||
.message.show {
|
||
display: block;
|
||
}
|
||
.template-meta {
|
||
font-size: 13px;
|
||
color: #4b5563;
|
||
line-height: 1.6;
|
||
}
|
||
.section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
@media (max-width: 768px) {
|
||
.user-chip {
|
||
position: static;
|
||
margin-top: 16px;
|
||
}
|
||
.header {
|
||
padding-top: 48px;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
<header class="header">
|
||
<div class="title">
|
||
<h1>LawRisk 法律风险提示系统</h1>
|
||
<p>超级管理员 · 用户、部门、主题与模板一站式管控</p>
|
||
</div>
|
||
<div class="user-chip" id="userChip">
|
||
<div class="user-name">
|
||
<span id="currentUserName">未登录</span>
|
||
<span class="user-tag" id="currentUserRole">role</span>
|
||
</div>
|
||
<div class="user-meta" id="currentUserDept">—</div>
|
||
<button class="logout-btn" id="logoutBtn">退出登录</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="message" id="messageBar"></div>
|
||
|
||
<div class="tabs-container">
|
||
<div class="tab-nav">
|
||
<button class="tab-button active" data-tab="users-tab">👤 用户管理</button>
|
||
<button class="tab-button" data-tab="departments-tab">🏢 服务部门</button>
|
||
<button class="tab-button" data-tab="themes-tab">📋 主题管理</button>
|
||
<button class="tab-button" data-tab="templates-tab">📄 模板管理</button>
|
||
<button class="tab-button" data-tab="org-chart-tab">🌳 组织架构</button>
|
||
</div>
|
||
|
||
<div class="tab-content active" id="users-tab">
|
||
<section class="card">
|
||
<h2>用户管理</h2>
|
||
<div class="section">
|
||
<h3>用户添加</h3>
|
||
<form id="userCreateForm">
|
||
<label>用户名
|
||
<input type="text" name="username" required placeholder="请输入登录账号">
|
||
</label>
|
||
<label>初始密码
|
||
<input type="password" name="password" required placeholder="设置登录密码">
|
||
</label>
|
||
<label>显示名称
|
||
<input type="text" name="display_name" placeholder="可选">
|
||
</label>
|
||
<label>绑定服务部门
|
||
<select name="service_department_id" id="userCreateDept">
|
||
<option value="">暂不绑定</option>
|
||
</select>
|
||
</label>
|
||
<button class="primary" type="submit">创建用户</button>
|
||
</form>
|
||
</div>
|
||
<div class="section">
|
||
<h3>密码 / 部门调整</h3>
|
||
<form id="userUpdateForm">
|
||
<label>选择用户
|
||
<select name="user_id" id="userUpdateSelect" required></select>
|
||
</label>
|
||
<label>新密码(留空表示不修改)
|
||
<input type="password" name="password" placeholder="可选">
|
||
</label>
|
||
<label>新的服务部门
|
||
<select name="service_department_id" id="userUpdateDept">
|
||
<option value="">保持不变 / 清除绑定</option>
|
||
</select>
|
||
</label>
|
||
<button class="primary" type="submit">保存调整</button>
|
||
</form>
|
||
</div>
|
||
<div class="section">
|
||
<h3>用户列表</h3>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>用户名</th>
|
||
<th>显示名</th>
|
||
<th>部门</th>
|
||
<th>角色</th>
|
||
<th>创建时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="userTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="tab-content" id="departments-tab">
|
||
<section class="card">
|
||
<h2>服务部门管理</h2>
|
||
<form id="deptCreateForm">
|
||
<label>部门名称
|
||
<input type="text" name="name" required>
|
||
</label>
|
||
<label>联系电话
|
||
<input type="text" name="phone" placeholder="座机或手机号">
|
||
</label>
|
||
<label>上级部门
|
||
<select name="parent_id" id="deptParentSelect">
|
||
<option value="">设为顶级</option>
|
||
</select>
|
||
</label>
|
||
<label>备注
|
||
<textarea name="description" rows="2" placeholder="可填写简要职责"></textarea>
|
||
</label>
|
||
<button class="primary" type="submit">新增服务部门</button>
|
||
</form>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>名称</th>
|
||
<th>上级</th>
|
||
<th>电话</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="deptTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="tab-content" id="themes-tab">
|
||
<section class="card">
|
||
<h2>主题列表管理</h2>
|
||
<form id="themeCreateForm">
|
||
<label>主题名称
|
||
<input type="text" name="name" required>
|
||
</label>
|
||
<button class="primary" type="submit">添加主题</button>
|
||
</form>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>主题</th>
|
||
<th>关联许可</th>
|
||
<th>覆盖区划</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="themeTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="tab-content" id="templates-tab">
|
||
<section class="card">
|
||
<h2>模板管理</h2>
|
||
<div class="template-meta" id="templateMeta">
|
||
模板未加载
|
||
</div>
|
||
<form id="templateForm">
|
||
<label>上传新的 Excel 模板
|
||
<input type="file" name="file" accept=".xlsx,.xls" required>
|
||
</label>
|
||
<button class="primary" type="submit">覆盖模板</button>
|
||
</form>
|
||
<button class="primary" type="button" id="downloadTemplateBtn">下载当前模板</button>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="tab-content" id="org-chart-tab">
|
||
<section class="card org-chart-card">
|
||
<h2><span>🌳</span> 组织架构</h2>
|
||
<div class="org-chart-controls">
|
||
<div class="control-group">
|
||
<input type="text" id="orgSearchInput" class="search-input" placeholder="搜索部门名称、编码或区域...">
|
||
<button type="button" id="orgSearchBtn" class="search-btn">搜索</button>
|
||
<button type="button" id="orgResetBtn" class="reset-btn">重置</button>
|
||
</div>
|
||
<div class="stats-bar">
|
||
<span>📊 总部门: <strong id="totalDepts">0</strong></span>
|
||
<span>🌿 层级: <strong id="totalLevels">0</strong></span>
|
||
<span>🪴 根部门: <strong id="rootDepts">0</strong></span>
|
||
</div>
|
||
</div>
|
||
<div class="org-chart-container">
|
||
<div class="org-list-root" id="orgChartContainer">
|
||
<div class="loading">正在加载组织架构...</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const API_BASE = '/fs-ai-asistant/api/workflow/lawrisk';
|
||
const messageBar = document.getElementById('messageBar');
|
||
const userTableBody = document.getElementById('userTableBody');
|
||
const userCreateDept = document.getElementById('userCreateDept');
|
||
const userUpdateDept = document.getElementById('userUpdateDept');
|
||
const userUpdateSelect = document.getElementById('userUpdateSelect');
|
||
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: [],
|
||
themes: [],
|
||
templateMeta: {}
|
||
};
|
||
|
||
let orgChartData = {
|
||
tree: [],
|
||
allNodes: [],
|
||
parentMap: {},
|
||
nodeMap: {}
|
||
};
|
||
let orgChartLoaded = false;
|
||
|
||
function showMessage(text, type = 'info') {
|
||
if (!text) {
|
||
messageBar.classList.remove('show');
|
||
messageBar.textContent = '';
|
||
return;
|
||
}
|
||
messageBar.textContent = text;
|
||
messageBar.style.background = type === 'error' ? '#fee2e2' : '#eef2ff';
|
||
messageBar.style.color = type === 'error' ? '#b91c1c' : '#312e81';
|
||
messageBar.classList.add('show');
|
||
setTimeout(() => {
|
||
messageBar.classList.remove('show');
|
||
}, 4000);
|
||
}
|
||
|
||
async function fetchJSON(url, options = {}) {
|
||
const resp = await fetch(url, {
|
||
credentials: 'include',
|
||
headers: options.method === 'GET' || options.body instanceof FormData
|
||
? options.headers
|
||
: {'Content-Type': 'application/json', ...(options.headers || {})},
|
||
...options
|
||
});
|
||
let data = {};
|
||
try {
|
||
data = await resp.json();
|
||
} catch (_) {
|
||
data = {};
|
||
}
|
||
if (!resp.ok || data.success === false) {
|
||
throw new Error(data.message || '操作失败');
|
||
}
|
||
return data;
|
||
}
|
||
|
||
async function loadCurrentUser() {
|
||
try {
|
||
const resp = await fetchJSON('/auth/me', {method: 'GET'});
|
||
const user = resp.user || {};
|
||
document.getElementById('currentUserName').textContent = user.display_name || user.username || '未知管理员';
|
||
document.getElementById('currentUserRole').textContent = user.role || 'admin';
|
||
const dept = user.department;
|
||
document.getElementById('currentUserDept').textContent = dept ? `${dept.name || ''}${dept.code ? ' · ' + dept.code : ''}` : '未绑定部门';
|
||
} catch (err) {
|
||
console.error('认证失败:', err);
|
||
// 认证失败时重定向到登录页面
|
||
window.location.href = '/fs-ai-asistant/lawrisk/login';
|
||
}
|
||
}
|
||
|
||
function renderDepartmentOptions() {
|
||
const options = ['<option value="">暂不绑定</option>'];
|
||
const updateOptions = ['<option value="">保持不变 / 清除绑定</option>'];
|
||
const parentOptions = ['<option value="">设为顶级</option>'];
|
||
state.departments.forEach(dept => {
|
||
const label = `${dept.name}${dept.region_name ? ' · ' + dept.region_name : ''}`;
|
||
options.push(`<option value="${dept.id}">${label}</option>`);
|
||
updateOptions.push(`<option value="${dept.id}">${label}</option>`);
|
||
parentOptions.push(`<option value="${dept.id}">${label}</option>`);
|
||
});
|
||
userCreateDept.innerHTML = options.join('');
|
||
userUpdateDept.innerHTML = updateOptions.join('');
|
||
deptParentSelect.innerHTML = parentOptions.join('');
|
||
}
|
||
|
||
function renderUserSelect() {
|
||
const opts = state.users.map(user => `<option value="${user.id}">${user.username} (${user.display_name || '未命名'})</option>`);
|
||
userUpdateSelect.innerHTML = opts.join('');
|
||
}
|
||
|
||
function renderUserTable() {
|
||
userTableBody.innerHTML = state.users.map(user => {
|
||
const dept = user.department ? user.department.name : '—';
|
||
return `<tr>
|
||
<td>${user.username}</td>
|
||
<td>${user.display_name || '—'}</td>
|
||
<td>${dept}</td>
|
||
<td><span class="pill">${user.role}</span></td>
|
||
<td>${user.created_at || '—'}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderDeptTable() {
|
||
deptTableBody.innerHTML = state.departments.map(dept => `
|
||
<tr>
|
||
<td>${dept.name}</td>
|
||
<td>${dept.parent_name || '—'}</td>
|
||
<td>${dept.phone || '—'}</td>
|
||
<td>
|
||
<button class="action danger" data-id="${dept.id}" data-action="delete-dept">删除</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function renderThemeTable() {
|
||
themeTableBody.innerHTML = state.themes.map(theme => `
|
||
<tr>
|
||
<td>${theme.name}</td>
|
||
<td>${theme.permit_count}</td>
|
||
<td>${theme.region_count}</td>
|
||
<td>
|
||
<button class="action" data-id="${theme.id}" data-name="${theme.name}" data-action="rename-theme">重命名</button>
|
||
|
|
||
<button class="action danger" data-id="${theme.id}" data-name="${theme.name}" data-action="delete-theme">删除</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function renderTemplateMeta() {
|
||
const meta = state.templateMeta;
|
||
if (!meta || !meta.exists) {
|
||
templateMetaBox.textContent = '暂未上传模板,请尽快上传标准模板文件。';
|
||
return;
|
||
}
|
||
templateMetaBox.innerHTML = `
|
||
<div>文件名:${meta.filename}</div>
|
||
<div>大小:${(meta.filesize / 1024).toFixed(1)} KB</div>
|
||
<div>更新时间:${meta.updated_at || '—'}</div>
|
||
<div>上传人:${meta.uploaded_by || '—'}</div>
|
||
`;
|
||
}
|
||
|
||
async function refreshUsers() {
|
||
const data = await fetchJSON(`${API_BASE}/admin/users`, {method: 'GET'});
|
||
state.users = data.data.users || [];
|
||
renderUserSelect();
|
||
renderUserTable();
|
||
}
|
||
|
||
async function refreshDepartments() {
|
||
const data = await fetchJSON(`${API_BASE}/admin/service-departments`, {method: 'GET'});
|
||
state.departments = data.data.departments || [];
|
||
renderDepartmentOptions();
|
||
renderDeptTable();
|
||
}
|
||
|
||
async function refreshThemes() {
|
||
const data = await fetchJSON(`${API_BASE}/admin/themes/catalog`, {method: 'GET'});
|
||
state.themes = data.data.themes || [];
|
||
renderThemeTable();
|
||
}
|
||
|
||
async function refreshTemplateMeta() {
|
||
const data = await fetchJSON(`${API_BASE}/admin/templates/permit`, {method: 'GET'});
|
||
state.templateMeta = data.data || {};
|
||
renderTemplateMeta();
|
||
}
|
||
|
||
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
||
try {
|
||
await fetchJSON('/auth/logout', {method: 'POST', headers: {'Content-Type': 'application/json'}});
|
||
window.location.href = '/fs-ai-asistant/lawrisk/login';
|
||
} catch (err) {
|
||
showMessage(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
document.getElementById('userCreateForm').addEventListener('submit', async (evt) => {
|
||
evt.preventDefault();
|
||
const form = evt.target;
|
||
const payload = {
|
||
username: form.username.value.trim(),
|
||
password: form.password.value.trim(),
|
||
display_name: form.display_name.value.trim(),
|
||
service_department_id: form.service_department_id.value || null
|
||
};
|
||
try {
|
||
await fetchJSON(`${API_BASE}/admin/users`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload)
|
||
});
|
||
showMessage('用户创建成功');
|
||
form.reset();
|
||
await refreshUsers();
|
||
} catch (err) {
|
||
showMessage(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
document.getElementById('userUpdateForm').addEventListener('submit', async (evt) => {
|
||
evt.preventDefault();
|
||
const form = evt.target;
|
||
const userId = form.user_id.value;
|
||
if (!userId) {
|
||
showMessage('请选择要调整的用户', 'error');
|
||
return;
|
||
}
|
||
const payload = {};
|
||
if (form.password.value) {
|
||
payload.password = form.password.value;
|
||
}
|
||
payload.service_department_id = form.service_department_id.value || null;
|
||
try {
|
||
await fetchJSON(`${API_BASE}/admin/users/${userId}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify(payload)
|
||
});
|
||
showMessage('用户信息已更新');
|
||
form.password.value = '';
|
||
await refreshUsers();
|
||
} catch (err) {
|
||
showMessage(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
document.getElementById('deptCreateForm').addEventListener('submit', async (evt) => {
|
||
evt.preventDefault();
|
||
const form = evt.target;
|
||
const payload = {
|
||
name: form.name.value.trim(),
|
||
phone: form.phone.value.trim(),
|
||
parent_id: form.parent_id.value || null,
|
||
description: form.description.value.trim()
|
||
};
|
||
try {
|
||
await fetchJSON(`${API_BASE}/admin/service-departments`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload)
|
||
});
|
||
showMessage('服务部门已创建');
|
||
form.reset();
|
||
await refreshDepartments();
|
||
} catch (err) {
|
||
showMessage(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
deptTableBody.addEventListener('click', async (evt) => {
|
||
const target = evt.target;
|
||
if (!target.dataset || target.dataset.action !== 'delete-dept') {
|
||
return;
|
||
}
|
||
const deptId = target.dataset.id;
|
||
if (!deptId) return;
|
||
if (!confirm('删除前请确认没有账号绑定该部门,确定要删除吗?')) {
|
||
return;
|
||
}
|
||
try {
|
||
await fetchJSON(`${API_BASE}/admin/service-departments/${deptId}`, {method: 'DELETE'});
|
||
showMessage('服务部门已删除');
|
||
await refreshDepartments();
|
||
await refreshUsers();
|
||
} catch (err) {
|
||
showMessage(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
document.getElementById('themeCreateForm').addEventListener('submit', async (evt) => {
|
||
evt.preventDefault();
|
||
const form = evt.target;
|
||
const payload = {name: form.name.value.trim()};
|
||
try {
|
||
await fetchJSON(`${API_BASE}/admin/themes/catalog`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload)
|
||
});
|
||
showMessage('主题添加成功');
|
||
form.reset();
|
||
await refreshThemes();
|
||
} catch (err) {
|
||
showMessage(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
themeTableBody.addEventListener('click', async (evt) => {
|
||
const target = evt.target;
|
||
if (!target.dataset) return;
|
||
const action = target.dataset.action;
|
||
const themeId = target.dataset.id;
|
||
const themeName = target.dataset.name;
|
||
if (!themeId || !action) return;
|
||
if (action === 'rename-theme') {
|
||
const value = prompt('请输入新的主题名称', themeName || '');
|
||
if (!value) return;
|
||
try {
|
||
await fetchJSON(`${API_BASE}/admin/themes/catalog/${themeId}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({name: value.trim()})
|
||
});
|
||
showMessage('主题名称已更新');
|
||
await refreshThemes();
|
||
} catch (err) {
|
||
showMessage(err.message, 'error');
|
||
}
|
||
} else if (action === 'delete-theme') {
|
||
if (!confirm(`确定删除主题「${themeName}」及其关联?`)) return;
|
||
try {
|
||
await fetchJSON(`${API_BASE}/admin/themes/catalog/${themeId}`, {method: 'DELETE'});
|
||
showMessage('主题已删除');
|
||
await refreshThemes();
|
||
} catch (err) {
|
||
showMessage(err.message, 'error');
|
||
}
|
||
}
|
||
});
|
||
|
||
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 = '<div class="loading">正在加载组织架构...</div>';
|
||
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 = '<div class="loading">暂无组织架构数据</div>';
|
||
return;
|
||
}
|
||
|
||
renderOrgTree(tree);
|
||
|
||
|
||
setTimeout(() => {
|
||
|
||
initDragAndDrop();
|
||
}, 100);
|
||
} catch (err) {
|
||
console.error('【DEBUG】加载组织架构失败:', err);
|
||
orgChartLoaded = false;
|
||
orgChartContainer.innerHTML = `<div class="loading" style="color:#b91c1c;">加载组织架构失败:${err.message}</div>`;
|
||
}
|
||
}
|
||
|
||
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 = '';
|
||
if (!tree.length) {
|
||
orgChartContainer.innerHTML = '<div class="loading">暂无组织架构数据</div>';
|
||
return;
|
||
}
|
||
const list = document.createElement('div');
|
||
list.className = 'org-list';
|
||
tree.forEach(node => {
|
||
list.appendChild(renderOrgListNode(node, 0));
|
||
});
|
||
orgChartContainer.appendChild(list);
|
||
}
|
||
|
||
function renderOrgListNode(node, level) {
|
||
const nodeId = node.id != null ? String(node.id) : '';
|
||
const nodeDiv = document.createElement('div');
|
||
nodeDiv.className = 'org-node';
|
||
nodeDiv.setAttribute('data-node-id', nodeId);
|
||
nodeDiv.setAttribute('data-level', level);
|
||
nodeDiv.style.setProperty('--indent', `${level * 30}px`);
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'org-node-header';
|
||
header.setAttribute('data-node-id', nodeId);
|
||
|
||
const dragHandle = document.createElement('div');
|
||
dragHandle.className = 'drag-handle';
|
||
dragHandle.setAttribute('title', '拖拽修改从属关系');
|
||
dragHandle.setAttribute('draggable', 'true');
|
||
dragHandle.textContent = '⋮⋮';
|
||
header.appendChild(dragHandle);
|
||
|
||
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
|
||
if (hasChildren) {
|
||
nodeDiv.classList.add('has-children');
|
||
const toggleBtn = document.createElement('button');
|
||
toggleBtn.type = 'button';
|
||
toggleBtn.className = 'toggle-btn';
|
||
header.appendChild(toggleBtn);
|
||
const shouldExpand = level <= 1;
|
||
nodeDiv.dataset.defaultExpanded = shouldExpand ? 'true' : 'false';
|
||
setNodeExpanded(nodeDiv, shouldExpand, toggleBtn);
|
||
toggleBtn.addEventListener('click', (evt) => {
|
||
evt.stopPropagation();
|
||
const willExpand = nodeDiv.classList.contains('collapsed');
|
||
setNodeExpanded(nodeDiv, willExpand, toggleBtn);
|
||
});
|
||
} else {
|
||
const spacer = document.createElement('div');
|
||
spacer.className = 'toggle-placeholder';
|
||
header.appendChild(spacer);
|
||
nodeDiv.dataset.defaultExpanded = 'true';
|
||
}
|
||
|
||
const info = document.createElement('div');
|
||
info.className = 'org-node-info';
|
||
const nameSpan = document.createElement('div');
|
||
nameSpan.className = 'node-name';
|
||
nameSpan.textContent = node.name || '未知部门';
|
||
info.appendChild(nameSpan);
|
||
|
||
const metaRow = document.createElement('div');
|
||
metaRow.className = 'org-node-meta';
|
||
if (node.code) {
|
||
const codeSpan = document.createElement('span');
|
||
codeSpan.className = 'node-code';
|
||
codeSpan.textContent = node.code;
|
||
metaRow.appendChild(codeSpan);
|
||
}
|
||
// 对于二级节点,如果名称已包含区域信息(如"XX区服务部门"),则不显示region_name标签
|
||
if (node.region_name && !(level === 1 && node.name && node.name.includes('区'))) {
|
||
const regionSpan = document.createElement('span');
|
||
regionSpan.className = 'node-region';
|
||
regionSpan.textContent = node.region_name;
|
||
metaRow.appendChild(regionSpan);
|
||
}
|
||
// 权限等级不再显示,改为自动计算
|
||
if (!metaRow.children.length) {
|
||
metaRow.style.display = 'none';
|
||
}
|
||
info.appendChild(metaRow);
|
||
|
||
// 添加操作按钮区域
|
||
const actions = document.createElement('div');
|
||
actions.className = 'org-node-actions';
|
||
actions.innerHTML = `
|
||
<button type="button" class="action-btn add-child-btn" data-node-id="${nodeId}" title="添加下级部门">
|
||
➕ 新增
|
||
</button>
|
||
<button type="button" class="action-btn edit-btn" data-node-id="${nodeId}" title="编辑部门">
|
||
✏️ 编辑
|
||
</button>
|
||
<button type="button" class="action-btn delete-btn" data-node-id="${nodeId}" title="删除部门">
|
||
🗑️ 删除
|
||
</button>
|
||
`;
|
||
|
||
info.appendChild(actions);
|
||
|
||
header.appendChild(info);
|
||
addTooltip(header, node);
|
||
nodeDiv.appendChild(header);
|
||
|
||
if (hasChildren) {
|
||
const fragment = document.createDocumentFragment();
|
||
node.children.forEach(child => {
|
||
fragment.appendChild(renderOrgListNode(child, level + 1));
|
||
});
|
||
const childrenWrapper = document.createElement('div');
|
||
childrenWrapper.className = 'org-children';
|
||
childrenWrapper.appendChild(fragment);
|
||
nodeDiv.appendChild(childrenWrapper);
|
||
}
|
||
|
||
return nodeDiv;
|
||
}
|
||
|
||
function findToggleButton(nodeElement) {
|
||
const nodeId = nodeElement.getAttribute('data-node-id');
|
||
if (!nodeId) {
|
||
return nodeElement.querySelector('.org-node-header > .toggle-btn');
|
||
}
|
||
return nodeElement.querySelector(`.org-node-header[data-node-id="${nodeId}"] > .toggle-btn`);
|
||
}
|
||
|
||
function setNodeExpanded(nodeElement, expanded, toggleBtn) {
|
||
if (!nodeElement.classList.contains('has-children')) {
|
||
return;
|
||
}
|
||
nodeElement.classList.toggle('collapsed', !expanded);
|
||
const button = toggleBtn || findToggleButton(nodeElement);
|
||
if (button) {
|
||
button.classList.toggle('collapsed', !expanded);
|
||
button.classList.toggle('expanded', expanded);
|
||
button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||
}
|
||
}
|
||
|
||
function restoreDefaultCollapseState() {
|
||
document.querySelectorAll('#orgChartContainer .org-node.has-children').forEach(nodeDiv => {
|
||
const defaultExpanded = nodeDiv.dataset.defaultExpanded !== 'false';
|
||
setNodeExpanded(nodeDiv, defaultExpanded);
|
||
});
|
||
}
|
||
|
||
function expandPathToNode(nodeId) {
|
||
let currentId = nodeId;
|
||
while (currentId) {
|
||
const nodeElement = document.querySelector(`#orgChartContainer .org-node[data-node-id="${currentId}"]`);
|
||
if (nodeElement) {
|
||
setNodeExpanded(nodeElement, true);
|
||
}
|
||
currentId = orgChartData.parentMap[currentId];
|
||
}
|
||
}
|
||
|
||
function ensureSearchVisibility(matchedNodes) {
|
||
matchedNodes.forEach(nodeId => {
|
||
expandPathToNode(nodeId);
|
||
});
|
||
}
|
||
|
||
function addTooltip(element, node) {
|
||
const tooltip = document.createElement('div');
|
||
tooltip.className = 'tooltip';
|
||
tooltip.innerHTML = `
|
||
<div style="font-weight:600;margin-bottom:4px;">${node.name || '未知部门'}</div>
|
||
${node.code ? `<div>编码: ${node.code}</div>` : ''}
|
||
${node.region_name ? `<div>区域: ${node.region_name}</div>` : ''}
|
||
${node.description ? `<div style="margin-top:4px;color:#d1d5db;">${node.description}</div>` : ''}
|
||
`;
|
||
document.body.appendChild(tooltip);
|
||
|
||
// Use document-level mousemove to track cursor even when tooltip is under the mouse
|
||
let isTooltipVisible = false;
|
||
|
||
element.addEventListener('mouseenter', (evt) => {
|
||
isTooltipVisible = true;
|
||
// Initial position at mouse location
|
||
updateTooltipPosition(evt, tooltip);
|
||
tooltip.classList.add('show');
|
||
});
|
||
|
||
element.addEventListener('mouseleave', () => {
|
||
isTooltipVisible = false;
|
||
tooltip.classList.remove('show');
|
||
});
|
||
|
||
// Use document-level mousemove to follow cursor even when tooltip is present
|
||
document.addEventListener('mousemove', (moveEvent) => {
|
||
if (isTooltipVisible) {
|
||
updateTooltipPosition(moveEvent, tooltip);
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateTooltipPosition(event, tooltip) {
|
||
// Use pageX/pageY to avoid scroll offset issues
|
||
const mouseX = event.pageX;
|
||
const mouseY = event.pageY;
|
||
|
||
// Position tooltip with offset from cursor
|
||
const offsetX = 15; // Horizontal offset from cursor
|
||
const offsetY = 15; // Vertical offset from cursor
|
||
|
||
let tooltipX = mouseX + offsetX;
|
||
let tooltipY = mouseY + offsetY;
|
||
|
||
// Ensure tooltip stays within viewport boundaries
|
||
const tooltipRect = tooltip.getBoundingClientRect();
|
||
const viewportWidth = window.innerWidth;
|
||
const viewportHeight = window.innerHeight;
|
||
const scrollX = window.pageXOffset || document.documentElement.scrollLeft;
|
||
const scrollY = window.pageYOffset || document.documentElement.scrollTop;
|
||
|
||
// Check right boundary
|
||
if (tooltipX + tooltipRect.width > viewportWidth + scrollX) {
|
||
tooltipX = mouseX - tooltipRect.width - 10; // Show on left side of cursor
|
||
}
|
||
|
||
// Check bottom boundary
|
||
if (tooltipY + tooltipRect.height > viewportHeight + scrollY) {
|
||
tooltipY = mouseY - tooltipRect.height - 10; // Show above cursor
|
||
}
|
||
|
||
// Check left boundary (in case tooltip is very wide)
|
||
if (tooltipX < scrollX) {
|
||
tooltipX = scrollX + 10;
|
||
}
|
||
|
||
// Check top boundary (in case tooltip is very tall)
|
||
if (tooltipY < scrollY) {
|
||
tooltipY = scrollY + 10;
|
||
}
|
||
|
||
tooltip.style.left = `${tooltipX}px`;
|
||
tooltip.style.top = `${tooltipY}px`;
|
||
tooltip.style.transform = 'none'; // Remove centering transform for cursor tracking
|
||
}
|
||
|
||
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('#orgChartContainer .org-node').forEach(nodeDiv => {
|
||
const nodeId = nodeDiv.getAttribute('data-node-id');
|
||
if (nodeId && matchedNodes.has(nodeId)) {
|
||
nodeDiv.classList.remove('hidden');
|
||
} else {
|
||
nodeDiv.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
if (matchedNodes.size) {
|
||
ensureSearchVisibility(matchedNodes);
|
||
}
|
||
toggleNoResultsOverlay(matchedNodes.size === 0);
|
||
}
|
||
|
||
function highlightText(searchTerm, nodeId, field) {
|
||
const nodeDiv = document.querySelector(`#orgChartContainer .org-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, '<span class="highlight">$1</span>');
|
||
}
|
||
|
||
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('#orgChartContainer .org-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;
|
||
});
|
||
restoreDefaultCollapseState();
|
||
if (clearInput) {
|
||
const input = document.getElementById('orgSearchInput');
|
||
if (input) input.value = '';
|
||
}
|
||
toggleNoResultsOverlay(false);
|
||
}
|
||
|
||
function toggleNoResultsOverlay(show) {
|
||
if (!orgChartContainer) return;
|
||
let overlay = orgChartContainer.querySelector('.no-results-overlay');
|
||
if (show) {
|
||
if (!overlay) {
|
||
overlay = document.createElement('div');
|
||
overlay.className = 'no-results-overlay';
|
||
overlay.innerHTML = `
|
||
<div style="font-size:40px;margin-bottom:8px;">🔍</div>
|
||
<p style="margin:0 0 6px;">未找到匹配的组织部门</p>
|
||
<p style="margin:0;color:#6b7280;font-size:13px;">尝试其他关键字或点击下方按钮</p>
|
||
`;
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.className = 'overlay-close-btn';
|
||
closeBtn.textContent = '返回完整组织架构';
|
||
closeBtn.addEventListener('click', () => {
|
||
resetOrgSearchView(true);
|
||
});
|
||
overlay.appendChild(closeBtn);
|
||
orgChartContainer.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;
|
||
const fileInput = form.file;
|
||
if (!fileInput.files.length) {
|
||
showMessage('请选择要上传的模板文件', 'error');
|
||
return;
|
||
}
|
||
const formData = new FormData();
|
||
formData.append('file', fileInput.files[0]);
|
||
try {
|
||
await fetchJSON(`${API_BASE}/admin/templates/permit`, {
|
||
method: 'POST',
|
||
body: formData,
|
||
headers: {}
|
||
});
|
||
showMessage('模板已更新');
|
||
form.reset();
|
||
await refreshTemplateMeta();
|
||
} catch (err) {
|
||
showMessage(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
document.getElementById('downloadTemplateBtn').addEventListener('click', () => {
|
||
window.open(`${API_BASE}/admin/permit-import/template`, '_blank');
|
||
});
|
||
|
||
async function bootstrap() {
|
||
await loadCurrentUser();
|
||
await Promise.all([
|
||
refreshUsers(),
|
||
refreshDepartments(),
|
||
refreshThemes(),
|
||
refreshTemplateMeta()
|
||
]);
|
||
}
|
||
|
||
bootstrap().catch(err => showMessage(err.message, 'error'));
|
||
|
||
// ============================================================================
|
||
// 组织架构管理功能 - 新增、编辑、删除
|
||
// ============================================================================
|
||
|
||
let currentDraggedNode = null;
|
||
|
||
function initOrgChartManagement() {
|
||
|
||
|
||
|
||
// 使用事件委托处理动态生成的按钮
|
||
document.addEventListener('click', (evt) => {
|
||
|
||
handleOrgActionClick(evt);
|
||
});
|
||
|
||
|
||
initDragAndDrop();
|
||
}
|
||
|
||
function handleOrgActionClick(evt) {
|
||
|
||
|
||
|
||
|
||
|
||
const target = evt.target;
|
||
if (!target.classList.contains('action-btn')) {
|
||
|
||
return;
|
||
}
|
||
|
||
|
||
|
||
const nodeId = target.dataset.nodeId;
|
||
|
||
|
||
const nodeData = orgChartData.nodeMap[nodeId];
|
||
|
||
|
||
if (target.classList.contains('add-child-btn')) {
|
||
|
||
showAddChildModal(nodeData);
|
||
} else if (target.classList.contains('edit-btn')) {
|
||
|
||
showEditModal(nodeData);
|
||
} else if (target.classList.contains('delete-btn')) {
|
||
|
||
showDeleteConfirm(nodeData);
|
||
} else {
|
||
|
||
}
|
||
}
|
||
|
||
function showAddChildModal(parentNode) {
|
||
|
||
|
||
// 根据父节点层级自动计算新节点的权限等级
|
||
const parentLevel = parentNode.level || 0;
|
||
const childLevel = parentLevel + 1;
|
||
const autoGrade = calculateGradeByLevel(childLevel);
|
||
const gradeInfo = getGradeInfo(autoGrade);
|
||
|
||
const modal = createModal('添加下级部门', (form) => {
|
||
const formData = new FormData(form);
|
||
return {
|
||
name: formData.get('name'),
|
||
code: formData.get('code'),
|
||
phone: formData.get('phone'),
|
||
parent_id: parentNode.id,
|
||
description: formData.get('description'),
|
||
grade: autoGrade // 自动计算,无需手动选择
|
||
};
|
||
}, async (data) => {
|
||
const response = await fetchJSON(`${API_BASE}/admin/service-departments`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(data)
|
||
});
|
||
showMessage(`部门创建成功!默认密码:${response.data.default_password}`, 'success');
|
||
await loadOrgChart();
|
||
});
|
||
|
||
|
||
|
||
const formFields = modal.querySelector('.modal-form-fields');
|
||
|
||
formFields.innerHTML = `
|
||
<label>部门名称
|
||
<input type="text" name="name" required placeholder="请输入部门名称">
|
||
</label>
|
||
<label>部门账号
|
||
<input type="text" name="code" required placeholder="请输入账号(将作为登录用户名)" pattern="[A-Za-z0-9]+" title="只能包含字母和数字">
|
||
<small style="color: #6b7280; font-size: 12px;">系统将自动创建账号,默认密码为:账号+123456</small>
|
||
</label>
|
||
<label>联系电话
|
||
<input type="text" name="phone" placeholder="座机或手机号">
|
||
</label>
|
||
<label>备注
|
||
<textarea name="description" rows="2" placeholder="可填写简要职责"></textarea>
|
||
</label>
|
||
`;
|
||
|
||
|
||
}
|
||
|
||
function showEditModal(nodeData) {
|
||
const modal = createModal('编辑部门', (form) => {
|
||
const formData = new FormData(form);
|
||
return {
|
||
name: formData.get('name'),
|
||
phone: formData.get('phone'),
|
||
description: formData.get('description')
|
||
// 编辑时不修改grade,grade根据层级自动计算
|
||
};
|
||
}, async (data) => {
|
||
const response = await fetchJSON(`${API_BASE}/admin/service-departments/${nodeData.id}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify(data)
|
||
});
|
||
showMessage('部门信息已更新', 'success');
|
||
await loadOrgChart();
|
||
});
|
||
|
||
const formFields = modal.querySelector('.modal-form-fields');
|
||
|
||
|
||
formFields.innerHTML = `
|
||
<label>部门名称
|
||
<input type="text" name="name" required value="${nodeData.name}">
|
||
</label>
|
||
<label>部门账号
|
||
<input type="text" value="${nodeData.code}" disabled>
|
||
<small style="color: #6b7280; font-size: 12px;">账号不可修改</small>
|
||
</label>
|
||
<label>联系电话
|
||
<input type="text" name="phone" value="${nodeData.phone || ''}">
|
||
</label>
|
||
<label>备注
|
||
<textarea name="description" rows="2">${nodeData.description || ''}</textarea>
|
||
</label>
|
||
`;
|
||
|
||
|
||
}
|
||
|
||
function showDeleteConfirm(nodeData) {
|
||
const childrenCount = orgChartData.allNodes.filter(n => n.id !== nodeData.id && orgChartData.parentMap[n.id] === nodeData.id).length;
|
||
|
||
let message = `确定要删除部门「${nodeData.name}」吗?`;
|
||
if (childrenCount > 0) {
|
||
message += `\n\n注意:该部门有 ${childrenCount} 个下级部门,删除后下级部门将变成顶级部门。`;
|
||
}
|
||
|
||
if (!confirm(message)) return;
|
||
|
||
fetchJSON(`${API_BASE}/admin/service-departments/${nodeData.id}`, {
|
||
method: 'DELETE'
|
||
}).then(() => {
|
||
showMessage('部门删除成功', 'success');
|
||
loadOrgChart();
|
||
}).catch(async (err) => {
|
||
if (err.message.includes('仍有账号绑定') || (err.message && err.message.includes('HAS_BOUND_USERS'))) {
|
||
const force = confirm(`${err.message}\n\n是否强制删除?(将自动解除所有账号绑定)`);
|
||
if (force) {
|
||
await fetchJSON(`${API_BASE}/admin/service-departments/${nodeData.id}?force=true`, {
|
||
method: 'DELETE'
|
||
});
|
||
showMessage('部门强制删除成功', 'success');
|
||
loadOrgChart();
|
||
}
|
||
} else {
|
||
showMessage(err.message, 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
function createModal(title, getFormData, onSubmit) {
|
||
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay';
|
||
|
||
|
||
overlay.innerHTML = `
|
||
<div class="modal">
|
||
<h3>${title}</h3>
|
||
<form class="modal-form">
|
||
<div class="modal-form-fields">
|
||
<!-- 表单字段将插入这里 -->
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button type="button" class="cancel-btn">取消</button>
|
||
<button type="submit" class="submit-btn">确定</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
`;
|
||
|
||
|
||
|
||
const form = overlay.querySelector('form');
|
||
const cancelBtn = overlay.querySelector('.cancel-btn');
|
||
const submitBtn = overlay.querySelector('.submit-btn');
|
||
|
||
|
||
|
||
|
||
cancelBtn.addEventListener('click', () => {
|
||
|
||
overlay.remove();
|
||
});
|
||
|
||
form.addEventListener('submit', async (evt) => {
|
||
|
||
evt.preventDefault();
|
||
try {
|
||
const data = getFormData(form);
|
||
|
||
await onSubmit(data);
|
||
overlay.remove();
|
||
} catch (err) {
|
||
console.error('【DEBUG】提交错误:', err);
|
||
showMessage(err.message || '操作失败', 'error');
|
||
}
|
||
});
|
||
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
|
||
overlay.classList.add('show');
|
||
|
||
|
||
|
||
|
||
overlay.querySelector('.modal').scrollTop = 0;
|
||
return overlay;
|
||
}
|
||
|
||
// ============================================================================
|
||
// 权限等级自动计算
|
||
// ============================================================================
|
||
|
||
/**
|
||
* 根据组织架构层级计算权限等级
|
||
* @param {number} level - 组织层级 (0=根级, 1=二级, 2=三级, 3+=四级及以下)
|
||
* @returns {number} 权限等级数值
|
||
*/
|
||
function calculateGradeByLevel(level) {
|
||
// 权限等级映射:层级越深,权限等级越低
|
||
const gradeMap = {
|
||
0: 90, // 根级部门 - 超级权限
|
||
1: 80, // 二级部门 - 高级权限
|
||
2: 70, // 三级部门 - 中级权限
|
||
3: 60, // 四级部门 - 一般权限
|
||
4: 60, // 五级及以下 - 一般权限
|
||
5: 60 // 六级及以下 - 一般权限
|
||
};
|
||
return gradeMap[level] || 60;
|
||
}
|
||
|
||
/**
|
||
* 获取权限等级信息
|
||
* @param {number} grade - 权限等级数值
|
||
* @returns {object} 权限等级信息对象
|
||
*/
|
||
function getGradeInfo(grade) {
|
||
const gradeInfoMap = {
|
||
90: { name: '超级权限', color: '#dc2626', description: '最高管理权限' },
|
||
80: { name: '高级权限', color: '#ea580c', description: '高级管理权限' },
|
||
70: { name: '中级权限', color: '#ca8a04', description: '中级管理权限' },
|
||
60: { name: '一般权限', color: '#16a34a', description: '一般操作权限' },
|
||
50: { name: '较低权限', color: '#0ea5e9', description: '有限操作权限' },
|
||
0: { name: '普通权限', color: '#6b7280', description: '基础查看权限' }
|
||
};
|
||
return gradeInfoMap[grade] || { name: '未知权限', color: '#6b7280', description: '权限信息不明确' };
|
||
}
|
||
|
||
/**
|
||
* 重新计算并同步所有节点的权限等级
|
||
* 基于当前组织架构的层级关系
|
||
*/
|
||
function syncGradesWithLevels() {
|
||
if (!orgChartData.tree || !orgChartData.tree.length) {
|
||
|
||
return;
|
||
}
|
||
|
||
|
||
let updateCount = 0;
|
||
|
||
// 递归遍历树结构,更新每个节点的权限等级
|
||
function updateNodeGrade(node, level) {
|
||
const expectedGrade = calculateGradeByLevel(level);
|
||
const currentGrade = node.grade || 0;
|
||
|
||
if (currentGrade !== expectedGrade) {
|
||
|
||
updateCount++;
|
||
}
|
||
|
||
// 递归更新子节点
|
||
if (node.children && node.children.length > 0) {
|
||
node.children.forEach(child => {
|
||
updateNodeGrade(child, level + 1);
|
||
});
|
||
}
|
||
}
|
||
|
||
// 从根节点开始更新
|
||
orgChartData.tree.forEach(rootNode => {
|
||
updateNodeGrade(rootNode, 0);
|
||
});
|
||
|
||
|
||
}
|
||
|
||
// ============================================================================
|
||
// 拖拽功能实现 - 修改从属关系
|
||
// ============================================================================
|
||
|
||
function initDragAndDrop() {
|
||
const dragHandles = document.querySelectorAll('.drag-handle');
|
||
|
||
dragHandles.forEach(handle => {
|
||
if (handle.dataset.dragBound === 'true') {
|
||
return;
|
||
}
|
||
handle.dataset.dragBound = 'true';
|
||
const nodeDiv = handle.closest('.org-node');
|
||
if (!nodeDiv) return;
|
||
const nodeId = nodeDiv.getAttribute('data-node-id');
|
||
if (!nodeId) return;
|
||
|
||
handle.addEventListener('dragstart', (evt) => {
|
||
currentDraggedNode = nodeId;
|
||
nodeDiv.classList.add('dragging');
|
||
if (evt.dataTransfer) {
|
||
evt.dataTransfer.effectAllowed = 'move';
|
||
}
|
||
});
|
||
|
||
handle.addEventListener('dragend', () => {
|
||
nodeDiv.classList.remove('dragging');
|
||
document.querySelectorAll('.drop-target').forEach(el => {
|
||
el.classList.remove('drop-target');
|
||
});
|
||
currentDraggedNode = null;
|
||
});
|
||
});
|
||
|
||
// 监听所有节点作为放置目标
|
||
document.querySelectorAll('.org-node').forEach(nodeDiv => {
|
||
nodeDiv.addEventListener('dragover', (evt) => {
|
||
evt.preventDefault();
|
||
const nodeId = nodeDiv.getAttribute('data-node-id');
|
||
if (currentDraggedNode && nodeId !== currentDraggedNode) {
|
||
nodeDiv.classList.add('drop-target');
|
||
}
|
||
});
|
||
|
||
nodeDiv.addEventListener('dragleave', () => {
|
||
nodeDiv.classList.remove('drop-target');
|
||
});
|
||
|
||
nodeDiv.addEventListener('drop', async (evt) => {
|
||
evt.preventDefault();
|
||
evt.stopPropagation();
|
||
|
||
const targetNodeId = nodeDiv.getAttribute('data-node-id');
|
||
nodeDiv.classList.remove('drop-target');
|
||
|
||
if (!currentDraggedNode || currentDraggedNode === targetNodeId) {
|
||
return;
|
||
}
|
||
|
||
const sourceNode = orgChartData.nodeMap[currentDraggedNode];
|
||
const targetNode = orgChartData.nodeMap[targetNodeId];
|
||
|
||
if (!sourceNode || !targetNode) return;
|
||
|
||
if (confirm(`确定要将「${sourceNode.name}」移动到「${targetNode.name}」的下级吗?`)) {
|
||
try {
|
||
await fetchJSON(`${API_BASE}/admin/service-departments/${sourceNode.id}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ parent_id: targetNodeId })
|
||
});
|
||
showMessage('层级关系已更新', 'success');
|
||
await loadOrgChart();
|
||
} catch (err) {
|
||
showMessage(err.message, 'error');
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
|
||
|
||
initOrgChartManagement();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|