diff --git a/admin.php b/admin.php
index c7a1238..7cf134a 100644
--- a/admin.php
+++ b/admin.php
@@ -4069,6 +4069,11 @@ const SN_STATUS_CN = {
active: '有效', destroyed: '已销毁解绑', cancelled: '已注销',
lost: '报失处理中', illegal: '非法绑定', reissued: '已补发作废'
};
+const SN_TYPE_CN = {
+ menu_snowball: '雪球菜单', land_wand: '区域选择工具',
+ echo_shard: '回声碎片', pvp_tool: 'PVP圈地棒'
+};
+function snTypeCn(t) { return SN_TYPE_CN[t] || t; }
const SN_LOST_CN = {
checking: '⏳ 核查中', manual: '🧑💻 待人工', rejected: '🚫 已驳回',
auto_reissue: '♻️ 已补发', done: '✅ 已完成'
@@ -4107,6 +4112,7 @@ async function renderSnItems(el) {
const pages = Math.max(1, Math.ceil(total / snAdm.size));
let html = '
🔢 SN防伪管理
' + snTabs() +
+ snIssueBox() +
'
' +
'' +
'
' +
'
命令通过 web_sn_commands 下发,游戏服 10~30 秒内执行并回执。' +
'
强制注销:豁免三条件门槛,直接执行且不进入冷静期;强制补发:豁免三条件门槛,按定义直接执行;' +
- '代办注销:按三条件校验,未达标会被拒绝(对玩家只提示"未达到办理条件")。
';
+ '
代办注销:按三条件校验,未达标会被拒绝(对玩家只提示"未达到办理条件")。' +
+ '
办理签发在列表页顶部(只填 玩家ID + 设备类型 即可直发,不依赖本条旧SN)。';
html += '
📨 命令记录
';
const cmds = d.commands || [];
@@ -4357,6 +4363,86 @@ function snAdmPollCmd(cmdId, sn, tries) {
}, 3000);
}
+/**
+ * 顶层「办理签发」区块:只传 玩家ID + 设备类型,直接为该玩家签发一台新设备。
+ * 与 SN 详情里的签发(基于"旧SN已注销"的重发场景)是两条不同链路——
+ * 这里没有旧 SN,后端 admin_issue_new 入队 issue_new → Java doIssueNew 按类型登记发放。
+ */
+function snIssueBox() {
+ let opts = '';
+ for (const [k, v] of Object.entries(SN_TYPE_CN)) {
+ opts += '
';
+ }
+ return '
' +
+ '
\U0001f3ab 办理签发(按玩家直发)
' +
+ '
' +
+ '' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '只填 玩家 ID + 设备类型 即可签发。前提:玩家在线 · 该类名下没有有效登记 · 注销冷静期(1小时)已结束;' +
+ '任一不满足会在 10~30 秒内回执具体原因。命令由游戏服异步执行。
' +
+ '
';
+}
+
+/**
+ * 顶层「办理签发」提交:只带 玩家ID + 设备类型。
+ */
+async function snAdmIssueNew() {
+ const pEl = document.getElementById('snIssuePlayer');
+ const tEl = document.getElementById('snIssueType');
+ const player = ((pEl && pEl.value) || '').trim();
+ const itemType = (tEl && tEl.value) || '';
+ if (!player) { toast('请输入玩家 ID', 'err'); return; }
+ if (!itemType) { toast('请选择设备类型', 'err'); return; }
+ const ok = await glassConfirm('为玩家 ' + player + ' 办理签发「' + snTypeCn(itemType) + '」?' +
+ '前提:玩家在线、该类名下无有效登记、注销冷静期已结束;未达标会回执是哪一条。', '\U0001f3ab');
+ if (!ok) return;
+ try {
+ const r = await snAdmApi('admin_issue_new', {
+ player: player, item_type: itemType, reason: '管理员顶层办理签发'
+ });
+ if (!r.success) { toast(r.message || '下发失败', 'err'); return; }
+ toast(r.message || '签发命令已下发', 'ok');
+ const cmdId = (r.data && r.data.id) || 0;
+ if (cmdId) snAdmPollIssue(cmdId, 0);
+ } catch (e) {
+ toast('下发失败: ' + e.message, 'err');
+ }
+}
+
+/**
+ * 顶层签发的回执轮询:这条命令没有 SN 详情页可挂,按命令 ID 直查
+ * (admin_cmd_result)。3 秒一次、最长 150 秒,与 snAdmPollCmd 同节奏。
+ */
+function snAdmPollIssue(cmdId, tries) {
+ const MAX_TRIES = 50;
+ setTimeout(async () => {
+ let c = null;
+ try {
+ const r = await snAdmApi('admin_cmd_result', { id: cmdId });
+ c = (r.success && r.data) || null;
+ } catch (e) { /* 网络抖动,下一轮再试 */ }
+ if (c && (c.status === 'done' || c.status === 'timeout')) {
+ const res = c.result || '';
+ if (c.status === 'timeout') toast('游戏服 3 分钟内未回执,命令已超时', 'err');
+ else toast('游戏服已执行:' + (res || '(无结果)'), snCmdLooksBad(res) ? 'err' : 'ok');
+ renderSnAdmin(document.getElementById('C'));
+ return;
+ }
+ if (tries < MAX_TRIES) snAdmPollIssue(cmdId, tries + 1);
+ else { toast('游戏服仍未回执,可稍后刷新查看', 'err'); renderSnAdmin(document.getElementById('C')); }
+ }, 3000);
+}
+
+/** 回执文本是否算失败(与 PHP snCmdOk 同口径:出现失败类词即按失败提示) */
+function snCmdLooksBad(res) {
+ return !res || /(失败|拒绝|未|不存在|异常|未知|不在线|超时|已有有效)/.test(res);
+}
+
async function renderSnLost(el) {
const r = await snAdmApi('admin_lost_list', { page: 1, size: 100 });
if (!r.success) { el.innerHTML = '
' + escAdmHtml(r.message || '加载失败') + '
'; return; }
diff --git a/api/sn.php b/api/sn.php
index 5dc77d2..acf7fea 100644
--- a/api/sn.php
+++ b/api/sn.php
@@ -15,6 +15,8 @@
* 管理端(admin session):
* admin_list / admin_detail / admin_cmd / admin_stats
* admin_lost_list / admin_lost_handle
+ * admin_issue_new 顶层办理签发(只传 玩家ID + 设备类型)
+ * admin_cmd_result 按命令ID查回执(顶层签发没有详情页可挂)
*/
while (ob_get_level() > 0) { @ob_end_clean(); }
@@ -69,6 +71,8 @@ try {
case 'admin_stats': snAdminStats(); break;
case 'admin_lost_list': snAdminLostList(); break;
case 'admin_lost_handle': snAdminLostHandle(); break;
+ case 'admin_issue_new': snAdminIssueNew(); break;
+ case 'admin_cmd_result': snAdminCmdResult(); break;
default:
error('未知操作: ' . $action);
@@ -1195,7 +1199,7 @@ function snAdminDetail() {
'losts' => $losts, 'commands' => $cmds, 'held' => $held]);
}
-/** 管理员下发命令:cancel / reissue / locate / bind / report_check */
+/** 管理员下发命令:cancel / reissue / locate / bind / report_check / issue */
function snAdminCmd() {
snRequireAdmin();
$cmd = trim(getParam('cmd', ''));
@@ -1238,6 +1242,52 @@ function snAdminCmd() {
success(['id' => $id], '命令已下发,等待游戏服务器执行(10~30 秒)');
}
+/**
+ * 顶层「办理签发」:只传 玩家ID + 设备类型,直接为该玩家签发一台新设备。
+ *
+ * 与 admin_cmd 的 issue(先查旧 SN、校验其已注销解绑 + 冷静期)是两条链路:
+ * 这里根本没有旧 SN,所以不在 PHP 侧做名额/冷静期预判——web_item_sn 是
+ * Java 推送的副本(30 秒一刷)可能滞后,权威判定在游戏服本地库。
+ * PHP 只做"参数齐不齐、类型认不认识"这两个恒真校验,其余交给 Java issue_new。
+ *
+ * is_force=0:冷静期与「同种类仅 1 个」照常生效,拒绝原因原样回执到管理端。
+ */
+function snAdminIssueNew() {
+ snRequireAdmin();
+ $player = trim(getParam('player', ''));
+ $itemType = trim(getParam('item_type', ''));
+ $reason = trim(getParam('reason', ''));
+ if ($player === '') error('缺少玩家ID');
+ if ($itemType === '') error('缺少设备类型');
+ $known = array('menu_snowball', 'land_wand', 'echo_shard', 'pvp_tool');
+ if (!in_array($itemType, $known, true)) {
+ error('未知设备类型:' . $itemType . '(可选 ' . implode(' / ', $known) . ')');
+ }
+
+ $db = getDB();
+ $id = snEnqueueCmd($db, 'issue_new', '', $player, $itemType,
+ $reason !== '' ? $reason : '管理员顶层办理签发', '', 0, 0);
+ success(['id' => $id], '签发命令已下发,等待游戏服务器执行(10~30 秒)');
+}
+
+/**
+ * 按命令 ID 查回执:顶层办理签发没有 SN 详情页可挂(命令的 sn 是空的,
+ * admin_detail 查不到),只能按 id 直查 status/result。
+ */
+function snAdminCmdResult() {
+ snRequireAdmin();
+ $id = (int)getParam('id', 0);
+ if ($id <= 0) error('缺少id');
+ $db = getDB();
+ $stmt = $db->prepare("SELECT id, cmd, status, result, created_at
+ FROM web_sn_commands WHERE id=:id");
+ $stmt->bindValue(':id', $id, SQLITE3_INTEGER);
+ $rr = $stmt->execute();
+ $row = $rr->fetchArray(SQLITE3_ASSOC);
+ if (!$row) error('命令不存在');
+ success($row);
+}
+
/** 管理端统计 */
function snAdminStats() {
snRequireAdmin();