原实现混用了 DaisyUI 的写法(<dialog class="modal"> + .modal-box), 而项目实际引入的是 FlyonUI:其 modal 由 HSOverlay 驱动,显示态靠 overlay-open 变体(&.open)生成,且该变体不会随 @plugin 自动注册 (Tailwind v4 只加载包内同名 flyonui.css,variants.css 被忽略)。 后果是 showModal() 只产生原生遮罩(页面变暗+其余按钮被锁), 对话框本身始终 opacity:0 不可见,也未发出任何请求。 本次改动: - modal 结构迁移为官方 overlay 结构,开合改用 HSOverlay.open/close, 取消/关闭按钮走 data-overlay 声明式关闭; - input.css 注册 overlay-open 变体(带注释说明不可删); - 顺带修正其余 DaisyUI 残留:tabs-border→tabs-bordered、 btn-ghost/badge-ghost/badge-neutral→btn-soft/badge-soft。
465 lines
16 KiB
JavaScript
465 lines
16 KiB
JavaScript
'use strict';
|
||
|
||
// idsync 管理界面。刻意不引入前端框架:页面数量少、状态简单,
|
||
// 原生 JS 足以胜任,且省去一整套构建链与依赖升级负担。
|
||
|
||
const $ = (sel) => document.querySelector(sel);
|
||
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||
|
||
// 操作类型的中文标签,与后端 syncer.OpKind 对应。
|
||
const OP_LABELS = {
|
||
create_group: '新建分组',
|
||
create_user: '新建用户',
|
||
update_user: '更新用户',
|
||
delete_user: '删除用户',
|
||
add_member: '加入分组',
|
||
remove_member: '移出分组',
|
||
};
|
||
|
||
const OP_BADGES = {
|
||
create_group: 'badge-success',
|
||
create_user: 'badge-success',
|
||
update_user: 'badge-info',
|
||
delete_user: 'badge-error',
|
||
add_member: 'badge-success',
|
||
remove_member: 'badge-error',
|
||
};
|
||
|
||
// 用户生命周期状态的中文标注。
|
||
const STATE_BADGES = {
|
||
active: ['在职', 'badge-success'],
|
||
leave_candidate: ['待发离职确认', 'badge-warning'],
|
||
pending_confirm: ['待人工确认离职', 'badge-warning'],
|
||
confirmed_leave: ['已确认离职', 'badge-error'],
|
||
deleted: ['已删除', 'badge-soft'],
|
||
};
|
||
|
||
// ---------------------------------------------------------------- 主题
|
||
|
||
function initTheme() {
|
||
const saved = localStorage.getItem('idsync-theme');
|
||
if (saved === 'light' || saved === 'dark') {
|
||
document.documentElement.dataset.theme = saved;
|
||
}
|
||
$('#themeToggle').addEventListener('click', () => {
|
||
const root = document.documentElement;
|
||
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
||
root.dataset.theme = next;
|
||
localStorage.setItem('idsync-theme', next);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- HTTP
|
||
|
||
async function api(path, options = {}) {
|
||
const res = await fetch(path, {
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
...options,
|
||
});
|
||
|
||
if (res.status === 401) {
|
||
showLogin();
|
||
throw new Error('登录状态已失效');
|
||
}
|
||
|
||
let body = null;
|
||
try {
|
||
body = await res.json();
|
||
} catch (_) {
|
||
// 非 JSON 响应(如上游超时返回的 HTML),下面按状态码统一处理
|
||
}
|
||
|
||
if (!res.ok) {
|
||
throw new Error((body && body.error) || `请求失败(HTTP ${res.status})`);
|
||
}
|
||
return body;
|
||
}
|
||
|
||
function showError(msg) {
|
||
const el = $('#globalError');
|
||
el.textContent = msg;
|
||
el.classList.remove('hidden');
|
||
setTimeout(() => el.classList.add('hidden'), 8000);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 视图切换
|
||
|
||
function showLogin() {
|
||
$('#loginView').classList.remove('hidden');
|
||
$('#loginView').classList.add('flex');
|
||
$('#appView').classList.add('hidden');
|
||
}
|
||
|
||
function showApp() {
|
||
$('#loginView').classList.add('hidden');
|
||
$('#loginView').classList.remove('flex');
|
||
$('#appView').classList.remove('hidden');
|
||
}
|
||
|
||
function switchView(name) {
|
||
$$('.view').forEach((el) => el.classList.add('hidden'));
|
||
const view = $(`#view-${name}`);
|
||
if (view) view.classList.remove('hidden');
|
||
|
||
$$('.tabs .tab').forEach((t) => {
|
||
t.classList.toggle('tab-active', t.dataset.view === name);
|
||
});
|
||
|
||
if (name === 'overview') { loadStatus(); loadPending(); }
|
||
if (name === 'users') loadUsers();
|
||
if (name === 'groups') loadGroups();
|
||
if (name === 'plan') loadPlan();
|
||
if (name === 'runs') loadRuns();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 登录
|
||
|
||
async function doLogin() {
|
||
const errEl = $('#loginError');
|
||
errEl.classList.add('hidden');
|
||
try {
|
||
await api('/api/login', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ username: $('#username').value, password: $('#password').value }),
|
||
});
|
||
showApp();
|
||
switchView('overview');
|
||
} catch (err) {
|
||
errEl.textContent = err.message;
|
||
errEl.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
async function doLogout() {
|
||
try {
|
||
await api('/api/logout', { method: 'POST' });
|
||
} catch (_) { /* 登出失败也要回登录页 */ }
|
||
showLogin();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 概览
|
||
|
||
async function loadStatus() {
|
||
try {
|
||
const d = await api('/api/status');
|
||
$('#statActive').textContent = d.counts.activeUsers;
|
||
$('#statPending').textContent = d.counts.pendingConfirm;
|
||
$('#statGroups').textContent = d.counts.departments;
|
||
|
||
$('#connStatus').innerHTML = [
|
||
statusRow('LLDAP 读(LDAP)', d.ldap.ok, d.ldap.error, d.ldap.url),
|
||
statusRow('飞书', d.feishu.ok, d.feishu.error, `App ID ${d.feishu.appId}`),
|
||
statusRow('LLDAP 写(GraphQL)', d.lldap.baseUrl ? true : false, '',
|
||
d.lldap.baseUrl || '未配置'),
|
||
].join('');
|
||
|
||
const skipped = d.counts.skipped || 0;
|
||
$('#skippedStatus').textContent = skipped;
|
||
$('#skippedStatusWrap').classList.toggle('hidden', skipped === 0);
|
||
|
||
$('#lastRunInfo').innerHTML = d.lastRun
|
||
? `<div class="space-y-1">
|
||
<div>时间:${fmtTime(d.lastRun.finishedAt)}</div>
|
||
<div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</div>
|
||
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} ·
|
||
删除 ${d.lastRun.deleted || 0} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
|
||
</div>`
|
||
: '<span class="text-base-content/50">暂无物化记录</span>';
|
||
} catch (err) {
|
||
showError(err.message);
|
||
}
|
||
}
|
||
|
||
function statusRow(name, ok, error, detail) {
|
||
const badge = ok
|
||
? '<span class="badge badge-success badge-sm">正常</span>'
|
||
: '<span class="badge badge-error badge-sm">异常</span>';
|
||
const extra = ok
|
||
? `<span class="text-base-content/60">${escapeHtml(detail || '')}</span>`
|
||
: `<span class="text-error">${escapeHtml(error || '')}</span>`;
|
||
return `<div class="flex items-center justify-between gap-3">
|
||
<span class="flex items-center gap-2"><span class="font-medium">${name}</span>${badge}</span>
|
||
${extra}
|
||
</div>`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 离职待确认
|
||
|
||
async function loadPending() {
|
||
try {
|
||
const d = await api('/api/resign/pending');
|
||
const rows = d.pending || [];
|
||
$('#pendingRows').innerHTML = rows.length
|
||
? rows.map((u) => `
|
||
<li class="flex items-center justify-between gap-3 py-1.5 border-b border-base-200 last:border-0">
|
||
<div>
|
||
<span class="font-medium">${escapeHtml(u.name || u.uid)}</span>
|
||
<span class="text-base-content/60 text-sm ml-2">${escapeHtml(u.uid)}</span>
|
||
</div>
|
||
<span class="text-base-content/40 text-sm whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</span>
|
||
</li>`).join('')
|
||
: '<li class="text-sm text-base-content/50 py-2">暂无待人工确认的离职。飞书离职卡片确认后,用户才会被删除。</li>';
|
||
} catch (err) {
|
||
$('#pendingRows').innerHTML = `<li class="text-error text-sm">${escapeHtml(err.message)}</li>`;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 快照
|
||
|
||
async function doSnapshot() {
|
||
const btn = $('#snapshotBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = '快照中…';
|
||
try {
|
||
await api('/api/snapshot', { method: 'POST' });
|
||
showError('快照完成:已把飞书现状写入镜像并推进离职流程。');
|
||
$('#globalError').className = 'alert alert-success text-sm mb-4';
|
||
loadPending();
|
||
} catch (err) {
|
||
showError(err.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = '立即快照';
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 用户
|
||
|
||
let usersCache = [];
|
||
|
||
async function loadUsers() {
|
||
try {
|
||
const d = await api('/api/users');
|
||
usersCache = d.users || [];
|
||
renderUsers();
|
||
} catch (err) {
|
||
showError(err.message);
|
||
}
|
||
}
|
||
|
||
function renderUsers() {
|
||
const kw = $('#userFilter').value.trim().toLowerCase();
|
||
const rows = usersCache.filter((u) => {
|
||
if (!kw) return true;
|
||
return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw));
|
||
});
|
||
|
||
$('#userRows').innerHTML = rows.map((u) => {
|
||
const st = STATE_BADGES[u.state] || [u.state || '未知', 'badge-soft'];
|
||
return `<tr>
|
||
<td class="font-medium">${escapeHtml(u.uid)}</td>
|
||
<td>${escapeHtml(u.name || '')}</td>
|
||
<td>${escapeHtml(u.email || '')}</td>
|
||
<td><span class="badge ${st[1]} badge-sm">${st[0]}</span></td>
|
||
<td>${(u.deptIds || []).length}</td>
|
||
<td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td>
|
||
<td>${u.skipped
|
||
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.skipReason || '')}">被跳过</span>`
|
||
: '<span class="text-base-content/40">—</span>'}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
$('#userEmpty').classList.toggle('hidden', rows.length > 0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 部门分组
|
||
|
||
async function loadGroups() {
|
||
try {
|
||
const d = await api('/api/groups');
|
||
const groups = d.groups || [];
|
||
$('#groupRows').innerHTML = groups.map((g) => `
|
||
<tr>
|
||
<td class="font-medium">${escapeHtml(g.Name || '')}</td>
|
||
<td>${escapeHtml(g.DeptID || '—')}</td>
|
||
<td>${escapeHtml(g.ParentID || '—')}</td>
|
||
<td>${g.GroupID || '—'}</td>
|
||
<td class="idsync-dn text-base-content/60">${escapeHtml(g.DN || '')}</td>
|
||
</tr>`).join('');
|
||
$('#groupEmpty').classList.toggle('hidden', groups.length > 0);
|
||
} catch (err) {
|
||
showError(err.message);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 变更预览
|
||
|
||
let currentPlan = null;
|
||
|
||
async function loadPlan() {
|
||
$('#planRows').innerHTML = '';
|
||
$('#planSummary').innerHTML = '';
|
||
$('#planWarnings').classList.add('hidden');
|
||
$('#planEmpty').classList.add('hidden');
|
||
try {
|
||
const plan = await api('/api/plan');
|
||
currentPlan = plan;
|
||
renderPlan(plan);
|
||
} catch (err) {
|
||
showError(err.message);
|
||
}
|
||
}
|
||
|
||
function renderPlan(plan) {
|
||
const s = plan.stats || {};
|
||
const cards = [
|
||
['新建用户', s.createUser, 'text-success'],
|
||
['更新用户', s.updateUser, 'text-info'],
|
||
['删除用户', s.deleteUser, 'text-error'],
|
||
['新建分组', s.createGroup, 'text-success'],
|
||
['加入分组', s.addMember, 'text-success'],
|
||
['移出分组', s.removeMember, 'text-error'],
|
||
];
|
||
|
||
$('#planSummary').innerHTML = cards.map(([label, value, cls]) => `
|
||
<div class="card bg-base-100 shadow-sm">
|
||
<div class="card-body p-4">
|
||
<span class="text-xs text-base-content/60">${label}</span>
|
||
<span class="text-2xl font-semibold ${value > 0 ? cls : 'text-base-content/30'}">${value || 0}</span>
|
||
</div>
|
||
</div>`).join('');
|
||
|
||
const warnings = plan.warnings || [];
|
||
if (warnings.length > 0) {
|
||
const el = $('#planWarnings');
|
||
el.innerHTML = `<div class="alert alert-warning text-sm">
|
||
<div>
|
||
<div class="font-medium mb-1">需要注意 ${warnings.length} 项</div>
|
||
<ul class="list-disc pl-5 space-y-1">
|
||
${warnings.map((w) => `<li>${escapeHtml(w)}</li>`).join('')}
|
||
</ul>
|
||
</div>
|
||
</div>`;
|
||
el.classList.remove('hidden');
|
||
}
|
||
|
||
const ops = plan.ops || [];
|
||
$('#planRows').innerHTML = ops.map((op) => `
|
||
<tr>
|
||
<td><span class="badge ${OP_BADGES[op.kind] || 'badge-soft'} badge-sm">${OP_LABELS[op.kind] || op.kind}</span></td>
|
||
<td class="font-medium">${escapeHtml(op.target)}</td>
|
||
<td>${escapeHtml(op.detail)}</td>
|
||
</tr>`).join('');
|
||
|
||
$('#planEmpty').classList.toggle('hidden', ops.length > 0);
|
||
}
|
||
|
||
// FlyonUI 的 overlay(modal)由 flyonui.js 的 HSOverlay 驱动:它切换 open/hidden class 来显隐。
|
||
// 不要改回原生 dialog.showModal()——那不会加上 .open,overlay-open 变体不生效,框就永远不可见。
|
||
function openApplyModal() {
|
||
if (window.HSOverlay) window.HSOverlay.open('#applyModal');
|
||
}
|
||
|
||
function closeApplyModal() {
|
||
if (window.HSOverlay) window.HSOverlay.close('#applyModal');
|
||
}
|
||
|
||
async function applyPlan(force = false) {
|
||
closeApplyModal();
|
||
try {
|
||
const d = await api('/api/sync', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ dryRun: false, force }),
|
||
});
|
||
const res = d.result || {};
|
||
showError(`物化完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},删除 ${res.Deleted || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`);
|
||
$('#globalError').className = (res.Failed > 0) ? 'alert alert-warning text-sm mb-4' : 'alert alert-success text-sm mb-4';
|
||
switchView('runs');
|
||
} catch (err) {
|
||
showError(err.message);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 物化历史
|
||
|
||
async function loadRuns() {
|
||
try {
|
||
const d = await api('/api/runs');
|
||
const runs = d.runs || [];
|
||
$('#runRows').innerHTML = runs.map((r) => `
|
||
<tr>
|
||
<td class="whitespace-nowrap">${fmtTime(r.startedAt)}</td>
|
||
<td>${r.mode === 'dry-run'
|
||
? '<span class="badge badge-soft badge-sm">预览</span>'
|
||
: '<span class="badge badge-primary badge-sm">执行</span>'}</td>
|
||
<td>${r.trigger === 'schedule' ? '定时' : '手动'}</td>
|
||
<td>${r.status === 'success'
|
||
? '<span class="badge badge-success badge-sm">成功</span>'
|
||
: '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td>
|
||
<td>${r.created}</td><td>${r.updated}</td><td>${r.deleted || 0}</td>
|
||
<td>${r.added}</td><td>${r.removed}</td>
|
||
<td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td>
|
||
</tr>`).join('');
|
||
$('#runEmpty').classList.toggle('hidden', runs.length > 0);
|
||
} catch (err) {
|
||
showError(err.message);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 工具
|
||
|
||
function fmtTime(s) {
|
||
if (!s) return '—';
|
||
const d = new Date(s);
|
||
if (isNaN(d.getTime())) return '—';
|
||
const p = (n) => String(n).padStart(2, '0');
|
||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s == null ? '' : s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 启动
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
initTheme();
|
||
|
||
$('#loginBtn').addEventListener('click', doLogin);
|
||
$('#password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
|
||
$('#username').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
|
||
$('#logoutBtn').addEventListener('click', doLogout);
|
||
|
||
$$('.tabs .tab').forEach((t) => {
|
||
t.addEventListener('click', () => switchView(t.dataset.view));
|
||
});
|
||
|
||
$('#userFilter').addEventListener('input', renderUsers);
|
||
$('#reloadUsers').addEventListener('click', loadUsers);
|
||
$('#reloadRuns').addEventListener('click', loadRuns);
|
||
$('#previewBtn').addEventListener('click', loadPlan);
|
||
$('#snapshotBtn').addEventListener('click', doSnapshot);
|
||
$('#refreshPending').addEventListener('click', loadPending);
|
||
|
||
$('#applyBtn').addEventListener('click', () => {
|
||
if (!currentPlan) return;
|
||
const s = currentPlan.stats || {};
|
||
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deleteUser || 0)
|
||
+ (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
|
||
if (total === 0) {
|
||
showError('没有需要应用的变更。');
|
||
return;
|
||
}
|
||
$('#applyModalBody').innerHTML = `
|
||
<div class="space-y-1">
|
||
<div>新建用户 <strong>${s.createUser || 0}</strong> · 更新用户 <strong>${s.updateUser || 0}</strong></div>
|
||
<div>删除用户 <strong>${s.deleteUser || 0}</strong></div>
|
||
<div>新建分组 <strong>${s.createGroup || 0}</strong> · 加入分组 <strong>${s.addMember || 0}</strong> · 移出分组 <strong>${s.removeMember || 0}</strong></div>
|
||
</div>`;
|
||
openApplyModal();
|
||
});
|
||
|
||
// 「取消」与右上角关闭按钮走 data-overlay 声明式关闭,无需额外监听。
|
||
$('#applyConfirm').addEventListener('click', () => applyPlan(false));
|
||
|
||
// 已有会话则直接进入主界面
|
||
api('/api/status')
|
||
.then(() => { showApp(); switchView('overview'); })
|
||
.catch(() => showLogin());
|
||
}); |