fix: 修复库存不同步bug(item_id字段名不匹配); 新增库存高频轮询; 新增工单系统; 修复admin_stock竞态条件; 修复opcache_reset竞态

This commit is contained in:
ypsdf1
2026-06-22 16:54:13 +08:00
parent d7a986894c
commit 4c7ec7490f
12 changed files with 2675 additions and 88 deletions
+497 -24
View File
@@ -1,3 +1,4 @@
<?php header('Cache-Control: no-cache, no-store, must-revalidate, max-age=0'); header('Pragma: no-cache'); ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
@@ -6,7 +7,7 @@
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache"> <meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0"> <meta http-equiv="Expires" content="0">
<title>SDF1 - 管理后台</title> <title>SDF1 - 管理后台 v<?php echo date('ymd_His', filemtime(__FILE__)); ?></title>
<style> <style>
:root { --bg:#0d1117; --card:#161b22; --border:#30363d; --text:#e6edf3; --dim:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d29922; --purple:#bc8cff; } :root { --bg:#0d1117; --card:#161b22; --border:#30363d; --text:#e6edf3; --dim:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d29922; --purple:#bc8cff; }
* { margin:0; padding:0; box-sizing:border-box; } * { margin:0; padding:0; box-sizing:border-box; }
@@ -90,6 +91,7 @@
<div class="si" data-p="online" onclick="go('online')">🟢 在线玩家</div> <div class="si" data-p="online" onclick="go('online')">🟢 在线玩家</div>
<div class="si" data-p="active" onclick="go('active')">⏱️ 活跃用户</div> <div class="si" data-p="active" onclick="go('active')">⏱️ 活跃用户</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> </div>
<div class="content" id="C"></div> <div class="content" id="C"></div>
</div> </div>
@@ -113,7 +115,7 @@ if ('serviceWorker' in navigator) {
} }
const A = 'api/admin.php'; const A = 'api/admin.php';
const _BUILD_TS = 1781928829; // 版本号,用于缓存失效 const _BUILD_TS = 1782098585; // 版本号,用于缓存失效
console.log('[INIT] Admin panel loaded, build:', _BUILD_TS); console.log('[INIT] Admin panel loaded, build:', _BUILD_TS);
// ★ 自检:验证新代码是否加载 // ★ 自检:验证新代码是否加载
@@ -150,6 +152,7 @@ function go(p) {
else if (p==='users') loadUsers(c); else if (p==='users') loadUsers(c);
else if (p==='online') loadOnlinePlayers(c); else if (p==='online') loadOnlinePlayers(c);
else if (p==='active') loadActivePlayers(c); else if (p==='active') loadActivePlayers(c);
else if (p==='tickets') loadTickets(c);
} }
// 检查登录状态 // 检查登录状态
@@ -237,10 +240,11 @@ async function loadDashboard(el) {
const subnetOrder = []; const subnetOrder = [];
players.forEach(p => { players.forEach(p => {
const ip = p.ip_address || '-'; const ip = p.ip_address || '-';
let subnet = ip; let subnet = '_unknown_' + p.player_name; // 默认每个玩家独立一组
if (ip !== '-') { // 只有有效IPv4地址才进行子网分组
if (ip !== '-' && ip.indexOf('.') !== -1) {
const parts = ip.split('.'); const parts = ip.split('.');
if (parts.length === 4) { if (parts.length === 4 && parts.every(part => part !== '' && !isNaN(part))) {
subnet = parts[0] + '.' + parts[1] + '.' + parts[2]; subnet = parts[0] + '.' + parts[1] + '.' + parts[2];
} }
} }
@@ -440,6 +444,13 @@ async function removeShop(id) {
async function loadCDK(el) { async function loadCDK(el) {
const r = await jsonApi('cdk.php?action=list'); const r = await jsonApi('cdk.php?action=list');
const list = r.data || []; const list = r.data || [];
const now = Date.now();
const threeMinAgo = now - 3 * 60 * 1000;
// 计算可撤销的CDK(3分钟内创建且未使用)
const recentCDKs = list.filter(c => c.created_at && (c.created_at * 1000) > threeMinAgo && !c.used);
const canUndo = recentCDKs.length > 0;
el.innerHTML = ` el.innerHTML = `
<div class="card"> <div class="card">
<h2>CDK管理</h2> <h2>CDK管理</h2>
@@ -448,18 +459,27 @@ async function loadCDK(el) {
<h2>生成CDK</h2> <h2>生成CDK</h2>
<div class="form-row"><label>金额</label><input id="cAmount" type="text" value="100" placeholder="固定金额或区间(如100-200)"></div> <div class="form-row"><label>金额</label><input id="cAmount" type="text" value="100" placeholder="固定金额或区间(如100-200)"></div>
<div class="form-row"><label>数量</label><input id="cCount" type="number" value="10" min="1" max="100"></div> <div class="form-row"><label>数量</label><input id="cCount" type="number" value="10" min="1" max="100"></div>
<button class="btn btn-green" onclick="doBatchCDK()">批量生成</button> <div style="display:flex;gap:8px">
<button class="btn btn-green" onclick="doBatchCDK()">批量生成</button>
${canUndo ? `<button class="btn btn-red" onclick="undoRecentCDK()">一键撤销 (${recentCDKs.length}个)</button>` : ''}
</div>
</div> </div>
</div> </div>
<table class="table"> <table class="table">
<tr><th>兑换码</th><th>金额</th><th>状态</th><th>使用者</th><th>创建时间</th></tr> <tr><th>兑换码</th><th>金额</th><th>状态</th><th>使用者</th><th>创建时间</th><th>操作</th></tr>
${list.map(c=>`<tr> ${list.map(c=>{
<td style="font-family:monospace">${c.code}</td> const deleteBtn = c.used ?
<td>${c.amount}</td> `<span style="color:var(--dim);font-size:12px">-</span>` :
<td>${c.used?'<span class="tag tag-used">已使用</span>':'<span class="tag tag-unused">未使用</span>'}</td> `<button class="btn btn-red" style="padding:2px 8px;font-size:12px" onclick="deleteCDK('${c.code}')">删除</button>`;
<td>${c.used_by||'-'}</td> return `<tr>
<td>${c.created_at?new Date(c.created_at*1000).toLocaleString():'-'}</td> <td style="font-family:monospace">${c.code}</td>
</tr>`).join('')} <td>${c.amount}</td>
<td>${c.used?'<span class="tag tag-used">已使用</span>':'<span class="tag tag-unused">未使用</span>'}</td>
<td>${c.used_by||'-'}</td>
<td>${c.created_at?new Date(c.created_at*1000).toLocaleString():'-'}</td>
<td>${deleteBtn}</td>
</tr>`;
}).join('')}
</table> </table>
</div>`; </div>`;
} }
@@ -499,6 +519,59 @@ async function doBatchCDK() {
} else { toast(r.message,'err'); } } else { toast(r.message,'err'); }
} }
// ===== CDK撤销和删除 =====
async function undoRecentCDK() {
if (!confirm('确定要撤销最近3分钟内生成的CDK吗?')) return;
const r = await jsonApi('cdk.php?action=list');
const list = r.data || [];
const now = Date.now();
const threeMinAgo = now - 3 * 60 * 1000;
// 筛选3分钟内创建且未使用的CDK
const recentCDKs = list.filter(c => c.created_at && (c.created_at * 1000) > threeMinAgo && !c.used);
if (recentCDKs.length === 0) {
toast('没有可撤销的CDK','err');
return;
}
let successCount = 0;
let failCount = 0;
for (const cdk of recentCDKs) {
try {
const result = await postApi('cdk_delete', {code: cdk.code});
if (result.success) {
successCount++;
} else {
failCount++;
}
} catch (e) {
failCount++;
}
}
if (successCount > 0) {
toast(`成功撤销${successCount}个CDK${failCount > 0 ? `,${failCount}个失败` : ''}`, 'ok');
loadCDK(document.getElementById('C'));
} else {
toast('撤销失败', 'err');
}
}
async function deleteCDK(code) {
if (!confirm(`确定要删除CDK ${code}吗?`)) return;
const r = await postApi('cdk_delete', {code});
if (r.success) {
toast('CDK已删除', 'ok');
loadCDK(document.getElementById('C'));
} else {
toast(r.message, 'err');
}
}
// ===== 流水 ===== // ===== 流水 =====
async function loadTx(el) { async function loadTx(el) {
el.innerHTML = ` el.innerHTML = `
@@ -821,15 +894,18 @@ function lazyLoadUsersPage(page, limit, search, onlineSet) {
// 追加用户行(日期搜索时按/24子网折叠) // 追加用户行(日期搜索时按/24子网折叠)
let displayUsers = users; let displayUsers = users;
if (isDateSearch && users.length > 0) { if (isDateSearch && users.length > 0) {
// 按/24子网分组 // 按/24子网分组(只对有效IPv4地址进行分组)
const subnetMap = {}; const subnetMap = {};
const subnetOrder = []; const subnetOrder = [];
users.forEach(u => { users.forEach(u => {
const ip = u.ip_address || '-'; const ip = u.ip_address || '-';
let subnet = ip; let subnet = '_unknown_' + u.player_name; // 默认每个玩家独立一组
if (ip !== '-') { // 只有有效IPv4地址才进行子网分组
if (ip !== '-' && ip.indexOf('.') !== -1) {
const parts = ip.split('.'); const parts = ip.split('.');
if (parts.length === 4) subnet = parts[0] + '.' + parts[1] + '.' + parts[2]; if (parts.length === 4 && parts.every(part => part !== '' && !isNaN(part))) {
subnet = parts[0] + '.' + parts[1] + '.' + parts[2];
}
} }
if (!subnetMap[subnet]) { subnetMap[subnet] = []; subnetOrder.push(subnet); } if (!subnetMap[subnet]) { subnetMap[subnet] = []; subnetOrder.push(subnet); }
subnetMap[subnet].push(u); subnetMap[subnet].push(u);
@@ -1295,14 +1371,17 @@ function loadActiveTab(tab, seconds) {
const totalBeforeDedup = res.total_before_dedup || players.length; const totalBeforeDedup = res.total_before_dedup || players.length;
const dedupCount = totalBeforeDedup - players.length; const dedupCount = totalBeforeDedup - players.length;
// 统计同子网玩家 // 统计同子网玩家(只对有效IPv4地址进行分组)
const subnetCountMap = {}; const subnetCountMap = {};
players.forEach(p => { players.forEach(p => {
const ip = p.ip_address || '-'; const ip = p.ip_address || '-';
if (ip !== '-') { // 只有有效IPv4地址才进行子网分组
if (ip !== '-' && ip.indexOf('.') !== -1) {
const parts = ip.split('.'); const parts = ip.split('.');
const subnet = parts.length === 4 ? parts[0]+'.'+parts[1]+'.'+parts[2] : ip; if (parts.length === 4 && parts.every(part => part !== '' && !isNaN(part))) {
subnetCountMap[subnet] = (subnetCountMap[subnet] || 0) + 1; const subnet = parts[0] + '.' + parts[1] + '.' + parts[2];
subnetCountMap[subnet] = (subnetCountMap[subnet] || 0) + 1;
}
} }
}); });
@@ -1318,9 +1397,12 @@ function loadActiveTab(tab, seconds) {
const ip = p.ip_address || '-'; const ip = p.ip_address || '-';
const ipLoc = p.ip_location || '-'; const ipLoc = p.ip_location || '-';
let subnet = ''; let subnet = '';
if (ip !== '-') { // 只有有效IPv4地址才进行子网分组
if (ip !== '-' && ip.indexOf('.') !== -1) {
const parts = ip.split('.'); const parts = ip.split('.');
subnet = parts.length === 4 ? parts[0]+'.'+parts[1]+'.'+parts[2] : ip; if (parts.length === 4 && parts.every(part => part !== '' && !isNaN(part))) {
subnet = parts[0] + '.' + parts[1] + '.' + parts[2];
}
} }
const isShared = subnet && (subnetCountMap[subnet] || 0) > 1; const isShared = subnet && (subnetCountMap[subnet] || 0) > 1;
const ipStyle = isShared ? 'color:var(--yellow);font-weight:700' : ''; const ipStyle = isShared ? 'color:var(--yellow);font-weight:700' : '';
@@ -1752,6 +1834,397 @@ function applyCustomColor() {
const savedTheme = localStorage.getItem('sdf1_theme'); const savedTheme = localStorage.getItem('sdf1_theme');
if (savedTheme) setTheme(savedTheme); if (savedTheme) setTheme(savedTheme);
})(); })();
// ===== 工单管理 =====
let ticketAdminState = { filter: 'all', view: 'list' };
async function loadTickets(el) {
ticketAdminState.view = 'list';
await loadTicketList(el);
}
async function loadTicketList(el) {
el.innerHTML = '<div class="card"><p style="color:var(--dim)">加载中...</p></div>';
try {
const url = new URL('api/ticket.php', location.href);
url.searchParams.set('action', 'list_all');
if (ticketAdminState.filter !== 'all') url.searchParams.set('status', ticketAdminState.filter);
const res = await fetch(url, {credentials: 'same-origin'});
const data = await res.json();
if (!data.success) { el.innerHTML = `<div class="card"><p style="color:var(--red)">${data.message}</p></div>`; return; }
const list = data.data.list;
const stats = data.data.stats;
const typeMap = {bug:'🐛 Bug',help:'❓ 求助',report:'📢 举报',apply:'📝 申请',other:'📎 其他'};
const statusMap = {submitted:{text:'⏳ 已提交',color:'var(--yellow)'},replied:{text:'💬 已回复',color:'var(--accent)'},completed:{text:'✅ 已完结',color:'var(--green)'},withdrawn:{text:'🔙 已撤销',color:'var(--dim)'},rejected:{text:'🚫 已驳回',color:'var(--red)'}};
const f = ticketAdminState.filter;
let html = `<div class="card"><h2>📋 工单管理</h2>
<div style="margin-bottom:10px"><button class="btn btn-primary" style="padding:5px 14px;font-size:12px" onclick="adminCreateTicketUI()">➕ 新建工单</button></div>
<div class="tabs">
<div class="tab ${f==='all'?'active':''}" onclick="ticketAdminState.filter='all';loadTicketList(document.getElementById('C'))">全部 (${stats.all})</div>
<div class="tab ${f==='submitted'?'active':''}" onclick="ticketAdminState.filter='submitted';loadTicketList(document.getElementById('C'))">⏳ 已提交 (${stats.submitted||0})</div>
<div class="tab ${f==='replied'?'active':''}" onclick="ticketAdminState.filter='replied';loadTicketList(document.getElementById('C'))">💬 已回复 (${stats.replied||0})</div>
<div class="tab ${f==='completed'?'active':''}" onclick="ticketAdminState.filter='completed';loadTicketList(document.getElementById('C'))">✅ 已完结 (${stats.completed||0})</div>
<div class="tab ${f==='withdrawn'?'active':''}" onclick="ticketAdminState.filter='withdrawn';loadTicketList(document.getElementById('C'))">🔙 已撤销 (${stats.withdrawn||0})</div>
<div class="tab ${f==='rejected'?'active':''}" onclick="ticketAdminState.filter='rejected';loadTicketList(document.getElementById('C'))">🚫 已驳回 (${stats.rejected||0})</div>
</div>`;
if (list.length === 0) {
html += `<p style="color:var(--dim);text-align:center;padding:30px">暂无工单</p>`;
} else {
html += `<table class="table"><thead><tr><th>ID</th><th>类型</th><th>标题</th><th>提交者</th><th>处理人</th><th>状态</th><th>时间</th><th>操作</th></tr></thead><tbody>`;
for (const t of list) {
const s = statusMap[t.status] || {text: t.status, color: 'var(--dim)'};
const date = new Date(t.created_at * 1000).toLocaleString('zh-CN');
html += `<tr>
<td>#${t.id}</td>
<td>${typeMap[t.type]||t.type}</td>
<td>${escAdmHtml(t.title)}</td>
<td>${escAdmHtml(t.requester)}</td>
<td>${t.assigned_to ? escAdmHtml(t.assigned_to) : '<span style="color:var(--dim)">-</span>'}</td>
<td><span style="color:${s.color}">${s.text}</span></td>
<td style="font-size:11px">${date}</td>
<td>
<button class="btn" style="padding:3px 8px;font-size:11px" onclick="viewAdminTicket(${t.id})">查看</button>
${t.status !== 'withdrawn' && t.status !== 'rejected' ? `<button class="btn" style="padding:3px 8px;font-size:11px;color:var(--yellow)" onclick="viewAdminTicket(${t.id})">回复</button>` : ''}
</td>
</tr>`;
}
html += `</tbody></table>`;
}
html += `</div>`;
el.innerHTML = html;
} catch (e) {
el.innerHTML = `<div class="card"><p style="color:var(--red)">加载失败: ${e.message}</p></div>`;
}
}
async function viewAdminTicket(id) {
const c = document.getElementById('C');
c.innerHTML = '<div class="card"><p style="color:var(--dim)">加载中...</p></div>';
try {
const url = new URL('api/ticket.php', location.href);
url.searchParams.set('action', 'detail');
url.searchParams.set('id', id);
url.searchParams.set('token', 'admin');
const res = await fetch(url, {credentials: 'same-origin'});
const data = await res.json();
if (!data.success) { c.innerHTML = `<div class="card"><p style="color:var(--red)">${data.message}</p></div>`; return; }
const t = data.data;
const typeMap = {bug:'🐛 Bug反馈',help:'❓ 求助',report:'📢 举报',apply:'📝 申请',other:'📎 其他'};
const statusMap = {submitted:{text:'⏳ 已提交',color:'var(--yellow)'},replied:{text:'💬 已回复',color:'var(--accent)'},withdrawn:{text:'🔙 已撤销',color:'var(--dim)'},rejected:{text:'🚫 已驳回',color:'var(--red)'}};
const s = statusMap[t.status] || {text: t.status, color: 'var(--dim)'};
const date = new Date(t.created_at * 1000).toLocaleString('zh-CN');
const canOperate = !['withdrawn','rejected','completed'].includes(t.status);
let html = `<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h2 style="margin:0">#${t.id} ${escAdmHtml(t.title)}</h2>
<span style="color:${s.color};font-size:13px">${s.text}</span>
</div>
<div style="font-size:13px;color:var(--dim);margin-bottom:12px">
类型: ${typeMap[t.type]||t.type} | 提交者: ${escAdmHtml(t.requester)} | 处理人: ${t.assigned_to ? escAdmHtml(t.assigned_to) : '未分配'} | 时间: ${date}
</div>
<div style="background:rgba(88,166,255,0.05);border:1px solid var(--border);border-radius:6px;padding:12px;margin-bottom:12px;font-size:13px;line-height:1.6">${adminRenderMd(t.description || '无描述')}</div>`;
// 驳回原因
if (t.reject_reason) {
html += `<div style="background:rgba(248,81,73,0.1);border:1px solid var(--red);border-radius:6px;padding:12px;margin-bottom:12px">
<div style="color:var(--red);font-size:12px;margin-bottom:4px">🚫 驳回原因</div>
<div style="font-size:13px">${adminRenderMd(t.reject_reason)}</div></div>`;
}
// 回复列表
html += `<div style="margin-bottom:12px"><div style="font-size:12px;color:var(--dim);margin-bottom:6px">💬 回复记录 (${t.replies ? t.replies.length : 0})</div>`;
if (t.replies && t.replies.length > 0) {
for (const r of t.replies) {
const rDate = new Date(r.created_at * 1000).toLocaleString('zh-CN');
const roleColors = {user:'var(--accent)',admin:'var(--red)',provider:'var(--green)'};
const roleNames = {user:'玩家',admin:'管理员',provider:'服务商'};
const bgColor = r.role === 'admin' ? 'rgba(248,81,73,0.08)' : r.role === 'provider' ? 'rgba(63,185,80,0.08)' : 'rgba(88,166,255,0.08)';
html += `<div style="background:${bgColor};border-radius:6px;padding:10px 14px;margin-bottom:6px">
<div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:4px">
<span><b style="color:${roleColors[r.role]||'var(--text)'}">${escAdmHtml(r.sender)}</b> <span style="color:var(--dim)">(${roleNames[r.role]||r.role})</span></span>
<span style="color:var(--dim)">${rDate}</span>
</div>
<div style="font-size:13px;line-height:1.6">${adminRenderMd(r.message)}</div>
</div>`;
}
} else {
html += `<p style="color:var(--dim);font-size:12px">暂无回复</p>`;
}
html += `</div>`;
// 操作区
if (canOperate) {
html += `<div style="border-top:1px solid var(--border);padding-top:12px">
<div style="margin-bottom:8px"><textarea id="admTicketReply" rows="2" placeholder="输入回复内容(支持Markdown)" style="width:100%;padding:8px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;resize:vertical;font-family:monospace"></textarea></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<button class="btn btn-primary" style="padding:5px 14px;font-size:12px" onclick="adminReplyTicket(${t.id})">💬 回复</button>
<button class="btn" style="padding:5px 14px;font-size:12px;color:var(--red)" onclick="adminRejectTicket(${t.id})">🚫 驳回</button>
<button class="btn" style="padding:5px 14px;font-size:12px;color:var(--green)" onclick="adminCompleteTicket(${t.id})">✅ 完结</button>
<input id="admAssignProvider" placeholder="服务商名" style="padding:5px 8px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:12px;width:120px">
<button class="btn" style="padding:5px 14px;font-size:12px;color:var(--green)" onclick="adminAssignTicket(${t.id})">📤 分配</button>
</div>
<p id="admTicketErr" style="color:var(--red);font-size:12px;margin-top:6px"></p>
</div>`;
}
html += `<div style="margin-top:12px"><button class="btn" style="padding:5px 14px;font-size:12px" onclick="loadTicketList(document.getElementById('C'))">← 返回列表</button></div></div>`;
c.innerHTML = html;
} catch (e) {
c.innerHTML = `<div class="card"><p style="color:var(--red)">加载失败: ${e.message}</p></div>`;
}
}
async function adminReplyTicket(id) {
const msg = document.getElementById('admTicketReply').value.trim();
const errEl = document.getElementById('admTicketErr');
if (!msg) { errEl.textContent = '请输入回复内容'; return; }
try {
const url = new URL('api/ticket.php', location.href);
url.searchParams.set('action', 'admin_reply');
url.searchParams.set('id', id);
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body: JSON.stringify({message: msg})});
const data = await res.json();
if (data.success) { viewAdminTicket(id); } else { errEl.textContent = data.message; }
} catch (e) { errEl.textContent = '发送失败: ' + e.message; }
}
async function adminRejectTicket(id) {
const reason = prompt('请输入驳回原因:');
if (!reason) return;
try {
const url = new URL('api/ticket.php', location.href);
url.searchParams.set('action', 'reject');
url.searchParams.set('id', id);
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body: JSON.stringify({reason: reason})});
const data = await res.json();
if (data.success) { loadTicketList(document.getElementById('C')); } else { alert(data.message); }
} catch (e) { alert('操作失败: ' + e.message); }
}
async function adminCompleteTicket(id) {
try {
const url = new URL('api/ticket.php', location.href);
url.searchParams.set('action', 'admin_complete');
url.searchParams.set('id', id);
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin'});
const data = await res.json();
if (data.success) { viewAdminTicket(id); } else { alert(data.message); }
} catch (e) { alert('操作失败: ' + e.message); }
}
async function adminAssignTicket(id) {
const provider = document.getElementById('admAssignProvider').value.trim();
if (!provider) { document.getElementById('admTicketErr').textContent = '请输入服务商名称'; return; }
try {
const url = new URL('api/ticket.php', location.href);
url.searchParams.set('action', 'assign');
url.searchParams.set('id', id);
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body: JSON.stringify({provider: provider})});
const data = await res.json();
if (data.success) { viewAdminTicket(id); } else { document.getElementById('admTicketErr').textContent = data.message; }
} catch (e) { document.getElementById('admTicketErr').textContent = '分配失败: ' + e.message; }
}
function adminCreateTicketUI() {
const c = document.getElementById('C');
let html = `<div class="card"><h2>➕ 新建工单</h2>
<div style="margin-bottom:10px">
<label style="font-size:12px;color:var(--dim)">类型</label>
<select id="admNewType" style="width:100%;padding:6px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;margin-top:4px">
<option value="bug">🐛 Bug</option>
<option value="help">❓ 求助</option>
<option value="report">📢 举报</option>
<option value="apply">📝 申请</option>
<option value="other">📎 其他</option>
</select>
</div>
<div style="margin-bottom:10px">
<label style="font-size:12px;color:var(--dim)">标题</label>
<input id="admNewTitle" style="width:100%;padding:6px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;margin-top:4px" placeholder="工单标题">
</div>
<div style="margin-bottom:10px">
<label style="font-size:12px;color:var(--dim)">描述(支持Markdown)</label>
<textarea id="admNewDesc" rows="4" style="width:100%;padding:6px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;margin-top:4px;resize:vertical;font-family:monospace" placeholder="工单描述"></textarea>
</div>
<div style="margin-bottom:10px">
<label style="font-size:12px;color:var(--dim)">派发服务商(可选)</label>
<input id="admNewProvider" style="width:100%;padding:6px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;margin-top:4px" placeholder="服务商玩家名(留空则进入抢单大厅)">
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-primary" style="padding:5px 14px;font-size:12px" onclick="adminSubmitCreateTicket()">创建</button>
<button class="btn" style="padding:5px 14px;font-size:12px" onclick="loadTicketList(document.getElementById('C'))">取消</button>
</div>
<p id="admCreateErr" style="color:var(--red);font-size:12px;margin-top:6px"></p>
</div>`;
c.innerHTML = html;
}
async function adminSubmitCreateTicket() {
const type = document.getElementById('admNewType').value;
const title = document.getElementById('admNewTitle').value.trim();
const desc = document.getElementById('admNewDesc').value.trim();
const provider = document.getElementById('admNewProvider').value.trim();
const errEl = document.getElementById('admCreateErr');
if (!title) { errEl.textContent = '请输入标题'; return; }
try {
const url = new URL('api/ticket.php', location.href);
url.searchParams.set('action', 'admin_create');
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body: JSON.stringify({type, title, description: desc, provider})});
const data = await res.json();
if (data.success) { loadTicketList(document.getElementById('C')); } else { errEl.textContent = data.message; }
} catch (e) { errEl.textContent = '创建失败: ' + e.message; }
}
function escAdmHtml(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function adminProcessInlineMd(text) {
let s = text;
s = s.replace(/^######\s+(.+)$/, '<h6 style="margin:2px 0;font-size:11px">$1</h6>');
s = s.replace(/^#####\s+(.+)$/, '<h5 style="margin:3px 0;font-size:12px">$1</h5>');
s = s.replace(/^####\s+(.+)$/, '<h4 style="margin:3px 0 2px;font-size:12px">$1</h4>');
s = s.replace(/^###\s+(.+)$/, '<h4 style="margin:4px 0 3px;font-size:13px">$1</h4>');
s = s.replace(/^##\s+(.+)$/, '<h3 style="margin:5px 0 3px;font-size:14px">$1</h3>');
s = s.replace(/^#\s+(.+)$/, '<h2 style="margin:6px 0 4px;font-size:15px">$1</h2>');
s = s.replace(/~~(.+?)~~/g, '<del>$1</del>');
s = s.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
s = s.replace(/\*(.+?)\*/g, '<i>$1</i>');
s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:6px;margin:4px 0">');
s = s.replace(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--accent)">$1</a>');
return s;
}
function adminRenderMd(text) {
if (!text) return '';
text = text.replace(/\r/g, '');
try {
let s = escAdmHtml(text);
// 1. 代码块提取保护
let cbs = [];
s = s.replace(/```(\w*)\n?([\s\S]*?)```/g, function(m, lang, code) {
let idx = cbs.length;
cbs.push('<pre style="background:rgba(88,166,255,0.1);padding:8px;border-radius:4px;overflow-x:auto;font-size:12px;white-space:pre-wrap">' + code.replace(/\n$/, '') + '</pre>');
return '\x00CB' + idx + '\x00';
});
// 2. 行内代码保护
s = s.replace(/`([^`]+)`/g, '<code style="background:rgba(88,166,255,0.15);padding:1px 4px;border-radius:2px;font-size:12px">$1</code>');
// 3. 引用块处理:先提取连续引用行,构建嵌套结构
let lines = s.split('\n');
let result = [];
let bqBuf = [];
function flushBq() {
if (bqBuf.length === 0) return;
let html = '';
let openDivs = 0;
for (let bi = 0; bi < bqBuf.length; bi++) {
let {level, content} = bqBuf[bi];
let prevLevel = (bi > 0) ? bqBuf[bi - 1].level : 0;
if (level > prevLevel) {
for (let j = prevLevel; j < level; j++) {
let pad = Math.min((j + 1) * 8, 32);
html += '<div style="border-left:3px solid var(--accent);padding:4px 10px;margin:2px 0 2px ' + pad + 'px;background:rgba(255,255,255,0.03)">';
openDivs++;
}
} else if (level < prevLevel) {
for (let j = prevLevel; j > level; j--) { html += '</div>'; openDivs--; }
} else if (bi > 0) {
html += '<br>';
}
let text = adminProcessInlineMd(content);
if (!text.trim()) text = '&nbsp;';
html += text;
}
for (let j = 0; j < openDivs; j++) html += '</div>';
result.push(html);
bqBuf = [];
}
for (let i = 0; i < lines.length; i++) {
let m = lines[i].match(/^((?:&gt;[ \t]*)+)(.*)$/);
if (m) {
bqBuf.push({ level: (m[1].match(/&gt;/g) || []).length, content: m[2] });
} else if (bqBuf.length > 0 && !lines[i].trim()) {
// ★ 空行在引用块内:向前看下一行是否仍是引用
let isContinuation = false;
for (let j = i + 1; j < lines.length; j++) {
if (lines[j].trim()) {
isContinuation = /^((?:&gt;[ \t]*)+)(.*)$/.test(lines[j]);
break;
}
}
if (isContinuation) {
bqBuf.push({ level: bqBuf[bqBuf.length - 1].level, content: '' });
} else {
flushBq();
}
} else {
flushBq();
result.push(lines[i]);
}
}
flushBq();
s = result.join('\n');
// 4. 非引用行的MD处理(标题、粗体、斜体、图片、链接等)
s = s.replace(/~~(.+?)~~/g, '<del>$1</del>');
s = s.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
s = s.replace(/\*(.+?)\*/g, '<i>$1</i>');
s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:6px;margin:6px 0">');
s = s.replace(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--accent)">$1</a>');
s = s.replace(/^######\s+(.+)$/gm, '<h6 style="margin:3px 0;font-size:11px">$1</h6>');
s = s.replace(/^#####\s+(.+)$/gm, '<h5 style="margin:4px 0;font-size:12px">$1</h5>');
s = s.replace(/^####\s+(.+)$/gm, '<h4 style="margin:4px 0 2px;font-size:12px">$1</h4>');
s = s.replace(/^###\s+(.+)$/gm, '<h4 style="margin:6px 0 4px;font-size:13px">$1</h4>');
s = s.replace(/^##\s+(.+)$/gm, '<h3 style="margin:8px 0 4px;font-size:14px">$1</h3>');
s = s.replace(/^#\s+(.+)$/gm, '<h2 style="margin:10px 0 6px;font-size:15px">$1</h2>');
// 5. 表格
let inTable = false;
let tableHtml = '';
let tLines = s.split('\n');
let tResult = [];
for (let i = 0; i < tLines.length; i++) {
let line = tLines[i].trim();
if (line.match(/^\|(.+)\|$/)) {
if (line.match(/^\|[\s\-:|]+\|$/)) { inTable = true; continue; }
if (!inTable && i + 1 < tLines.length && tLines[i+1].trim().match(/^\|[\s\-:|]+\|$/)) {
inTable = true;
let cells = line.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
tableHtml = '<table style="border-collapse:collapse;width:100%;margin:6px 0;font-size:12px"><tr style="background:rgba(88,166,255,0.15)">';
for (let c of cells) tableHtml += '<th style="border:1px solid var(--border);padding:4px 8px;text-align:left">' + c + '</th>';
tableHtml += '</tr>';
continue;
}
if (inTable) {
let cells = line.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
tableHtml += '<tr>';
for (let c of cells) tableHtml += '<td style="border:1px solid var(--border);padding:4px 8px">' + c + '</td>';
tableHtml += '</tr>';
continue;
}
}
if (inTable && tableHtml) {
tableHtml += '</table>';
tResult.push(tableHtml);
tableHtml = '';
inTable = false;
}
tResult.push(tLines[i]);
}
if (tableHtml) { tableHtml += '</table>'; tResult.push(tableHtml); }
s = tResult.join('\n');
// 6. 列表、分割线、换行
s = s.replace(/^- (.+)$/gm, '<li style="margin-left:16px;font-size:12px">$1</li>');
s = s.replace(/^---$/gm, '<hr style="border:none;border-top:1px solid var(--border);margin:10px 0">');
s = s.replace(/\n/g, '<br>');
// 7. 还原代码块
for (let i = 0; i < cbs.length; i++) {
s = s.replace('\x00CB' + i + '\x00', cbs[i]);
}
return s;
} catch(e) { console.error('[MD_RENDER] Error:', e); return escAdmHtml(text).replace(/\n/g, '<br>'); }
}
</script> </script>
</body> </body>
</html> </html>
+64 -15
View File
@@ -17,8 +17,8 @@ ini_set('display_errors', '0');
ini_set('log_errors', '1'); ini_set('log_errors', '1');
ini_set('memory_limit', '256M'); ini_set('memory_limit', '256M');
if (function_exists('opcache_reset')) { @opcache_reset(); } // 仅刷新当前文件的OPcache(不要opcache_reset——会重置整个服务器缓存导致竞态条件)
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__, true); } if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
// ★ 注册shutdown函数:确保ob缓冲区在致命错误时也能刷新 // ★ 注册shutdown函数:确保ob缓冲区在致命错误时也能刷新
register_shutdown_function(function() { register_shutdown_function(function() {
@@ -43,6 +43,7 @@ if (session_status() === PHP_SESSION_NONE) {
$action = getParam('action', 'status'); $action = getParam('action', 'status');
try {
switch ($action) { switch ($action) {
case 'login': adminDoLogin(); break; case 'login': adminDoLogin(); break;
case 'logout': adminDoLogout(); break; case 'logout': adminDoLogout(); break;
@@ -73,8 +74,15 @@ switch ($action) {
case 'get_stats_ex': adminGetStatsEx(); break; case 'get_stats_ex': adminGetStatsEx(); break;
case 'list_same_ip': adminListSameIp(); break; case 'list_same_ip': adminListSameIp(); break;
case 'list_users_paginated': adminListUsersPaginated(); break; case 'list_users_paginated': adminListUsersPaginated(); break;
case 'cdk_delete': adminDeleteCDK(); break;
default: exit(json_encode(['success' => false, 'message' => 'Unknown action: ' . $action], JSON_UNESCAPED_UNICODE)); default: exit(json_encode(['success' => false, 'message' => 'Unknown action: ' . $action], JSON_UNESCAPED_UNICODE));
} }
} catch (\Throwable $e) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => ['code' => 500, 'message' => 'Internal error: ' . $e->getMessage()]], JSON_UNESCAPED_UNICODE);
exit;
}
// ===== 各函数定义 ===== // ===== 各函数定义 =====
function adminDoLogin() { function adminDoLogin() {
@@ -190,15 +198,25 @@ function adminShopUpdate() {
requireAdminSession(); requireAdminSession();
$db = getDB(); $db = getDB();
try { try {
$stmt = $db->prepare("UPDATE shop_items SET display_name=:dn, buy_price=:bp, sell_price=:sp, stock=:st WHERE id=:id"); $itemId = getParam('id');
$stmt->bindValue(':dn', getParam('display_name')); if (!$itemId) error('缺少商品ID');
$stmt->bindValue(':bp', getParam('buy_price')); $stock = getParam('stock');
$stmt->bindValue(':sp', getParam('sell_price')); // ★ 库存改动写入admin_stock(防止Java全量同步覆盖)
// admin_stock非NULL时pullShopStock优先返回此值给Java
// 前端只发送id和stock,所以只更新stock和admin_stock
$stmt = $db->prepare("UPDATE shop_items SET stock=:st, admin_stock=:ast, last_modified=:lm WHERE id=:id");
$stmt->bindValue(':st', getParam('stock')); $stmt->bindValue(':st', getParam('stock'));
$stmt->bindValue(':id', getParam('item_id')); $stmt->bindValue(':ast', $stock, $stock !== null ? SQLITE3_INTEGER : SQLITE3_NULL);
$stmt->bindValue(':lm', time(), SQLITE3_INTEGER);
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
$stmt->execute(); $stmt->execute();
exit(json_encode(['success' => true, 'message' => 'OK'], JSON_UNESCAPED_UNICODE)); $rowsChanged = $db->changes();
} catch (Exception $e) { // ★ 同步通知Java插件立即拉取库存改动
if ($rowsChanged > 0) {
notifyJavaPluginImmediatePull();
}
exit(json_encode(['success' => true, 'message' => 'OK', 'updated' => $rowsChanged], JSON_UNESCAPED_UNICODE));
} catch (\Throwable $e) {
@error_log('[Function] Error: ' . $e->getMessage()); @error_log('[Function] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE)); exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
} }
@@ -208,17 +226,20 @@ function adminShopAdd() {
requireAdminSession(); requireAdminSession();
$db = getDB(); $db = getDB();
try { try {
$stmt = $db->prepare("INSERT OR REPLACE INTO shop_items VALUES (:id,:cat,:dn,:mat,:bp,:sp,:st,0,0,0)"); $itemId = getParam('id');
$stmt->bindValue(':id', getParam('item_id')); if (!$itemId) error('缺少商品ID');
$stmt = $db->prepare("INSERT OR REPLACE INTO shop_items (id, category, display_name, material, buy_price, sell_price, stock, hourly_sales, total_sales, last_sync, last_modified) VALUES (:id,:cat,:dn,:mat,:bp,:sp,:st,0,0,0,:lm)");
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
$stmt->bindValue(':cat', getParam('category', 'weapon')); $stmt->bindValue(':cat', getParam('category', 'weapon'));
$stmt->bindValue(':dn', getParam('display_name', '')); $stmt->bindValue(':dn', getParam('display_name', ''));
$stmt->bindValue(':mat', getParam('material', 'stone_sword')); $stmt->bindValue(':mat', getParam('material', 'stone_sword'));
$stmt->bindValue(':bp', getParam('buy_price', 0)); $stmt->bindValue(':bp', getParam('buy_price', 0));
$stmt->bindValue(':sp', getParam('sell_price', -1)); $stmt->bindValue(':sp', getParam('sell_price', -1));
$stmt->bindValue(':st', getParam('stock', -1)); $stmt->bindValue(':st', getParam('stock', -1));
$stmt->bindValue(':lm', time(), SQLITE3_INTEGER);
$stmt->execute(); $stmt->execute();
exit(json_encode(['success' => true, 'message' => 'OK'], JSON_UNESCAPED_UNICODE)); exit(json_encode(['success' => true, 'message' => 'OK'], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) { } catch (\Throwable $e) {
@error_log('[Function] Error: ' . $e->getMessage()); @error_log('[Function] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE)); exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
} }
@@ -228,11 +249,14 @@ function adminShopRemove() {
requireAdminSession(); requireAdminSession();
$db = getDB(); $db = getDB();
try { try {
$itemId = getParam('id');
if (!$itemId) error('缺少商品ID');
$stmt = $db->prepare("DELETE FROM shop_items WHERE id=:id"); $stmt = $db->prepare("DELETE FROM shop_items WHERE id=:id");
$stmt->bindValue(':id', getParam('item_id')); $stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
$stmt->execute(); $stmt->execute();
exit(json_encode(['success' => true, 'message' => 'OK'], JSON_UNESCAPED_UNICODE)); $rowsChanged = $db->changes();
} catch (Exception $e) { exit(json_encode(['success' => true, 'message' => 'OK', 'deleted' => $rowsChanged], JSON_UNESCAPED_UNICODE));
} catch (\Throwable $e) {
@error_log('[Function] Error: ' . $e->getMessage()); @error_log('[Function] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE)); exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
} }
@@ -2214,3 +2238,28 @@ function adminGetStatsEx() {
exit(json_encode(['success' => false, 'message' => 'Internal error: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE)); exit(json_encode(['success' => false, 'message' => 'Internal error: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE));
} }
} }
// ===== 删除CDK(管理员操作) =====
function adminDeleteCDK() {
requireAdminSession();
$code = getParam('code');
if (!$code) exit(json_encode(['success' => false, 'message' => '缺少code'], JSON_UNESCAPED_UNICODE));
$db = getDB();
try {
$stmt = $db->prepare("DELETE FROM cdk WHERE code = :code");
$stmt->bindValue(':code', strtoupper(trim($code)), SQLITE3_TEXT);
$result = $stmt->execute();
// 检查是否有行被删除
$changes = $db->changes();
if ($changes > 0) {
exit(json_encode(['success' => true, 'message' => 'CDK已删除'], JSON_UNESCAPED_UNICODE));
} else {
exit(json_encode(['success' => false, 'message' => 'CDK不存在'], JSON_UNESCAPED_UNICODE));
}
} catch (Exception $e) {
@error_log('[adminDeleteCDK] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
}
}
+11 -4
View File
@@ -13,6 +13,7 @@ ob_end_clean();
$action = getParam('action', 'query'); $action = getParam('action', 'query');
$token = getParam('token'); $token = getParam('token');
try {
switch ($action) { switch ($action) {
case 'query': case 'query':
balanceQuery($token); balanceQuery($token);
@@ -26,6 +27,12 @@ switch ($action) {
default: default:
error('未知操作: ' . $action); error('未知操作: ' . $action);
} }
} catch (\Throwable $e) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => ['code' => 500, 'message' => 'Internal error: ' . $e->getMessage()]], JSON_UNESCAPED_UNICODE);
exit;
}
// ===== 查询余额 ===== // ===== 查询余额 =====
function balanceQuery($token) { function balanceQuery($token) {
@@ -242,7 +249,7 @@ function balanceTransactions($token) {
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER); $stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$result = $stmt->execute(); $result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row; while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
} catch (Exception $e) { /* ignore */ } } catch (\Throwable $e) { /* ignore */ }
// 游戏内交易 // 游戏内交易
try { try {
$stmt = $db->prepare("SELECT java_id as id, player_name, type, amount, operator, reason, '' as detail, tx_time as created_at, 'game' as source, balance_before, balance_after FROM game_transactions WHERE player_name = :player ORDER BY tx_time DESC LIMIT :limit"); $stmt = $db->prepare("SELECT java_id as id, player_name, type, amount, operator, reason, '' as detail, tx_time as created_at, 'game' as source, balance_before, balance_after FROM game_transactions WHERE player_name = :player ORDER BY tx_time DESC LIMIT :limit");
@@ -250,7 +257,7 @@ function balanceTransactions($token) {
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER); $stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$result = $stmt->execute(); $result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row; while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
} catch (Exception $e) { /* ignore */ } } catch (\Throwable $e) { /* ignore */ }
} else { } else {
// 管理员查看全服流水 // 管理员查看全服流水
if ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') { if ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') {
@@ -262,14 +269,14 @@ function balanceTransactions($token) {
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER); $stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$result = $stmt->execute(); $result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row; while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
} catch (Exception $e) { /* ignore */ } } catch (\Throwable $e) { /* ignore */ }
// 游戏内交易 // 游戏内交易
try { try {
$stmt = $db->prepare("SELECT java_id as id, player_name, type, amount, operator, reason, '' as detail, tx_time as created_at, 'game' as source, balance_before, balance_after FROM game_transactions ORDER BY tx_time DESC LIMIT :limit"); $stmt = $db->prepare("SELECT java_id as id, player_name, type, amount, operator, reason, '' as detail, tx_time as created_at, 'game' as source, balance_before, balance_after FROM game_transactions ORDER BY tx_time DESC LIMIT :limit");
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER); $stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$result = $stmt->execute(); $result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row; while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
} catch (Exception $e) { /* ignore */ } } catch (\Throwable $e) { /* ignore */ }
} }
// 按时间排序(倒序) // 按时间排序(倒序)
+8 -1
View File
@@ -16,6 +16,7 @@ while (ob_get_level() > 0) { ob_end_clean(); }
$action = getParam('action', 'exchange'); $action = getParam('action', 'exchange');
$token = getParam('token'); $token = getParam('token');
try {
switch ($action) { switch ($action) {
case 'exchange': case 'exchange':
cdkExchange($token); cdkExchange($token);
@@ -35,6 +36,12 @@ switch ($action) {
default: default:
error('未知操作: ' . $action); error('未知操作: ' . $action);
} }
} catch (\Throwable $e) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => ['code' => 500, 'message' => 'Internal error: ' . $e->getMessage()]], JSON_UNESCAPED_UNICODE);
exit;
}
// ===== CDK兑换 ===== // ===== CDK兑换 =====
function cdkExchange($token) { function cdkExchange($token) {
@@ -382,7 +389,7 @@ function validateWebloginTokenForCDK($webToken) {
'created_at' => $createdAt, 'created_at' => $createdAt,
'expires_at' => $createdAt + $expireSeconds 'expires_at' => $createdAt + $expireSeconds
]; ];
} catch (Exception $e) { } catch (\Throwable $e) {
return false; return false;
} }
} }
+8 -3
View File
@@ -13,6 +13,7 @@ ob_end_clean();
$action = getParam('action', 'check'); $action = getParam('action', 'check');
$token = getParam('token'); $token = getParam('token');
try {
switch ($action) { switch ($action) {
case 'check': case 'check':
registerCheck(); registerCheck();
@@ -29,6 +30,10 @@ switch ($action) {
default: default:
error('未知操作: ' . $action); error('未知操作: ' . $action);
} }
} catch (\Throwable $e) {
while (ob_get_level() > 0) ob_end_clean();
error('服务器内部错误: ' . $e->getMessage(), 500);
}
// ===== 检查用户名 ===== // ===== 检查用户名 =====
function registerCheck() { function registerCheck() {
@@ -206,7 +211,7 @@ function registerQuery($token) {
$bondResult = $bondStmt->execute(); $bondResult = $bondStmt->execute();
$bondRow = $bondResult->fetchArray(SQLITE3_ASSOC); $bondRow = $bondResult->fetchArray(SQLITE3_ASSOC);
if ($bondRow) $bonds = (int)($bondRow['amount'] ?? 0); if ($bondRow) $bonds = (int)($bondRow['amount'] ?? 0);
} catch (Exception $e) {} } catch (\Throwable $e) {}
// ★ 获取累计在线天数(不重复日期计数) // ★ 获取累计在线天数(不重复日期计数)
$totalOnlineDays = 0; $totalOnlineDays = 0;
@@ -216,7 +221,7 @@ function registerQuery($token) {
$odResult = $odStmt->execute(); $odResult = $odStmt->execute();
$odRow = $odResult->fetchArray(SQLITE3_ASSOC); $odRow = $odResult->fetchArray(SQLITE3_ASSOC);
if ($odRow) $totalOnlineDays = (int)($odRow['cnt'] ?? 0); if ($odRow) $totalOnlineDays = (int)($odRow['cnt'] ?? 0);
} catch (Exception $e) {} } catch (\Throwable $e) {}
// ★ 获取累计签到天数 // ★ 获取累计签到天数
$totalCheckinDays = 0; $totalCheckinDays = 0;
@@ -226,7 +231,7 @@ function registerQuery($token) {
$ciResult = $ciStmt->execute(); $ciResult = $ciStmt->execute();
$ciRow = $ciResult->fetchArray(SQLITE3_ASSOC); $ciRow = $ciResult->fetchArray(SQLITE3_ASSOC);
if ($ciRow) $totalCheckinDays = (int)($ciRow['cnt'] ?? 0); if ($ciRow) $totalCheckinDays = (int)($ciRow['cnt'] ?? 0);
} catch (Exception $e) {} } catch (\Throwable $e) {}
// 合并额外数据 // 合并额外数据
$row['bonds'] = $bonds; $row['bonds'] = $bonds;
+9 -2
View File
@@ -14,6 +14,7 @@ while (ob_get_level() > 0) { ob_end_clean(); }
$action = getParam('action', 'list'); $action = getParam('action', 'list');
$token = getParam('token'); $token = getParam('token');
try {
switch ($action) { switch ($action) {
case 'list': case 'list':
shopList($token); shopList($token);
@@ -27,6 +28,10 @@ switch ($action) {
default: default:
error('未知操作: ' . $action); error('未知操作: ' . $action);
} }
} catch (\Throwable $e) {
while (ob_get_level() > 0) ob_end_clean();
error('服务器内部错误: ' . $e->getMessage(), 500);
}
// ===== 商品列表(公开,无需token) ===== // ===== 商品列表(公开,无需token) =====
function shopList($token = null) { function shopList($token = null) {
@@ -185,9 +190,11 @@ function shopBuy($token) {
// ★ 实际购买 — 包装在事务中,防止database is locked // ★ 实际购买 — 包装在事务中,防止database is locked
$db->exec('BEGIN IMMEDIATE'); $db->exec('BEGIN IMMEDIATE');
try { try {
// 更新库存 // 更新库存(同时写admin_stock确保Java同步)
if ($item['stock'] > 0) { if ($item['stock'] > 0) {
$stmt = $db->prepare("UPDATE shop_items SET stock = stock - :amount, hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id"); $newStock = $item['stock'] - $amount;
$stmt = $db->prepare("UPDATE shop_items SET stock = :ns, admin_stock = :ns, hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
$stmt->bindValue(':ns', $newStock, SQLITE3_INTEGER);
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER); $stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT); $stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
$stmt->execute(); $stmt->execute();
+287 -30
View File
@@ -1,19 +1,36 @@
<?php <?php
// ★ 同步API - 插件与Web端数据同步 // ★ 同步API - 插件与Web端数据同步
// ★ Version: 2026-06-12-1940 - clear OPcache at top // ★ Version: 2026-06-22-v2 - added require_once guard + removed opcache_reset
// ★ 防止任何输出污染JSON响应 // ★ 防止任何输出污染JSON响应
ob_start(); ob_start();
// 清除OPcache(如果可用) // 仅刷新当前文件的OPcache(不要调用opcache_reset——它会重置整个服务器缓存,导致并发竞态条件)
if (function_exists('opcache_reset')) { @opcache_reset(); }
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); } if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
// ★ 再次确保清理前置输出 // 清理前置输出
while (ob_get_level() > 0) { while (ob_get_level() > 0) {
ob_end_clean(); ob_end_clean();
} }
ob_start(); ob_start();
require_once __DIR__ . '/../core.php'; // ★ 安全检查:确保core.php存在,避免require_once失败时输出HTML错误
// ★ 再次确保清理前置输出 $coreFile = __DIR__ . '/../core.php';
if (!file_exists($coreFile)) {
while (ob_get_level() > 0) ob_end_clean();
header('Content-Type: application/json; charset=utf-8');
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'core.php not found: ' . $coreFile], JSON_UNESCAPED_UNICODE);
exit;
}
// ★ 设置错误处理器:防止require_once失败时输出HTML
set_error_handler(function($errno, $errstr, $errfile, $errline) {
@error_log("[sync.php PRE-LOAD] $errstr in $errfile on line $errline");
return true; // 抑制输出
});
require_once $coreFile;
// ★ 恢复错误处理器 + 清理前置输出
restore_error_handler();
while (ob_get_level() > 0) { while (ob_get_level() > 0) {
ob_end_clean(); ob_end_clean();
} }
@@ -115,10 +132,14 @@ function smtpSendEmail($host, $port, $user, $pass, $to, $subject, $htmlBody, $he
$action = getParam('action', ''); $action = getParam('action', '');
try {
switch ($action) { switch ($action) {
case 'sync_shop': case 'sync_shop':
syncShop(); syncShop();
break; break;
case 'sync_service_providers':
syncServiceProviders();
break;
case 'sync_bonds': case 'sync_bonds':
syncBonds(); syncBonds();
break; break;
@@ -167,6 +188,9 @@ switch ($action) {
case 'web_login_request': case 'web_login_request':
webLoginRequest(); webLoginRequest();
break; break;
case 'cancel_web_login':
cancelWebLogin();
break;
case 'check_web_login_result': case 'check_web_login_result':
checkWebLoginResult(); checkWebLoginResult();
break; break;
@@ -194,6 +218,12 @@ switch ($action) {
case 'pull_shop_stock': case 'pull_shop_stock':
pullShopStock(); pullShopStock();
break; break;
case 'clear_admin_stock':
clearAdminStock();
break;
case 'check_shop_stock_changed':
checkShopStockChanged();
break;
case 'notify_sync': case 'notify_sync':
notifySync(); notifySync();
break; break;
@@ -272,6 +302,10 @@ switch ($action) {
default: default:
error('未知操作: ' . $action); error('未知操作: ' . $action);
} }
} catch (\Throwable $e) {
while (ob_get_level() > 0) ob_end_clean();
error('服务器内部错误: ' . $e->getMessage(), 500);
}
// ===== 插件推送商品数据 ===== // ===== 插件推送商品数据 =====
function syncShop() { function syncShop() {
@@ -306,9 +340,22 @@ function syncShop() {
// 游戏端stock=0表示无限供应,Web端用-1表示无限 // 游戏端stock=0表示无限供应,Web端用-1表示无限
$stock = (int)($item['stock'] ?? -1); $stock = (int)($item['stock'] ?? -1);
if ($stock == 0) $stock = -1; // 0 → 无限 // 仅≤-2的未定义值转为-1(无限),0和正数保留原值
$stmt = $db->prepare("INSERT OR REPLACE INTO shop_items (id, category, display_name, material, buy_price, sell_price, stock, hourly_sales, total_sales, last_sync) VALUES (:id, :cat, :name, :mat, :bp, :sp, :st, :hs, :ts, :time)"); // ★ 检查该商品是否有管理员库存改动(admin_stock非NULL)
$checkStmt = $db->prepare("SELECT admin_stock FROM shop_items WHERE id = :id");
$checkStmt->bindValue(':id', $id, SQLITE3_TEXT);
$checkResult = $checkStmt->execute();
$checkRow = $checkResult->fetchArray(SQLITE3_ASSOC);
$hasAdminStock = $checkRow && $checkRow['admin_stock'] !== null && $checkRow['admin_stock'] !== '';
if ($hasAdminStock) {
// ★ 管理员修改过库存:跳过stock字段,只更新其他字段
$stmt = $db->prepare("INSERT INTO shop_items (id, category, display_name, material, buy_price, sell_price, stock, hourly_sales, total_sales, last_sync) VALUES (:id, :cat, :name, :mat, :bp, :sp, :st, :hs, :ts, :time) ON CONFLICT(id) DO UPDATE SET category=:cat, display_name=:name, material=:mat, buy_price=:bp, sell_price=:sp, hourly_sales=:hs, total_sales=:ts, last_sync=:time");
} else {
// ★ 正常:更新所有字段包括stock
$stmt = $db->prepare("INSERT INTO shop_items (id, category, display_name, material, buy_price, sell_price, stock, hourly_sales, total_sales, last_sync) VALUES (:id, :cat, :name, :mat, :bp, :sp, :st, :hs, :ts, :time) ON CONFLICT(id) DO UPDATE SET category=:cat, display_name=:name, material=:mat, buy_price=:bp, sell_price=:sp, stock=:st, hourly_sales=:hs, total_sales=:ts, last_sync=:time");
}
$stmt->bindValue(':id', $id, SQLITE3_TEXT); $stmt->bindValue(':id', $id, SQLITE3_TEXT);
$stmt->bindValue(':cat', $item['category'] ?? '默认', SQLITE3_TEXT); $stmt->bindValue(':cat', $item['category'] ?? '默认', SQLITE3_TEXT);
$stmt->bindValue(':name', $item['display_name'] ?? $id, SQLITE3_TEXT); $stmt->bindValue(':name', $item['display_name'] ?? $id, SQLITE3_TEXT);
@@ -324,11 +371,54 @@ function syncShop() {
} }
success(['synced' => $count], "同步了{$count}个商品"); success(['synced' => $count], "同步了{$count}个商品");
}
// 一次性修复:将数据库中所有stock=0改为-1(无限供应) // ===== 插件推送服务商列表 =====
$fixResult = $db->exec("UPDATE shop_items SET stock = -1 WHERE stock = 0"); function syncServiceProviders() {
if ($fixResult !== false) { $token = getParam('token');
// 静默修复,不影响正常流程 $secret = getParam('secret');
if ($token) {
$tokenInfo = validateToken($token);
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) {
if (!($secret && $secret === SECRET_KEY)) {
error('同步需要管理权限token或SECRET_KEY');
}
}
} elseif ($secret) {
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
} else {
error('缺少认证信息');
}
$db = getDB();
$data = getParam('data'); // 可能是JSON字符串或已解析数组(从php://input)
if (!$data) error('缺少data参数');
// getParam从php://input解析JSON body时可能直接返回数组
$providers = is_array($data) ? $data : json_decode($data, true);
if (!$providers || !is_array($providers)) error('data格式无效');
$db->exec("BEGIN IMMEDIATE");
try {
// 全量替换
$db->exec("DELETE FROM web_service_providers");
$stmt = $db->prepare("INSERT INTO web_service_providers (player_name, role, active, join_time) VALUES (:p, :r, :a, :t)");
$count = 0;
foreach ($providers as $p) {
$stmt->bindValue(':p', $p['player_name'] ?? '', SQLITE3_TEXT);
$stmt->bindValue(':r', $p['role'] ?? 'waiter', SQLITE3_TEXT);
$stmt->bindValue(':a', $p['active'] ?? 1, SQLITE3_INTEGER);
$stmt->bindValue(':t', $p['join_time'] ?? 0, SQLITE3_INTEGER);
$stmt->execute();
$count++;
}
$db->exec("COMMIT");
debugLog("syncServiceProviders: 同步了 $count 个服务商");
success(['count' => $count], "同步了 $count 个服务商");
} catch (\Throwable $e) {
$db->exec("ROLLBACK");
error('同步失败: ' . $e->getMessage());
} }
} }
@@ -1368,6 +1458,16 @@ function pushPlayerLoginStatus() {
$isRegistered = $val ? (int)$val : 0; $isRegistered = $val ? (int)$val : 0;
} }
// ★ 读取玩家IP地址(Java端push_player_login_status携带)
$playerIp = '';
if (isset($_GET['ip'])) {
$playerIp = $_GET['ip'];
} elseif (isset($_POST['ip'])) {
$playerIp = $_POST['ip'];
} else {
$playerIp = getParam('ip') ?: '';
}
if (!$player || !$webToken) { if (!$player || !$webToken) {
@error_log("[pushPlayerLoginStatus] FAIL: missing player or web_token. player=" . var_export($player, true) . ", web_token=" . var_export($webToken, true)); @error_log("[pushPlayerLoginStatus] FAIL: missing player or web_token. player=" . var_export($player, true) . ", web_token=" . var_export($webToken, true));
error('缺少player或web_token参数'); error('缺少player或web_token参数');
@@ -1376,17 +1476,19 @@ function pushPlayerLoginStatus() {
@error_log("[pushPlayerLoginStatus] SUCCESS: player=$player, token=" . substr($webToken, 0, 20) . "..., online=$isOnline, registered=$isRegistered, expire=$expireSeconds"); @error_log("[pushPlayerLoginStatus] SUCCESS: player=$player, token=" . substr($webToken, 0, 20) . "..., online=$isOnline, registered=$isRegistered, expire=$expireSeconds");
$db = getDB(); $db = getDB();
$now = time();
// ★ 事务包裹所有写操作,防止 "database is locked"
$db->exec("BEGIN IMMEDIATE");
try {
// 1. 同步weblogin_tokens表(登录页面需要的token) // 1. 同步weblogin_tokens表(登录页面需要的token)
$db->exec("CREATE TABLE IF NOT EXISTS weblogin_tokens (player_name TEXT PRIMARY KEY, web_token TEXT NOT NULL, created_at INTEGER NOT NULL, expire_seconds INTEGER DEFAULT 600)"); $db->exec("CREATE TABLE IF NOT EXISTS weblogin_tokens (player_name TEXT PRIMARY KEY, web_token TEXT NOT NULL, created_at INTEGER NOT NULL, expire_seconds INTEGER DEFAULT 600)");
$now = time();
$stmt = $db->prepare("INSERT OR REPLACE INTO weblogin_tokens (player_name, web_token, created_at, expire_seconds) VALUES (:player, :token, :time, :expire)"); $stmt = $db->prepare("INSERT OR REPLACE INTO weblogin_tokens (player_name, web_token, created_at, expire_seconds) VALUES (:player, :token, :time, :expire)");
$stmt->bindValue(':player', $player, SQLITE3_TEXT); $stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT); $stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
$stmt->bindValue(':time', $now, SQLITE3_INTEGER); $stmt->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt->bindValue(':expire', $expireSeconds, SQLITE3_INTEGER); $stmt->bindValue(':expire', $expireSeconds, SQLITE3_INTEGER);
$stmt->execute(); $stmt->execute();
@error_log("[pushPlayerLoginStatus] Token stored: player=$player, created_at=$now, expire=$expireSeconds");
// 2. 如果玩家在游戏中已登录,更新online_players表(只更新已存在的记录,不凭空插入) // 2. 如果玩家在游戏中已登录,更新online_players表(只更新已存在的记录,不凭空插入)
if ($isOnline) { if ($isOnline) {
@@ -1402,9 +1504,40 @@ function pushPlayerLoginStatus() {
$sessionStmt = $db->prepare("INSERT OR REPLACE INTO web_session_log (player_name, login_time, ip_address) VALUES (:player, :time, :ip)"); $sessionStmt = $db->prepare("INSERT OR REPLACE INTO web_session_log (player_name, login_time, ip_address) VALUES (:player, :time, :ip)");
$sessionStmt->bindValue(':player', $player, SQLITE3_TEXT); $sessionStmt->bindValue(':player', $player, SQLITE3_TEXT);
$sessionStmt->bindValue(':time', $now, SQLITE3_INTEGER); $sessionStmt->bindValue(':time', $now, SQLITE3_INTEGER);
$sessionStmt->bindValue(':ip', 'plugin_sync', SQLITE3_TEXT); $sessionStmt->bindValue(':ip', !empty($playerIp) ? $playerIp : 'plugin_sync', SQLITE3_TEXT);
$sessionStmt->execute(); $sessionStmt->execute();
// ★ 如果有IP,更新player_ip_changes表
if (!empty($playerIp)) {
try {
$db->exec("CREATE TABLE IF NOT EXISTS player_ip_changes (
player_name TEXT PRIMARY KEY,
old_ip TEXT DEFAULT '',
new_ip TEXT NOT NULL,
changed_at INTEGER NOT NULL,
synced_at INTEGER DEFAULT 0
)");
$checkStmt = $db->prepare("SELECT new_ip FROM player_ip_changes WHERE player_name = :player");
$checkStmt->bindValue(':player', $player, SQLITE3_TEXT);
$checkResult = $checkStmt->execute();
$existing = $checkResult->fetchArray(SQLITE3_ASSOC);
if (!$existing || $existing['new_ip'] !== $playerIp) {
$oldIp = $existing ? $existing['new_ip'] : '';
$ipStmt = $db->prepare("INSERT OR REPLACE INTO player_ip_changes (player_name, old_ip, new_ip, changed_at, synced_at) VALUES (:name, :old, :new, :time, :synced)");
$ipStmt->bindValue(':name', $player, SQLITE3_TEXT);
$ipStmt->bindValue(':old', $oldIp, SQLITE3_TEXT);
$ipStmt->bindValue(':new', $playerIp, SQLITE3_TEXT);
$ipStmt->bindValue(':time', $now, SQLITE3_INTEGER);
$ipStmt->bindValue(':synced', $now, SQLITE3_INTEGER);
$ipStmt->execute();
@error_log("[pushPlayerLoginStatus] IP更新: {$player} {$oldIp}→{$playerIp}");
}
} catch (Exception $e) {
@error_log("[pushPlayerLoginStatus] IP写入失败 {$player}: " . $e->getMessage());
}
}
// ★ 记录每日登录(游戏内登录也算) // ★ 记录每日登录(游戏内登录也算)
$today = date('Y-m-d'); $today = date('Y-m-d');
$dlStmt = $db->prepare("INSERT OR REPLACE INTO player_daily_logins (player_name, login_date, first_login, last_login) VALUES (:name, :date, :first, :last)"); $dlStmt = $db->prepare("INSERT OR REPLACE INTO player_daily_logins (player_name, login_date, first_login, last_login) VALUES (:name, :date, :first, :last)");
@@ -1428,6 +1561,13 @@ function pushPlayerLoginStatus() {
} }
} }
$db->exec("COMMIT");
} catch (\Throwable $e) {
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
@error_log("[pushPlayerLoginStatus] Transaction failed: " . $e->getMessage());
// 仍然返回成功,因为token已写入(或即将写入),不让Java端重试
}
success(['player' => $player], '玩家登录状态已同步'); success(['player' => $player], '玩家登录状态已同步');
} }
@@ -1443,6 +1583,9 @@ function checkWebLoginConfirmations() {
$db = getDB(); $db = getDB();
$db->exec("CREATE TABLE IF NOT EXISTS web_login_confirmations (player_name TEXT PRIMARY KEY, confirmed_at INTEGER NOT NULL, consumed INTEGER DEFAULT 0)"); $db->exec("CREATE TABLE IF NOT EXISTS web_login_confirmations (player_name TEXT PRIMARY KEY, confirmed_at INTEGER NOT NULL, consumed INTEGER DEFAULT 0)");
// ★ 事务包裹所有写操作
$db->exec("BEGIN IMMEDIATE");
try {
// ★ 清理超过10分钟的过期确认记录 // ★ 清理超过10分钟的过期确认记录
$expireTime = time() - 600; $expireTime = time() - 600;
$db->exec("DELETE FROM web_login_confirmations WHERE confirmed_at < " . $expireTime); $db->exec("DELETE FROM web_login_confirmations WHERE confirmed_at < " . $expireTime);
@@ -1466,6 +1609,11 @@ function checkWebLoginConfirmations() {
$db->exec("DELETE FROM web_login_confirmations WHERE consumed = 1 AND confirmed_at < " . (time() - 1800)); $db->exec("DELETE FROM web_login_confirmations WHERE consumed = 1 AND confirmed_at < " . (time() - 1800));
} }
$db->exec("COMMIT");
} catch (\Throwable $e) {
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
}
success($confirmations); success($confirmations);
} }
@@ -1484,6 +1632,9 @@ function checkWebLoginVerified() {
$db = getDB(); $db = getDB();
$db->exec("CREATE TABLE IF NOT EXISTS web_login_verified (player_name TEXT PRIMARY KEY, verified_at INTEGER NOT NULL)"); $db->exec("CREATE TABLE IF NOT EXISTS web_login_verified (player_name TEXT PRIMARY KEY, verified_at INTEGER NOT NULL)");
// ★ 事务包裹所有读写操作
$db->exec("BEGIN IMMEDIATE");
try {
// ★ 清理超过10分钟的过期验证记录(防止残留数据导致误登录) // ★ 清理超过10分钟的过期验证记录(防止残留数据导致误登录)
$expireTime = time() - 600; // 10分钟 $expireTime = time() - 600; // 10分钟
$db->exec("DELETE FROM web_login_verified WHERE verified_at < " . $expireTime); $db->exec("DELETE FROM web_login_verified WHERE verified_at < " . $expireTime);
@@ -1524,6 +1675,11 @@ function checkWebLoginVerified() {
} }
} }
$db->exec("COMMIT");
} catch (\Throwable $e) {
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
}
success($verified); success($verified);
} }
@@ -1537,9 +1693,9 @@ function webLoginRequest() {
error('缺少player或password参数'); error('缺少player或password参数');
} }
// 简单验证玩家名格式(3-16位字母数字下划线) // 简单验证玩家名格式(1-16位字母数字下划线)
if (!preg_match('/^[a-zA-Z0-9_]{3,16}$/', $player)) { if (!preg_match('/^[a-zA-Z0-9_]{1,16}$/', $player)) {
error('玩家名格式不正确(3-16位字母数字下划线)'); error('玩家名格式不正确(1-16位字母数字下划线)');
} }
$db = getDB(); $db = getDB();
@@ -1559,14 +1715,25 @@ function webLoginRequest() {
result_time INTEGER DEFAULT 0 result_time INTEGER DEFAULT 0
)"); )");
// ★ 事务包裹所有写操作
$db->exec("BEGIN IMMEDIATE");
try {
// 清理10分钟前的过期请求 // 清理10分钟前的过期请求
$db->exec("DELETE FROM web_login_requests WHERE request_time < " . (time() - 600)); $db->exec("DELETE FROM web_login_requests WHERE request_time < " . (time() - 600));
// 检查是否有进行中的请求 // 清理该玩家超过2分钟的旧pending请求(防止永久阻塞)
$checkStmt2 = $db->prepare("SELECT id FROM web_login_requests WHERE player_name = :player AND status = 'pending'"); $cleanupStmt = $db->prepare("DELETE FROM web_login_requests WHERE player_name = :player AND status = 'pending' AND request_time < :cutoff");
$cleanupStmt->bindValue(':player', $player, SQLITE3_TEXT);
$cleanupStmt->bindValue(':cutoff', time() - 120, SQLITE3_INTEGER);
$cleanupStmt->execute();
// 检查是否有最近2分钟内的进行中请求
$checkStmt2 = $db->prepare("SELECT id FROM web_login_requests WHERE player_name = :player AND status = 'pending' AND request_time > :cutoff2");
$checkStmt2->bindValue(':player', $player, SQLITE3_TEXT); $checkStmt2->bindValue(':player', $player, SQLITE3_TEXT);
$checkStmt2->bindValue(':cutoff2', time() - 120, SQLITE3_INTEGER);
$checkResult2 = $checkStmt2->execute(); $checkResult2 = $checkStmt2->execute();
if ($checkResult2->fetchArray()) { if ($checkResult2->fetchArray()) {
$db->exec("ROLLBACK");
error('已有进行中的登录请求,请稍后再试'); error('已有进行中的登录请求,请稍后再试');
} }
@@ -1578,6 +1745,13 @@ function webLoginRequest() {
$requestId = $db->lastInsertRowID(); $requestId = $db->lastInsertRowID();
$db->exec("COMMIT");
} catch (\Throwable $e) {
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
@error_log("[webLoginRequest] Transaction failed: " . $e->getMessage());
error('提交失败: ' . $e->getMessage());
}
debugLog("webLoginRequest: 新请求", ['player' => $player, 'request_id' => $requestId]); debugLog("webLoginRequest: 新请求", ['player' => $player, 'request_id' => $requestId]);
success([ success([
@@ -1587,6 +1761,32 @@ function webLoginRequest() {
], "请求已提交"); ], "请求已提交");
} }
// ===== 取消/清理待处理的登录请求 =====
function cancelWebLogin() {
$player = getParam('player');
if (!$player) error('缺少player参数');
$db = getDB();
$db->exec("CREATE TABLE IF NOT EXISTS web_login_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_name TEXT NOT NULL,
password TEXT NOT NULL,
request_time INTEGER NOT NULL,
status TEXT DEFAULT 'pending',
result TEXT DEFAULT '',
result_time INTEGER DEFAULT 0
)");
// 将该玩家所有pending请求标记为cancelled
$stmt = $db->prepare("UPDATE web_login_requests SET status = 'cancelled', result = '{\"success\":false,\"message\":\"用户取消\"}', result_time = :time WHERE player_name = :player AND status = 'pending'");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
debugLog("cancelWebLogin: 清理了玩家 $player 的pending请求");
success([], "已清理");
}
// ===== Web端轮询密码登录结果 ===== // ===== Web端轮询密码登录结果 =====
function checkWebLoginResult() { function checkWebLoginResult() {
$player = getParam('player'); $player = getParam('player');
@@ -1681,8 +1881,15 @@ function checkPendingWebLogins() {
result_time INTEGER DEFAULT 0 result_time INTEGER DEFAULT 0
)"); )");
// ★ 事务包裹清理操作
$db->exec("BEGIN IMMEDIATE");
try {
// 清理10分钟前的过期请求 // 清理10分钟前的过期请求
$db->exec("DELETE FROM web_login_requests WHERE request_time < " . (time() - 600)); $db->exec("DELETE FROM web_login_requests WHERE request_time < " . (time() - 600));
$db->exec("COMMIT");
} catch (\Throwable $e) {
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
}
// 获取所有pending请求(只返回id、player_name、password,不返回密码明文给前端) // 获取所有pending请求(只返回id、player_name、password,不返回密码明文给前端)
$stmt = $db->prepare("SELECT id, player_name, password FROM web_login_requests WHERE status = 'pending'"); $stmt = $db->prepare("SELECT id, player_name, password FROM web_login_requests WHERE status = 'pending'");
@@ -1716,6 +1923,8 @@ function completeWebLoginRequest() {
} }
$db = getDB(); $db = getDB();
// ★ 事务包裹所有写操作,防止database is locked
$db->exec("BEGIN IMMEDIATE");
try { try {
$stmt = $db->prepare("SELECT id FROM web_login_requests WHERE id = :id AND player_name = :player"); $stmt = $db->prepare("SELECT id FROM web_login_requests WHERE id = :id AND player_name = :player");
$stmt->bindValue(':id', (int)$requestId, SQLITE3_INTEGER); $stmt->bindValue(':id', (int)$requestId, SQLITE3_INTEGER);
@@ -1723,6 +1932,7 @@ function completeWebLoginRequest() {
$row = $stmt->execute()->fetchArray(); $row = $stmt->execute()->fetchArray();
if (!$row) { if (!$row) {
$db->exec("ROLLBACK");
debugLog("completeWebLoginRequest: 请求不存在", ['request_id' => $requestId, 'player' => $player]); debugLog("completeWebLoginRequest: 请求不存在", ['request_id' => $requestId, 'player' => $player]);
error('请求不存在'); error('请求不存在');
} }
@@ -1738,8 +1948,6 @@ function completeWebLoginRequest() {
$updateStmt->bindValue(':id', (int)$requestId, SQLITE3_INTEGER); $updateStmt->bindValue(':id', (int)$requestId, SQLITE3_INTEGER);
$updateStmt->execute(); $updateStmt->execute();
debugLog("completeWebLoginRequest: 更新成功", ['request_id' => $requestId, 'status' => $status]);
// 如果验证成功,写入web_login_confirmations和web_login_verified // 如果验证成功,写入web_login_confirmations和web_login_verified
if ($status === 'success') { if ($status === 'success') {
$db->exec("CREATE TABLE IF NOT EXISTS web_login_confirmations (player_name TEXT PRIMARY KEY, confirmed_at INTEGER NOT NULL, consumed INTEGER DEFAULT 0)"); $db->exec("CREATE TABLE IF NOT EXISTS web_login_confirmations (player_name TEXT PRIMARY KEY, confirmed_at INTEGER NOT NULL, consumed INTEGER DEFAULT 0)");
@@ -1766,8 +1974,11 @@ function completeWebLoginRequest() {
$dlStmt->execute(); $dlStmt->execute();
} }
$db->exec("COMMIT");
debugLog("completeWebLoginRequest: 更新成功", ['request_id' => $requestId, 'status' => $status]);
success(['status' => $status], "结果已写入"); success(['status' => $status], "结果已写入");
} catch (Exception $e) { } catch (\Throwable $e) {
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
debugLog("completeWebLoginRequest: 数据库异常", ['error' => $e->getMessage()]); debugLog("completeWebLoginRequest: 数据库异常", ['error' => $e->getMessage()]);
error('数据库错误: ' . $e->getMessage()); error('数据库错误: ' . $e->getMessage());
} }
@@ -2089,16 +2300,61 @@ function pullShopStock() {
if (!$secret || $secret !== SECRET_KEY) error('认证失败'); if (!$secret || $secret !== SECRET_KEY) error('认证失败');
$db = getDB(); $db = getDB();
$stmt = $db->prepare("SELECT id, stock, last_sync FROM shop_items"); // ★ admin_stock非NULL时优先返回(管理员手动改的库存)
// 防止Java全量同步覆盖管理员的修改
$stmt = $db->prepare("SELECT id, stock, last_sync, admin_stock FROM shop_items");
$result = $stmt->execute(); $result = $stmt->execute();
$items = []; $items = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) { while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
// ★ 如果admin_stock有值,用admin_stock替代stock返回给Java
if ($row['admin_stock'] !== null && $row['admin_stock'] !== '') {
$row['stock'] = (int)$row['admin_stock'];
$row['admin_stock_override'] = true;
}
unset($row['admin_stock']); // 不暴露内部字段
$items[] = $row; $items[] = $row;
} }
// ★ 成功获取数据后立即清除admin_stock标记(同一次请求完成,消除竞态)
// 防止Java端clearAdminStock()单独请求失败导致admin_stock永远非NULL
$db->exec("UPDATE shop_items SET admin_stock = NULL WHERE admin_stock IS NOT NULL");
success(['items' => $items, 'count' => count($items)]); success(['items' => $items, 'count' => count($items)]);
} }
// ===== 插件确认已应用管理员库存改动 =====
function clearAdminStock() {
$secret = getParam('secret');
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
$db = getDB();
$db->exec("UPDATE shop_items SET admin_stock = NULL WHERE admin_stock IS NOT NULL");
success([], '已清除管理员库存标记');
}
// ===== 高频轮询:检测库存是否有改动(轻量级接口) =====
function checkShopStockChanged() {
$secret = getParam('secret');
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
$db = getDB();
$stmt = $db->prepare("SELECT MAX(last_modified) as max_lm, SUM(CASE WHEN admin_stock IS NOT NULL THEN 1 ELSE 0 END) as admin_count FROM shop_items");
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
$maxModified = $row['max_lm'] ? (int)$row['max_lm'] : 0;
$adminCount = $row['admin_count'] ? (int)$row['admin_count'] : 0;
// ★ 如果admin_stock非NULL(管理员改了库存但Java还没拉取),强制返回changed=true
$clientLastModified = (int)getParam('last_modified', 0);
$changed = ($maxModified > $clientLastModified) || ($adminCount > 0);
success([
'changed' => $changed,
'last_modified' => $maxModified,
'admin_pending' => $adminCount
]);
}
// ===== 通知插件立即同步数据 ===== // ===== 通知插件立即同步数据 =====
function notifySync() { function notifySync() {
$secret = getParam('secret'); $secret = getParam('secret');
@@ -2750,6 +3006,9 @@ function checkPendingWebRegisterRequests() {
)"); )");
} }
// ★ 事务包裹DELETE+标记processing,防止database is locked
$db->exec("BEGIN IMMEDIATE");
try {
$db->exec("DELETE FROM web_register_requests WHERE created_at < " . (time() - 1800)); $db->exec("DELETE FROM web_register_requests WHERE created_at < " . (time() - 1800));
$stmt = $db->prepare("SELECT id, player_name, password_hash, salt, email, ip_address, created_at FROM web_register_requests WHERE status = 'pending' ORDER BY created_at ASC"); $stmt = $db->prepare("SELECT id, player_name, password_hash, salt, email, ip_address, created_at FROM web_register_requests WHERE status = 'pending' ORDER BY created_at ASC");
@@ -2764,14 +3023,12 @@ function checkPendingWebRegisterRequests() {
// 使用事务标记为processing // 使用事务标记为processing
if (!empty($ids)) { if (!empty($ids)) {
try {
$db->exec('BEGIN TRANSACTION');
$idList = implode(',', $ids); $idList = implode(',', $ids);
$db->exec("UPDATE web_register_requests SET status = 'processing', processed_at = " . time() . " WHERE id IN ($idList)"); $db->exec("UPDATE web_register_requests SET status = 'processing', processed_at = " . time() . " WHERE id IN ($idList)");
$db->exec('COMMIT'); }
} catch (Exception $e) { $db->exec("COMMIT");
try { $db->exec('ROLLBACK'); } catch (Exception $e2) {} } catch (\Throwable $e) {
} try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
} }
success(['requests' => $requests, 'count' => count($requests)]); success(['requests' => $requests, 'count' => count($requests)]);
+825
View File
@@ -0,0 +1,825 @@
<?php
/**
* 工单系统API
*
* 用户端:提交工单、查看我的工单、查看详情、回复(MD)、撤销
* 管理员端:查看所有工单、回复、驳回、分配服务商
* 服务商端:抢单大厅、我处理的工单、回复、标记完结
*/
while (ob_get_level() > 0) { @ob_end_clean(); }
ob_start();
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store, no-cache, must-revalidate');
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
// 仅刷新当前文件的OPcache(不要opcache_reset——会重置整个服务器缓存导致竞态条件)
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
register_shutdown_function(function() {
while (ob_get_level() > 0) { @ob_end_flush(); }
});
require_once __DIR__ . '/../core.php';
if (session_status() === PHP_SESSION_NONE) { session_start(); }
$action = getParam('action', '');
try {
switch ($action) {
// ===== 用户端 =====
case 'create': ticketCreate(); break;
case 'my_list': ticketMyList(); break;
case 'detail': ticketDetail(); break;
case 'reply': ticketReply(); break;
case 'withdraw': ticketWithdraw(); break;
// ===== 管理员端 =====
case 'list_all': ticketListAll(); break;
case 'admin_reply': ticketAdminReply(); break;
case 'reject': ticketReject(); break;
case 'assign': ticketAssign(); break;
case 'admin_create': ticketAdminCreate(); break;
case 'admin_complete': ticketAdminComplete(); break;
// ===== 服务商端 =====
case 'provider_list': ticketProviderList(); break;
case 'available': ticketAvailable(); break;
case 'grab': ticketGrab(); break;
case 'provider_reply': ticketProviderReply(); break;
case 'complete': ticketProviderComplete(); break;
// ===== 通用 =====
case 'providers': ticketProviders(); break;
case 'provider_check': ticketProviderCheck(); break;
default:
error('未知操作: ' . $action);
}
} catch (\Throwable $e) {
error('服务器错误: ' . $e->getMessage());
}
// ============================================================
// 工具函数
// ============================================================
function requireWebToken() {
$token = getParam('token');
if (!$token) error('缺少token', 401);
$info = validateTokenSilent($token);
if (!$info) error('token无效或已过期', 401);
return $info;
}
function requireAdmin() {
if (!isAdminLoggedIn()) error('未登录管理后台', 401);
}
/**
* 检查是否为管理员或授权的服务商用户
* 管理员通过session认证,用户通过token认证
*/
function getAuthUser() {
if (isAdminLoggedIn()) return ['player' => 'admin', 'role' => 'admin'];
return requireWebToken();
}
function isServiceProviderInGame($playerName) {
// 优先从Web.db的web_service_providers表读取(Java同步数据)
$db = getDB();
try {
$stmt = $db->prepare("SELECT 1 FROM web_service_providers WHERE player_name = :p AND active = 1");
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray();
if ($row !== false) return true;
} catch (\Throwable $e) { /* 表可能不存在 */ }
// 回退:尝试读取Java的login.db(本地开发环境)
$loginDbPath = GAME_LOGIN_DB;
if (!file_exists($loginDbPath)) return false;
try {
$loginDb = new SQLite3($loginDbPath);
$loginDb->enableExceptions(true);
$stmt = $loginDb->prepare("SELECT 1 FROM service_providers WHERE player_name = :p AND active = 1");
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray();
$loginDb->close();
return $row !== false;
} catch (Exception $e) {
return false;
}
}
function getServiceProviderRole($playerName) {
// 优先从Web.db读取
$db = getDB();
try {
$stmt = $db->prepare("SELECT role FROM web_service_providers WHERE player_name = :p AND active = 1");
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if ($row) return $row['role'];
} catch (\Throwable $e) { /* 表可能不存在 */ }
// 回退:读取Java的login.db
$loginDbPath = GAME_LOGIN_DB;
if (!file_exists($loginDbPath)) return null;
try {
$loginDb = new SQLite3($loginDbPath);
$loginDb->enableExceptions(true);
$stmt = $loginDb->prepare("SELECT role FROM service_providers WHERE player_name = :p AND active = 1");
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
$loginDb->close();
return $row ? $row['role'] : null;
} catch (Exception $e) {
return null;
}
}
function ticketCreate() {
$info = requireWebToken();
$player = $info['player'];
$type = trim(getParam('type', ''));
$title = trim(getParam('title', ''));
$description = trim(getParam('description', ''));
if (empty($type)) error('请选择工单类型');
if (empty($title)) error('请输入工单标题');
if (mb_strlen($title) > 100) error('标题不能超过100字');
if (mb_strlen($description) > 5000) error('描述不能超过5000字');
$validTypes = ['bug', 'help', 'report', 'apply', 'other'];
if (!in_array($type, $validTypes)) error('无效的工单类型');
$db = getDB();
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("INSERT INTO web_tickets (type, status, requester, title, description, created_at, updated_at) VALUES (:type, 'submitted', :player, :title, :desc, :now, :now)");
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':title', $title, SQLITE3_TEXT);
$stmt->bindValue(':desc', $description, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
$ticketId = $db->lastInsertRowID();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('创建失败: ' . $e->getMessage());
}
success(['id' => $ticketId], '工单提交成功');
}
function ticketMyList() {
$info = requireWebToken();
$player = $info['player'];
$db = getDB();
$status = getParam('status', '');
$page = max(1, (int)getParam('page', 1));
$limit = 20;
$offset = ($page - 1) * $limit;
$where = "WHERE requester = :player";
$params = [':player' => $player];
if ($status && $status !== 'all') {
$where .= " AND status = :status";
$params[':status'] = $status;
}
$countStmt = $db->prepare("SELECT COUNT(*) as total FROM web_tickets $where");
foreach ($params as $k => $v) $countStmt->bindValue($k, $v, SQLITE3_TEXT);
$total = $countStmt->execute()->fetchArray(SQLITE3_ASSOC)['total'];
$stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY created_at DESC LIMIT :limit OFFSET :offset");
foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT);
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$stmt->bindValue(':offset', $offset, SQLITE3_INTEGER);
$result = $stmt->execute();
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
success([
'list' => $list,
'total' => (int)$total,
'page' => $page,
'pages' => ceil($total / $limit)
]);
}
function ticketDetail() {
$id = (int)getParam('id', 0);
if ($id <= 0) error('无效的工单ID');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) error('工单不存在');
// 权限检查:管理员可以直接查看,普通用户只能看自己的工单
if (isAdminLoggedIn()) {
// 管理员,直接放行
} else {
$info = requireWebToken();
$player = $info['player'];
if ($row['requester'] !== $player && $row['assigned_to'] !== $player) {
error('无权查看此工单');
}
}
// 获取回复列表
$replies = getTicketReplies($id);
$row['replies'] = $replies;
success($row);
}
function ticketReply() {
$info = requireWebToken();
$player = $info['player'];
$id = (int)getParam('id', 0);
$message = trim(getParam('message', ''));
if ($id <= 0) error('无效的工单ID');
if (empty($message)) error('回复内容不能为空');
if (mb_strlen($message) > 5000) error('回复内容不能超过5000字');
$db = getDB();
// 检查工单状态和权限
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['requester'] !== $player) error('只能回复自己的工单');
if (in_array($ticket['status'], ['withdrawn', 'rejected'])) error('该工单已关闭,无法回复');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
// 插入回复
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'user', :msg, :now)");
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
$stmt->bindValue(':sender', $player, SQLITE3_TEXT);
$stmt->bindValue(':msg', $message, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
// 更新工单状态为已回复
$stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('回复失败');
}
success(null, '回复成功');
}
function ticketWithdraw() {
$info = requireWebToken();
$player = $info['player'];
$id = (int)getParam('id', 0);
if ($id <= 0) error('无效的工单ID');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['requester'] !== $player) error('无权操作此工单');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("UPDATE web_tickets SET status = 'withdrawn', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', time(), SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('撤销失败');
}
success(null, '工单已撤销');
}
// ===== 管理员端 =====
function ticketListAll() {
requireAdmin();
$db = getDB();
// 兜底建表(服务器core.php可能版本旧)
try {
$db->exec("CREATE TABLE IF NOT EXISTS web_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
status TEXT DEFAULT 'submitted',
requester TEXT NOT NULL,
assigned_to TEXT DEFAULT '',
title TEXT NOT NULL,
description TEXT DEFAULT '',
reject_reason TEXT DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)");
$db->exec("CREATE TABLE IF NOT EXISTS web_ticket_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
sender TEXT NOT NULL,
role TEXT DEFAULT 'user',
message TEXT NOT NULL,
created_at INTEGER NOT NULL
)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_wt_requester ON web_tickets(requester)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_wt_status ON web_tickets(status)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_wt_assigned ON web_tickets(assigned_to)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_wtr_ticket ON web_ticket_replies(ticket_id)");
} catch (\Throwable $e) {
error('工单表初始化失败: ' . $e->getMessage());
}
try {
$status = getParam('status', 'all');
$page = max(1, (int)getParam('page', 1));
$search = trim(getParam('search', ''));
$limit = 20;
$offset = ($page - 1) * $limit;
$where = "WHERE 1=1";
$params = [];
if ($status && $status !== 'all') {
$where .= " AND status = :status";
$params[':status'] = $status;
}
if ($search) {
$where .= " AND (requester LIKE :search OR title LIKE :search2)";
$params[':search'] = "%$search%";
$params[':search2'] = "%$search%";
}
$countStmt = $db->prepare("SELECT COUNT(*) as total FROM web_tickets $where");
foreach ($params as $k => $v) $countStmt->bindValue($k, $v, SQLITE3_TEXT);
$total = $countStmt->execute()->fetchArray(SQLITE3_ASSOC)['total'];
$stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY created_at DESC LIMIT :limit OFFSET :offset");
foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT);
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$stmt->bindValue(':offset', $offset, SQLITE3_INTEGER);
$result = $stmt->execute();
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
// 统计各状态数量
$stats = [];
foreach (['submitted', 'replied', 'completed', 'withdrawn', 'rejected'] as $s) {
$cnt = $db->querySingle("SELECT COUNT(*) FROM web_tickets WHERE status = '$s'");
$stats[$s] = (int)$cnt;
}
$stats['all'] = (int)$db->querySingle("SELECT COUNT(*) FROM web_tickets");
success([
'list' => $list,
'total' => (int)$total,
'page' => $page,
'pages' => ceil($total / $limit),
'stats' => $stats
]);
} catch (\Throwable $e) {
error('查询工单失败: ' . $e->getMessage());
}
}
function ticketAdminReply() {
requireAdmin();
$id = (int)getParam('id', 0);
$message = trim(getParam('message', ''));
if ($id <= 0) error('无效的工单ID');
if (empty($message)) error('回复内容不能为空');
if (mb_strlen($message) > 5000) error('回复内容不能超过5000字');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, 'admin', 'admin', :msg, :now)");
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
$stmt->bindValue(':msg', $message, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
// 管理员回复 → 状态更新为已回复(不改变assigned_to,保留原服务商所有权)
$stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('回复失败');
}
success(null, '回复成功');
}
function ticketReject() {
requireAdmin();
$id = (int)getParam('id', 0);
$reason = trim(getParam('reason', ''));
if ($id <= 0) error('无效的工单ID');
if (empty($reason)) error('请输入驳回原因');
if (mb_strlen($reason) > 500) error('驳回原因不能超过500字');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['status'] === 'withdrawn' || $ticket['status'] === 'rejected') error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("UPDATE web_tickets SET status = 'rejected', reject_reason = :reason, updated_at = :now WHERE id = :id");
$stmt->bindValue(':reason', $reason, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('驳回失败');
}
success(null, '工单已驳回');
}
function ticketAssign() {
requireAdmin();
$id = (int)getParam('id', 0);
$provider = trim(getParam('provider', ''));
if ($id <= 0) error('无效的工单ID');
if (empty($provider)) error('请输入服务商名称');
// 验证是否为服务商
if (!isServiceProviderInGame($provider)) error("$provider 不是服务商");
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("UPDATE web_tickets SET assigned_to = :provider, status = 'replied', updated_at = :now WHERE id = :id");
$stmt->bindValue(':provider', $provider, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('分配失败');
}
success(null, "已分配给 $provider");
}
function ticketAdminCreate() {
requireAdmin();
$type = trim(getParam('type', ''));
$title = trim(getParam('title', ''));
$description = trim(getParam('description', ''));
$provider = trim(getParam('provider', ''));
if (empty($type)) error('请选择工单类型');
if (empty($title)) error('请输入工单标题');
if (mb_strlen($title) > 100) error('标题不能超过100字');
if (mb_strlen($description) > 5000) error('描述不能超过5000字');
$validTypes = ['bug', 'help', 'report', 'apply', 'other'];
if (!in_array($type, $validTypes)) error('无效的工单类型');
// 如果指定了服务商,验证身份
if ($provider && !isServiceProviderInGame($provider)) {
error("$provider 不是服务商");
}
$db = getDB();
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("INSERT INTO web_tickets (type, status, requester, assigned_to, title, description, created_at, updated_at) VALUES (:type, 'replied', 'admin', :provider, :title, :desc, :now, :now)");
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
$stmt->bindValue(':provider', $provider, SQLITE3_TEXT);
$stmt->bindValue(':title', $title, SQLITE3_TEXT);
$stmt->bindValue(':desc', $description, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
$ticketId = $db->lastInsertRowID();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('创建失败: ' . $e->getMessage());
}
$msg = '工单已创建';
if ($provider) $msg .= ",已派发给 $provider";
success(['id' => $ticketId], $msg);
}
function ticketAdminComplete() {
requireAdmin();
$id = (int)getParam('id', 0);
if ($id <= 0) error('无效的工单ID');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, 'admin', 'admin', '✅ 工单已由管理员标记完结', :now)");
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
$stmt = $db->prepare("UPDATE web_tickets SET status = 'completed', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('操作失败');
}
success(null, '已标记完结');
}
// ===== 服务商端 =====
function ticketProviderList() {
$info = requireWebToken();
$player = $info['player'];
// 验证服务商身份
$role = getServiceProviderRole($player);
if (!$role) error('你不是服务商');
$db = getDB();
$type = getParam('type', 'all');
// processing: 活跃(已提交/已回复)
// completed: 已完结/已撤销/已驳回
// all: 全部跟过的工单
$where = "WHERE assigned_to = :player";
$params = [':player' => $player];
if ($type === 'processing') {
$where .= " AND status IN ('submitted', 'replied')";
} elseif ($type === 'completed') {
$where .= " AND status IN ('completed', 'withdrawn', 'rejected')";
}
// 'all' 不加status过滤
$stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY updated_at DESC LIMIT 100");
foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT);
$result = $stmt->execute();
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
success(['list' => $list, 'role' => $role]);
}
function ticketAvailable() {
$info = requireWebToken();
$player = $info['player'];
if (!isServiceProviderInGame($player)) error('你不是服务商');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE status = 'submitted' AND assigned_to = '' ORDER BY created_at DESC LIMIT 50");
$result = $stmt->execute();
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
success(['list' => $list]);
}
function ticketGrab() {
$info = requireWebToken();
$player = $info['player'];
$id = (int)getParam('id', 0);
if ($id <= 0) error('无效的工单ID');
if (!isServiceProviderInGame($player)) error('你不是服务商');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['status'] !== 'submitted') error('该工单已被接单');
if (!empty($ticket['assigned_to'])) error('该工单已被分配');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("UPDATE web_tickets SET assigned_to = :provider, updated_at = :now WHERE id = :id");
$stmt->bindValue(':provider', $player, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('抢单失败');
}
success(null, '抢单成功');
}
function ticketProviderReply() {
$info = requireWebToken();
$player = $info['player'];
$id = (int)getParam('id', 0);
$message = trim(getParam('message', ''));
if ($id <= 0) error('无效的工单ID');
if (empty($message)) error('回复内容不能为空');
if (mb_strlen($message) > 5000) error('回复内容不能超过5000字');
if (!isServiceProviderInGame($player)) error('你不是服务商');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['assigned_to'] !== $player) error('这不是分配给你的工单');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'provider', :msg, :now)");
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
$stmt->bindValue(':sender', $player, SQLITE3_TEXT);
$stmt->bindValue(':msg', $message, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
$stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('回复失败');
}
success(null, '回复成功');
}
function ticketProviderComplete() {
$info = requireWebToken();
$player = $info['player'];
$id = (int)getParam('id', 0);
if ($id <= 0) error('无效的工单ID');
if (!isServiceProviderInGame($player)) error('你不是服务商');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['assigned_to'] !== $player) error('这不是分配给你的工单');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
// 插入完结回复
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'provider', '✅ 工单已处理完毕', :now)");
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
$stmt->bindValue(':sender', $player, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
// 状态改为completed
$stmt = $db->prepare("UPDATE web_tickets SET status = 'completed', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('操作失败');
}
success(null, '已标记完结');
}
// ===== 通用 =====
function ticketProviders() {
if (!isAdminLoggedIn()) error('未登录管理后台');
$loginDbPath = GAME_LOGIN_DB;
if (!file_exists($loginDbPath)) success(['list' => []]);
try {
$loginDb = new SQLite3($loginDbPath);
$result = $loginDb->query("SELECT player_name, role, join_time FROM service_providers WHERE active = 1");
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
$loginDb->close();
success(['list' => $list]);
} catch (Exception $e) {
success(['list' => []]);
}
}
function ticketProviderCheck() {
$info = requireWebToken();
$player = $info['player'];
$isProvider = isServiceProviderInGame($player);
$role = $isProvider ? getServiceProviderRole($player) : null;
success(['is_provider' => $isProvider, 'role' => $role]);
}
// ===== 辅助函数 =====
function getTicketReplies($ticketId) {
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_ticket_replies WHERE ticket_id = :tid ORDER BY created_at ASC");
$stmt->bindValue(':tid', $ticketId, SQLITE3_INTEGER);
$result = $stmt->execute();
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
return $list;
}
+46 -2
View File
@@ -119,9 +119,19 @@ function initTables(SQLite3 $db) {
stock INTEGER DEFAULT -1, stock INTEGER DEFAULT -1,
hourly_sales INTEGER DEFAULT 0, hourly_sales INTEGER DEFAULT 0,
total_sales INTEGER DEFAULT 0, total_sales INTEGER DEFAULT 0,
last_sync INTEGER DEFAULT 0 last_sync INTEGER DEFAULT 0,
admin_stock INTEGER DEFAULT NULL
)"); )");
// ★ 兼容升级:给shop_items加admin_stock字段(已存在则跳过)
try {
$db->exec("ALTER TABLE shop_items ADD COLUMN admin_stock INTEGER DEFAULT NULL");
} catch (\Throwable $e) { /* 字段已存在,忽略 */ }
// ★ 兼容升级:给shop_items加last_modified字段(高频轮询检测改动用)
try {
$db->exec("ALTER TABLE shop_items ADD COLUMN last_modified INTEGER DEFAULT 0");
} catch (\Throwable $e) { /* 字段已存在,忽略 */ }
// CDK表 // CDK表
$db->exec("CREATE TABLE IF NOT EXISTS cdk ( $db->exec("CREATE TABLE IF NOT EXISTS cdk (
code TEXT PRIMARY KEY, code TEXT PRIMARY KEY,
@@ -323,6 +333,40 @@ function initTables(SQLite3 $db) {
$db->exec("ALTER TABLE player_ip_locations ADD COLUMN player_name TEXT DEFAULT 'global'"); $db->exec("ALTER TABLE player_ip_locations ADD COLUMN player_name TEXT DEFAULT 'global'");
} catch (Exception $e) { /* 列已存在 */ } } catch (Exception $e) { /* 列已存在 */ }
// ★ 服务商同步表(Java端推送service_providers到Web.db)
$db->exec("CREATE TABLE IF NOT EXISTS web_service_providers (
player_name TEXT PRIMARY KEY,
role TEXT DEFAULT 'waiter',
active INTEGER DEFAULT 1,
join_time INTEGER DEFAULT 0
)");
// ★ 工单系统表
$db->exec("CREATE TABLE IF NOT EXISTS web_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
status TEXT DEFAULT 'submitted',
requester TEXT NOT NULL,
assigned_to TEXT DEFAULT '',
title TEXT NOT NULL,
description TEXT DEFAULT '',
reject_reason TEXT DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)");
$db->exec("CREATE TABLE IF NOT EXISTS web_ticket_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
sender TEXT NOT NULL,
role TEXT DEFAULT 'user',
message TEXT NOT NULL,
created_at INTEGER NOT NULL
)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_tickets_requester ON web_tickets(requester)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_tickets_status ON web_tickets(status)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_tickets_assigned ON web_tickets(assigned_to)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_ticket_replies_ticket ON web_ticket_replies(ticket_id)");
$db->exec('COMMIT'); $db->exec('COMMIT');
} catch (Exception $e) { } catch (Exception $e) {
$db->exec('ROLLBACK'); $db->exec('ROLLBACK');
@@ -536,7 +580,7 @@ function jsonResponse($data, $code = 200) {
while (ob_get_level() > 0) { ob_end_clean(); } while (ob_get_level() > 0) { ob_end_clean(); }
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
http_response_code($code); http_response_code($code);
echo json_encode($data, JSON_UNESCAPED_UNICODE); echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
exit; exit;
} }
+412 -3
View File
@@ -115,6 +115,8 @@ if ($currentVersion !== $BUILD_VERSION) {
.btn-green:hover { background: #56d364; } .btn-green:hover { background: #56d364; }
.btn-red { background: var(--red); color: #fff; } .btn-red { background: var(--red); color: #fff; }
.btn-orange { background: var(--yellow); color: #000; } .btn-orange { background: var(--yellow); color: #000; }
.btn-dim { background: var(--card); color: var(--dim); border: 1px solid var(--border); }
.btn-dim:hover { color: var(--text); border-color: var(--accent); }
.table { width: 100%; border-collapse: collapse; font-size: 13px; } .table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th { text-align: left; padding: 10px 12px; color: var(--dim); border-bottom: 1px solid var(--border); } .table th { text-align: left; padding: 10px 12px; color: var(--dim); border-bottom: 1px solid var(--border); }
.table td { padding: 10px 12px; border-bottom: 1px solid var(--border); } .table td { padding: 10px 12px; border-bottom: 1px solid var(--border); }
@@ -167,6 +169,7 @@ if ($currentVersion !== $BUILD_VERSION) {
<div class="sidebar-item" data-page="shop" onclick="switchPage('shop')">🛒 商城</div> <div class="sidebar-item" data-page="shop" onclick="switchPage('shop')">🛒 商城</div>
<div class="sidebar-item" data-page="cdk" onclick="switchPage('cdk')">🎁 CDK兑换</div> <div class="sidebar-item" data-page="cdk" onclick="switchPage('cdk')">🎁 CDK兑换</div>
<div class="sidebar-item" data-page="balance" onclick="switchPage('balance')">💰 余额查询</div> <div class="sidebar-item" data-page="balance" onclick="switchPage('balance')">💰 余额查询</div>
<div class="sidebar-item" data-page="ticket" onclick="switchPage('ticket')">📋 工单系统</div>
</div> </div>
<div class="content" id="content"> <div class="content" id="content">
@@ -766,6 +769,7 @@ if ($currentVersion !== $BUILD_VERSION) {
else if (page === 'cdk') renderCDK(c); else if (page === 'cdk') renderCDK(c);
else if (page === 'balance') renderBalance(c); else if (page === 'balance') renderBalance(c);
else if (page === 'account') renderAccount(c); else if (page === 'account') renderAccount(c);
else if (page === 'ticket') renderTicket(c);
} }
// ★ 新增:Token轮询检查(不续期,只检查是否被Java端主动销毁) // ★ 新增:Token轮询检查(不续期,只检查是否被Java端主动销毁)
@@ -958,7 +962,7 @@ if ($currentVersion !== $BUILD_VERSION) {
// ===== 个人中心仪表盘 ===== // ===== 个人中心仪表盘 =====
async function renderDashboard(el) { async function renderDashboard(el) {
if (IS_PREVIEW || !currentPlayer) { if (IS_PREVIEW || !currentPlayer || (!AUTHENTICATED && NEED_PASSWORD)) {
el.innerHTML = ` el.innerHTML = `
<div class="card" style="text-align:center;padding:48px 20px"> <div class="card" style="text-align:center;padding:48px 20px">
<div style="font-size:64px;margin-bottom:16px">📊</div> <div style="font-size:64px;margin-bottom:16px">📊</div>
@@ -1073,7 +1077,7 @@ if ($currentVersion !== $BUILD_VERSION) {
// ===== 余额 ===== // ===== 余额 =====
function renderBalance(el) { function renderBalance(el) {
if (!currentPlayer) { if (!currentPlayer || (!AUTHENTICATED && NEED_PASSWORD)) {
el.innerHTML = '<div class="card"><h2>余额查询</h2><div style="text-align:center;padding:40px 20px"><div style="font-size:48px;margin-bottom:16px">🔒</div><p style="color:var(--dim);font-size:14px;margin-bottom:20px">请先登录游戏</p></div></div>'; el.innerHTML = '<div class="card"><h2>余额查询</h2><div style="text-align:center;padding:40px 20px"><div style="font-size:48px;margin-bottom:16px">🔒</div><p style="color:var(--dim);font-size:14px;margin-bottom:20px">请先登录游戏</p></div></div>';
return; return;
} }
@@ -1109,7 +1113,7 @@ if ($currentVersion !== $BUILD_VERSION) {
// ===== 账号 ===== // ===== 账号 =====
function renderAccount(el) { function renderAccount(el) {
if (IS_PREVIEW) { if (IS_PREVIEW || (!AUTHENTICATED && NEED_PASSWORD)) {
el.innerHTML = `<div class="card"><h2>账号信息</h2><div style="text-align:center;padding:40px 20px"><div style="font-size:48px;margin-bottom:16px">🔒</div><p style="color:var(--dim);font-size:14px;margin-bottom:20px">游客模式下无法查看账号信息</p><p style="color:var(--dim);font-size:12px">请先登录游戏获取Web访问令牌</p></div></div>`; el.innerHTML = `<div class="card"><h2>账号信息</h2><div style="text-align:center;padding:40px 20px"><div style="font-size:48px;margin-bottom:16px">🔒</div><p style="color:var(--dim);font-size:14px;margin-bottom:20px">游客模式下无法查看账号信息</p><p style="color:var(--dim);font-size:12px">请先登录游戏获取Web访问令牌</p></div></div>`;
return; return;
} }
@@ -1268,6 +1272,10 @@ if ($currentVersion !== $BUILD_VERSION) {
function closeModal() { function closeModal() {
document.getElementById('modalOverlay').style.display = 'none'; document.getElementById('modalOverlay').style.display = 'none';
// ★ 密码验证被取消 → 清除token,进入游客模式(防止未认证玩家数据泄露)
if (NEED_PASSWORD && !AUTHENTICATED) {
showGuestMode();
}
} }
function confirmModal() {} function confirmModal() {}
@@ -1373,6 +1381,407 @@ if ($currentVersion !== $BUILD_VERSION) {
document.getElementById('resetError').textContent = '连接失败: ' + e.message; document.getElementById('resetError').textContent = '连接失败: ' + e.message;
} }
} }
// ===== 工单系统 =====
let ticketState = { view: 'list', filter: 'all' };
async function renderTicket(el) {
if (IS_PREVIEW || !AUTHENTICATED) {
el.innerHTML = `<div class="card"><h2>📋 工单系统</h2><p style="color:var(--dim)">请先登录后使用工单功能</p></div>`;
return;
}
if (ticketState.view === 'list') await renderTicketList(el);
else if (ticketState.view === 'create') renderTicketCreate(el);
else if (ticketState.view === 'detail') await renderTicketDetail(el, ticketState.ticketId);
}
function renderTicketNav() {
const f = ticketState.filter;
return `<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;align-items:center">
<button class="btn btn-primary" onclick="ticketState.view='create';renderTicket(document.getElementById('content'))" style="font-size:13px">✏️ 提交工单</button>
<button class="btn ${f==='all'?'':'btn-dim'}" onclick="ticketState.filter='all';ticketState.view='list';renderTicket(document.getElementById('content'))" style="font-size:12px;padding:5px 12px">全部</button>
<button class="btn ${f==='submitted'?'':'btn-dim'}" onclick="ticketState.filter='submitted';ticketState.view='list';renderTicket(document.getElementById('content'))" style="font-size:12px;padding:5px 12px">⏳ 已提交</button>
<button class="btn ${f==='replied'?'':'btn-dim'}" onclick="ticketState.filter='replied';ticketState.view='list';renderTicket(document.getElementById('content'))" style="font-size:12px;padding:5px 12px">💬 已回复</button>
<button class="btn ${f==='withdrawn'?'':'btn-dim'}" onclick="ticketState.filter='withdrawn';ticketState.view='list';renderTicket(document.getElementById('content'))" style="font-size:12px;padding:5px 12px">🔙 已撤销</button>
<button class="btn ${f==='rejected'?'':'btn-dim'}" onclick="ticketState.filter='rejected';ticketState.view='list';renderTicket(document.getElementById('content'))" style="font-size:12px;padding:5px 12px">🚫 已驳回</button>
</div>`;
}
async function renderTicketList(el) {
el.innerHTML = `<div class="card"><h2>📋 工单系统</h2>${renderTicketNav()}<p style="color:var(--dim)">加载中...</p></div>`;
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'my_list');
url.searchParams.set('token', TOKEN);
if (ticketState.filter !== 'all') url.searchParams.set('status', ticketState.filter);
const res = await fetch(url);
const data = await res.json();
if (!data.success) { el.innerHTML = `<div class="card"><h2>📋 工单系统</h2><p style="color:var(--red)">${data.message}</p></div>`; return; }
const list = data.data.list;
let html = `<div class="card"><h2>📋 我的工单 (${data.data.total})</h2>${renderTicketNav()}`;
if (list.length === 0) {
html += `<p style="color:var(--dim);text-align:center;padding:40px 0">暂无工单</p>`;
} else {
const typeMap = {bug:'🐛 Bug反馈',help:'❓ 求助',report:'📢 举报',apply:'📝 申请',other:'📎 其他'};
const statusMap = {submitted:{text:'⏳ 已提交',color:'var(--yellow)'},replied:{text:'💬 已回复',color:'var(--accent)'},completed:{text:'✅ 已完结',color:'var(--green)'},withdrawn:{text:'🔙 已撤销',color:'var(--dim)'},rejected:{text:'🚫 已驳回',color:'var(--red)'}};
html += `<div style="display:flex;flex-direction:column;gap:8px">`;
for (const t of list) {
const s = statusMap[t.status] || {text: t.status, color: 'var(--dim)'};
const date = new Date(t.created_at * 1000).toLocaleString('zh-CN');
html += `<div onclick="ticketState.view='detail';ticketState.ticketId=${t.id};renderTicket(document.getElementById('content'))" style="background:rgba(88,166,255,0.05);border:1px solid var(--border);border-radius:8px;padding:14px 16px;cursor:pointer;transition:all 0.2s" onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<span style="font-weight:600;font-size:14px">#${t.id} ${escHtml(t.title)}</span>
<span style="color:${s.color};font-size:12px">${s.text}</span>
</div>
<div style="display:flex;gap:16px;font-size:12px;color:var(--dim)">
<span>${typeMap[t.type]||t.type}</span>
<span>${date}</span>
${t.assigned_to ? '<span>👤 '+escHtml(t.assigned_to)+'</span>' : ''}
</div>
</div>`;
}
html += `</div>`;
}
html += `</div>`;
el.innerHTML = html;
} catch (e) {
el.innerHTML = `<div class="card"><h2>📋 工单系统</h2><p style="color:var(--red)">加载失败: ${e.message}</p></div>`;
}
}
function renderTicketCreate(el) {
const types = [{v:'bug',l:'🐛 Bug反馈'},{v:'help',l:'❓ 求助'},{v:'report',l:'📢 举报'},{v:'apply',l:'📝 申请'},{v:'other',l:'📎 其他'}];
let options = types.map(t => `<option value="${t.v}">${t.l}</option>`).join('');
el.innerHTML = `<div class="card">
<h2>✏️ 提交工单</h2>
<div style="margin-bottom:12px"><label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">工单类型</label>
<select id="ticketType" style="width:100%;padding:10px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:14px">${options}</select></div>
<div style="margin-bottom:12px"><label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">标题</label>
<input id="ticketTitle" placeholder="简要描述您的问题" style="width:100%;padding:10px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:14px"></div>
<div style="margin-bottom:12px"><label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">详细描述</label>
<textarea id="ticketDesc" rows="6" placeholder="详细描述您的问题,支持Markdown格式" style="width:100%;padding:10px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:14px;resize:vertical;font-family:monospace"></textarea></div>
<p style="color:var(--dim);font-size:12px;margin-bottom:12px">💡 支持标准Markdown语法:**粗体**、*斜体*、\`代码\`、列表、标题等</p>
<div style="display:flex;gap:8px">
<button class="btn btn-primary" onclick="submitTicket()">提交工单</button>
<button class="btn" onclick="ticketState.view='list';renderTicket(document.getElementById('content'))">取消</button>
</div>
<p id="ticketError" style="color:var(--red);margin-top:8px;font-size:13px"></p>
</div>`;
}
async function submitTicket() {
const type = document.getElementById('ticketType').value;
const title = document.getElementById('ticketTitle').value.trim();
const desc = document.getElementById('ticketDesc').value.trim();
const errEl = document.getElementById('ticketError');
if (!title) { errEl.textContent = '请输入标题'; return; }
if (!desc) { errEl.textContent = '请输入描述'; return; }
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'create');
url.searchParams.set('token', TOKEN);
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({type, title, description: desc})
});
const data = await res.json();
if (data.success) {
ticketState.view = 'list';
renderTicket(document.getElementById('content'));
} else {
errEl.textContent = data.message;
}
} catch (e) {
errEl.textContent = '提交失败: ' + e.message;
}
}
async function renderTicketDetail(el, id) {
el.innerHTML = `<div class="card"><p style="color:var(--dim)">加载中...</p></div>`;
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'detail');
url.searchParams.set('token', TOKEN);
url.searchParams.set('id', id);
const res = await fetch(url);
const data = await res.json();
if (!data.success) { el.innerHTML = `<div class="card"><p style="color:var(--red)">${data.message}</p><button class="btn" onclick="ticketState.view='list';renderTicket(document.getElementById('content'))">返回</button></div>`; return; }
const t = data.data;
const typeMap = {bug:'🐛 Bug反馈',help:'❓ 求助',report:'📢 举报',apply:'📝 申请',other:'📎 其他'};
const statusMap = {submitted:{text:'⏳ 已提交',color:'var(--yellow)'},replied:{text:'💬 已回复',color:'var(--accent)'},completed:{text:'✅ 已完结',color:'var(--green)'},withdrawn:{text:'🔙 已撤销',color:'var(--dim)'},rejected:{text:'🚫 已驳回',color:'var(--red)'}};
const s = statusMap[t.status] || {text: t.status, color: 'var(--dim)'};
const date = new Date(t.created_at * 1000).toLocaleString('zh-CN');
const canReply = !['withdrawn','rejected','completed'].includes(t.status);
let html = `<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0">#${t.id} ${escHtml(t.title)}</h2>
<span style="color:${s.color};font-size:13px">${s.text}</span>
</div>
<div style="display:flex;gap:16px;font-size:13px;color:var(--dim);margin-bottom:16px">
<span>${typeMap[t.type]||t.type}</span>
<span>👤 ${escHtml(t.requester)}</span>
<span>📅 ${date}</span>
${t.assigned_to ? '<span>🧑‍💻 '+escHtml(t.assigned_to)+'</span>' : ''}
</div>`;
// 描述
html += `<div style="background:rgba(88,166,255,0.05);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:16px">
<div style="color:var(--dim);font-size:12px;margin-bottom:8px">描述</div>
<div style="font-size:14px;line-height:1.6">${renderMarkdown(t.description || '无描述')}</div>
</div>`;
// 驳回原因
if (t.reject_reason) {
html += `<div style="background:rgba(248,81,73,0.1);border:1px solid var(--red);border-radius:8px;padding:16px;margin-bottom:16px">
<div style="color:var(--red);font-size:12px;margin-bottom:8px">🚫 驳回原因</div>
<div style="font-size:14px">${renderMarkdown(t.reject_reason)}</div>
</div>`;
}
// 回复列表
html += `<div style="margin-bottom:16px"><div style="color:var(--dim);font-size:12px;margin-bottom:8px">💬 回复记录 (${t.replies ? t.replies.length : 0})</div>`;
if (t.replies && t.replies.length > 0) {
for (const r of t.replies) {
const rDate = new Date(r.created_at * 1000).toLocaleString('zh-CN');
const roleColors = {user:'var(--accent)',admin:'var(--red)',provider:'var(--green)'};
const roleNames = {user:'玩家',admin:'管理员',provider:'服务商'};
const bgColor = r.role === 'admin' ? 'rgba(248,81,73,0.08)' : r.role === 'provider' ? 'rgba(63,185,80,0.08)' : 'rgba(88,166,255,0.08)';
html += `<div style="background:${bgColor};border-radius:8px;padding:12px 16px;margin-bottom:8px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<span style="font-size:13px"><span style="color:${roleColors[r.role]||'var(--text)'};font-weight:600">${escHtml(r.sender)}</span> <span style="color:var(--dim);font-size:11px">(${roleNames[r.role]||r.role})</span></span>
<span style="color:var(--dim);font-size:11px">${rDate}</span>
</div>
<div style="font-size:14px;line-height:1.6">${renderMarkdown(r.message)}</div>
</div>`;
}
} else {
html += `<p style="color:var(--dim);font-size:13px">暂无回复</p>`;
}
html += `</div>`;
// 回复框
if (canReply) {
html += `<div style="margin-bottom:16px">
<textarea id="ticketReplyMsg" rows="3" placeholder="输入回复内容(支持Markdown)" style="width:100%;padding:10px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:14px;resize:vertical;font-family:monospace"></textarea>
<p id="ticketReplyError" style="color:var(--red);font-size:12px;margin-top:4px"></p>
</div>`;
}
// 操作按钮
html += `<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn" onclick="ticketState.view='list';renderTicket(document.getElementById('content'))">← 返回列表</button>`;
if (canReply) {
html += `<button class="btn btn-primary" onclick="submitTicketReply(${t.id})">发送回复</button>`;
}
if (t.status === 'submitted' || t.status === 'replied') {
html += `<button class="btn" style="color:var(--yellow);border-color:var(--yellow)" onclick="withdrawTicket(${t.id})">撤销工单</button>`;
}
html += `</div></div>`;
el.innerHTML = html;
} catch (e) {
el.innerHTML = `<div class="card"><p style="color:var(--red)">加载失败: ${e.message}</p><button class="btn" onclick="ticketState.view='list';renderTicket(document.getElementById('content'))">返回</button></div>`;
}
}
async function submitTicketReply(id) {
const msg = document.getElementById('ticketReplyMsg').value.trim();
const errEl = document.getElementById('ticketReplyError');
if (!msg) { errEl.textContent = '请输入回复内容'; return; }
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'reply');
url.searchParams.set('token', TOKEN);
url.searchParams.set('id', id);
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: msg})
});
const data = await res.json();
if (data.success) {
renderTicket(document.getElementById('content'));
} else {
errEl.textContent = data.message;
}
} catch (e) {
errEl.textContent = '发送失败: ' + e.message;
}
}
async function withdrawTicket(id) {
if (!confirm('确定要撤销此工单吗?')) return;
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'withdraw');
url.searchParams.set('token', TOKEN);
url.searchParams.set('id', id);
const res = await fetch(url);
const data = await res.json();
if (data.success) {
ticketState.view = 'list';
renderTicket(document.getElementById('content'));
} else {
alert(data.message);
}
} catch (e) {
alert('撤销失败: ' + e.message);
}
}
// ★ 简易Markdown渲染器
function renderInlineMd(text) {
let s = text;
s = s.replace(/^######\s+(.+)$/, '<h6 style="margin:2px 0;font-size:12px">$1</h6>');
s = s.replace(/^#####\s+(.+)$/, '<h5 style="margin:3px 0;font-size:13px">$1</h5>');
s = s.replace(/^####\s+(.+)$/, '<h4 style="margin:3px 0 2px;font-size:14px">$1</h4>');
s = s.replace(/^###\s+(.+)$/, '<h4 style="margin:4px 0 3px;font-size:14px">$1</h4>');
s = s.replace(/^##\s+(.+)$/, '<h3 style="margin:6px 0 4px;font-size:15px">$1</h3>');
s = s.replace(/^#\s+(.+)$/, '<h2 style="margin:8px 0 5px;font-size:16px">$1</h2>');
s = s.replace(/~~(.+?)~~/g, '<del>$1</del>');
s = s.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
s = s.replace(/\*(.+?)\*/g, '<i>$1</i>');
s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:6px;margin:4px 0">');
s = s.replace(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--accent)">$1</a>');
return s;
}
function renderMarkdown(text) {
if (!text) return '';
text = text.replace(/\r/g, '');
try {
let s = escHtml(text);
// 1. 代码块提取保护
let cbs = [];
s = s.replace(/```(\w*)\n?([\s\S]*?)```/g, function(m, lang, code) {
let idx = cbs.length;
cbs.push('<pre style="background:rgba(88,166,255,0.1);padding:12px;border-radius:6px;overflow-x:auto;font-size:13px;white-space:pre-wrap">' + code.replace(/\n$/, '') + '</pre>');
return '\x00CB' + idx + '\x00';
});
// 2. 行内代码保护
s = s.replace(/`([^`]+)`/g, '<code style="background:rgba(88,166,255,0.15);padding:2px 6px;border-radius:3px;font-size:13px">$1</code>');
// 3. 引用块处理:提取连续引用行,构建嵌套结构
let lines = s.split('\n');
let result = [];
let bqBuf = [];
function flushBq() {
if (bqBuf.length === 0) return;
let html = '';
let openDivs = 0;
for (let bi = 0; bi < bqBuf.length; bi++) {
let {level, content} = bqBuf[bi];
let prevLevel = (bi > 0) ? bqBuf[bi - 1].level : 0;
if (level > prevLevel) {
for (let j = prevLevel; j < level; j++) {
let pad = Math.min((j + 1) * 8, 32);
html += '<div style="border-left:3px solid var(--accent);padding:4px 10px;margin:2px 0 2px ' + pad + 'px;background:rgba(255,255,255,0.03)">';
openDivs++;
}
} else if (level < prevLevel) {
for (let j = prevLevel; j > level; j--) { html += '</div>'; openDivs--; }
} else if (bi > 0) {
html += '<br>';
}
let text = renderInlineMd(content);
if (!text.trim()) text = '&nbsp;';
html += text;
}
for (let j = 0; j < openDivs; j++) html += '</div>';
result.push(html);
bqBuf = [];
}
for (let i = 0; i < lines.length; i++) {
let m = lines[i].match(/^((?:&gt;[ \t]*)+)(.*)$/);
if (m) {
bqBuf.push({ level: (m[1].match(/&gt;/g) || []).length, content: m[2] });
} else if (bqBuf.length > 0 && !lines[i].trim()) {
// ★ 空行在引用块内:向前看下一行是否仍是引用
let isContinuation = false;
for (let j = i + 1; j < lines.length; j++) {
if (lines[j].trim()) {
isContinuation = /^((?:&gt;[ \t]*)+)(.*)$/.test(lines[j]);
break;
}
}
if (isContinuation) {
bqBuf.push({ level: bqBuf[bqBuf.length - 1].level, content: '' });
} else {
flushBq();
}
} else {
flushBq();
result.push(lines[i]);
}
}
flushBq();
s = result.join('\n');
// 4. 非引用行的MD处理
s = s.replace(/~~(.+?)~~/g, '<del>$1</del>');
s = s.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
s = s.replace(/\*(.+?)\*/g, '<i>$1</i>');
s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:6px;margin:6px 0">');
s = s.replace(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--accent)">$1</a>');
s = s.replace(/^######\s+(.+)$/gm, '<h6 style="margin:4px 0;font-size:12px">$1</h6>');
s = s.replace(/^#####\s+(.+)$/gm, '<h5 style="margin:5px 0;font-size:13px">$1</h5>');
s = s.replace(/^####\s+(.+)$/gm, '<h4 style="margin:6px 0 4px;font-size:14px">$1</h4>');
s = s.replace(/^###\s+(.+)$/gm, '<h4 style="margin:8px 0 4px;font-size:14px">$1</h4>');
s = s.replace(/^##\s+(.+)$/gm, '<h3 style="margin:10px 0 6px;font-size:15px">$1</h3>');
s = s.replace(/^#\s+(.+)$/gm, '<h2 style="margin:12px 0 8px;font-size:16px">$1</h2>');
// 5. 表格
let inTable = false;
let tableHtml = '';
let tLines = s.split('\n');
let tResult = [];
for (let i = 0; i < tLines.length; i++) {
let line = tLines[i].trim();
if (line.match(/^\|(.+)\|$/)) {
if (line.match(/^\|[\s\-:|]+\|$/)) { inTable = true; continue; }
if (!inTable && i + 1 < tLines.length && tLines[i+1].trim().match(/^\|[\s\-:|]+\|$/)) {
inTable = true;
let cells = line.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
tableHtml = '<table style="border-collapse:collapse;width:100%;margin:8px 0;font-size:13px"><tr style="background:rgba(88,166,255,0.15)">';
for (let c of cells) tableHtml += '<th style="border:1px solid var(--border);padding:6px 10px;text-align:left">' + c + '</th>';
tableHtml += '</tr>';
continue;
}
if (inTable) {
let cells = line.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
tableHtml += '<tr>';
for (let c of cells) tableHtml += '<td style="border:1px solid var(--border);padding:6px 10px">' + c + '</td>';
tableHtml += '</tr>';
continue;
}
}
if (inTable && tableHtml) {
tableHtml += '</table>';
tResult.push(tableHtml);
tableHtml = '';
inTable = false;
}
tResult.push(tLines[i]);
}
if (tableHtml) { tableHtml += '</table>'; tResult.push(tableHtml); }
s = tResult.join('\n');
// 6. 列表、分割线、换行
s = s.replace(/^- (.+)$/gm, '<li style="margin-left:20px;font-size:13px">$1</li>');
s = s.replace(/^---$/gm, '<hr style="border:none;border-top:1px solid var(--border);margin:12px 0">');
s = s.replace(/\n/g, '<br>');
// 7. 还原代码块
for (let i = 0; i < cbs.length; i++) {
s = s.replace('\x00CB' + i + '\x00', cbs[i]);
}
return s;
} catch(e) { console.error('[MD_RENDER] Error:', e); return escHtml(text).replace(/\n/g, '<br>'); }
}
function escHtml(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
</script> </script>
</body> </body>
</html> </html>
+503
View File
@@ -0,0 +1,503 @@
<?php
header('Cache-Control: no-cache, no-store, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
$BUILD_VERSION = 'v' . filemtime(__FILE__);
$currentVersion = isset($_GET['_v']) ? $_GET['_v'] : '';
if ($currentVersion !== $BUILD_VERSION) {
$params = $_GET;
unset($params['_v']);
$queryString = http_build_query($params);
$path = strtok($_SERVER['REQUEST_URI'], '?');
$newUrl = $path . ($queryString ? '?' . $queryString . '&' : '?') . '_v=' . $BUILD_VERSION;
header('Location: ' . $newUrl, true, 302);
exit;
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>服务商面板 - 草原探险</title>
<style>
:root { --bg:#0d1117; --card:#161b22; --border:#30363d; --text:#e6edf3; --dim:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d29922; --purple:#bc8cff; }
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--text); font-family:'Segoe UI',system-ui,sans-serif; min-height:100vh; }
.header { background:linear-gradient(135deg,#1a2e1a 0%,#0d1117 100%); border-bottom:1px solid var(--border); padding:14px 24px; display:flex; justify-content:space-between; align-items:center; }
.header h1 { font-size:18px; color:var(--green); }
.container { max-width:900px; margin:20px auto; padding:0 16px; }
.card { background:var(--card); border:1px solid var(--border); border-radius:8px; padding:20px; margin-bottom:16px; }
.card h2 { font-size:16px; margin-bottom:12px; color:var(--green); }
.btn { padding:8px 16px; border:none; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600; transition:all 0.2s; }
.btn-primary { background:var(--accent); color:#fff; }
.btn-primary:hover { background:#79c0ff; }
.btn-green { background:var(--green); color:#fff; }
.btn-dim { background:var(--card); color:var(--dim); border:1px solid var(--border); }
.btn-dim:hover { color:var(--text); border-color:var(--accent); }
input, textarea, select { width:100%; padding:10px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--text); font-size:14px; }
textarea { resize:vertical; font-family:monospace; }
.tabs { display:flex; gap:6px; margin-bottom:16px; flex-wrap:wrap; }
.tab { padding:6px 14px; background:var(--bg); border:1px solid var(--border); border-radius:4px; cursor:pointer; font-size:12px; color:var(--dim); }
.tab.active { background:var(--accent); color:#fff; border-color:var(--accent); }
.ticket-item { background:rgba(63,185,80,0.05); border:1px solid var(--border); border-radius:8px; padding:14px 16px; margin-bottom:8px; cursor:pointer; transition:all 0.2s; }
.ticket-item:hover { border-color:var(--green); }
.status-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; }
.md-content { font-size:14px; line-height:1.6; }
.md-content pre { background:rgba(63,185,80,0.1); padding:10px; border-radius:4px; overflow-x:auto; font-size:12px; }
.md-content code { background:rgba(63,185,80,0.15); padding:1px 4px; border-radius:2px; font-size:12px; }
.reply-box { background:rgba(63,185,80,0.05); border-radius:6px; padding:10px 14px; margin-bottom:8px; }
.login-box { max-width:400px; margin:100px auto; }
.login-box h2 { text-align:center; margin-bottom:20px; color:var(--green); }
.error { color:var(--red); font-size:13px; margin-top:8px; }
</style>
</head>
<body>
<div class="header">
<h1>🧑‍💻 服务商面板</h1>
<div style="display:flex;gap:8px;align-items:center">
<span id="providerInfo" style="color:var(--dim);font-size:13px"></span>
<button class="btn btn-dim" onclick="doLogout()" style="font-size:12px;padding:4px 12px">退出</button>
</div>
</div>
<div class="container" id="mainContainer">
<!-- 登录/面板 动态渲染 -->
</div>
<script>
const API = 'api/';
let TOKEN = localStorage.getItem('sdf1_provider_token') || '';
let PLAYER = localStorage.getItem('sdf1_provider_player') || '';
let PROVIDER_ROLE = '';
(async function() {
if (TOKEN && PLAYER) {
await checkProviderAuth();
} else {
renderLogin();
}
})();
function renderLogin() {
document.getElementById('mainContainer').innerHTML = `
<div class="card login-box">
<h2>🧑‍💻 服务商登录</h2>
<p style="color:var(--dim);text-align:center;font-size:13px;margin-bottom:16px">使用游戏账号密码登录(仅限已授权的服务商)</p>
<div style="margin-bottom:12px">
<label style="color:var(--dim);font-size:12px;display:block;margin-bottom:4px">玩家名</label>
<input id="spUser" placeholder="游戏内玩家名" value="${escH(PLAYER)}">
</div>
<div style="margin-bottom:12px">
<label style="color:var(--dim);font-size:12px;display:block;margin-bottom:4px">密码</label>
<input id="spPass" type="password" placeholder="游戏登录密码" onkeydown="if(event.key==='Enter')doProviderLogin()">
</div>
<button class="btn btn-primary" style="width:100%;padding:12px" onclick="doProviderLogin()">登录</button>
<p id="spError" class="error"></p>
</div>`;
}
async function doProviderLogin() {
const user = document.getElementById('spUser').value.trim();
const pass = document.getElementById('spPass').value;
const errEl = document.getElementById('spError');
if (!user || !pass) { errEl.textContent = '请输入用户名和密码'; return; }
try {
// 0. 先清理可能残留的旧请求
const cancelUrl = new URL(API + 'sync.php', location.href);
cancelUrl.searchParams.set('action', 'cancel_web_login');
cancelUrl.searchParams.set('player', user);
await fetch(cancelUrl);
// 1. 先创建weblogin token
const regUrl = new URL(API + 'sync.php', location.href);
regUrl.searchParams.set('action', 'web_login_request');
const regRes = await fetch(regUrl, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({player: user, password: pass})
});
const regData = await regRes.json();
if (!regData.success) { errEl.textContent = regData.message || '登录请求失败'; return; }
// 2. 等待Java端处理(轮询)
const requestId = regData.data && regData.data.request_id;
if (!requestId) { errEl.textContent = '未获取请求ID'; return; }
let verified = false;
for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 1000));
const checkUrl = new URL(API + 'sync.php', location.href);
checkUrl.searchParams.set('action', 'check_web_login_result');
checkUrl.searchParams.set('request_id', requestId);
checkUrl.searchParams.set('player', user);
const checkRes = await fetch(checkUrl);
const checkData = await checkRes.json();
if (checkData.success && checkData.data && checkData.data.status === 'success') {
TOKEN = checkData.data.token || checkData.data.web_token || '';
verified = true;
break;
}
if (checkData.data && checkData.data.status === 'failed') {
// 密码错误,清理pending请求
const cancelUrl3 = new URL(API + 'sync.php', location.href);
cancelUrl3.searchParams.set('action', 'cancel_web_login');
cancelUrl3.searchParams.set('player', user);
await fetch(cancelUrl3);
errEl.textContent = checkData.data.message || '密码错误';
return;
}
}
if (!verified) {
// 超时,清理pending请求
const cancelUrl2 = new URL(API + 'sync.php', location.href);
cancelUrl2.searchParams.set('action', 'cancel_web_login');
cancelUrl2.searchParams.set('player', user);
await fetch(cancelUrl2);
errEl.textContent = '验证超时,请确认游戏服务器在线';
return;
}
// 3. 验证服务商身份
PLAYER = user;
localStorage.setItem('sdf1_provider_token', TOKEN);
localStorage.setItem('sdf1_provider_player', PLAYER);
await checkProviderAuth();
} catch (e) {
errEl.textContent = '登录失败: ' + e.message;
}
}
async function checkProviderAuth() {
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'provider_check');
url.searchParams.set('token', TOKEN);
const res = await fetch(url);
const data = await res.json();
if (data.success && data.data.is_provider) {
PROVIDER_ROLE = data.data.role || 'waiter';
document.getElementById('providerInfo').textContent = `${PLAYER} (${PROVIDER_ROLE})`;
renderPanel();
} else {
TOKEN = '';
localStorage.removeItem('sdf1_provider_token');
renderLogin();
setTimeout(() => {
const errEl = document.getElementById('spError');
if (errEl) errEl.textContent = '你不是授权的服务商,请联系管理员';
}, 100);
}
} catch (e) {
TOKEN = '';
localStorage.removeItem('sdf1_provider_token');
renderLogin();
}
}
let spView = 'available';
function renderPanel() {
const c = document.getElementById('mainContainer');
c.innerHTML = `
<div class="tabs">
<div class="tab ${spView==='available'?'active':''}" onclick="spView='available';renderPanel()">📋 抢单大厅</div>
<div class="tab ${spView==='my'?'active':''}" onclick="spView='my';renderPanel()">👤 我的工单</div>
</div>
<div id="spContent"><p style="color:var(--dim)">加载中...</p></div>`;
if (spView === 'available') loadAvailableTickets();
else loadMyProviderTickets();
}
async function loadAvailableTickets() {
const el = document.getElementById('spContent');
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'available');
url.searchParams.set('token', TOKEN);
const res = await fetch(url);
const data = await res.json();
if (!data.success) { el.innerHTML = `<p style="color:var(--red)">${data.message}</p>`; return; }
const list = data.data.list;
const typeMap = {bug:'🐛 Bug反馈',help:'❓ 求助',report:'📢 举报',apply:'📝 申请',other:'📎 其他'};
if (list.length === 0) {
el.innerHTML = `<div class="card"><p style="color:var(--dim);text-align:center;padding:30px">暂无待接工单</p></div>`;
return;
}
let html = '';
for (const t of list) {
const date = new Date(t.created_at * 1000).toLocaleString('zh-CN');
html += `<div class="ticket-item" onclick="viewSPTicket(${t.id},'available')">
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
<span style="font-weight:600;font-size:14px">#${t.id} ${escH(t.title)}</span>
<span style="font-size:12px;color:var(--yellow)">⏳ 待接</span>
</div>
<div style="font-size:12px;color:var(--dim)">${typeMap[t.type]||t.type} | 👤 ${escH(t.requester)} | 📅 ${date}</div>
</div>`;
}
el.innerHTML = html;
} catch (e) { el.innerHTML = `<p style="color:var(--red)">加载失败: ${e.message}</p>`; }
}
async function loadMyProviderTickets() {
const el = document.getElementById('spContent');
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'provider_list');
url.searchParams.set('token', TOKEN);
url.searchParams.set('type', 'processing');
const res = await fetch(url);
const data = await res.json();
if (!data.success) { el.innerHTML = `<p style="color:var(--red)">${data.message}</p>`; return; }
const list = data.data.list;
const typeMap = {bug:'🐛 Bug反馈',help:'❓ 求助',report:'📢 举报',apply:'📝 申请',other:'📎 其他'};
const statusMap = {submitted:{text:'⏳ 已提交',color:'var(--yellow)'},replied:{text:'💬 已回复',color:'var(--accent)'},completed:{text:'✅ 已完结',color:'var(--green)'},withdrawn:{text:'🔙 已撤销',color:'var(--dim)'},rejected:{text:'🚫 已驳回',color:'var(--red)'}};
if (list.length === 0) {
el.innerHTML = `<div class="card"><p style="color:var(--dim);text-align:center;padding:30px">暂无处理中的工单</p></div>`;
return;
}
let html = '';
for (const t of list) {
const s = statusMap[t.status] || {text: t.status, color: 'var(--dim)'};
const date = new Date(t.created_at * 1000).toLocaleString('zh-CN');
html += `<div class="ticket-item" onclick="viewSPTicket(${t.id},'my')">
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
<span style="font-weight:600;font-size:14px">#${t.id} ${escH(t.title)}</span>
<span style="font-size:12px;color:${s.color}">${s.text}</span>
</div>
<div style="font-size:12px;color:var(--dim)">${typeMap[t.type]||t.type} | 👤 ${escH(t.requester)} | 📅 ${date}</div>
</div>`;
}
el.innerHTML = html;
} catch (e) { el.innerHTML = `<p style="color:var(--red)">加载失败: ${e.message}</p>`; }
}
async function viewSPTicket(id, from) {
const el = document.getElementById('spContent');
el.innerHTML = '<p style="color:var(--dim)">加载中...</p>';
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'detail');
url.searchParams.set('token', TOKEN);
url.searchParams.set('id', id);
const res = await fetch(url);
const data = await res.json();
if (!data.success) { el.innerHTML = `<p style="color:var(--red)">${data.message}</p>`; return; }
const t = data.data;
const typeMap = {bug:'🐛 Bug反馈',help:'❓ 求助',report:'📢 举报',apply:'📝 申请',other:'📎 其他'};
const statusMap = {submitted:{text:'⏳ 已提交',color:'var(--yellow)'},replied:{text:'💬 已回复',color:'var(--accent)'},completed:{text:'✅ 已完结',color:'var(--green)'},withdrawn:{text:'🔙 已撤销',color:'var(--dim)'},rejected:{text:'🚫 已驳回',color:'var(--red)'}};
const s = statusMap[t.status] || {text: t.status, color: 'var(--dim)'};
const date = new Date(t.created_at * 1000).toLocaleString('zh-CN');
const isMyTicket = t.assigned_to === PLAYER;
const canReply = !['withdrawn','rejected','completed'].includes(t.status) && isMyTicket;
const canGrab = t.status === 'submitted' && !t.assigned_to;
let html = `<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h2 style="margin:0">#${t.id} ${escH(t.title)}</h2>
<span style="color:${s.color};font-size:13px">${s.text}</span>
</div>
<div style="font-size:12px;color:var(--dim);margin-bottom:12px">
${typeMap[t.type]||t.type} | 提交者: ${escH(t.requester)} | 处理人: ${t.assigned_to ? escH(t.assigned_to) : '未分配'} | ${date}
</div>
<div style="background:rgba(63,185,80,0.05);border:1px solid var(--border);border-radius:6px;padding:12px;margin-bottom:12px">
<div style="color:var(--dim);font-size:11px;margin-bottom:4px">描述</div>
<div class="md-content">${spRenderMd(t.description || '无描述')}</div>
</div>`;
if (t.reject_reason) {
html += `<div style="background:rgba(248,81,73,0.1);border:1px solid var(--red);border-radius:6px;padding:12px;margin-bottom:12px">
<div style="color:var(--red);font-size:11px;margin-bottom:4px">🚫 驳回原因</div>
<div class="md-content">${spRenderMd(t.reject_reason)}</div></div>`;
}
// 回复列表
html += `<div style="margin-bottom:12px"><div style="font-size:11px;color:var(--dim);margin-bottom:6px">💬 回复 (${t.replies ? t.replies.length : 0})</div>`;
if (t.replies && t.replies.length > 0) {
for (const r of t.replies) {
const rDate = new Date(r.created_at * 1000).toLocaleString('zh-CN');
const roleColors = {user:'var(--accent)',admin:'var(--red)',provider:'var(--green)'};
const roleNames = {user:'玩家',admin:'管理员',provider:'服务商'};
const bgColor = r.role === 'admin' ? 'rgba(248,81,73,0.08)' : r.role === 'provider' ? 'rgba(63,185,80,0.08)' : 'rgba(88,166,255,0.08)';
html += `<div class="reply-box" style="background:${bgColor}">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:4px">
<span><b style="color:${roleColors[r.role]||'var(--text)'}">${escH(r.sender)}</b> <span style="color:var(--dim)">(${roleNames[r.role]||r.role})</span></span>
<span style="color:var(--dim)">${rDate}</span>
</div>
<div class="md-content">${spRenderMd(r.message)}</div>
</div>`;
}
} else {
html += `<p style="color:var(--dim);font-size:12px">暂无回复</p>`;
}
html += `</div>`;
// 操作
html += `<div style="display:flex;gap:8px;flex-wrap:wrap">`;
if (canGrab) {
html += `<button class="btn btn-green" onclick="grabSPTicket(${t.id})">📩 接单</button>`;
}
if (canReply) {
html += `<div style="width:100%;margin-top:8px">
<textarea id="spReplyMsg" rows="2" placeholder="输入回复内容(支持Markdown)" style="font-family:monospace"></textarea>
<div style="margin-top:6px;display:flex;gap:8px">
<button class="btn btn-primary" onclick="spReply(${t.id})">💬 回复</button>
<button class="btn btn-green" onclick="spComplete(${t.id})">✅ 标记完结</button>
</div>
</div>`;
}
html += `<button class="btn btn-dim" onclick="spView='${from}';renderPanel()" style="margin-top:8px">← 返回</button></div>`;
html += `</div>`;
el.innerHTML = html;
} catch (e) { el.innerHTML = `<p style="color:var(--red)">加载失败: ${e.message}</p>`; }
}
async function grabSPTicket(id) {
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'grab');
url.searchParams.set('token', TOKEN);
url.searchParams.set('id', id);
const res = await fetch(url);
const data = await res.json();
if (data.success) { viewSPTicket(id, 'available'); } else { alert(data.message); }
} catch (e) { alert('接单失败: ' + e.message); }
}
async function spReply(id) {
const msg = document.getElementById('spReplyMsg').value.trim();
if (!msg) { alert('请输入回复内容'); return; }
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'provider_reply');
url.searchParams.set('token', TOKEN);
url.searchParams.set('id', id);
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({message: msg})});
const data = await res.json();
if (data.success) { viewSPTicket(id, 'my'); } else { alert(data.message); }
} catch (e) { alert('回复失败: ' + e.message); }
}
async function spComplete(id) {
if (!confirm('确定标记此工单已完结?')) return;
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'complete');
url.searchParams.set('token', TOKEN);
url.searchParams.set('id', id);
const res = await fetch(url);
const data = await res.json();
if (data.success) { viewSPTicket(id, 'my'); } else { alert(data.message); }
} catch (e) { alert('操作失败: ' + e.message); }
}
function doLogout() {
TOKEN = '';
PLAYER = '';
localStorage.removeItem('sdf1_provider_token');
localStorage.removeItem('sdf1_provider_player');
renderLogin();
}
function escH(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function spRenderMd(text) {
if (!text) return '';
text = text.replace(/\r/g, '');
try {
let s = escH(text);
// 代码块(提取保护,防止<br>污染)
let cbs = [];
s = s.replace(/```(\w*)\n?([\s\S]*?)```/g, function(m, lang, code) {
let idx = cbs.length;
cbs.push('<pre style="background:rgba(63,185,80,0.1);padding:10px;border-radius:4px;overflow-x:auto;font-size:12px;white-space:pre-wrap">' + code.replace(/\n$/, '') + '</pre>');
return '\x00CB' + idx + '\x00';
});
// 行内代码
s = s.replace(/`([^`]+)`/g, '<code style="background:rgba(63,185,80,0.15);padding:1px 4px;border-radius:2px;font-size:12px">$1</code>');
// 删除线 ~~text~~
s = s.replace(/~~(.+?)~~/g, '<del>$1</del>');
// 粗体+斜体
s = s.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
s = s.replace(/\*(.+?)\*/g, '<i>$1</i>');
// 图片 ![alt](url)
s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:6px;margin:6px 0">');
// 链接(排除图片)
s = s.replace(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--accent)">$1</a>');
// 标题(从多到少匹配,严格匹配空格分隔)
s = s.replace(/^######\s+(.+)$/gm, '<h6 style="margin:3px 0;font-size:11px">$1</h6>');
s = s.replace(/^#####\s+(.+)$/gm, '<h5 style="margin:4px 0;font-size:12px">$1</h5>');
s = s.replace(/^####\s+(.+)$/gm, '<h5 style="margin:4px 0 2px;font-size:12px">$1</h5>');
s = s.replace(/^###\s+(.+)$/gm, '<h4 style="margin:6px 0 2px;font-size:13px">$1</h4>');
s = s.replace(/^##\s+(.+)$/gm, '<h3 style="margin:8px 0 4px;font-size:14px">$1</h3>');
s = s.replace(/^#\s+(.+)$/gm, '<h2 style="margin:10px 0 6px;font-size:15px">$1</h2>');
// 引用块(支持嵌套)
s = s.replace(/^(?:&gt;[ \t]*)+(.*)$/gm, function(m, content) {
let nestLevel = (m.match(/&gt;/g) || []).length;
let leftPad = Math.min(nestLevel * 6, 24);
let text = content || '&nbsp;';
return '<div style="border-left:3px solid var(--accent);padding:2px 8px;margin:4px 0 4px ' + leftPad + 'px;color:#aaa;font-style:italic;background:rgba(255,255,255,0.03)">' + text + '</div>';
});
// 表格处理
let tableLines = [];
let inTable = false;
let tableHtml = '';
let lines = s.split('\n');
let result = [];
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
if (line.match(/^\|(.+)\|$/)) {
// 分隔行 |---|---|
if (line.match(/^\|[\s\-:|]+\|$/)) {
inTable = true;
continue;
}
if (!inTable && i + 1 < lines.length && lines[i+1].trim().match(/^\|[\s\-:|]+\|$/)) {
// 下一行是分隔行,这是表头
inTable = true;
let cells = line.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
tableHtml = '<table style="border-collapse:collapse;width:100%;margin:6px 0;font-size:12px"><tr style="background:rgba(63,185,80,0.15)">';
for (let c of cells) tableHtml += '<th style="border:1px solid var(--border);padding:4px 8px;text-align:left">' + c + '</th>';
tableHtml += '</tr>';
continue;
}
if (inTable) {
let cells = line.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
tableHtml += '<tr>';
for (let c of cells) tableHtml += '<td style="border:1px solid var(--border);padding:4px 8px">' + c + '</td>';
tableHtml += '</tr>';
continue;
}
}
if (inTable && tableHtml) {
tableHtml += '</table>';
result.push(tableHtml);
tableHtml = '';
inTable = false;
}
result.push(lines[i]);
}
if (tableHtml) {
tableHtml += '</table>';
result.push(tableHtml);
}
s = result.join('\n');
// 无序列表
s = s.replace(/^- (.+)$/gm, '<li style="margin-left:16px;font-size:12px">$1</li>');
// 分割线
s = s.replace(/^---$/gm, '<hr style="border:none;border-top:1px solid var(--border);margin:8px 0">');
// 换行
s = s.replace(/\n/g, '<br>');
// 还原代码块占位符
for (let i = 0; i < cbs.length; i++) {
s = s.replace('\x00CB' + i + '\x00', cbs[i]);
}
return s;
} catch(e) { console.error('[MD_RENDER] Error:', e); return escH(text).replace(/\n/g, '<br>'); }
}
</script>
</body>
</html>
+5 -4
View File
@@ -247,13 +247,14 @@ if ($action === 'status') {
$db->close(); $db->close();
} elseif ($action === 'upload') { } elseif ($action === 'upload' || (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK)) {
// 通过 POST 上传文件 // 通过 POST 上传文件
$target = $_POST['target'] ?? ''; // 支持 target= 和 path= 两种参数名
$allowed = ['api/admin.php', 'api/sync.php', 'api/cdk.php', 'core.php', 'config.php']; $target = $_POST['target'] ?? $_POST['path'] ?? '';
$allowed = ['api/admin.php', 'api/sync.php', 'api/cdk.php', 'api/ticket.php', 'api/register.php', 'api/shop.php', 'api/balance.php', 'core.php', 'config.php', 'player.php', 'admin.php', 'service.php', 'upload_via_http.php'];
if (!in_array($target, $allowed)) { if (!in_array($target, $allowed)) {
echo "ERROR: Target not allowed. Use: " . implode(', ', $allowed) . "\n"; echo "ERROR: Target not allowed: $target\nAllowed: " . implode(', ', $allowed) . "\n";
exit; exit;
} }