fs-lawrisk/static/super_admin.html

3172 lines
110 KiB
HTML
Raw Normal View History

2025-11-14 15:46:18 +08:00
<!DOCTYPE html>
<html lang="zh-CN">
2025-11-14 15:46:18 +08:00
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LawRisk 超级管理员控制台</title>
<style>
* {
box-sizing: border-box;
}
2025-11-14 15:46:18 +08:00
body {
margin: 0;
padding: 0;
font-family: "Microsoft YaHei", "PingFang SC", -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(135deg, #f7fafc 0%, #e2e8f0 100%);
2025-11-14 15:46:18 +08:00
min-height: 100vh;
color: #1f2937;
}
2025-11-14 15:46:18 +08:00
.page {
max-width: 1400px;
margin: 0 auto;
padding: 32px 20px 60px;
}
2025-11-14 15:46:18 +08:00
.header {
background: #fff;
border-radius: 18px;
padding: 28px;
box-shadow: 0 20px 60px rgba(79, 70, 229, 0.12);
margin-bottom: 24px;
position: relative;
}
2025-11-14 15:46:18 +08:00
.title {
text-align: center;
}
2025-11-14 15:46:18 +08:00
.title h1 {
font-size: 30px;
margin: 0;
color: #111827;
}
2025-11-14 15:46:18 +08:00
.title p {
margin: 6px 0 0;
color: #4b5563;
font-size: 15px;
}
2025-11-14 15:46:18 +08:00
.user-chip {
position: absolute;
top: 36px;
/* Align vertically with title center approx */
right: 32px;
padding: 0;
background: transparent;
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
z-index: 10;
}
.user-info-text {
2025-11-14 15:46:18 +08:00
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
2025-11-14 15:46:18 +08:00
}
2025-11-14 15:46:18 +08:00
.user-name {
font-weight: 600;
font-size: 15px;
2025-11-14 15:46:18 +08:00
display: flex;
align-items: center;
gap: 8px;
color: #1f2937;
2025-11-14 15:46:18 +08:00
}
2025-11-14 15:46:18 +08:00
.user-tag {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(0, 0, 0, 0.05);
color: #4b5563;
2025-11-14 15:46:18 +08:00
}
2025-11-14 15:46:18 +08:00
.user-meta {
font-size: 12px;
color: #64748b;
2025-11-14 15:46:18 +08:00
}
2025-11-14 15:46:18 +08:00
.logout-btn {
background: #fff;
color: #ef4444;
border: 1px solid #fee2e2;
border-radius: 8px;
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
2025-11-14 15:46:18 +08:00
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.logout-btn:hover {
background: #fef2f2;
border-color: #ef4444;
2025-11-14 15:46:18 +08:00
}
2025-11-14 15:46:18 +08:00
.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: #e2e8f0;
color: #1e3a5f;
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, #2c5282, #4a5568);
color: #fff;
box-shadow: 0 8px 20px rgba(79, 70, 229, 0.25);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
2025-11-14 15:46:18 +08:00
.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;
}
2025-11-14 15:46:18 +08:00
h2 {
margin: 0;
font-size: 20px;
color: #111827;
}
2025-11-14 15:46:18 +08:00
h3 {
margin: 0;
font-size: 16px;
color: #374151;
}
2025-11-14 15:46:18 +08:00
label {
font-size: 13px;
color: #6b7280;
}
input,
select,
textarea {
2025-11-14 15:46:18 +08:00
width: 100%;
border: 1px solid #d1d5db;
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
margin-top: 4px;
}
2025-11-14 15:46:18 +08:00
textarea {
resize: vertical;
}
2025-11-14 15:46:18 +08:00
button.primary {
background: linear-gradient(135deg, #2c5282, #4a5568);
2025-11-14 15:46:18 +08:00
border: none;
color: #fff;
padding: 10px 18px;
border-radius: 999px;
cursor: pointer;
font-weight: 600;
margin-top: 8px;
}
2025-11-14 15:46:18 +08:00
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th,
td {
2025-11-14 15:46:18 +08:00
padding: 8px 6px;
border-bottom: 1px solid #f3f4f6;
text-align: left;
}
2025-11-14 15:46:18 +08:00
th {
font-weight: 600;
color: #4b5563;
}
2025-11-14 15:46:18 +08:00
.table-wrap {
max-height: 260px;
overflow: auto;
}
2025-11-14 15:46:18 +08:00
.pill {
padding: 2px 8px;
border-radius: 999px;
background: #eef2ff;
color: #2c5282;
2025-11-14 15:46:18 +08:00
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: #2c5282;
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, #2c5282, #4a5568);
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: #2c5282;
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, #e2e8f0 0%, #f0f4ff 100%);
border-left-color: #22d3ee;
border: 2px solid #22d3ee;
}
.org-node[data-level="0"] .node-name {
color: #0c4a6e;
font-size: 18px;
font-weight: 700;
}
.org-node[data-level="0"] .org-node-meta .node-code {
color: #1e3a5f;
font-weight: 600;
}
.org-node[data-level="0"] .org-node-meta .node-region {
background: #e2e8f0;
color: #1e3a5f;
border: 1px solid #c7d2fe;
}
.org-node[data-level="0"] .toggle-btn {
background: #e2e8f0;
border-color: #22d3ee;
color: #1e3a5f;
}
.org-node[data-level="0"] .toggle-btn:hover {
background: #c7d2fe;
}
/* 层级 1 - 二级节点 */
.org-node[data-level="1"] {
background: linear-gradient(135deg, #f7fafc 0%, #f7fafc 100%);
border-left-color: #cbd5e0;
border: 2px solid #cbd5e0;
margin-left: 20px;
}
.org-node[data-level="1"] .node-name {
color: #1e3a5f;
font-size: 17px;
font-weight: 600;
}
.org-node[data-level="1"] .org-node-meta .node-code {
color: #2c5282;
font-weight: 600;
}
.org-node[data-level="1"] .org-node-meta .node-region {
background: #f7fafc;
color: #2c5282;
border: 1px solid #e2e8f0;
}
.org-node[data-level="1"] .toggle-btn {
background: #f7fafc;
border-color: #cbd5e0;
color: #2c5282;
}
.org-node[data-level="1"] .toggle-btn:hover {
background: #e2e8f0;
}
/* 层级 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, #f7fafc 0%, #f7fafc 100%);
border-left-color: #e2e8f0;
border: 2px solid #e2e8f0;
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: #2c5282;
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: #f7fafc;
color: #2c5282;
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: #f7fafc;
border-color: #e2e8f0;
color: #2c5282;
}
.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: #1e3a5f;
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 #2c5282;
background: linear-gradient(135deg, #eef2ff 0%, #e2e8f0 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: #1e3a5f;
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: #2c5282;
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, #2c5282, #4a5568);
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: #2c5282;
color: #fff;
font-weight: 600;
}
2025-11-14 15:46:18 +08:00
.action {
border: none;
background: none;
color: #2563eb;
cursor: pointer;
padding: 0;
font-size: 13px;
}
2025-11-14 15:46:18 +08:00
.danger {
color: #dc2626;
}
2025-11-14 15:46:18 +08:00
.message {
margin-bottom: 16px;
padding: 12px 16px;
border-radius: 12px;
background: #eef2ff;
color: #0c4a6e;
2025-11-14 15:46:18 +08:00
display: none;
}
2025-11-14 15:46:18 +08:00
.message.show {
display: block;
}
2025-11-14 15:46:18 +08:00
.template-meta {
font-size: 13px;
color: #4b5563;
line-height: 1.6;
}
2025-11-14 15:46:18 +08:00
.section {
display: flex;
flex-direction: column;
gap: 12px;
}
.card-desc {
margin: 4px 0 0;
color: #6b7280;
font-size: 13px;
}
.user-card .card-top {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
}
.action-group {
display: inline-flex;
gap: 10px;
align-items: center;
}
.user-subnav {
display: inline-flex;
gap: 8px;
padding: 6px;
background: #f8fafc;
border-radius: 12px;
margin: 12px 0 6px;
}
.subtab-button {
border: none;
background: transparent;
padding: 10px 16px;
border-radius: 10px;
cursor: pointer;
font-weight: 600;
color: #475569;
}
.subtab-button.active {
background: linear-gradient(135deg, #2c5282, #4a5568);
color: #fff;
box-shadow: 0 10px 24px rgba(79, 70, 229, 0.2);
}
.user-subtab {
display: none;
margin-top: 6px;
}
.user-subtab.active {
display: block;
}
.user-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin: 10px 0 6px;
}
.user-toolbar .filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.user-toolbar input[type="search"] {
min-width: 240px;
}
.user-toolbar select {
min-width: 150px;
}
.user-table {
max-height: none;
}
.user-table table tr.active-row {
background: #eef2ff;
}
.user-table table tr:hover {
background: #f8fafc;
}
.table-hint {
margin: 10px 0 0;
color: #6b7280;
font-size: 12px;
}
.input-control {
border: 1px solid #d1d5db;
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
background: #fff;
}
.table-actions {
display: inline-flex;
gap: 8px;
align-items: center;
}
.link-btn {
background: none;
border: none;
color: #2563eb;
cursor: pointer;
padding: 0;
font-size: 13px;
}
.link-btn.danger {
color: #dc2626;
}
.ghost-btn {
background: #f8fafc;
border: 1px solid #e5e7eb;
color: #1f2937;
padding: 10px 16px;
border-radius: 10px;
cursor: pointer;
}
.ghost-btn.danger {
color: #b91c1c;
border-color: #fecdd3;
background: #fff1f2;
}
.drawer {
position: fixed;
top: 0;
right: -420px;
width: 400px;
max-width: 92vw;
height: 100vh;
background: #fff;
box-shadow: -12px 0 30px rgba(15, 23, 42, 0.12);
padding: 22px 22px 40px;
transition: right 0.28s ease;
z-index: 40;
overflow-y: auto;
}
.drawer.open {
right: 0;
}
.drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
opacity: 0;
visibility: hidden;
transition: all 0.25s ease;
z-index: 30;
}
.drawer-backdrop.show {
opacity: 1;
visibility: visible;
}
.drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.drawer-eyebrow {
margin: 0;
color: #6b7280;
font-size: 12px;
letter-spacing: 0.5px;
}
.drawer-subtitle {
margin: 4px 0 0;
color: #4b5563;
font-size: 13px;
}
.drawer-close {
border: none;
background: #f1f5f9;
width: 34px;
height: 34px;
border-radius: 10px;
cursor: pointer;
font-size: 18px;
line-height: 1;
}
.drawer-section {
margin: 16px 0 12px;
padding: 14px;
background: #f8fafc;
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px 14px;
}
.summary-grid .label {
color: #6b7280;
font-size: 12px;
}
.summary-grid .value {
font-weight: 600;
color: #111827;
}
.drawer-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.drawer-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 6px;
}
.user-create-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px 16px;
}
.user-create-form .form-help,
.user-create-form .form-actions {
grid-column: 1 / -1;
}
.form-help {
color: #6b7280;
font-size: 12px;
background: #f8fafc;
border-radius: 10px;
padding: 10px 12px;
}
.form-actions {
display: flex;
justify-content: flex-end;
}
2025-11-14 15:46:18 +08:00
@media (max-width: 768px) {
.user-chip {
position: static;
margin-top: 16px;
}
2025-11-14 15:46:18 +08:00
.header {
padding-top: 48px;
}
.user-toolbar {
align-items: stretch;
}
.drawer {
width: 100%;
}
2025-11-14 15:46:18 +08:00
}
</style>
</head>
2025-11-14 15:46:18 +08:00
<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-info-text">
<div class="user-name">
<span id="currentUserName">未登录</span>
<span class="user-tag" id="currentUserRole">role</span>
</div>
<div class="user-meta" id="currentUserDept"></div>
</div>
<button class="logout-btn" id="logoutBtn">退出登录</button>
2025-11-14 15:46:18 +08:00
</div>
</header>
2025-11-14 15:46:18 +08:00
<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 user-card">
<div class="card-top">
<div>
<h2>用户管理</h2>
<p class="card-desc">将“创建账号”和“用户列表”拆分为独立模块,列表内直接查看详情、编辑与删除。</p>
</div>
<div class="action-group">
<button class="primary open-create-btn" type="button">+ 创建账号</button>
</div>
</div>
<div class="user-subnav" role="tablist">
<button class="subtab-button active" data-user-tab="user-list-panel">用户列表</button>
<button class="subtab-button" data-user-tab="user-create-panel">创建账号</button>
</div>
<div class="user-subtab active" id="user-list-panel">
<div class="user-toolbar">
<div class="filters">
<input type="search" id="userSearchInput" class="input-control"
placeholder="搜索账号 / 显示名 / 部门">
<select id="userRoleFilter" class="input-control">
<option value="">全部角色</option>
</select>
</div>
<div class="toolbar-actions">
<button class="ghost-btn open-create-btn" type="button">+ 创建账号</button>
</div>
</div>
<div class="table-wrap user-table">
<table>
<thead>
<tr>
<th>用户名</th>
<th>显示名</th>
<th>部门</th>
<th>区域</th>
<th>角色</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="userTableBody"></tbody>
</table>
</div>
<p class="table-hint">默认停留在列表,点击“详情/编辑”右侧抽屉展开,直接在此页删除账号。</p>
</div>
<div class="user-subtab" id="user-create-panel">
<div class="section">
<h3>创建账号</h3>
<p class="card-desc">表单独立为创建模块,提交成功后刷新列表并高亮新账号。</p>
</div>
<form id="userCreateForm" class="user-create-form">
<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>单位电话(可选)
<input type="text" name="department_phone" placeholder="用于自动创建的单位联系电话">
</label>
<label>所属区域(可选)
<select id="userCreateRegion" class="form-select" name="region_id">
<option value="">不选择区域(默认继承上级区域)</option>
</select>
</label>
<label>上级单位(可选,不选则创建顶级市级单位)
<select id="userCreateParent" class="form-select">
<option value="">不选择上级(顶级单位)</option>
</select>
</label>
<div class="form-help">将为该账号自动创建同名单位并绑定;选择上级则形成从属关系。</div>
<div class="form-actions">
<button class="primary" type="submit">创建用户</button>
</div>
</form>
</div>
</section>
<aside class="drawer" id="userDetailDrawer">
<div class="drawer-header">
<div>
<p class="drawer-eyebrow">账号详情</p>
<h3 id="userDrawerTitle"></h3>
<p class="drawer-subtitle" id="userDrawerMeta"></p>
</div>
<button class="drawer-close" type="button" id="closeUserDrawer">×</button>
</div>
<div class="drawer-section" id="userSummary"></div>
<form id="userEditForm" class="drawer-form">
<label>显示名称
<input type="text" id="userEditDisplayName" name="display_name" placeholder="用于展示的名称">
</label>
<label>角色
<select id="userEditRole" name="role"></select>
</label>
<label>绑定服务部门
<select id="userEditDept" name="service_department_id">
<option value="">保持不变 / 解除绑定</option>
</select>
</label>
<label>重置密码(可选)
<input type="password" id="userEditPassword" name="password" placeholder="留空则不修改密码">
</label>
<div class="drawer-actions">
<button class="primary" type="submit">保存修改</button>
<button class="ghost-btn danger" type="button" id="deleteUserFromDrawer">删除账号</button>
</div>
</form>
</aside>
<div class="drawer-backdrop" id="drawerBackdrop"></div>
</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="code" placeholder="例如 FSXXXX自动大写留空将生成随机账号">
</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>
2025-11-14 15:46:18 +08:00
<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>
2025-11-14 15:46:18 +08:00
</div>
<script>
const API_BASE = '/fs-ai-asistant/api/workflow/lawrisk';
const messageBar = document.getElementById('messageBar');
const userTableBody = document.getElementById('userTableBody');
const userSearchInput = document.getElementById('userSearchInput');
const userRoleFilter = document.getElementById('userRoleFilter');
const userDetailDrawer = document.getElementById('userDetailDrawer');
const drawerBackdrop = document.getElementById('drawerBackdrop');
const userDrawerTitle = document.getElementById('userDrawerTitle');
const userDrawerMeta = document.getElementById('userDrawerMeta');
const userSummaryBox = document.getElementById('userSummary');
const userEditForm = document.getElementById('userEditForm');
const userEditDept = document.getElementById('userEditDept');
const userEditRole = document.getElementById('userEditRole');
const userEditDisplayName = document.getElementById('userEditDisplayName');
const userEditPassword = document.getElementById('userEditPassword');
const deleteUserFromDrawerBtn = document.getElementById('deleteUserFromDrawer');
const closeUserDrawerBtn = document.getElementById('closeUserDrawer');
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 userSubtabButtons = document.querySelectorAll('[data-user-tab]');
const userSubtabs = document.querySelectorAll('.user-subtab');
const openCreateButtons = document.querySelectorAll('.open-create-btn');
const orgChartContainer = document.getElementById('orgChartContainer');
const totalDeptsEl = document.getElementById('totalDepts');
const totalLevelsEl = document.getElementById('totalLevels');
const rootDeptsEl = document.getElementById('rootDepts');
let state = {
users: [],
departments: [],
regions: [],
themes: [],
templateMeta: {},
userFilter: {
keyword: '',
role: ''
}
};
let activeUserId = null;
const DEFAULT_ROLE_ORDER = ['department_admin', 'department_user', 'admin'];
const ROLE_LABELS = {
admin: '超级管理员',
department_admin: '部门管理员',
department_user: '部门用户'
};
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' : '#0c4a6e';
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;
}
function formatDate(value) {
if (!value) return '—';
if (typeof value === 'string') {
return value.replace('T', ' ').split('.')[0];
}
return value;
}
function getRoleLabel(role) {
return ROLE_LABELS[role] || role || '—';
}
function getUserById(userId) {
const targetId = String(userId || '');
return state.users.find(user => String(user.id) === targetId);
}
async function loadCurrentUser() {
try {
const resp = await fetchJSON('/fs-ai-asistant/api/workflow/lawrisk/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;
const deptPieces = [];
if (dept) {
if (dept.name) deptPieces.push(dept.name);
if (dept.code) deptPieces.push(dept.code);
if (dept.region_name) {
deptPieces.push(dept.region_name);
} else if (dept.region_id) {
deptPieces.push(dept.region_id);
}
}
document.getElementById('currentUserDept').textContent = dept ? (deptPieces.join(' · ') || '未绑定部门') : '未绑定部门';
} catch (err) {
console.error('认证失败:', err);
// 认证失败时重定向到登录页面
window.location.href = '/fs-ai-asistant/api/workflow/lawrisk/login';
}
}
function renderDepartmentOptions() {
const parentOptions = ['<option value="">设为顶级</option>'];
const serviceOptions = ['<option value="">保持不变 / 解除绑定</option>'];
state.departments.forEach(dept => {
const label = `${dept.name}${dept.region_name ? ' · ' + dept.region_name : ''}`;
parentOptions.push(`<option value="${dept.id}">${label}</option>`);
serviceOptions.push(`<option value="${dept.id}">${label}</option>`);
});
if (deptParentSelect) {
deptParentSelect.innerHTML = parentOptions.join('');
}
renderUserCreateParentSelector();
renderUserCreateRegionSelector();
if (userEditDept) {
userEditDept.innerHTML = serviceOptions.join('');
}
}
function renderUserCreateParentSelector() {
const parentSelect = document.getElementById('userCreateParent');
if (!parentSelect) return;
const options = ['<option value="">不选择上级(顶级单位)</option>'];
state.departments.forEach(dept => {
const pieces = [dept.name];
if (dept.code) pieces.push(dept.code);
if (dept.region_name) pieces.push(dept.region_name);
options.push(`<option value="${dept.id}">${pieces.join(' · ')}</option>`);
});
parentSelect.innerHTML = options.join('');
}
function buildRegionOptions(selectedId = '', includeBlank = true) {
const options = [];
if (includeBlank) {
options.push('<option value="">不选择区域(继承上级或稍后再设)</option>');
}
let hasSelected = false;
(state.regions || []).forEach(region => {
const value = region.id || '';
const name = region.name || value || '未命名区域';
const selected = String(selectedId || '') === String(value) ? 'selected' : '';
if (selected) {
hasSelected = true;
}
options.push(`<option value="${value}" ${selected}>${name}</option>`);
});
if (selectedId && !hasSelected && !options.find(opt => opt.includes(`value="${selectedId}"`))) {
options.push(`<option value="${selectedId}" selected>${selectedId}</option>`);
}
if (!options.length) {
options.push('<option value="">暂无区域数据</option>');
}
return options.join('');
}
function renderUserCreateRegionSelector() {
const select = document.getElementById('userCreateRegion');
if (!select) return;
const current = select.value;
select.innerHTML = buildRegionOptions(current, true);
}
function renderRoleOptions() {
const roles = new Set(DEFAULT_ROLE_ORDER);
state.users.forEach(user => {
if (user.role) {
roles.add(user.role);
}
});
const roleList = Array.from(roles);
if (userRoleFilter) {
const current = state.userFilter.role;
const options = ['<option value=\"\">全部角色</option>'].concat(roleList.map(role => `<option value=\"${role}\">${getRoleLabel(role)}</option>`));
userRoleFilter.innerHTML = options.join('');
if (current && !roles.has(current)) {
state.userFilter.role = '';
} else if (current) {
userRoleFilter.value = current;
}
}
if (userEditRole) {
userEditRole.innerHTML = roleList.map(role => `<option value=\"${role}\">${getRoleLabel(role)}</option>`).join('');
}
}
function applyUserFilters(users) {
const keyword = (state.userFilter.keyword || '').toLowerCase();
const roleFilter = state.userFilter.role || '';
return users.filter(user => {
const deptName = user.department ? (user.department.name || '') : '';
const regionName = user.department ? (user.department.region_name || user.department.region_id || '') : '';
const text = `${user.username || ''} ${user.display_name || ''} ${deptName} ${regionName}`.toLowerCase();
const matchKeyword = !keyword || text.includes(keyword);
const matchRole = !roleFilter || user.role === roleFilter;
return matchKeyword && matchRole;
});
}
function renderUserTable() {
if (!userTableBody) return;
const filtered = applyUserFilters(state.users);
if (!filtered.length) {
userTableBody.innerHTML = `<tr><td colspan="7" style="text-align:center;color:#6b7280;padding:14px 0;">暂无用户,点击右上角“创建账号”开始添加</td></tr>`;
return;
}
userTableBody.innerHTML = filtered.map(user => {
const dept = user.department ? user.department.name : '—';
const region = user.department ? (user.department.region_name || user.department.region_id || '—') : '—';
const isActive = activeUserId && String(user.id) === String(activeUserId);
return `<tr data-id="${user.id}" class="${isActive ? 'active-row' : ''}">
2025-11-14 15:46:18 +08:00
<td>${user.username}</td>
<td>${user.display_name || '—'}</td>
<td>${dept}</td>
2025-11-27 17:13:49 +08:00
<td>${region}</td>
<td><span class="pill">${getRoleLabel(user.role)}</span></td>
<td>${formatDate(user.created_at)}</td>
<td>
<div class="table-actions">
<button class="link-btn" data-action="view-user" data-id="${user.id}">详情/编辑</button>
<span style="color:#e5e7eb;">|</span>
<button class="link-btn danger" data-action="delete-user" data-id="${user.id}">删除</button>
</div>
</td>
2025-11-14 15:46:18 +08:00
</tr>`;
}).join('');
}
2025-11-14 15:46:18 +08:00
function renderDeptTable() {
deptTableBody.innerHTML = state.departments.map(dept => `
2025-11-14 15:46:18 +08:00
<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('');
}
2025-11-14 15:46:18 +08:00
function renderThemeTable() {
themeTableBody.innerHTML = state.themes.map(theme => `
2025-11-14 15:46:18 +08:00
<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>
&nbsp;|&nbsp;
<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 = `
2025-11-14 15:46:18 +08:00
<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(options = {}) {
const data = await fetchJSON(`${API_BASE}/admin/users`, { method: 'GET' });
state.users = data.data.users || [];
renderRoleOptions();
renderUserTable();
if (options.syncDrawer !== false) {
syncUserDrawer();
}
}
async function refreshRegions() {
const data = await fetchJSON(`${API_BASE}/admin/regions`, { method: 'GET' });
state.regions = data.data.regions || [];
renderUserCreateRegionSelector();
}
async function refreshDepartments() {
const data = await fetchJSON(`${API_BASE}/admin/service-departments`, { method: 'GET' });
state.departments = data.data.departments || [];
renderDepartmentOptions();
renderDeptTable();
syncUserDrawer();
}
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();
}
function switchUserSubtab(tabId) {
if (!tabId) return;
userSubtabs.forEach(panel => {
panel.classList.toggle('active', panel.id === tabId);
});
userSubtabButtons.forEach(button => {
button.classList.toggle('active', button.dataset.userTab === tabId);
});
}
function fillUserDrawer(user) {
if (!user) return;
if (userDrawerTitle) {
userDrawerTitle.textContent = user.display_name || user.username || '未命名账号';
}
if (userDrawerMeta) {
const roleLabel = getRoleLabel(user.role);
userDrawerMeta.textContent = `${user.username || '—'} · ${roleLabel}`;
}
if (userSummaryBox) {
const dept = user.department ? `${user.department.name || ''}${user.department.code ? ' · ' + user.department.code : ''}` : '未绑定';
const deptPhone = user.department ? (user.department.phone || '—') : '—';
const region = user.department ? (user.department.region_name || user.department.region_id || '—') : '—';
userSummaryBox.innerHTML = `
<div class="summary-grid">
<div><div class="label">登录账号</div><div class="value">${user.username || '—'}</div></div>
<div><div class="label">显示名</div><div class="value">${user.display_name || '—'}</div></div>
<div><div class="label">角色</div><div class="value"><span class="pill">${getRoleLabel(user.role)}</span></div></div>
<div><div class="label">绑定部门</div><div class="value">${dept}</div></div>
2025-11-27 17:13:49 +08:00
<div><div class="label">所属区域</div><div class="value">${region}</div></div>
<div><div class="label">单位电话</div><div class="value">${deptPhone}</div></div>
<div><div class="label">创建时间</div><div class="value">${formatDate(user.created_at)}</div></div>
</div>
`;
}
if (userEditDisplayName) userEditDisplayName.value = user.display_name || '';
if (userEditRole) userEditRole.value = user.role || '';
if (userEditDept) userEditDept.value = user.department ? (user.department.id || '') : '';
if (userEditPassword) userEditPassword.value = '';
}
function openUserDrawer(userId) {
const user = getUserById(userId);
if (!user) {
showMessage('未找到该用户', 'error');
return;
2025-11-27 17:13:49 +08:00
}
activeUserId = String(user.id);
switchUserSubtab('user-list-panel');
fillUserDrawer(user);
renderUserTable();
if (userDetailDrawer) {
userDetailDrawer.classList.add('open');
}
if (drawerBackdrop) {
drawerBackdrop.classList.add('show');
2025-11-27 17:13:49 +08:00
}
}
function closeUserDrawer() {
if (userEditPassword) {
userEditPassword.value = '';
}
if (userDetailDrawer) {
userDetailDrawer.classList.remove('open');
}
if (drawerBackdrop) {
drawerBackdrop.classList.remove('show');
}
}
function syncUserDrawer() {
if (!userDetailDrawer || !userDetailDrawer.classList.contains('open')) {
return;
}
if (!activeUserId) {
closeUserDrawer();
return;
}
const user = getUserById(activeUserId);
if (user) {
fillUserDrawer(user);
} else {
closeUserDrawer();
}
}
async function handleDeleteUser(userId) {
const targetId = userId || activeUserId;
if (!targetId) {
showMessage('请先选择需要删除的用户', 'error');
return;
}
const user = getUserById(targetId);
const name = user ? (user.display_name || user.username || targetId) : targetId;
if (!confirm(`确定删除账号「${name}」?`)) {
return;
}
try {
await fetchJSON(`${API_BASE}/admin/users/${targetId}`, { method: 'DELETE' });
showMessage('用户已删除');
if (String(activeUserId) === String(targetId)) {
activeUserId = null;
closeUserDrawer();
}
await refreshUsers();
} catch (err) {
showMessage(err.message, 'error');
}
}
document.getElementById('logoutBtn').addEventListener('click', async () => {
try {
await fetchJSON('/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' } });
window.location.href = '/fs-ai-asistant/api/workflow/lawrisk/login';
} catch (err) {
showMessage(err.message, 'error');
}
});
const userCreateForm = document.getElementById('userCreateForm');
if (userCreateForm) {
userCreateForm.addEventListener('submit', async (evt) => {
evt.preventDefault();
const form = evt.target;
const parentSelect = document.getElementById('userCreateParent');
const regionSelect = document.getElementById('userCreateRegion');
const parentDepartmentId = parentSelect ? (parentSelect.value || '') : '';
let regionId = regionSelect ? regionSelect.value : '';
if (!regionId && parentDepartmentId) {
const parentDept = state.departments.find(dept => String(dept.id) === String(parentDepartmentId));
if (parentDept) {
regionId = parentDept.region_id || '';
}
}
if (!regionId && !parentDepartmentId) {
showMessage('请选择所属区域,或指定上级单位继承区域', 'error');
return;
}
const payload = {
username: form.username.value.trim(),
password: form.password.value.trim(),
display_name: form.display_name.value.trim(),
parent_department_id: parentDepartmentId || null,
region_id: regionId || null,
department_phone: (form.department_phone.value || '').trim()
};
try {
const resp = await fetchJSON(`${API_BASE}/admin/users`, {
method: 'POST',
body: JSON.stringify(payload)
});
const createdId = resp && resp.data && resp.data.user ? resp.data.user.id : null;
activeUserId = createdId ? String(createdId) : activeUserId;
showMessage('用户创建成功');
form.reset();
if (parentSelect) {
parentSelect.value = '';
}
if (regionSelect) {
regionSelect.value = '';
}
form.department_phone.value = '';
switchUserSubtab('user-list-panel');
await refreshDepartments();
await refreshUsers();
} catch (err) {
showMessage(err.message, 'error');
}
});
const parentSelect = document.getElementById('userCreateParent');
const regionSelect = document.getElementById('userCreateRegion');
if (parentSelect && regionSelect) {
parentSelect.addEventListener('change', () => {
if (regionSelect.value) {
return;
}
const parentDept = state.departments.find(dept => String(dept.id) === String(parentSelect.value || ''));
regionSelect.value = parentDept ? (parentDept.region_id || '') : '';
});
}
}
if (userEditForm) {
userEditForm.addEventListener('submit', async (evt) => {
evt.preventDefault();
if (!activeUserId) {
showMessage('请先在列表中选择用户', 'error');
return;
}
const payload = {
display_name: userEditDisplayName ? userEditDisplayName.value.trim() : ''
};
if (userEditRole) {
payload.role = userEditRole.value;
}
if (userEditDept) {
payload.service_department_id = userEditDept.value || null;
}
if (userEditPassword && userEditPassword.value) {
payload.password = userEditPassword.value;
}
try {
await fetchJSON(`${API_BASE}/admin/users/${activeUserId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
});
showMessage('用户信息已更新');
if (userEditPassword) {
userEditPassword.value = '';
}
await refreshUsers();
} catch (err) {
showMessage(err.message, 'error');
}
});
}
if (userTableBody) {
userTableBody.addEventListener('click', (evt) => {
const target = evt.target;
if (!(target instanceof HTMLElement)) return;
const action = target.dataset.action;
const userId = target.dataset.id || (target.closest('[data-id]') ? target.closest('[data-id]').dataset.id : '');
if (action === 'view-user') {
openUserDrawer(userId);
return;
}
if (action === 'delete-user') {
handleDeleteUser(userId);
return;
}
const row = target.closest('tr[data-id]');
if (row && !target.closest('.table-actions')) {
openUserDrawer(row.dataset.id);
}
});
}
if (userSearchInput) {
let timer;
userSearchInput.addEventListener('input', (evt) => {
clearTimeout(timer);
const value = evt.target.value;
timer = setTimeout(() => {
state.userFilter.keyword = value.trim();
renderUserTable();
}, 150);
});
}
if (userRoleFilter) {
userRoleFilter.addEventListener('change', (evt) => {
state.userFilter.role = evt.target.value || '';
renderUserTable();
});
}
openCreateButtons.forEach(button => {
button.addEventListener('click', () => switchUserSubtab('user-create-panel'));
});
userSubtabButtons.forEach(button => {
button.addEventListener('click', () => switchUserSubtab(button.dataset.userTab));
});
if (closeUserDrawerBtn) {
closeUserDrawerBtn.addEventListener('click', closeUserDrawer);
}
if (drawerBackdrop) {
drawerBackdrop.addEventListener('click', closeUserDrawer);
}
if (deleteUserFromDrawerBtn) {
deleteUserFromDrawerBtn.addEventListener('click', () => handleDeleteUser());
}
document.addEventListener('keydown', (evt) => {
if (evt.key === 'Escape') {
closeUserDrawer();
}
});
document.getElementById('deptCreateForm').addEventListener('submit', async (evt) => {
evt.preventDefault();
const form = evt.target;
const code = (form.code.value || '').trim();
const payload = {
name: form.name.value.trim(),
code: code ? code.toUpperCase() : '',
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 !== 'users-tab') {
closeUserDrawer();
}
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 || '',
region_id: node.region_id || '',
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([
refreshRegions(),
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'),
region_id: formData.get('region_id'),
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>
2025-11-27 17:13:49 +08:00
<label>所属区域
<select name="region_id">
${buildRegionOptions(parentNode.region_id || '', true)}
</select>
<small style="color: #6b7280; font-size: 12px;">不选择则默认继承上级区域</small>
</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'),
region_id: formData.get('region_id'),
description: formData.get('description')
// 编辑时不修改gradegrade根据层级自动计算
};
}, 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>
2025-11-27 17:13:49 +08:00
<label>所属区域
<select name="region_id">
${buildRegionOptions(nodeData.region_id || '', true)}
</select>
</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>
2025-11-14 15:46:18 +08:00
</body>
</html>