fix: 修复库存不同步bug(item_id字段名不匹配); 新增库存高频轮询; 新增工单系统; 修复admin_stock竞态条件; 修复opcache_reset竞态
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
<?php header('Cache-Control: no-cache, no-store, must-revalidate, max-age=0'); header('Pragma: no-cache'); ?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
@@ -6,7 +7,7 @@
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>SDF1 - 管理后台</title>
|
||||
<title>SDF1 - 管理后台 v<?php echo date('ymd_His', filemtime(__FILE__)); ?></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; }
|
||||
@@ -90,6 +91,7 @@
|
||||
<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="reset_requests" onclick="go('reset_requests')">🔑 密码重置审核</div>
|
||||
<div class="si" data-p="tickets" onclick="go('tickets')">📋 工单管理</div>
|
||||
</div>
|
||||
<div class="content" id="C"></div>
|
||||
</div>
|
||||
@@ -113,7 +115,7 @@ if ('serviceWorker' in navigator) {
|
||||
}
|
||||
|
||||
const A = 'api/admin.php';
|
||||
const _BUILD_TS = 1781928829; // 版本号,用于缓存失效
|
||||
const _BUILD_TS = 1782098585; // 版本号,用于缓存失效
|
||||
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==='online') loadOnlinePlayers(c);
|
||||
else if (p==='active') loadActivePlayers(c);
|
||||
else if (p==='tickets') loadTickets(c);
|
||||
}
|
||||
|
||||
// 检查登录状态
|
||||
@@ -237,10 +240,11 @@ async function loadDashboard(el) {
|
||||
const subnetOrder = [];
|
||||
players.forEach(p => {
|
||||
const ip = p.ip_address || '-';
|
||||
let subnet = ip;
|
||||
if (ip !== '-') {
|
||||
let subnet = '_unknown_' + p.player_name; // 默认每个玩家独立一组
|
||||
// 只有有效IPv4地址才进行子网分组
|
||||
if (ip !== '-' && ip.indexOf('.') !== -1) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -440,6 +444,13 @@ async function removeShop(id) {
|
||||
async function loadCDK(el) {
|
||||
const r = await jsonApi('cdk.php?action=list');
|
||||
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 = `
|
||||
<div class="card">
|
||||
<h2>CDK管理</h2>
|
||||
@@ -448,18 +459,27 @@ async function loadCDK(el) {
|
||||
<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="cCount" type="number" value="10" min="1" max="100"></div>
|
||||
<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>
|
||||
<table class="table">
|
||||
<tr><th>兑换码</th><th>金额</th><th>状态</th><th>使用者</th><th>创建时间</th></tr>
|
||||
${list.map(c=>`<tr>
|
||||
<tr><th>兑换码</th><th>金额</th><th>状态</th><th>使用者</th><th>创建时间</th><th>操作</th></tr>
|
||||
${list.map(c=>{
|
||||
const deleteBtn = c.used ?
|
||||
`<span style="color:var(--dim);font-size:12px">-</span>` :
|
||||
`<button class="btn btn-red" style="padding:2px 8px;font-size:12px" onclick="deleteCDK('${c.code}')">删除</button>`;
|
||||
return `<tr>
|
||||
<td style="font-family:monospace">${c.code}</td>
|
||||
<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>
|
||||
</tr>`).join('')}
|
||||
<td>${deleteBtn}</td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
@@ -499,6 +519,59 @@ async function doBatchCDK() {
|
||||
} 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) {
|
||||
el.innerHTML = `
|
||||
@@ -821,15 +894,18 @@ function lazyLoadUsersPage(page, limit, search, onlineSet) {
|
||||
// 追加用户行(日期搜索时按/24子网折叠)
|
||||
let displayUsers = users;
|
||||
if (isDateSearch && users.length > 0) {
|
||||
// 按/24子网分组
|
||||
// 按/24子网分组(只对有效IPv4地址进行分组)
|
||||
const subnetMap = {};
|
||||
const subnetOrder = [];
|
||||
users.forEach(u => {
|
||||
const ip = u.ip_address || '-';
|
||||
let subnet = ip;
|
||||
if (ip !== '-') {
|
||||
let subnet = '_unknown_' + u.player_name; // 默认每个玩家独立一组
|
||||
// 只有有效IPv4地址才进行子网分组
|
||||
if (ip !== '-' && ip.indexOf('.') !== -1) {
|
||||
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); }
|
||||
subnetMap[subnet].push(u);
|
||||
@@ -1295,15 +1371,18 @@ function loadActiveTab(tab, seconds) {
|
||||
const totalBeforeDedup = res.total_before_dedup || players.length;
|
||||
const dedupCount = totalBeforeDedup - players.length;
|
||||
|
||||
// 统计同子网玩家
|
||||
// 统计同子网玩家(只对有效IPv4地址进行分组)
|
||||
const subnetCountMap = {};
|
||||
players.forEach(p => {
|
||||
const ip = p.ip_address || '-';
|
||||
if (ip !== '-') {
|
||||
// 只有有效IPv4地址才进行子网分组
|
||||
if (ip !== '-' && ip.indexOf('.') !== -1) {
|
||||
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))) {
|
||||
const subnet = parts[0] + '.' + parts[1] + '.' + parts[2];
|
||||
subnetCountMap[subnet] = (subnetCountMap[subnet] || 0) + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
div.innerHTML = `
|
||||
@@ -1318,9 +1397,12 @@ function loadActiveTab(tab, seconds) {
|
||||
const ip = p.ip_address || '-';
|
||||
const ipLoc = p.ip_location || '-';
|
||||
let subnet = '';
|
||||
if (ip !== '-') {
|
||||
// 只有有效IPv4地址才进行子网分组
|
||||
if (ip !== '-' && ip.indexOf('.') !== -1) {
|
||||
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 ipStyle = isShared ? 'color:var(--yellow);font-weight:700' : '';
|
||||
@@ -1752,6 +1834,397 @@ function applyCustomColor() {
|
||||
const savedTheme = localStorage.getItem('sdf1_theme');
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
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 = ' ';
|
||||
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(/^((?:>[ \t]*)+)(.*)$/);
|
||||
if (m) {
|
||||
bqBuf.push({ level: (m[1].match(/>/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 = /^((?:>[ \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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+64
-15
@@ -17,8 +17,8 @@ ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
ini_set('memory_limit', '256M');
|
||||
|
||||
if (function_exists('opcache_reset')) { @opcache_reset(); }
|
||||
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__, true); }
|
||||
// 仅刷新当前文件的OPcache(不要opcache_reset——会重置整个服务器缓存导致竞态条件)
|
||||
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
|
||||
|
||||
// ★ 注册shutdown函数:确保ob缓冲区在致命错误时也能刷新
|
||||
register_shutdown_function(function() {
|
||||
@@ -43,6 +43,7 @@ if (session_status() === PHP_SESSION_NONE) {
|
||||
|
||||
$action = getParam('action', 'status');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'login': adminDoLogin(); break;
|
||||
case 'logout': adminDoLogout(); break;
|
||||
@@ -73,8 +74,15 @@ switch ($action) {
|
||||
case 'get_stats_ex': adminGetStatsEx(); break;
|
||||
case 'list_same_ip': adminListSameIp(); 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));
|
||||
}
|
||||
} 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() {
|
||||
@@ -190,15 +198,25 @@ function adminShopUpdate() {
|
||||
requireAdminSession();
|
||||
$db = getDB();
|
||||
try {
|
||||
$stmt = $db->prepare("UPDATE shop_items SET display_name=:dn, buy_price=:bp, sell_price=:sp, stock=:st WHERE id=:id");
|
||||
$stmt->bindValue(':dn', getParam('display_name'));
|
||||
$stmt->bindValue(':bp', getParam('buy_price'));
|
||||
$stmt->bindValue(':sp', getParam('sell_price'));
|
||||
$itemId = getParam('id');
|
||||
if (!$itemId) error('缺少商品ID');
|
||||
$stock = getParam('stock');
|
||||
// ★ 库存改动写入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(':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();
|
||||
exit(json_encode(['success' => true, 'message' => 'OK'], JSON_UNESCAPED_UNICODE));
|
||||
} catch (Exception $e) {
|
||||
$rowsChanged = $db->changes();
|
||||
// ★ 同步通知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());
|
||||
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
@@ -208,17 +226,20 @@ function adminShopAdd() {
|
||||
requireAdminSession();
|
||||
$db = getDB();
|
||||
try {
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO shop_items VALUES (:id,:cat,:dn,:mat,:bp,:sp,:st,0,0,0)");
|
||||
$stmt->bindValue(':id', getParam('item_id'));
|
||||
$itemId = getParam('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(':dn', getParam('display_name', ''));
|
||||
$stmt->bindValue(':mat', getParam('material', 'stone_sword'));
|
||||
$stmt->bindValue(':bp', getParam('buy_price', 0));
|
||||
$stmt->bindValue(':sp', getParam('sell_price', -1));
|
||||
$stmt->bindValue(':st', getParam('stock', -1));
|
||||
$stmt->bindValue(':lm', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
exit(json_encode(['success' => true, 'message' => 'OK'], JSON_UNESCAPED_UNICODE));
|
||||
} catch (Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
@error_log('[Function] Error: ' . $e->getMessage());
|
||||
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
@@ -228,11 +249,14 @@ function adminShopRemove() {
|
||||
requireAdminSession();
|
||||
$db = getDB();
|
||||
try {
|
||||
$itemId = getParam('id');
|
||||
if (!$itemId) error('缺少商品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();
|
||||
exit(json_encode(['success' => true, 'message' => 'OK'], JSON_UNESCAPED_UNICODE));
|
||||
} catch (Exception $e) {
|
||||
$rowsChanged = $db->changes();
|
||||
exit(json_encode(['success' => true, 'message' => 'OK', 'deleted' => $rowsChanged], JSON_UNESCAPED_UNICODE));
|
||||
} catch (\Throwable $e) {
|
||||
@error_log('[Function] Error: ' . $e->getMessage());
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 删除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
@@ -13,6 +13,7 @@ ob_end_clean();
|
||||
$action = getParam('action', 'query');
|
||||
$token = getParam('token');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'query':
|
||||
balanceQuery($token);
|
||||
@@ -26,6 +27,12 @@ switch ($action) {
|
||||
default:
|
||||
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) {
|
||||
@@ -242,7 +249,7 @@ function balanceTransactions($token) {
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (Exception $e) { /* ignore */ }
|
||||
} catch (\Throwable $e) { /* ignore */ }
|
||||
// 游戏内交易
|
||||
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");
|
||||
@@ -250,7 +257,7 @@ function balanceTransactions($token) {
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (Exception $e) { /* ignore */ }
|
||||
} catch (\Throwable $e) { /* ignore */ }
|
||||
} else {
|
||||
// 管理员查看全服流水
|
||||
if ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') {
|
||||
@@ -262,14 +269,14 @@ function balanceTransactions($token) {
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (Exception $e) { /* ignore */ }
|
||||
} catch (\Throwable $e) { /* ignore */ }
|
||||
// 游戏内交易
|
||||
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->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (Exception $e) { /* ignore */ }
|
||||
} catch (\Throwable $e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// 按时间排序(倒序)
|
||||
|
||||
+8
-1
@@ -16,6 +16,7 @@ while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
$action = getParam('action', 'exchange');
|
||||
$token = getParam('token');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'exchange':
|
||||
cdkExchange($token);
|
||||
@@ -35,6 +36,12 @@ switch ($action) {
|
||||
default:
|
||||
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兑换 =====
|
||||
function cdkExchange($token) {
|
||||
@@ -382,7 +389,7 @@ function validateWebloginTokenForCDK($webToken) {
|
||||
'created_at' => $createdAt,
|
||||
'expires_at' => $createdAt + $expireSeconds
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -13,6 +13,7 @@ ob_end_clean();
|
||||
$action = getParam('action', 'check');
|
||||
$token = getParam('token');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'check':
|
||||
registerCheck();
|
||||
@@ -29,6 +30,10 @@ switch ($action) {
|
||||
default:
|
||||
error('未知操作: ' . $action);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
while (ob_get_level() > 0) ob_end_clean();
|
||||
error('服务器内部错误: ' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
// ===== 检查用户名 =====
|
||||
function registerCheck() {
|
||||
@@ -206,7 +211,7 @@ function registerQuery($token) {
|
||||
$bondResult = $bondStmt->execute();
|
||||
$bondRow = $bondResult->fetchArray(SQLITE3_ASSOC);
|
||||
if ($bondRow) $bonds = (int)($bondRow['amount'] ?? 0);
|
||||
} catch (Exception $e) {}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// ★ 获取累计在线天数(不重复日期计数)
|
||||
$totalOnlineDays = 0;
|
||||
@@ -216,7 +221,7 @@ function registerQuery($token) {
|
||||
$odResult = $odStmt->execute();
|
||||
$odRow = $odResult->fetchArray(SQLITE3_ASSOC);
|
||||
if ($odRow) $totalOnlineDays = (int)($odRow['cnt'] ?? 0);
|
||||
} catch (Exception $e) {}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// ★ 获取累计签到天数
|
||||
$totalCheckinDays = 0;
|
||||
@@ -226,7 +231,7 @@ function registerQuery($token) {
|
||||
$ciResult = $ciStmt->execute();
|
||||
$ciRow = $ciResult->fetchArray(SQLITE3_ASSOC);
|
||||
if ($ciRow) $totalCheckinDays = (int)($ciRow['cnt'] ?? 0);
|
||||
} catch (Exception $e) {}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// 合并额外数据
|
||||
$row['bonds'] = $bonds;
|
||||
|
||||
+9
-2
@@ -14,6 +14,7 @@ while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
$action = getParam('action', 'list');
|
||||
$token = getParam('token');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
shopList($token);
|
||||
@@ -27,6 +28,10 @@ switch ($action) {
|
||||
default:
|
||||
error('未知操作: ' . $action);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
while (ob_get_level() > 0) ob_end_clean();
|
||||
error('服务器内部错误: ' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
// ===== 商品列表(公开,无需token) =====
|
||||
function shopList($token = null) {
|
||||
@@ -185,9 +190,11 @@ function shopBuy($token) {
|
||||
// ★ 实际购买 — 包装在事务中,防止database is locked
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
// 更新库存
|
||||
// 更新库存(同时写admin_stock确保Java同步)
|
||||
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(':id', $itemId, SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
|
||||
+286
-29
@@ -1,19 +1,36 @@
|
||||
<?php
|
||||
// ★ 同步API - 插件与Web端数据同步
|
||||
// ★ Version: 2026-06-12-1940 - clear OPcache at top
|
||||
// ★ Version: 2026-06-22-v2 - added require_once guard + removed opcache_reset
|
||||
// ★ 防止任何输出污染JSON响应
|
||||
ob_start();
|
||||
// 清除OPcache(如果可用)
|
||||
if (function_exists('opcache_reset')) { @opcache_reset(); }
|
||||
// 仅刷新当前文件的OPcache(不要调用opcache_reset——它会重置整个服务器缓存,导致并发竞态条件)
|
||||
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
|
||||
// ★ 再次确保清理前置输出
|
||||
// 清理前置输出
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
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) {
|
||||
ob_end_clean();
|
||||
}
|
||||
@@ -115,10 +132,14 @@ function smtpSendEmail($host, $port, $user, $pass, $to, $subject, $htmlBody, $he
|
||||
|
||||
$action = getParam('action', '');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'sync_shop':
|
||||
syncShop();
|
||||
break;
|
||||
case 'sync_service_providers':
|
||||
syncServiceProviders();
|
||||
break;
|
||||
case 'sync_bonds':
|
||||
syncBonds();
|
||||
break;
|
||||
@@ -167,6 +188,9 @@ switch ($action) {
|
||||
case 'web_login_request':
|
||||
webLoginRequest();
|
||||
break;
|
||||
case 'cancel_web_login':
|
||||
cancelWebLogin();
|
||||
break;
|
||||
case 'check_web_login_result':
|
||||
checkWebLoginResult();
|
||||
break;
|
||||
@@ -194,6 +218,12 @@ switch ($action) {
|
||||
case 'pull_shop_stock':
|
||||
pullShopStock();
|
||||
break;
|
||||
case 'clear_admin_stock':
|
||||
clearAdminStock();
|
||||
break;
|
||||
case 'check_shop_stock_changed':
|
||||
checkShopStockChanged();
|
||||
break;
|
||||
case 'notify_sync':
|
||||
notifySync();
|
||||
break;
|
||||
@@ -272,6 +302,10 @@ switch ($action) {
|
||||
default:
|
||||
error('未知操作: ' . $action);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
while (ob_get_level() > 0) ob_end_clean();
|
||||
error('服务器内部错误: ' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
// ===== 插件推送商品数据 =====
|
||||
function syncShop() {
|
||||
@@ -306,9 +340,22 @@ function syncShop() {
|
||||
|
||||
// 游戏端stock=0表示无限供应,Web端用-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(':cat', $item['category'] ?? '默认', SQLITE3_TEXT);
|
||||
$stmt->bindValue(':name', $item['display_name'] ?? $id, SQLITE3_TEXT);
|
||||
@@ -324,11 +371,54 @@ function syncShop() {
|
||||
}
|
||||
|
||||
success(['synced' => $count], "同步了{$count}个商品");
|
||||
}
|
||||
|
||||
// 一次性修复:将数据库中所有stock=0改为-1(无限供应)
|
||||
$fixResult = $db->exec("UPDATE shop_items SET stock = -1 WHERE stock = 0");
|
||||
if ($fixResult !== false) {
|
||||
// 静默修复,不影响正常流程
|
||||
// ===== 插件推送服务商列表 =====
|
||||
function syncServiceProviders() {
|
||||
$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;
|
||||
}
|
||||
|
||||
// ★ 读取玩家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) {
|
||||
@error_log("[pushPlayerLoginStatus] FAIL: missing player or web_token. player=" . var_export($player, true) . ", web_token=" . var_export($webToken, true));
|
||||
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");
|
||||
|
||||
$db = getDB();
|
||||
$now = time();
|
||||
|
||||
// ★ 事务包裹所有写操作,防止 "database is locked"
|
||||
$db->exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
// 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)");
|
||||
$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->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':expire', $expireSeconds, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
@error_log("[pushPlayerLoginStatus] Token stored: player=$player, created_at=$now, expire=$expireSeconds");
|
||||
|
||||
// 2. 如果玩家在游戏中已登录,更新online_players表(只更新已存在的记录,不凭空插入)
|
||||
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->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$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();
|
||||
|
||||
// ★ 如果有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');
|
||||
$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], '玩家登录状态已同步');
|
||||
}
|
||||
|
||||
@@ -1443,6 +1583,9 @@ function checkWebLoginConfirmations() {
|
||||
$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("BEGIN IMMEDIATE");
|
||||
try {
|
||||
// ★ 清理超过10分钟的过期确认记录
|
||||
$expireTime = time() - 600;
|
||||
$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("COMMIT");
|
||||
} catch (\Throwable $e) {
|
||||
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
|
||||
}
|
||||
|
||||
success($confirmations);
|
||||
}
|
||||
|
||||
@@ -1484,6 +1632,9 @@ function checkWebLoginVerified() {
|
||||
$db = getDB();
|
||||
$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分钟的过期验证记录(防止残留数据导致误登录)
|
||||
$expireTime = time() - 600; // 10分钟
|
||||
$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);
|
||||
}
|
||||
|
||||
@@ -1537,9 +1693,9 @@ function webLoginRequest() {
|
||||
error('缺少player或password参数');
|
||||
}
|
||||
|
||||
// 简单验证玩家名格式(3-16位字母数字下划线)
|
||||
if (!preg_match('/^[a-zA-Z0-9_]{3,16}$/', $player)) {
|
||||
error('玩家名格式不正确(3-16位字母数字下划线)');
|
||||
// 简单验证玩家名格式(1-16位字母数字下划线)
|
||||
if (!preg_match('/^[a-zA-Z0-9_]{1,16}$/', $player)) {
|
||||
error('玩家名格式不正确(1-16位字母数字下划线)');
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
@@ -1559,14 +1715,25 @@ function webLoginRequest() {
|
||||
result_time INTEGER DEFAULT 0
|
||||
)");
|
||||
|
||||
// ★ 事务包裹所有写操作
|
||||
$db->exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
// 清理10分钟前的过期请求
|
||||
$db->exec("DELETE FROM web_login_requests WHERE request_time < " . (time() - 600));
|
||||
|
||||
// 检查是否有进行中的请求
|
||||
$checkStmt2 = $db->prepare("SELECT id FROM web_login_requests WHERE player_name = :player AND status = 'pending'");
|
||||
// 清理该玩家超过2分钟的旧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(':cutoff2', time() - 120, SQLITE3_INTEGER);
|
||||
$checkResult2 = $checkStmt2->execute();
|
||||
if ($checkResult2->fetchArray()) {
|
||||
$db->exec("ROLLBACK");
|
||||
error('已有进行中的登录请求,请稍后再试');
|
||||
}
|
||||
|
||||
@@ -1578,6 +1745,13 @@ function webLoginRequest() {
|
||||
|
||||
$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]);
|
||||
|
||||
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端轮询密码登录结果 =====
|
||||
function checkWebLoginResult() {
|
||||
$player = getParam('player');
|
||||
@@ -1681,8 +1881,15 @@ function checkPendingWebLogins() {
|
||||
result_time INTEGER DEFAULT 0
|
||||
)");
|
||||
|
||||
// ★ 事务包裹清理操作
|
||||
$db->exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
// 清理10分钟前的过期请求
|
||||
$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,不返回密码明文给前端)
|
||||
$stmt = $db->prepare("SELECT id, player_name, password FROM web_login_requests WHERE status = 'pending'");
|
||||
@@ -1716,6 +1923,8 @@ function completeWebLoginRequest() {
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
// ★ 事务包裹所有写操作,防止database is locked
|
||||
$db->exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT id FROM web_login_requests WHERE id = :id AND player_name = :player");
|
||||
$stmt->bindValue(':id', (int)$requestId, SQLITE3_INTEGER);
|
||||
@@ -1723,6 +1932,7 @@ function completeWebLoginRequest() {
|
||||
$row = $stmt->execute()->fetchArray();
|
||||
|
||||
if (!$row) {
|
||||
$db->exec("ROLLBACK");
|
||||
debugLog("completeWebLoginRequest: 请求不存在", ['request_id' => $requestId, 'player' => $player]);
|
||||
error('请求不存在');
|
||||
}
|
||||
@@ -1738,8 +1948,6 @@ function completeWebLoginRequest() {
|
||||
$updateStmt->bindValue(':id', (int)$requestId, SQLITE3_INTEGER);
|
||||
$updateStmt->execute();
|
||||
|
||||
debugLog("completeWebLoginRequest: 更新成功", ['request_id' => $requestId, 'status' => $status]);
|
||||
|
||||
// 如果验证成功,写入web_login_confirmations和web_login_verified
|
||||
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)");
|
||||
@@ -1766,8 +1974,11 @@ function completeWebLoginRequest() {
|
||||
$dlStmt->execute();
|
||||
}
|
||||
|
||||
$db->exec("COMMIT");
|
||||
debugLog("completeWebLoginRequest: 更新成功", ['request_id' => $requestId, 'status' => $status]);
|
||||
success(['status' => $status], "结果已写入");
|
||||
} catch (Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
|
||||
debugLog("completeWebLoginRequest: 数据库异常", ['error' => $e->getMessage()]);
|
||||
error('数据库错误: ' . $e->getMessage());
|
||||
}
|
||||
@@ -2089,16 +2300,61 @@ function pullShopStock() {
|
||||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||||
|
||||
$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();
|
||||
$items = [];
|
||||
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;
|
||||
}
|
||||
|
||||
// ★ 成功获取数据后立即清除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)]);
|
||||
}
|
||||
|
||||
// ===== 插件确认已应用管理员库存改动 =====
|
||||
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() {
|
||||
$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));
|
||||
|
||||
$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
|
||||
if (!empty($ids)) {
|
||||
try {
|
||||
$db->exec('BEGIN TRANSACTION');
|
||||
$idList = implode(',', $ids);
|
||||
$db->exec("UPDATE web_register_requests SET status = 'processing', processed_at = " . time() . " WHERE id IN ($idList)");
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
try { $db->exec('ROLLBACK'); } catch (Exception $e2) {}
|
||||
}
|
||||
$db->exec("COMMIT");
|
||||
} catch (\Throwable $e) {
|
||||
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
|
||||
}
|
||||
|
||||
success(['requests' => $requests, 'count' => count($requests)]);
|
||||
|
||||
+825
@@ -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;
|
||||
}
|
||||
@@ -119,9 +119,19 @@ function initTables(SQLite3 $db) {
|
||||
stock INTEGER DEFAULT -1,
|
||||
hourly_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表
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS cdk (
|
||||
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'");
|
||||
} 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');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
@@ -536,7 +580,7 @@ function jsonResponse($data, $code = 200) {
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
http_response_code($code);
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
+412
-3
@@ -115,6 +115,8 @@ if ($currentVersion !== $BUILD_VERSION) {
|
||||
.btn-green:hover { background: #56d364; }
|
||||
.btn-red { background: var(--red); color: #fff; }
|
||||
.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 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); }
|
||||
@@ -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="cdk" onclick="switchPage('cdk')">🎁 CDK兑换</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 class="content" id="content">
|
||||
@@ -766,6 +769,7 @@ if ($currentVersion !== $BUILD_VERSION) {
|
||||
else if (page === 'cdk') renderCDK(c);
|
||||
else if (page === 'balance') renderBalance(c);
|
||||
else if (page === 'account') renderAccount(c);
|
||||
else if (page === 'ticket') renderTicket(c);
|
||||
}
|
||||
|
||||
// ★ 新增:Token轮询检查(不续期,只检查是否被Java端主动销毁)
|
||||
@@ -958,7 +962,7 @@ if ($currentVersion !== $BUILD_VERSION) {
|
||||
|
||||
// ===== 个人中心仪表盘 =====
|
||||
async function renderDashboard(el) {
|
||||
if (IS_PREVIEW || !currentPlayer) {
|
||||
if (IS_PREVIEW || !currentPlayer || (!AUTHENTICATED && NEED_PASSWORD)) {
|
||||
el.innerHTML = `
|
||||
<div class="card" style="text-align:center;padding:48px 20px">
|
||||
<div style="font-size:64px;margin-bottom:16px">📊</div>
|
||||
@@ -1073,7 +1077,7 @@ if ($currentVersion !== $BUILD_VERSION) {
|
||||
|
||||
// ===== 余额 =====
|
||||
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>';
|
||||
return;
|
||||
}
|
||||
@@ -1109,7 +1113,7 @@ if ($currentVersion !== $BUILD_VERSION) {
|
||||
|
||||
// ===== 账号 =====
|
||||
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>`;
|
||||
return;
|
||||
}
|
||||
@@ -1268,6 +1272,10 @@ if ($currentVersion !== $BUILD_VERSION) {
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modalOverlay').style.display = 'none';
|
||||
// ★ 密码验证被取消 → 清除token,进入游客模式(防止未认证玩家数据泄露)
|
||||
if (NEED_PASSWORD && !AUTHENTICATED) {
|
||||
showGuestMode();
|
||||
}
|
||||
}
|
||||
|
||||
function confirmModal() {}
|
||||
@@ -1373,6 +1381,407 @@ if ($currentVersion !== $BUILD_VERSION) {
|
||||
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 = ' ';
|
||||
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(/^((?:>[ \t]*)+)(.*)$/);
|
||||
if (m) {
|
||||
bqBuf.push({ level: (m[1].match(/>/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 = /^((?:>[ \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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+503
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
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>');
|
||||
// 图片 
|
||||
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(/^(?:>[ \t]*)+(.*)$/gm, function(m, content) {
|
||||
let nestLevel = (m.match(/>/g) || []).length;
|
||||
let leftPad = Math.min(nestLevel * 6, 24);
|
||||
let text = content || ' ';
|
||||
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
@@ -247,13 +247,14 @@ if ($action === 'status') {
|
||||
|
||||
$db->close();
|
||||
|
||||
} elseif ($action === 'upload') {
|
||||
} elseif ($action === 'upload' || (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK)) {
|
||||
// 通过 POST 上传文件
|
||||
$target = $_POST['target'] ?? '';
|
||||
$allowed = ['api/admin.php', 'api/sync.php', 'api/cdk.php', 'core.php', 'config.php'];
|
||||
// 支持 target= 和 path= 两种参数名
|
||||
$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)) {
|
||||
echo "ERROR: Target not allowed. Use: " . implode(', ', $allowed) . "\n";
|
||||
echo "ERROR: Target not allowed: $target\nAllowed: " . implode(', ', $allowed) . "\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user