diff --git a/admin.php b/admin.php
index 4f78095..ccb0e08 100644
--- a/admin.php
+++ b/admin.php
@@ -164,6 +164,7 @@
' +
+ mk('items', '📦 设备列表') +
+ mk('lost', '🚨 报失工单') +
+ mk('stats', '📊 统计') +
+ '
';
+}
+
+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 = '' +
+ '
' +
+ '
🔢 ' + escAdmHtml(it.sn || sn) + '
' +
+ '' +
+ '
' +
+ 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) +
+ '
';
+
+ html += '
' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '
' +
+ '
命令通过 web_sn_commands 下发,游戏服 10~30 秒内执行并回执。
';
+
+ html += '
📨 命令记录
';
+ const cmds = d.commands || [];
+ if (!cmds.length) html += '
暂无
';
+ else {
+ html += '
' +
+ '| ID | 命令 | 状态 | ' +
+ '原因 | 结果 | 时间 |
';
+ for (const c of cmds) {
+ html += '' +
+ '| ' + c.id + ' | ' +
+ '' + escAdmHtml(c.cmd) + ' | ' +
+ '' + escAdmHtml(c.status) + ' | ' +
+ '' + escAdmHtml(c.reason) + ' | ' +
+ '' + escAdmHtml(c.result || '') + ' | ' +
+ '' + snFmtTs(c.created_at) + ' |
';
+ }
+ html += '
';
+ }
+
+ html += '
📜 操作日志
' + snAdmLogTable(d.logs || [], ['时间', '动作', '玩家', '说明'], r => [
+ snFmtTs(r.time), r.action, r.player, r.detail
+ ]);
+
+ html += '
📦 出入库登记
' + 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 += '
🚨 报失记录
' +
+ 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 += '
📡 最近上报(手持快照)
' +
+ '
持有者 ' + escAdmHtml(d.held.holder || '-') +
+ ' · 主人 ' + escAdmHtml(d.held.owner || '-') +
+ ' · ' + (d.held.held ? '手持中' : '在背包') +
+ ' · ' + snFmtTs(d.held.time) + '
';
+ }
+
+ html += '
';
+ el.innerHTML = html;
+}
+
+function snAdmRow(k, v) {
+ return '
@@ -1052,6 +1053,7 @@ if ($currentVersion !== $BUILD_VERSION) {
else if (page === 'groups') renderGroups(c);
else if (page === 'account') renderAccount(c);
else if (page === 'ticket') renderTicket(c);
+ else if (page === 'sn') renderSn(c);
else if (page === 'recharge') renderRecharge(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 '
' + v[0] + '';
+ }
+
+ async function renderSnList(el) {
+ el.innerHTML = '
';
+ try {
+ const r = await api('sn.php', { action: 'my_list' });
+ if (!r.success) {
+ el.innerHTML = '
🔢 SN设备管理
' + escHtml(r.message || '加载失败') + '
';
+ return;
+ }
+ const d = r.data || {};
+ const list = d.list || [];
+ const cdLeft = d.cooldown_left || 0;
+ let html = '
' +
+ '
🔢 我的SN设备 (' + list.length + ')
' +
+ '
每件插件签发的设备都带有唯一SN码(玩家名 + 10位时间戳 + 2位识别码)。游戏内使用 /printer item 可导出SN记录,/printer 查看流水。
';
+ if (cdLeft > 0) {
+ html += '
⚠️ 注销冷静期剩余 ' + Math.ceil(cdLeft / 60) + ' 分钟,期间无法申领新设备。
';
+ }
+ html += '
' +
+ '' +
+ '' +
+ '
';
+
+ if (!list.length) {
+ html += '
暂无名下设备。可在游戏内申领雪球菜单 / 区域选择工具 / 回声碎片 / PVP圈地棒。
';
+ } else {
+ html += '
' +
+ '' +
+ '| SN码 | 类型 | 状态 | ' +
+ '位置 | 签发时间 | 操作 |
';
+ for (const row of list) {
+ const canDo = row.is_owner && (row.status === 'active' || row.status === 'lost');
+ html += '' +
+ '| ' +
+ '' + escHtml(row.sn) + ' | ' +
+ '' + escHtml(row.type_cn || '') + ' | ' +
+ '' + snStatusBadge(row.status) + ' | ' +
+ '' + escHtml(row.loc_desc || '') + ' | ' +
+ '' + escHtml(row.issue_time_str || '') + ' | ' +
+ '';
+ if (canDo) {
+ html += ' ' +
+ ' ' +
+ '';
+ }
+ html += ' |
';
+ }
+ html += '
';
+ }
+ html += '
';
+ el.innerHTML = html;
+ } catch (e) {
+ el.innerHTML = '
加载失败: ' + escHtml(e.message) + '
';
+ }
+ }
+
+ 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 = '
';
+ try {
+ const r = await api('sn.php', { action: 'query', sn: sn });
+ if (!r.success) {
+ el.innerHTML = '
' + escHtml(r.message || '查询失败') + '
' +
+ '
';
+ return;
+ }
+ const d = r.data || {};
+ const head = '
' +
+ '
🔢 ' + escHtml(d.sn || sn) + '
' +
+ '';
+
+ let html = '
' + head;
+ if (d.is_owner === false) {
+ html += '
该SN不属于你,仅展示脱敏信息。
' +
+ '
' +
+ snRow('类型', d.type_cn) + snRow('状态', d.status_cn) + snRow('持有人', d.owner_mask) +
+ '
';
+ el.innerHTML = html;
+ return;
+ }
+
+ const canDo = (d.status === 'active' || d.status === 'lost');
+ html += '
' +
+ snRow('类型', d.type_cn) +
+ snRow('状态', d.status_cn) +
+ snRow('签发时间', d.issue_time_str) +
+ snRow('最近出现', d.last_seen_str) +
+ snRow('当前位置', d.loc_desc) +
+ '
';
+ if (canDo) {
+ html += '
' +
+ '' +
+ '' +
+ '' +
+ '
';
+ }
+
+ html += '
📜 操作日志
';
+ const logs = d.logs || [];
+ if (!logs.length) html += '
暂无记录
';
+ else {
+ html += '
' +
+ '| 时间 | 动作 | 玩家 | 说明 |
';
+ for (const l of logs) {
+ html += '' +
+ '| ' + escHtml(snFmtTime(l.time)) + ' | ' +
+ '' + escHtml(l.action) + ' | ' +
+ '' + escHtml(l.player) + ' | ' +
+ '' + escHtml(l.detail) + ' |
';
+ }
+ html += '
';
+ }
+
+ html += '
📦 出入库登记
';
+ const stock = d.stock || [];
+ if (!stock.length) html += '
暂无记录
';
+ else {
+ html += '
' +
+ '| 时间 | 动作 | 容器 | 坐标 | 领地 | 玩家 |
';
+ for (const st of stock) {
+ const land = st.in_land ? (st.land_name || '是') : '-';
+ html += '' +
+ '| ' + escHtml(snFmtTime(st.time)) + ' | ' +
+ '' + escHtml(st.action) + ' | ' +
+ '' + escHtml(st.container_type || '-') + ' | ' +
+ '' + escHtml((st.world || '') + ' ' + st.x + ',' + st.y + ',' + st.z) + ' | ' +
+ '' + escHtml(land) + ' | ' +
+ '' + escHtml(st.player || '-') + ' |
';
+ }
+ html += '
';
+ }
+
+ const losts = d.losts || [];
+ if (losts.length) {
+ html += '
🚨 报失记录
';
+ for (const lo of losts) {
+ html += '- ' + escHtml(snFmtTime(lo.created_at)) + ' · ' + escHtml(lo.status) +
+ (lo.result ? ' · ' + escHtml(lo.result) : '') + '
';
+ }
+ html += '
';
+ }
+
+ html += '
';
+ el.innerHTML = html;
+ } catch (e) {
+ el.innerHTML = '