Files
caowangrenbo 1f11e615a9 fix: WebUI 应用变更确认框改用 FlyonUI overlay 组件
原实现混用了 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。
2026-09-30 00:41:03 +08:00

465 lines
16 KiB
JavaScript
Raw Permalink 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.
'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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ---------------------------------------------------------------- 启动
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());
});