公网 /account/ 下绝对路径 /api/admin 会被 OpenIM 接走,无条件 res.json() 把非 JSON 响应打成英文异常。按页面路径拼接前缀,并先看状态再解析。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
273 lines
11 KiB
HTML
273 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>员工账号管理</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: -apple-system, "Microsoft YaHei", "PingFang SC", sans-serif; background: #f3f5f7; color: #1f2329; padding: 24px; }
|
|
.wrap { max-width: 920px; margin: 0 auto; }
|
|
h1 { font-size: 20px; margin-bottom: 16px; }
|
|
.card { background: #fff; border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
|
|
.card h2 { font-size: 15px; margin-bottom: 12px; }
|
|
input, textarea, button { font: inherit; }
|
|
input, textarea { border: 1px solid #d0d5dd; border-radius: 6px; padding: 8px 10px; outline: none; }
|
|
input:focus, textarea:focus { border-color: #1677ff; }
|
|
button { border: none; border-radius: 6px; padding: 8px 16px; cursor: pointer; background: #1677ff; color: #fff; }
|
|
button.ghost { background: #eef1f5; color: #1f2329; }
|
|
button.danger { background: #f04438; }
|
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
|
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
.row input { flex: 1; min-width: 140px; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #eef1f5; }
|
|
th { color: #667085; font-weight: 500; }
|
|
.tag { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; }
|
|
.tag.on { background: #e7f6ec; color: #12934a; }
|
|
.tag.off { background: #fdecea; color: #d92d20; }
|
|
td button { padding: 4px 10px; font-size: 13px; margin-right: 6px; }
|
|
textarea { width: 100%; min-height: 110px; font-family: inherit; resize: vertical; }
|
|
.msg { margin-top: 10px; font-size: 13px; white-space: pre-wrap; max-height: 200px; overflow: auto; }
|
|
.msg.err { color: #d92d20; }
|
|
.msg.okm { color: #12934a; }
|
|
.hint { font-size: 12px; color: #98a2b3; margin-top: 6px; }
|
|
#loginBar { display: flex; gap: 8px; margin-bottom: 16px; }
|
|
#loginBar input { flex: 1; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<h1>员工账号管理</h1>
|
|
|
|
<div class="card">
|
|
<div id="loginBar">
|
|
<input id="token" type="password" placeholder="请输入管理口令">
|
|
<button onclick="saveToken()">确定</button>
|
|
</div>
|
|
<div id="msg" class="msg"></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>批量导入员工名单</h2>
|
|
<textarea id="csv" placeholder="每行一条:工号,姓名,初始密码 例如: 10001,张三,abc123456 10002,李四,abc123456"></textarea>
|
|
<div class="hint">支持逗号或 Tab 分隔(Excel 复制出来直接粘贴即可);重复工号会覆盖原账号(姓名与密码重置、状态恢复为启用)。</div>
|
|
<div class="row" style="margin-top:10px">
|
|
<button onclick="doImport()">导入</button>
|
|
<button class="ghost" onclick="$('fileInput').click()">选择文件导入</button>
|
|
<button class="ghost" onclick="downloadTemplate()">下载导入模板</button>
|
|
<input id="fileInput" type="file" accept=".csv,.txt" style="display:none" onchange="importFile(this)">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>添加员工</h2>
|
|
<div class="row">
|
|
<input id="addNo" placeholder="工号">
|
|
<input id="addName" placeholder="姓名">
|
|
<input id="addPwd" placeholder="初始密码(至少6位)">
|
|
<button onclick="addEmp()">添加</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>员工列表 <span id="total" style="color:#98a2b3;font-weight:400"></span></h2>
|
|
<div class="row" style="margin-bottom:10px"><button class="ghost" onclick="loadList()">刷新</button></div>
|
|
<table>
|
|
<thead><tr><th>工号</th><th>姓名</th><th>状态</th><th>更新时间</th><th>操作</th></tr></thead>
|
|
<tbody id="tbody"><tr><td colspan="5" style="color:#98a2b3">尚未加载</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const $ = (id) => document.getElementById(id);
|
|
$('token').value = localStorage.getItem('adminToken') || '';
|
|
|
|
function saveToken() { localStorage.setItem('adminToken', $('token').value.trim()); show('口令已保存', true); loadList(); }
|
|
function show(text, ok) { const m = $('msg'); m.textContent = text; m.className = 'msg ' + (ok ? 'okm' : 'err'); }
|
|
|
|
function adminApiUrl(apiPath, pagePath) {
|
|
const pathname = pagePath != null
|
|
? pagePath
|
|
: (typeof location !== 'undefined' ? location.pathname : '/');
|
|
let prefix = String(pathname || '/');
|
|
if (prefix.length > 1 && prefix.endsWith('/')) prefix = prefix.slice(0, -1);
|
|
if (prefix === '/admin' || prefix.endsWith('/admin')) {
|
|
prefix = prefix.slice(0, -6);
|
|
}
|
|
if (prefix === '/') prefix = '';
|
|
const route = apiPath.charAt(0) === '/' ? apiPath : '/' + apiPath;
|
|
return prefix + route;
|
|
}
|
|
|
|
function httpStatusHint(status) {
|
|
const n = Number(status);
|
|
if (n === 401) return '管理口令错误或未提供';
|
|
if (n === 403) return '没有权限执行此操作';
|
|
if (n === 404) return '接口地址不正确,请从管理页重新打开';
|
|
if (n === 502 || n === 503 || n === 504) return '服务暂时不可用,请稍后重试';
|
|
if (n >= 500) return '服务暂时出错,请稍后重试';
|
|
if (n >= 400) return '请求失败,请稍后重试';
|
|
return '';
|
|
}
|
|
|
|
function toUserMessage(err) {
|
|
const raw = err && typeof err === 'object' && err.message != null
|
|
? String(err.message)
|
|
: String(err || '');
|
|
const s = raw.trim();
|
|
if (!s) return '请求失败,请稍后重试';
|
|
if (/unexpected end of json|failed to execute ['"]json['"]|json parse|not valid json|unexpected token/i.test(s)) {
|
|
return '服务器返回了无法识别的内容';
|
|
}
|
|
if (/failed to fetch|networkerror|load failed|network request failed/i.test(s)) {
|
|
return '网络异常,请检查连接后重试';
|
|
}
|
|
return s;
|
|
}
|
|
|
|
async function readAdminJson(res) {
|
|
const status = res.status;
|
|
const ctype = String((res.headers && res.headers.get && res.headers.get('content-type')) || '').toLowerCase();
|
|
let text = '';
|
|
try {
|
|
text = await res.text();
|
|
} catch {
|
|
throw new Error(httpStatusHint(status) || '无法读取服务器响应');
|
|
}
|
|
const trimmed = (text || '').trim();
|
|
if (!trimmed) {
|
|
throw new Error(httpStatusHint(status) || '服务器没有返回内容');
|
|
}
|
|
const looksJson = ctype.indexOf('json') !== -1 || trimmed.charAt(0) === '{' || trimmed.charAt(0) === '[';
|
|
if (!looksJson) {
|
|
throw new Error(httpStatusHint(status) || '服务器返回了无法识别的内容');
|
|
}
|
|
let j;
|
|
try {
|
|
j = JSON.parse(trimmed);
|
|
} catch {
|
|
throw new Error(httpStatusHint(status) || '服务器返回了无法识别的内容');
|
|
}
|
|
if (!j || typeof j !== 'object') {
|
|
throw new Error(httpStatusHint(status) || '服务器返回了无法识别的内容');
|
|
}
|
|
if (j.code !== 0) {
|
|
throw new Error(j.msg || httpStatusHint(status) || '请求失败');
|
|
}
|
|
return j.data;
|
|
}
|
|
|
|
async function api(path, body) {
|
|
let res;
|
|
try {
|
|
res = await fetch(adminApiUrl(path), {
|
|
method: body ? 'POST' : 'GET',
|
|
headers: { 'Content-Type': 'application/json', 'admin-token': $('token').value.trim() },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
} catch (e) {
|
|
throw new Error(toUserMessage(e));
|
|
}
|
|
try {
|
|
return await readAdminJson(res);
|
|
} catch (e) {
|
|
throw new Error(toUserMessage(e));
|
|
}
|
|
}
|
|
|
|
async function loadList() {
|
|
try {
|
|
const d = await api('/api/admin/employees');
|
|
$('total').textContent = '(共 ' + d.total + ' 人)';
|
|
$('tbody').innerHTML = d.employees.map(e => `
|
|
<tr>
|
|
<td>${e.staffNo}</td><td>${e.name}</td>
|
|
<td><span class="tag ${e.status === 'active' ? 'on' : 'off'}">${e.status === 'active' ? '启用' : '已停用'}</span></td>
|
|
<td>${(e.updatedAt || '').replace('T', ' ').slice(0, 19)}</td>
|
|
<td>
|
|
${e.status === 'active'
|
|
? `<button class="danger" onclick="toggle('${e.staffNo}', false)">停用</button>`
|
|
: `<button class="ghost" onclick="toggle('${e.staffNo}', true)">启用</button>`}
|
|
<button class="ghost" onclick="resetPwd('${e.staffNo}')">重置密码</button>
|
|
<button class="danger" onclick="deleteEmp('${e.staffNo}')">删除</button>
|
|
</td>
|
|
</tr>`).join('') || '<tr><td colspan="5" style="color:#98a2b3">暂无员工,请先导入</td></tr>';
|
|
} catch (e) { show(toUserMessage(e), false); }
|
|
}
|
|
|
|
async function toggle(staffNo, enable) {
|
|
try {
|
|
await api('/api/admin/' + (enable ? 'enable' : 'disable'), { staffNo });
|
|
show((enable ? '已启用 ' : '已停用 ') + staffNo, true);
|
|
loadList();
|
|
} catch (e) { show(toUserMessage(e), false); }
|
|
}
|
|
|
|
async function resetPwd(staffNo) {
|
|
const pwd = prompt('为 ' + staffNo + ' 设置新密码(至少 6 位):');
|
|
if (!pwd) return;
|
|
try {
|
|
await api('/api/admin/reset_password', { staffNo, password: pwd });
|
|
show('已重置 ' + staffNo + ' 的密码', true);
|
|
loadList();
|
|
} catch (e) { show(toUserMessage(e), false); }
|
|
}
|
|
|
|
async function addEmp() {
|
|
try {
|
|
await api('/api/admin/employees', { staffNo: $('addNo').value.trim(), name: $('addName').value.trim(), password: $('addPwd').value });
|
|
show('已添加 ' + $('addNo').value.trim(), true);
|
|
$('addNo').value = $('addName').value = $('addPwd').value = '';
|
|
loadList();
|
|
} catch (e) { show(toUserMessage(e), false); }
|
|
}
|
|
|
|
async function doImport() {
|
|
try {
|
|
const d = await api('/api/admin/import', { csv: $('csv').value });
|
|
const bad = d.results.filter(r => !r.ok);
|
|
show('导入完成:成功 ' + d.imported + ' / ' + d.total + (bad.length ? '\n失败明细:\n' + bad.map(b => b.line + ' → ' + b.msg).join('\n') : ''), bad.length === 0);
|
|
loadList();
|
|
} catch (e) { show(toUserMessage(e), false); }
|
|
}
|
|
|
|
// 直接选择模板 CSV 文件导入,不用复制粘贴
|
|
async function importFile(input) {
|
|
const file = input.files[0];
|
|
input.value = '';
|
|
if (!file) return;
|
|
try {
|
|
let text = await file.text();
|
|
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1); // 去 BOM
|
|
const d = await api('/api/admin/import', { csv: text });
|
|
const bad = d.results.filter(r => !r.ok);
|
|
show('文件「' + file.name + '」导入完成:成功 ' + d.imported + ' / ' + d.total + (bad.length ? '\n失败明细:\n' + bad.map(b => b.line + ' → ' + b.msg).join('\n') : ''), bad.length === 0);
|
|
loadList();
|
|
} catch (e) { show(toUserMessage(e), false); }
|
|
}
|
|
|
|
async function deleteEmp(staffNo) {
|
|
if (!confirm('确定删除 ' + staffNo + ' 吗?删除后该员工立即无法登录,需要重新添加才能恢复。')) return;
|
|
try {
|
|
await api('/api/admin/delete', { staffNo });
|
|
show('已删除 ' + staffNo, true);
|
|
loadList();
|
|
} catch (e) { show(toUserMessage(e), false); }
|
|
}
|
|
|
|
// 下载 CSV 导入模板(加 BOM 让 Excel 正确识别中文)
|
|
function downloadTemplate() {
|
|
const csv = '\uFEFF工号,姓名,初始密码\n10001,张三,abc123456\n10002,李四,abc123456\n';
|
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = '员工导入模板.csv';
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|