feat: SN防伪后端 api/sn.php、8张SN数据表与玩家端/管理端SN页面
This commit is contained in:
@@ -164,6 +164,7 @@
|
|||||||
<div class="si" data-p="online_curve" onclick="go('online_curve')">📈 在线曲线</div>
|
<div class="si" data-p="online_curve" onclick="go('online_curve')">📈 在线曲线</div>
|
||||||
<div class="si" data-p="reset_requests" onclick="go('reset_requests')">🔑 密码重置审核</div>
|
<div class="si" data-p="reset_requests" onclick="go('reset_requests')">🔑 密码重置审核</div>
|
||||||
<div class="si" data-p="tickets" onclick="go('tickets')">📋 工单管理</div>
|
<div class="si" data-p="tickets" onclick="go('tickets')">📋 工单管理</div>
|
||||||
|
<div class="si" data-p="sn" onclick="go('sn')">🔢 SN防伪管理</div>
|
||||||
<div class="si" data-p="lands" onclick="go('lands')">🏡 领地管理</div>
|
<div class="si" data-p="lands" onclick="go('lands')">🏡 领地管理</div>
|
||||||
<div class="si" data-p="usergroups" onclick="go('usergroups')">👥 用户组</div>
|
<div class="si" data-p="usergroups" onclick="go('usergroups')">👥 用户组</div>
|
||||||
<div class="si" data-p="cashier" onclick="openCashierPage()">🧾 收银台(独立页)</div>
|
<div class="si" data-p="cashier" onclick="openCashierPage()">🧾 收银台(独立页)</div>
|
||||||
@@ -235,6 +236,7 @@ function go(p) {
|
|||||||
else if (p==='online_curve') loadOnlineCurve(c);
|
else if (p==='online_curve') loadOnlineCurve(c);
|
||||||
else if (p==='reset_requests') loadResetRequests(c);
|
else if (p==='reset_requests') loadResetRequests(c);
|
||||||
else if (p==='tickets') loadTickets(c);
|
else if (p==='tickets') loadTickets(c);
|
||||||
|
else if (p==='sn') loadSn(c);
|
||||||
else if (p==='lands') loadLands(c);
|
else if (p==='lands') loadLands(c);
|
||||||
else if (p==='usergroups') loadUserGroups(c);
|
else if (p==='usergroups') loadUserGroups(c);
|
||||||
else if (p==='cashier') loadCashier(c);
|
else if (p==='cashier') loadCashier(c);
|
||||||
@@ -3987,6 +3989,375 @@ async function loadOnlineCurve(el) {
|
|||||||
} catch (e) { el.innerHTML = '<div class="card">网络错误: '+escAdmHtml(e.message)+'</div>'; }
|
} catch (e) { el.innerHTML = '<div class="card">网络错误: '+escAdmHtml(e.message)+'</div>'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== SN 防伪管理 ====================
|
||||||
|
const snAdm = { tab: 'items', kw: '', status: '', page: 1, size: 20, sn: '' };
|
||||||
|
|
||||||
|
async function snAdmApi(action, params = {}) {
|
||||||
|
const url = new URL('api/sn.php', location.href);
|
||||||
|
url.searchParams.set('action', action);
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v === '' || v === null || v === undefined) continue;
|
||||||
|
url.searchParams.set(k, v);
|
||||||
|
}
|
||||||
|
const res = await fetch(url, { credentials: 'same-origin' });
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSn(el) {
|
||||||
|
snAdm.tab = 'items';
|
||||||
|
snAdm.page = 1;
|
||||||
|
snAdm.kw = '';
|
||||||
|
snAdm.status = '';
|
||||||
|
await renderSnAdmin(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSnAdmin(el) {
|
||||||
|
el.innerHTML = '<div class="card"><p style="color:var(--dim)">加载中...</p></div>';
|
||||||
|
try {
|
||||||
|
if (snAdm.tab === 'lost') { await renderSnLost(el); return; }
|
||||||
|
if (snAdm.tab === 'stats') { await renderSnStats(el); return; }
|
||||||
|
if (snAdm.tab === 'detail') { await renderSnDetail(el, snAdm.sn); return; }
|
||||||
|
await renderSnItems(el);
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = '<div class="card"><p style="color:var(--red)">SN管理加载失败: ' + escAdmHtml(e.message) + '</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function snTabs() {
|
||||||
|
const t = snAdm.tab;
|
||||||
|
const base = (t === 'detail' ? 'items' : t);
|
||||||
|
const mk = (k, label) => '<div class="tab ' + (base === k ? 'active' : '') + '" onclick="snAdmTab(\'' + k + '\')">' + label + '</div>';
|
||||||
|
return '<div class="tabs">' +
|
||||||
|
mk('items', '📦 设备列表') +
|
||||||
|
mk('lost', '🚨 报失工单') +
|
||||||
|
mk('stats', '📊 统计') +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function snAdmTab(tab) {
|
||||||
|
snAdm.tab = tab;
|
||||||
|
snAdm.page = 1;
|
||||||
|
renderSnAdmin(document.getElementById('C'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const SN_STATUS_CN = {
|
||||||
|
active: '有效', destroyed: '已销毁解绑', cancelled: '已注销',
|
||||||
|
lost: '报失处理中', illegal: '非法绑定', reissued: '已补发作废'
|
||||||
|
};
|
||||||
|
const SN_LOST_CN = {
|
||||||
|
checking: '⏳ 核查中', manual: '🧑💻 待人工', rejected: '🚫 已驳回',
|
||||||
|
auto_reissue: '♻️ 已补发', done: '✅ 已完成'
|
||||||
|
};
|
||||||
|
|
||||||
|
function snStatusCn(s) { return SN_STATUS_CN[s] || s; }
|
||||||
|
|
||||||
|
function snAdmSearch() {
|
||||||
|
snAdm.kw = (document.getElementById('snKw') || {}).value || '';
|
||||||
|
snAdm.status = (document.getElementById('snSt') || {}).value || '';
|
||||||
|
snAdm.page = 1;
|
||||||
|
renderSnAdmin(document.getElementById('C'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function snAdmPage(p) {
|
||||||
|
snAdm.page = p;
|
||||||
|
renderSnAdmin(document.getElementById('C'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSnItems(el) {
|
||||||
|
const r = await snAdmApi('admin_list', {
|
||||||
|
kw: snAdm.kw, status: snAdm.status, page: snAdm.page, size: snAdm.size
|
||||||
|
});
|
||||||
|
if (!r.success) { el.innerHTML = '<div class="card"><p style="color:var(--red)">' + escAdmHtml(r.message || '加载失败') + '</p></div>'; return; }
|
||||||
|
const d = r.data || {};
|
||||||
|
const list = d.list || [];
|
||||||
|
const total = d.total || 0;
|
||||||
|
const pages = Math.max(1, Math.ceil(total / snAdm.size));
|
||||||
|
|
||||||
|
let html = '<div class="card"><h2>🔢 SN防伪管理</h2>' + snTabs() +
|
||||||
|
'<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">' +
|
||||||
|
'<input id="snKw" type="text" placeholder="搜索 SN / 玩家 / 领地..." value="' + escAdmHtml(snAdm.kw) + '" style="flex:1;min-width:180px">' +
|
||||||
|
'<select id="snSt" style="min-width:130px">' +
|
||||||
|
'<option value="">全部状态</option>';
|
||||||
|
for (const [k, v] of Object.entries(SN_STATUS_CN)) {
|
||||||
|
html += '<option value="' + k + '"' + (snAdm.status === k ? ' selected' : '') + '>' + v + '</option>';
|
||||||
|
}
|
||||||
|
html += '</select>' +
|
||||||
|
'<button class="btn btn-primary" onclick="snAdmSearch()">🔍 查询</button>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
html += '<p style="color:var(--dim)">暂无数据。游戏服每 30 秒推送一次目录快照。</p></div>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">' +
|
||||||
|
'<tr style="color:var(--dim);text-align:left">' +
|
||||||
|
'<th style="padding:6px">SN码</th><th style="padding:6px">类型</th><th style="padding:6px">状态</th>' +
|
||||||
|
'<th style="padding:6px">主人</th><th style="padding:6px">位置</th><th style="padding:6px">签发时间</th>' +
|
||||||
|
'<th style="padding:6px">操作</th></tr>';
|
||||||
|
for (const row of list) {
|
||||||
|
html += '<tr style="border-top:1px solid var(--border)">' +
|
||||||
|
'<td style="padding:7px 6px;font-family:monospace;word-break:break-all">' + escAdmHtml(row.sn) + '</td>' +
|
||||||
|
'<td style="padding:7px 6px">' + escAdmHtml(row.type_cn || '') + '</td>' +
|
||||||
|
'<td style="padding:7px 6px;color:' + (row.status === 'active' ? 'var(--green)' : (row.status === 'illegal' ? 'var(--red)' : 'var(--yellow)')) + '">' + escAdmHtml(row.status_cn || snStatusCn(row.status)) + '</td>' +
|
||||||
|
'<td style="padding:7px 6px">' + escAdmHtml(row.owner || '') + '</td>' +
|
||||||
|
'<td style="padding:7px 6px;color:var(--dim)">' + escAdmHtml(row.loc_desc || '') + '</td>' +
|
||||||
|
'<td style="padding:7px 6px;color:var(--dim);white-space:nowrap">' + escAdmHtml(row.issue_time_str || '') + '</td>' +
|
||||||
|
'<td style="padding:7px 6px;white-space:nowrap">' +
|
||||||
|
'<button class="btn btn-dim" style="padding:3px 8px;font-size:12px" onclick="snAdmDetail(\'' + escAdmHtml(row.sn) + '\')">详情</button>' +
|
||||||
|
'</td></tr>';
|
||||||
|
}
|
||||||
|
html += '</table></div>';
|
||||||
|
|
||||||
|
html += '<div style="display:flex;gap:8px;align-items:center;margin-top:12px;flex-wrap:wrap">' +
|
||||||
|
'<button class="btn" ' + (snAdm.page <= 1 ? 'disabled' : '') + ' onclick="snAdmPage(' + (snAdm.page - 1) + ')">上一页</button>' +
|
||||||
|
'<span style="color:var(--dim);font-size:13px">第 ' + snAdm.page + ' / ' + pages + ' 页 · 共 ' + total + ' 条</span>' +
|
||||||
|
'<button class="btn" ' + (snAdm.page >= pages ? 'disabled' : '') + ' onclick="snAdmPage(' + (snAdm.page + 1) + ')">下一页</button>' +
|
||||||
|
'</div></div>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snAdmDetail(sn) {
|
||||||
|
snAdm.tab = 'detail';
|
||||||
|
snAdm.sn = sn;
|
||||||
|
renderSnAdmin(document.getElementById('C'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function snAdmBack() {
|
||||||
|
snAdm.tab = 'items';
|
||||||
|
renderSnAdmin(document.getElementById('C'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function snFmtTs(ts) {
|
||||||
|
const n = parseInt(ts, 10) || 0;
|
||||||
|
if (!n) return '';
|
||||||
|
const t = n >= 100000000000 ? Math.floor(n / 1000) : n;
|
||||||
|
const d = new Date(t * 1000);
|
||||||
|
const p = x => (x < 10 ? '0' : '') + x;
|
||||||
|
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' +
|
||||||
|
p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSnDetail(el, sn) {
|
||||||
|
const r = await snAdmApi('admin_detail', { sn: sn });
|
||||||
|
if (!r.success) {
|
||||||
|
el.innerHTML = '<div class="card"><p style="color:var(--red)">' + escAdmHtml(r.message || '加载失败') + '</p>' +
|
||||||
|
'<button class="btn" onclick="snAdmBack()">← 返回</button></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const d = r.data || {};
|
||||||
|
const it = d.sn || {};
|
||||||
|
let html = '<div class="card">' +
|
||||||
|
'<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:12px">' +
|
||||||
|
'<h2 style="margin:0">🔢 ' + escAdmHtml(it.sn || sn) + '</h2>' +
|
||||||
|
'<button class="btn" onclick="snAdmBack()">← 返回列表</button></div>' +
|
||||||
|
'<table style="width:100%;border-collapse:collapse;font-size:13px">' +
|
||||||
|
snAdmRow('类型', it.type_cn) +
|
||||||
|
snAdmRow('状态', it.status_cn) +
|
||||||
|
snAdmRow('主人', it.owner) +
|
||||||
|
snAdmRow('签发时间', it.issue_time_str) +
|
||||||
|
snAdmRow('当前位置', it.loc_desc) +
|
||||||
|
snAdmRow('绑定原因', it.bind_reason ? (it.bind_reason + ' @ ' + snFmtTs(it.bind_time)) : '-') +
|
||||||
|
snAdmRow('报失次数', it.lost_count) +
|
||||||
|
'</table>';
|
||||||
|
|
||||||
|
html += '<div style="display:flex;gap:8px;margin-top:14px;flex-wrap:wrap">' +
|
||||||
|
'<button class="btn" onclick="snAdmCmd(\'locate\')">📍 立即定位</button>' +
|
||||||
|
'<button class="btn btn-yellow" onclick="snAdmCmd(\'reissue\')">♻️ 强制补发</button>' +
|
||||||
|
'<button class="btn btn-red" onclick="snAdmCmd(\'cancel\')">🗑️ 注销</button>' +
|
||||||
|
'<button class="btn btn-red" onclick="snAdmCmd(\'bind\')">🔒 永久绑定</button>' +
|
||||||
|
'<button class="btn btn-yellow" onclick="snAdmCmd(\'report_check\')">🚩 报失核查</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<p style="color:var(--dim);font-size:12px;margin-top:8px">命令通过 web_sn_commands 下发,游戏服 10~30 秒内执行并回执。</p>';
|
||||||
|
|
||||||
|
html += '<h3 style="margin:18px 0 8px;font-size:14px">📨 命令记录</h3>';
|
||||||
|
const cmds = d.commands || [];
|
||||||
|
if (!cmds.length) html += '<p style="color:var(--dim);font-size:13px">暂无</p>';
|
||||||
|
else {
|
||||||
|
html += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px">' +
|
||||||
|
'<tr style="color:var(--dim);text-align:left"><th style="padding:5px">ID</th><th style="padding:5px">命令</th><th style="padding:5px">状态</th>' +
|
||||||
|
'<th style="padding:5px">原因</th><th style="padding:5px">结果</th><th style="padding:5px">时间</th></tr>';
|
||||||
|
for (const c of cmds) {
|
||||||
|
html += '<tr style="border-top:1px solid var(--border)">' +
|
||||||
|
'<td style="padding:5px">' + c.id + '</td>' +
|
||||||
|
'<td style="padding:5px">' + escAdmHtml(c.cmd) + '</td>' +
|
||||||
|
'<td style="padding:5px">' + escAdmHtml(c.status) + '</td>' +
|
||||||
|
'<td style="padding:5px;color:var(--dim)">' + escAdmHtml(c.reason) + '</td>' +
|
||||||
|
'<td style="padding:5px;color:var(--dim);word-break:break-all">' + escAdmHtml(c.result || '') + '</td>' +
|
||||||
|
'<td style="padding:5px;color:var(--dim);white-space:nowrap">' + snFmtTs(c.created_at) + '</td></tr>';
|
||||||
|
}
|
||||||
|
html += '</table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<h3 style="margin:18px 0 8px;font-size:14px">📜 操作日志</h3>' + snAdmLogTable(d.logs || [], ['时间', '动作', '玩家', '说明'], r => [
|
||||||
|
snFmtTs(r.time), r.action, r.player, r.detail
|
||||||
|
]);
|
||||||
|
|
||||||
|
html += '<h3 style="margin:18px 0 8px;font-size:14px">📦 出入库登记</h3>' + snAdmLogTable(d.stock || [], ['时间', '动作', '容器', '坐标', '领地', '玩家'], r => [
|
||||||
|
snFmtTs(r.time), r.action, r.container_type || '-',
|
||||||
|
(r.world || '') + ' ' + r.x + ',' + r.y + ',' + r.z,
|
||||||
|
r.in_land ? (r.land_name || '是') : '-', r.player || '-'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const losts = d.losts || [];
|
||||||
|
html += '<h3 style="margin:18px 0 8px;font-size:14px">🚨 报失记录</h3>' +
|
||||||
|
snAdmLogTable(losts, ['时间', '状态', '次数', '原因', '结论'], r => [
|
||||||
|
snFmtTs(r.created_at), SN_LOST_CN[r.status] || r.status, r.report_count, r.reason || '-', r.result || '-'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (d.held) {
|
||||||
|
html += '<h3 style="margin:18px 0 8px;font-size:14px">📡 最近上报(手持快照)</h3>' +
|
||||||
|
'<p style="color:var(--dim);font-size:13px">持有者 ' + escAdmHtml(d.held.holder || '-') +
|
||||||
|
' · 主人 ' + escAdmHtml(d.held.owner || '-') +
|
||||||
|
' · ' + (d.held.held ? '手持中' : '在背包') +
|
||||||
|
' · ' + snFmtTs(d.held.time) + '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snAdmRow(k, v) {
|
||||||
|
return '<tr style="border-top:1px solid var(--border)">' +
|
||||||
|
'<td style="padding:7px 6px;color:var(--dim);width:120px">' + escAdmHtml(k) + '</td>' +
|
||||||
|
'<td style="padding:7px 6px;word-break:break-all">' + escAdmHtml(v == null || v === '' ? '-' : String(v)) + '</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function snAdmLogTable(rows, heads, mapper) {
|
||||||
|
if (!rows.length) return '<p style="color:var(--dim);font-size:13px">暂无记录</p>';
|
||||||
|
let h = '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px">' +
|
||||||
|
'<tr style="color:var(--dim);text-align:left">';
|
||||||
|
for (const t of heads) h += '<th style="padding:5px">' + t + '</th>';
|
||||||
|
h += '</tr>';
|
||||||
|
for (const r of rows) {
|
||||||
|
const cells = mapper(r);
|
||||||
|
h += '<tr style="border-top:1px solid var(--border)">';
|
||||||
|
for (const c of cells) {
|
||||||
|
h += '<td style="padding:5px;word-break:break-all">' + escAdmHtml(c == null ? '' : String(c)) + '</td>';
|
||||||
|
}
|
||||||
|
h += '</tr>';
|
||||||
|
}
|
||||||
|
return h + '</table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snAdmCmd(cmd) {
|
||||||
|
const sn = snAdm.sn;
|
||||||
|
if (!sn) return;
|
||||||
|
const names = { locate: '定位', reissue: '强制补发', cancel: '注销', bind: '永久绑定', report_check: '报失核查' };
|
||||||
|
const warns = {
|
||||||
|
locate: '立即查询该SN在游戏世界中的位置?',
|
||||||
|
reissue: '强制补发 ' + sn + '?旧SN作废并签发新SN,玩家需在线。',
|
||||||
|
cancel: '注销 ' + sn + '?将销毁对应实物并进入1小时冷静期。',
|
||||||
|
bind: '把 ' + sn + ' 永久绑定为非法物品?该SN不可恢复。',
|
||||||
|
report_check: '立即核查 ' + sn + ' 的当前位置并给出报失结论?'
|
||||||
|
};
|
||||||
|
if (!await glassConfirm(warns[cmd] || ('确认对 ' + sn + ' 执行「' + names[cmd] + '」?'))) return;
|
||||||
|
try {
|
||||||
|
const r = await snAdmApi('admin_cmd', { cmd: cmd, sn: sn, reason: '管理员手动执行' });
|
||||||
|
if (!r.success) { toast(r.message || '下发失败', 'err'); return; }
|
||||||
|
toast(r.message || '命令已下发', 'ok');
|
||||||
|
setTimeout(() => renderSnAdmin(document.getElementById('C')), 1200);
|
||||||
|
} catch (e) {
|
||||||
|
toast('下发失败: ' + e.message, 'err');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSnLost(el) {
|
||||||
|
const r = await snAdmApi('admin_lost_list', { page: 1, size: 100 });
|
||||||
|
if (!r.success) { el.innerHTML = '<div class="card"><p style="color:var(--red)">' + escAdmHtml(r.message || '加载失败') + '</p></div>'; return; }
|
||||||
|
const list = (r.data && r.data.list) || [];
|
||||||
|
const total = (r.data && r.data.total) || 0;
|
||||||
|
|
||||||
|
let html = '<div class="card"><h2>🚨 SN 报失工单 (' + total + ')</h2>' + snTabs();
|
||||||
|
if (!list.length) {
|
||||||
|
html += '<p style="color:var(--dim)">暂无报失工单。</p></div>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
html += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">' +
|
||||||
|
'<tr style="color:var(--dim);text-align:left">' +
|
||||||
|
'<th style="padding:6px">ID</th><th style="padding:6px">SN</th><th style="padding:6px">类型</th>' +
|
||||||
|
'<th style="padding:6px">玩家</th><th style="padding:6px">状态</th><th style="padding:6px">次数</th>' +
|
||||||
|
'<th style="padding:6px">原因 / 结论</th><th style="padding:6px">时间</th><th style="padding:6px">操作</th></tr>';
|
||||||
|
for (const row of list) {
|
||||||
|
const pending = (row.status === 'checking' || row.status === 'manual');
|
||||||
|
html += '<tr style="border-top:1px solid var(--border)">' +
|
||||||
|
'<td style="padding:7px 6px">' + row.id + '</td>' +
|
||||||
|
'<td style="padding:7px 6px;font-family:monospace;word-break:break-all">' +
|
||||||
|
'<a href="javascript:void(0)" onclick="snAdmDetail(\'' + escAdmHtml(row.sn) + '\')" style="color:var(--accent)">' + escAdmHtml(row.sn) + '</a></td>' +
|
||||||
|
'<td style="padding:7px 6px">' + escAdmHtml(row.type_cn || '') + '</td>' +
|
||||||
|
'<td style="padding:7px 6px">' + escAdmHtml(row.player) + '</td>' +
|
||||||
|
'<td style="padding:7px 6px">' + escAdmHtml(SN_LOST_CN[row.status] || row.status) + '</td>' +
|
||||||
|
'<td style="padding:7px 6px">' + (row.report_count || 1) + '</td>' +
|
||||||
|
'<td style="padding:7px 6px;color:var(--dim);word-break:break-all">' + escAdmHtml((row.reason || '') + (row.result ? ' → ' + row.result : '')) + '</td>' +
|
||||||
|
'<td style="padding:7px 6px;color:var(--dim);white-space:nowrap">' + snFmtTs(row.created_at) + '</td>' +
|
||||||
|
'<td style="padding:7px 6px;white-space:nowrap">';
|
||||||
|
if (pending) {
|
||||||
|
html += '<button class="btn btn-dim" style="padding:3px 8px;font-size:12px" onclick="snAdmLostHandle(' + row.id + ',\'approve\')">通过补发</button> ' +
|
||||||
|
'<button class="btn btn-dim" style="padding:3px 8px;font-size:12px" onclick="snAdmLostHandle(' + row.id + ',\'reject\')">驳回</button>';
|
||||||
|
} else {
|
||||||
|
html += '<span style="color:var(--dim)">已处理</span>';
|
||||||
|
}
|
||||||
|
html += '</td></tr>';
|
||||||
|
}
|
||||||
|
html += '</table></div></div>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snAdmLostHandle(id, act) {
|
||||||
|
const tip = act === 'approve'
|
||||||
|
? '通过该报失工单?将立即向玩家下发补发命令。'
|
||||||
|
: '驳回该报失工单?';
|
||||||
|
if (!await glassConfirm(tip)) return;
|
||||||
|
let reason = act === 'approve' ? '人工核查通过' : '人工核查未通过';
|
||||||
|
if (act === 'reject') {
|
||||||
|
try {
|
||||||
|
const v = await showModal('驳回报失', '请填写驳回原因', '人工核查未通过');
|
||||||
|
if (v === false || v === null || v === undefined) return;
|
||||||
|
if (String(v).trim()) reason = String(v).trim();
|
||||||
|
} catch (e) { /* 保持默认原因 */ }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await snAdmApi('admin_lost_handle', { id: id, act: act, reason: reason });
|
||||||
|
if (!r.success) { toast(r.message || '处理失败', 'err'); return; }
|
||||||
|
toast(r.message || '处理成功', 'ok');
|
||||||
|
renderSnAdmin(document.getElementById('C'));
|
||||||
|
} catch (e) {
|
||||||
|
toast('处理失败: ' + e.message, 'err');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSnStats(el) {
|
||||||
|
const r = await snAdmApi('admin_stats', {});
|
||||||
|
if (!r.success) { el.innerHTML = '<div class="card"><p style="color:var(--red)">' + escAdmHtml(r.message || '加载失败') + '</p></div>'; return; }
|
||||||
|
const d = r.data || {};
|
||||||
|
let html = '<div class="card"><h2>📊 SN 统计</h2>' + snTabs() +
|
||||||
|
'<table style="width:100%;border-collapse:collapse;font-size:13px;margin-top:8px">' +
|
||||||
|
snAdmRow('SN 总数', d.total || 0) +
|
||||||
|
snAdmRow('待处理报失', d.lost_pending || 0) +
|
||||||
|
snAdmRow('事件条数', d.events || 0) +
|
||||||
|
'</table>' +
|
||||||
|
'<h3 style="margin:18px 0 8px;font-size:14px">按状态</h3>';
|
||||||
|
const bs = d.by_status || {};
|
||||||
|
if (!Object.keys(bs).length) html += '<p style="color:var(--dim)">暂无数据</p>';
|
||||||
|
else {
|
||||||
|
html += '<table style="width:100%;border-collapse:collapse;font-size:13px">';
|
||||||
|
for (const [k, v] of Object.entries(bs)) html += snAdmRow(snStatusCn(k) + ' (' + k + ')', v);
|
||||||
|
html += '</table>';
|
||||||
|
}
|
||||||
|
html += '<h3 style="margin:18px 0 8px;font-size:14px">按类型</h3>';
|
||||||
|
const bt = d.by_type || {};
|
||||||
|
if (!Object.keys(bt).length) html += '<p style="color:var(--dim)">暂无数据</p>';
|
||||||
|
else {
|
||||||
|
html += '<table style="width:100%;border-collapse:collapse;font-size:13px">';
|
||||||
|
for (const [k, v] of Object.entries(bt)) html += snAdmRow(k, v);
|
||||||
|
html += '</table>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
// 切换页面时清理定时器
|
// 切换页面时清理定时器
|
||||||
const _origGo = go;
|
const _origGo = go;
|
||||||
go = function(p) {
|
go = function(p) {
|
||||||
|
|||||||
+1059
File diff suppressed because it is too large
Load Diff
@@ -811,6 +811,122 @@ function initTables(SQLite3 $db) {
|
|||||||
// 列已存在 → 忽略
|
// 列已存在 → 忽略
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ===== SN 防刷系统(PHP 端镜像 + 报失/命令队列)=====
|
||||||
|
// 物品 SN 全量镜像:由 Java 定时 push_catalog 同步
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS web_item_sn (
|
||||||
|
sn TEXT PRIMARY KEY,
|
||||||
|
item_type TEXT DEFAULT '',
|
||||||
|
owner TEXT DEFAULT '',
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
issue_time INTEGER DEFAULT 0,
|
||||||
|
loc_type TEXT DEFAULT '',
|
||||||
|
loc_player TEXT DEFAULT '',
|
||||||
|
loc_world TEXT DEFAULT '',
|
||||||
|
loc_x INTEGER DEFAULT 0,
|
||||||
|
loc_y INTEGER DEFAULT 0,
|
||||||
|
loc_z INTEGER DEFAULT 0,
|
||||||
|
container_type TEXT DEFAULT '',
|
||||||
|
in_land INTEGER DEFAULT 0,
|
||||||
|
land_name TEXT DEFAULT '',
|
||||||
|
last_seen INTEGER DEFAULT 0,
|
||||||
|
cancel_time INTEGER DEFAULT 0,
|
||||||
|
lost_count INTEGER DEFAULT 0,
|
||||||
|
lost_state TEXT DEFAULT '',
|
||||||
|
remark TEXT DEFAULT '',
|
||||||
|
bind_reason TEXT DEFAULT '',
|
||||||
|
bind_time INTEGER DEFAULT 0,
|
||||||
|
updated_at INTEGER DEFAULT 0
|
||||||
|
)");
|
||||||
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_item_sn_owner ON web_item_sn(owner, item_type)");
|
||||||
|
|
||||||
|
// SN 操作日志尾部(Java sn_log 镜像)
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS web_sn_log (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
sn TEXT DEFAULT '',
|
||||||
|
item_type TEXT DEFAULT '',
|
||||||
|
action TEXT DEFAULT '',
|
||||||
|
player TEXT DEFAULT '',
|
||||||
|
detail TEXT DEFAULT '',
|
||||||
|
time INTEGER DEFAULT 0
|
||||||
|
)");
|
||||||
|
|
||||||
|
// 出入库登记(容器坐标 / 是否在领地 / 领地名)
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS web_sn_stock (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
sn TEXT DEFAULT '',
|
||||||
|
item_type TEXT DEFAULT '',
|
||||||
|
action TEXT DEFAULT '',
|
||||||
|
world TEXT DEFAULT '',
|
||||||
|
x INTEGER DEFAULT 0,
|
||||||
|
y INTEGER DEFAULT 0,
|
||||||
|
z INTEGER DEFAULT 0,
|
||||||
|
container_type TEXT DEFAULT '',
|
||||||
|
in_land INTEGER DEFAULT 0,
|
||||||
|
land_name TEXT DEFAULT '',
|
||||||
|
player TEXT DEFAULT '',
|
||||||
|
time INTEGER DEFAULT 0
|
||||||
|
)");
|
||||||
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_sn_stock_sn ON web_sn_stock(sn, time)");
|
||||||
|
|
||||||
|
// 手持/背包 SN 快照(每 10~30 秒上报)
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS web_sn_held (
|
||||||
|
sn TEXT PRIMARY KEY,
|
||||||
|
player TEXT DEFAULT '',
|
||||||
|
holder TEXT DEFAULT '',
|
||||||
|
owner TEXT DEFAULT '',
|
||||||
|
item_type TEXT DEFAULT '',
|
||||||
|
held INTEGER DEFAULT 0,
|
||||||
|
time INTEGER DEFAULT 0
|
||||||
|
)");
|
||||||
|
|
||||||
|
// Java -> PHP 事件流(拾取归属不符 / 销毁 / 非法处理)
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS web_sn_event (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
action TEXT DEFAULT '',
|
||||||
|
sn TEXT DEFAULT '',
|
||||||
|
player TEXT DEFAULT '',
|
||||||
|
detail TEXT DEFAULT '',
|
||||||
|
created_at INTEGER DEFAULT 0
|
||||||
|
)");
|
||||||
|
|
||||||
|
// 玩家报失工单
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS web_sn_lost (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sn TEXT DEFAULT '',
|
||||||
|
player TEXT DEFAULT '',
|
||||||
|
reason TEXT DEFAULT '',
|
||||||
|
status TEXT DEFAULT 'checking',
|
||||||
|
result TEXT DEFAULT '',
|
||||||
|
report_count INTEGER DEFAULT 1,
|
||||||
|
created_at INTEGER DEFAULT 0,
|
||||||
|
handled_at INTEGER DEFAULT 0
|
||||||
|
)");
|
||||||
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_sn_lost_sn ON web_sn_lost(sn, created_at)");
|
||||||
|
|
||||||
|
// PHP -> Java 命令队列(注销 / 补发 / 报失核查 / 永久绑定)
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS web_sn_commands (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
cmd TEXT DEFAULT '',
|
||||||
|
sn TEXT DEFAULT '',
|
||||||
|
player TEXT DEFAULT '',
|
||||||
|
item_type TEXT DEFAULT '',
|
||||||
|
reason TEXT DEFAULT '',
|
||||||
|
link_type TEXT DEFAULT '',
|
||||||
|
link_id INTEGER DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
result TEXT DEFAULT '',
|
||||||
|
created_at INTEGER DEFAULT 0,
|
||||||
|
sent_at INTEGER DEFAULT 0,
|
||||||
|
done_at INTEGER DEFAULT 0
|
||||||
|
)");
|
||||||
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_sn_cmd_status ON web_sn_commands(status, id)");
|
||||||
|
|
||||||
|
// 冷静期(注销后 1 小时禁止申领,展示用;真正生效在 Java 侧)
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS web_sn_cooldown (
|
||||||
|
player TEXT PRIMARY KEY,
|
||||||
|
until INTEGER DEFAULT 0
|
||||||
|
)");
|
||||||
$db->exec('COMMIT');
|
$db->exec('COMMIT');
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$db->exec('ROLLBACK');
|
$db->exec('ROLLBACK');
|
||||||
|
|||||||
+223
@@ -280,6 +280,7 @@ if ($currentVersion !== $BUILD_VERSION) {
|
|||||||
<div class="sidebar-item" data-page="orders" onclick="switchPage('orders')">📋 我的订单</div>
|
<div class="sidebar-item" data-page="orders" onclick="switchPage('orders')">📋 我的订单</div>
|
||||||
<div class="sidebar-item" data-page="groups" onclick="switchPage('groups')">👥 用户组</div>
|
<div class="sidebar-item" data-page="groups" onclick="switchPage('groups')">👥 用户组</div>
|
||||||
<div class="sidebar-item" data-page="ticket" onclick="switchPage('ticket')">📋 工单系统</div>
|
<div class="sidebar-item" data-page="ticket" onclick="switchPage('ticket')">📋 工单系统</div>
|
||||||
|
<div class="sidebar-item" data-page="sn" onclick="switchPage('sn')">🔢 SN设备管理</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content" id="content">
|
<div class="content" id="content">
|
||||||
@@ -1052,6 +1053,7 @@ if ($currentVersion !== $BUILD_VERSION) {
|
|||||||
else if (page === 'groups') renderGroups(c);
|
else if (page === 'groups') renderGroups(c);
|
||||||
else if (page === 'account') renderAccount(c);
|
else if (page === 'account') renderAccount(c);
|
||||||
else if (page === 'ticket') renderTicket(c);
|
else if (page === 'ticket') renderTicket(c);
|
||||||
|
else if (page === 'sn') renderSn(c);
|
||||||
else if (page === 'recharge') renderRecharge(c);
|
else if (page === 'recharge') renderRecharge(c);
|
||||||
else if (page === 'orders') renderMyOrders(c);
|
else if (page === 'orders') renderMyOrders(c);
|
||||||
}
|
}
|
||||||
@@ -4003,6 +4005,227 @@ async function clearAllMemberPerms(landName, targetPlayer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== SN 防伪设备管理 ====================
|
||||||
|
var snState = { view: 'list', sn: '' };
|
||||||
|
|
||||||
|
async function renderSn(el) {
|
||||||
|
if (snState.view === 'detail' && snState.sn) { await renderSnDetail(el, snState.sn); return; }
|
||||||
|
snState.view = 'list';
|
||||||
|
await renderSnList(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snStatusBadge(s) {
|
||||||
|
const m = {
|
||||||
|
active: ['有效', 'var(--green)'],
|
||||||
|
destroyed: ['已销毁解绑', 'var(--dim)'],
|
||||||
|
cancelled: ['已注销', 'var(--yellow)'],
|
||||||
|
lost: ['报失处理中', 'var(--yellow)'],
|
||||||
|
illegal: ['非法绑定', 'var(--red)'],
|
||||||
|
reissued: ['已补发作废', 'var(--dim)']
|
||||||
|
};
|
||||||
|
const v = m[s] || [s, 'var(--dim)'];
|
||||||
|
return '<span style="color:' + v[1] + '">' + v[0] + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSnList(el) {
|
||||||
|
el.innerHTML = '<div class="card"><h2>🔢 SN设备管理</h2><p style="color:var(--dim)">加载中...</p></div>';
|
||||||
|
try {
|
||||||
|
const r = await api('sn.php', { action: 'my_list' });
|
||||||
|
if (!r.success) {
|
||||||
|
el.innerHTML = '<div class="card"><h2>🔢 SN设备管理</h2><p style="color:var(--red)">' + escHtml(r.message || '加载失败') + '</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const d = r.data || {};
|
||||||
|
const list = d.list || [];
|
||||||
|
const cdLeft = d.cooldown_left || 0;
|
||||||
|
let html = '<div class="card">' +
|
||||||
|
'<h2>🔢 我的SN设备 (' + list.length + ')</h2>' +
|
||||||
|
'<p style="color:var(--dim);font-size:13px;margin-bottom:10px">每件插件签发的设备都带有唯一SN码(玩家名 + 10位时间戳 + 2位识别码)。游戏内使用 <b>/printer item</b> 可导出SN记录,<b>/printer</b> 查看流水。</p>';
|
||||||
|
if (cdLeft > 0) {
|
||||||
|
html += '<div style="background:rgba(255,180,0,0.08);border:1px solid rgba(255,180,0,0.45);border-radius:8px;padding:10px 12px;color:var(--yellow);font-size:13px;margin-bottom:12px">⚠️ 注销冷静期剩余 ' + Math.ceil(cdLeft / 60) + ' 分钟,期间无法申领新设备。</div>';
|
||||||
|
}
|
||||||
|
html += '<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap">' +
|
||||||
|
'<input id="snQueryInput" type="text" placeholder="输入SN码查询 / 办理..." maxlength="64" style="flex:1;min-width:200px">' +
|
||||||
|
'<button class="btn btn-primary" onclick="snDoAction(\'query\')">🔍 查询</button>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
html += '<p style="color:var(--dim)">暂无名下设备。可在游戏内申领雪球菜单 / 区域选择工具 / 回声碎片 / PVP圈地棒。</p>';
|
||||||
|
} else {
|
||||||
|
html += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">' +
|
||||||
|
'<tr style="color:var(--dim);text-align:left">' +
|
||||||
|
'<th style="padding:6px">SN码</th><th style="padding:6px">类型</th><th style="padding:6px">状态</th>' +
|
||||||
|
'<th style="padding:6px">位置</th><th style="padding:6px">签发时间</th><th style="padding:6px">操作</th></tr>';
|
||||||
|
for (const row of list) {
|
||||||
|
const canDo = row.is_owner && (row.status === 'active' || row.status === 'lost');
|
||||||
|
html += '<tr style="border-top:1px solid var(--border)">' +
|
||||||
|
'<td style="padding:7px 6px;font-family:monospace;word-break:break-all">' +
|
||||||
|
'<a href="javascript:void(0)" onclick="snOpen(\'' + escHtml(row.sn) + '\')" style="color:var(--accent)">' + escHtml(row.sn) + '</a></td>' +
|
||||||
|
'<td style="padding:7px 6px">' + escHtml(row.type_cn || '') + '</td>' +
|
||||||
|
'<td style="padding:7px 6px">' + snStatusBadge(row.status) + '</td>' +
|
||||||
|
'<td style="padding:7px 6px;color:var(--dim)">' + escHtml(row.loc_desc || '') + '</td>' +
|
||||||
|
'<td style="padding:7px 6px;color:var(--dim)">' + escHtml(row.issue_time_str || '') + '</td>' +
|
||||||
|
'<td style="padding:7px 6px;white-space:nowrap">';
|
||||||
|
if (canDo) {
|
||||||
|
html += '<button class="btn btn-dim" style="padding:3px 8px;font-size:12px" onclick="snDoAction(\'report_lost\',\'' + escHtml(row.sn) + '\')">报失</button> ' +
|
||||||
|
'<button class="btn btn-dim" style="padding:3px 8px;font-size:12px" onclick="snDoAction(\'reissue\',\'' + escHtml(row.sn) + '\')">补发</button> ' +
|
||||||
|
'<button class="btn btn-dim" style="padding:3px 8px;font-size:12px" onclick="snDoAction(\'cancel\',\'' + escHtml(row.sn) + '\')">注销</button>';
|
||||||
|
}
|
||||||
|
html += '</td></tr>';
|
||||||
|
}
|
||||||
|
html += '</table></div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = '<div class="card"><p style="color:var(--red)">加载失败: ' + escHtml(e.message) + '</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function snOpen(sn) {
|
||||||
|
snState.view = 'detail';
|
||||||
|
snState.sn = sn;
|
||||||
|
renderSn(document.getElementById('content'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snDoAction(action, sn) {
|
||||||
|
const box = document.getElementById('snQueryInput');
|
||||||
|
sn = sn || (box ? box.value.trim() : '');
|
||||||
|
if (!sn) { toast('请输入SN码', 'error'); return; }
|
||||||
|
if (action !== 'query') {
|
||||||
|
const tips = {
|
||||||
|
report_lost: '确定对 ' + sn + ' 报失吗?系统会立即核对物品位置:若在容器内或在其他玩家身上将被拒绝;确认丢失后自动补发新SN。',
|
||||||
|
reissue: '确定申请补发 ' + sn + ' 吗?旧SN将作废,并签发带新SN的同类物品。',
|
||||||
|
cancel: '确定注销 ' + sn + ' 吗?将立即销毁对应实物,并进入1小时冷静期,期间无法申领新设备。'
|
||||||
|
};
|
||||||
|
if (!await glassConfirm(tips[action] || '确认执行该操作?')) return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await api('sn.php', { action: action, sn: sn });
|
||||||
|
if (!r.success) { toast(r.message || '操作失败', 'error'); return; }
|
||||||
|
toast(r.message || '操作成功');
|
||||||
|
if (action === 'query') snOpen(sn);
|
||||||
|
else renderSn(document.getElementById('content'));
|
||||||
|
} catch (e) {
|
||||||
|
toast('操作失败: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function snBack() {
|
||||||
|
snState.view = 'list';
|
||||||
|
snState.sn = '';
|
||||||
|
renderSn(document.getElementById('content'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSnDetail(el, sn) {
|
||||||
|
el.innerHTML = '<div class="card"><h2>SN详情</h2><p style="color:var(--dim)">加载中...</p></div>';
|
||||||
|
try {
|
||||||
|
const r = await api('sn.php', { action: 'query', sn: sn });
|
||||||
|
if (!r.success) {
|
||||||
|
el.innerHTML = '<div class="card"><p style="color:var(--red)">' + escHtml(r.message || '查询失败') + '</p>' +
|
||||||
|
'<button class="btn" onclick="snBack()">← 返回</button></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const d = r.data || {};
|
||||||
|
const head = '<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:12px">' +
|
||||||
|
'<h2 style="margin:0">🔢 ' + escHtml(d.sn || sn) + '</h2>' +
|
||||||
|
'<button class="btn" onclick="snBack()">← 返回列表</button></div>';
|
||||||
|
|
||||||
|
let html = '<div class="card">' + head;
|
||||||
|
if (d.is_owner === false) {
|
||||||
|
html += '<p style="color:var(--dim);font-size:13px">该SN不属于你,仅展示脱敏信息。</p>' +
|
||||||
|
'<table style="width:100%;border-collapse:collapse;font-size:13px">' +
|
||||||
|
snRow('类型', d.type_cn) + snRow('状态', d.status_cn) + snRow('持有人', d.owner_mask) +
|
||||||
|
'</table></div>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canDo = (d.status === 'active' || d.status === 'lost');
|
||||||
|
html += '<table style="width:100%;border-collapse:collapse;font-size:13px">' +
|
||||||
|
snRow('类型', d.type_cn) +
|
||||||
|
snRow('状态', d.status_cn) +
|
||||||
|
snRow('签发时间', d.issue_time_str) +
|
||||||
|
snRow('最近出现', d.last_seen_str) +
|
||||||
|
snRow('当前位置', d.loc_desc) +
|
||||||
|
'</table>';
|
||||||
|
if (canDo) {
|
||||||
|
html += '<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">' +
|
||||||
|
'<button class="btn btn-yellow" onclick="snDoAction(\'report_lost\',\'' + escHtml(d.sn) + '\')">🚩 报失</button>' +
|
||||||
|
'<button class="btn" onclick="snDoAction(\'reissue\',\'' + escHtml(d.sn) + '\')">♻️ 申请补发</button>' +
|
||||||
|
'<button class="btn btn-red" onclick="snDoAction(\'cancel\',\'' + escHtml(d.sn) + '\')">🗑️ 注销</button>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<h3 style="margin:18px 0 8px;font-size:14px">📜 操作日志</h3>';
|
||||||
|
const logs = d.logs || [];
|
||||||
|
if (!logs.length) html += '<p style="color:var(--dim);font-size:13px">暂无记录</p>';
|
||||||
|
else {
|
||||||
|
html += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px">' +
|
||||||
|
'<tr style="color:var(--dim);text-align:left"><th style="padding:5px">时间</th><th style="padding:5px">动作</th><th style="padding:5px">玩家</th><th style="padding:5px">说明</th></tr>';
|
||||||
|
for (const l of logs) {
|
||||||
|
html += '<tr style="border-top:1px solid var(--border)">' +
|
||||||
|
'<td style="padding:5px;white-space:nowrap;color:var(--dim)">' + escHtml(snFmtTime(l.time)) + '</td>' +
|
||||||
|
'<td style="padding:5px">' + escHtml(l.action) + '</td>' +
|
||||||
|
'<td style="padding:5px">' + escHtml(l.player) + '</td>' +
|
||||||
|
'<td style="padding:5px;color:var(--dim);word-break:break-all">' + escHtml(l.detail) + '</td></tr>';
|
||||||
|
}
|
||||||
|
html += '</table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<h3 style="margin:18px 0 8px;font-size:14px">📦 出入库登记</h3>';
|
||||||
|
const stock = d.stock || [];
|
||||||
|
if (!stock.length) html += '<p style="color:var(--dim);font-size:13px">暂无记录</p>';
|
||||||
|
else {
|
||||||
|
html += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px">' +
|
||||||
|
'<tr style="color:var(--dim);text-align:left"><th style="padding:5px">时间</th><th style="padding:5px">动作</th><th style="padding:5px">容器</th><th style="padding:5px">坐标</th><th style="padding:5px">领地</th><th style="padding:5px">玩家</th></tr>';
|
||||||
|
for (const st of stock) {
|
||||||
|
const land = st.in_land ? (st.land_name || '是') : '-';
|
||||||
|
html += '<tr style="border-top:1px solid var(--border)">' +
|
||||||
|
'<td style="padding:5px;white-space:nowrap;color:var(--dim)">' + escHtml(snFmtTime(st.time)) + '</td>' +
|
||||||
|
'<td style="padding:5px">' + escHtml(st.action) + '</td>' +
|
||||||
|
'<td style="padding:5px">' + escHtml(st.container_type || '-') + '</td>' +
|
||||||
|
'<td style="padding:5px;font-family:monospace">' + escHtml((st.world || '') + ' ' + st.x + ',' + st.y + ',' + st.z) + '</td>' +
|
||||||
|
'<td style="padding:5px">' + escHtml(land) + '</td>' +
|
||||||
|
'<td style="padding:5px">' + escHtml(st.player || '-') + '</td></tr>';
|
||||||
|
}
|
||||||
|
html += '</table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const losts = d.losts || [];
|
||||||
|
if (losts.length) {
|
||||||
|
html += '<h3 style="margin:18px 0 8px;font-size:14px">🚨 报失记录</h3><ul style="font-size:13px;color:var(--dim);padding-left:18px;margin:0">';
|
||||||
|
for (const lo of losts) {
|
||||||
|
html += '<li>' + escHtml(snFmtTime(lo.created_at)) + ' · ' + escHtml(lo.status) +
|
||||||
|
(lo.result ? ' · ' + escHtml(lo.result) : '') + '</li>';
|
||||||
|
}
|
||||||
|
html += '</ul>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = '<div class="card"><p style="color:var(--red)">加载失败: ' + escHtml(e.message) + '</p>' +
|
||||||
|
'<button class="btn" onclick="snBack()">← 返回</button></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function snRow(k, v) {
|
||||||
|
return '<tr style="border-top:1px solid var(--border)">' +
|
||||||
|
'<td style="padding:7px 6px;color:var(--dim);width:110px">' + escHtml(k) + '</td>' +
|
||||||
|
'<td style="padding:7px 6px;word-break:break-all">' + escHtml(v == null ? '' : String(v)) + '</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function snFmtTime(ts) {
|
||||||
|
const n = parseInt(ts, 10) || 0;
|
||||||
|
if (!n) return '';
|
||||||
|
const t = n >= 100000000000 ? Math.floor(n / 1000) : n;
|
||||||
|
const d = new Date(t * 1000);
|
||||||
|
const p = x => (x < 10 ? '0' : '') + x;
|
||||||
|
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' +
|
||||||
|
p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
function escHtml(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
function escHtml(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user