fs-lawrisk/static/super_admin.html

2379 lines
73 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>
&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 = `
<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')
// 编辑时不修改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>
<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>