新增独立收银台页面与收银员账号体系:支持收银员/管理员登录、手动折扣(服务端强制上限)、订单记录;同步修复代购≥1000仍误报需玩家密码问题(#733 #734)

This commit is contained in:
ypsdf1
2026-07-06 19:27:14 +08:00
parent 0e1f26508b
commit 59701d1627
5 changed files with 1148 additions and 17 deletions
+106 -1
View File
@@ -110,7 +110,8 @@
<div class="si" data-p="tickets" onclick="go('tickets')">📋 工单管理</div>
<div class="si" data-p="lands" onclick="go('lands')">🏡 领地管理</div>
<div class="si" data-p="usergroups" onclick="go('usergroups')">👥 用户组</div>
<div class="si" data-p="cashier" onclick="go('cashier')">🧾 收银台</div>
<div class="si" data-p="cashier" onclick="openCashierPage()">🧾 收银台(独立页)</div>
<div class="si" data-p="cashier_manage" onclick="go('cashier_manage')">🧾 收银员管理</div>
</div>
<div class="content" id="C"></div>
</div>
@@ -176,6 +177,7 @@ function go(p) {
else if (p==='lands') loadLands(c);
else if (p==='usergroups') loadUserGroups(c);
else if (p==='cashier') loadCashier(c);
else if (p==='cashier_manage') loadCashierManage(c);
}
// 检查登录状态
@@ -3165,6 +3167,109 @@ function glassPrompt(label, currentVal, hint = '') {
setTimeout(() => input.focus(), 100);
});
}
// ===== 收银员管理(独立收银台账号)=====
function openCashierPage() {
window.open('cashier.php', '_blank');
}
async function loadCashierManage(el) {
el.innerHTML = `<div class="card">
<h2>🧾 收银员管理</h2>
<p style="color:var(--dim);font-size:12px;margin-bottom:12px">收银员可登录独立收银台(cashier.php)代玩家购买商品并手动打折,折扣上限在此设置。管理员亦可登录收银台。</p>
<div class="card" style="background:var(--bg)">
<h2 style="font-size:14px;color:var(--green)">+ 新增收银员</h2>
<div class="form-row"><label>账号</label><input id="cmUser" placeholder="3-20位字母/数字/下划线"></div>
<div class="form-row"><label>密码</label><input id="cmPass" type="password" placeholder="登录密码"></div>
<div class="form-row"><label>折扣上限%</label><input id="cmLimit" type="number" min="0" max="100" value="10"></div>
<button class="btn btn-green" onclick="cmAdd()">创建收银员</button>
</div>
<div id="cmList" style="margin-top:14px">加载中…</div>
</div>`;
await cmLoad();
}
async function cmAdd() {
const username = (document.getElementById('cmUser').value || '').trim();
const password = document.getElementById('cmPass').value || '';
const limit = parseInt(document.getElementById('cmLimit').value || '0', 10);
if (!username || !password) { toast('账号和密码不能为空', 'err'); return; }
if (limit < 0 || limit > 100) { toast('折扣上限需在0-100', 'err'); return; }
try {
const d = await fetch('api/cashier.php?action=add', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'username=' + encodeURIComponent(username) + '&password=' + encodeURIComponent(password) + '&discount_limit_percent=' + limit
}).then(r => r.json());
if (d.success) { toast('收银员已创建', 'ok'); document.getElementById('cmUser').value = ''; document.getElementById('cmPass').value = ''; cmLoad(); }
else toast(d.message || '创建失败', 'err');
} catch (e) { toast(e.message, 'err'); }
}
async function cmLoad() {
const wrap = document.getElementById('cmList');
if (!wrap) return;
try {
const d = await fetch('api/cashier.php?action=cashier_list').then(r => r.json());
if (!d.success) { wrap.innerHTML = '<div style="color:var(--red);font-size:13px">' + escAdmHtml(d.message) + '</div>'; return; }
if (!d.data.length) { wrap.innerHTML = '<div style="color:var(--dim);font-size:13px">暂无收银员</div>'; return; }
wrap.innerHTML = '<table class="table"><tr><th>ID</th><th>账号</th><th>折扣上限</th><th>创建时间</th><th>操作</th></tr>' + d.data.map(c => `
<tr>
<td>${c.id}</td>
<td>${escAdmHtml(c.username)}</td>
<td>${c.discount_limit_percent}%</td>
<td style="color:var(--dim)">${c.created_at ? new Date(c.created_at * 1000).toLocaleString('zh-CN') : '-'}</td>
<td>
<button class="btn btn-yellow" style="padding:4px 10px;font-size:11px" onclick="cmEdit(${c.id},'${escAdmHtml(c.username)}',${c.discount_limit_percent})">编辑</button>
<button class="btn btn-red" style="padding:4px 10px;font-size:11px" onclick="cmDelete(${c.id})">删除</button>
</td>
</tr>`).join('') + '</table>';
} catch (e) { wrap.innerHTML = '<div style="color:var(--red);font-size:13px">' + escAdmHtml(e.message) + '</div>'; }
}
async function cmEdit(id, username, curLimit) {
const overlay = document.createElement('div');
overlay.className = 'glass-alert-overlay show';
overlay.innerHTML = `<div class="glass-alert-card">
<div class="alert-icon">✏️</div>
<div class="alert-msg">编辑收银员:<b>${escAdmHtml(username)}</b></div>
<div class="alert-label">新密码(留空则不修改)</div>
<input class="alert-input" type="password" id="cmNewPw" placeholder="留空=不修改">
<div class="alert-label">折扣上限(0-100,当前 ${curLimit}%)</div>
<input class="alert-input" type="number" id="cmNewLimit" min="0" max="100" value="${curLimit}">
<div class="alert-btns">
<button class="ag-cancel" onclick="this.closest('.glass-alert-overlay').remove()">取消</button>
<button class="ag-ok" onclick="cmEditSave(${id}, this)">保存</button>
</div>
</div>`;
document.body.appendChild(overlay);
setTimeout(() => document.getElementById('cmNewPw').focus(), 50);
}
async function cmEditSave(id, btn) {
const pw = document.getElementById('cmNewPw').value;
const limit = parseInt(document.getElementById('cmNewLimit').value || '0', 10);
if (limit < 0 || limit > 100) { toast('折扣上限需在0-100', 'err'); return; }
const body = new URLSearchParams();
body.set('id', String(id));
if (pw) body.set('password', pw);
body.set('discount_limit_percent', String(limit));
btn.disabled = true;
try {
const d = await fetch('api/cashier.php?action=edit', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() }).then(r => r.json());
if (d.success) { toast('已更新', 'ok'); document.querySelector('.glass-alert-overlay.show')?.remove(); cmLoad(); }
else { toast(d.message || '更新失败', 'err'); btn.disabled = false; }
} catch (e) { toast(e.message, 'err'); btn.disabled = false; }
}
async function cmDelete(id) {
if (!confirm('确认删除该收银员?此操作不可恢复。')) return;
try {
const d = await fetch('api/cashier.php?action=delete', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'id=' + id }).then(r => r.json());
if (d.success) { toast('已删除', 'ok'); cmLoad(); }
else toast(d.message || '删除失败', 'err');
} catch (e) { toast(e.message, 'err'); }
}
</script>
<script src="cashier.js?<?php echo filemtime(__FILE__); ?>"></script>
</body>
+276
View File
@@ -0,0 +1,276 @@
<?php
/**
* 收银台 API(独立页面后端)
* 支持:收银员登录/登出/会话、玩家查询、订单列表、收银员管理(仅管理员)
*/
// ★ 清除所有缓冲 + 发送 JSON Content-Type
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');
header('Pragma: no-cache');
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('memory_limit', '256M');
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
register_shutdown_function(function() {
while (ob_get_level() > 0) {
@ob_end_flush();
}
});
if (!extension_loaded('sqlite3')) {
exit(json_encode(['success' => false, 'message' => 'Missing SQLite3'], JSON_UNESCAPED_UNICODE));
}
require_once __DIR__ . '/../core.php';
if (!function_exists('error') || !function_exists('success') || !function_exists('getParam')) {
exit(json_encode(['success' => false, 'message' => 'core.php load failed'], JSON_UNESCAPED_UNICODE));
}
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$action = getParam('action', 'status');
try {
switch ($action) {
case 'login': cashierDoLogin(); break;
case 'logout': cashierDoLogout(); break;
case 'session': cashierSession(); break;
case 'player_check': cashierPlayerCheck(); break;
case 'order_list': cashierOrderList(); break;
case 'cashier_list': cashierList(); break;
case 'add': cashierAdd(); break;
case 'edit': cashierEdit(); break;
case 'delete': cashierDelete(); break;
default:
exit(json_encode(['success' => false, 'message' => '未知操作: ' . $action], JSON_UNESCAPED_UNICODE));
}
} catch (Exception $e) {
@error_log('[cashier.php] ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => '服务器错误: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE));
}
// ===== 收银员登录 =====
function cashierDoLogin() {
$username = trim(getParam('username', ''));
$password = getParam('password', '');
if ($username === '' || $password === '') {
exit(json_encode(['success' => false, 'message' => '请输入账号和密码'], JSON_UNESCAPED_UNICODE));
}
if (cashierLogin($username, $password)) {
$c = getCurrentCashier();
exit(json_encode([
'success' => true,
'data' => [
'username' => $c['username'],
'discount_limit_percent' => $c['discount_limit_percent']
]
], JSON_UNESCAPED_UNICODE));
}
exit(json_encode(['success' => false, 'message' => '账号或密码错误'], JSON_UNESCAPED_UNICODE));
}
// ===== 收银员登出 =====
function cashierDoLogout() {
cashierLogout();
exit(json_encode(['success' => true, 'message' => '已退出登录'], JSON_UNESCAPED_UNICODE));
}
// ===== 当前会话信息 =====
function cashierSession() {
if (isAdminLoggedIn()) {
exit(json_encode([
'success' => true,
'data' => [
'role' => 'admin',
'username' => 'admin',
'discount_limit_percent' => 100
]
], JSON_UNESCAPED_UNICODE));
}
if (isCashierLoggedIn()) {
$c = getCurrentCashier();
exit(json_encode([
'success' => true,
'data' => [
'role' => 'cashier',
'username' => $c['username'],
'discount_limit_percent' => $c['discount_limit_percent']
]
], JSON_UNESCAPED_UNICODE));
}
exit(json_encode(['success' => false, 'message' => '未登录'], JSON_UNESCAPED_UNICODE));
}
// ===== 查询目标玩家余额/在线状态(收银员或管理员均可)=====
function cashierPlayerCheck() {
requireCashierOrAdminSession();
$player = trim(getParam('player'));
if (!$player) exit(json_encode(['success' => false, 'message' => '请输入玩家名'], JSON_UNESCAPED_UNICODE));
if (!preg_match('/^[a-zA-Z0-9_]{3,16}$/', $player)) {
exit(json_encode(['success' => false, 'message' => '玩家名格式不正确(3-16位英文/数字/下划线)'], JSON_UNESCAPED_UNICODE));
}
$db = getDB();
$db->exec("CREATE TABLE IF NOT EXISTS bond_cache (player_name TEXT PRIMARY KEY, amount INTEGER DEFAULT 0, updated_at INTEGER DEFAULT 0)");
try {
$db->exec("INSERT OR IGNORE INTO bond_cache (player_name, amount, updated_at) VALUES ('" . str_replace("'", "''", $player) . "', 0, " . time() . ")");
$stmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
$balance = $row ? (int)$row['amount'] : 0;
$online = false;
try {
$ostmt = $db->prepare("SELECT player_name FROM online_players WHERE player_name = :name");
$ostmt->bindValue(':name', $player, SQLITE3_TEXT);
$orow = $ostmt->execute()->fetchArray(SQLITE3_ASSOC);
$online = $orow ? true : false;
} catch (\Throwable $e) {}
exit(json_encode([
'success' => true,
'data' => ['player' => $player, 'exists' => true, 'balance' => $balance, 'online' => $online]
], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[cashier.php] Player check error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => '查询失败: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE));
}
}
// ===== 订单列表 =====
function cashierOrderList() {
$role = requireCashierOrAdminSession();
$db = getDB();
$limit = (int)getParam('limit', 100);
if ($limit <= 0 || $limit > 500) $limit = 100;
$sql = "SELECT * FROM cashier_orders";
$params = [];
// 收银员只能看自己的订单
if ($role === 'cashier') {
$c = getCurrentCashier();
$sql .= " WHERE operator_name = :op";
$params[':op'] = $c['username'];
}
$sql .= " ORDER BY created_at DESC LIMIT " . (int)$limit;
$stmt = $db->prepare($sql);
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v, SQLITE3_TEXT);
}
$result = $stmt->execute();
$rows = [];
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
if (!empty($r['items_detail'])) {
$r['items_detail'] = json_decode($r['items_detail'], true) ?: [];
}
$rows[] = $r;
}
exit(json_encode(['success' => true, 'data' => $rows], JSON_UNESCAPED_UNICODE));
}
// ===== 收银员列表(仅管理员)=====
function cashierList() {
requireAdminSession();
$db = getDB();
$result = $db->prepare("SELECT id, username, discount_limit_percent, created_at, created_by FROM cashiers ORDER BY id ASC")->execute();
$rows = [];
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
$rows[] = $r;
}
exit(json_encode(['success' => true, 'data' => $rows], JSON_UNESCAPED_UNICODE));
}
// ===== 新增收银员(仅管理员)=====
function cashierAdd() {
requireAdminSession();
$username = trim(getParam('username', ''));
$password = getParam('password', '');
$discountLimit = (int)getParam('discount_limit_percent', 0);
if ($username === '' || $password === '') {
exit(json_encode(['success' => false, 'message' => '账号和密码不能为空'], JSON_UNESCAPED_UNICODE));
}
if (!preg_match('/^[a-zA-Z0-9_]{3,20}$/', $username)) {
exit(json_encode(['success' => false, 'message' => '账号格式不正确(3-20位英文/数字/下划线)'], JSON_UNESCAPED_UNICODE));
}
if ($discountLimit < 0 || $discountLimit > 100) {
exit(json_encode(['success' => false, 'message' => '折扣上限需在0-100之间'], JSON_UNESCAPED_UNICODE));
}
$db = getDB();
// 查重
$chk = $db->prepare("SELECT id FROM cashiers WHERE username = :u");
$chk->bindValue(':u', $username, SQLITE3_TEXT);
if ($chk->execute()->fetchArray(SQLITE3_ASSOC)) {
exit(json_encode(['success' => false, 'message' => '账号已存在'], JSON_UNESCAPED_UNICODE));
}
$hash = password_hash($password, PASSWORD_DEFAULT);
$salt = bin2hex(random_bytes(8));
$stmt = $db->prepare("INSERT INTO cashiers (username, password_hash, salt, discount_limit_percent, created_at, created_by) VALUES (:u,:h,:s,:d,:t,'admin')");
$stmt->bindValue(':u', $username, SQLITE3_TEXT);
$stmt->bindValue(':h', $hash, SQLITE3_TEXT);
$stmt->bindValue(':s', $salt, SQLITE3_TEXT);
$stmt->bindValue(':d', $discountLimit, SQLITE3_INTEGER);
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
$stmt->execute();
exit(json_encode(['success' => true, 'message' => '收银员创建成功'], JSON_UNESCAPED_UNICODE));
}
// ===== 编辑收银员(仅管理员):可改密码 / 折扣上限 =====
function cashierEdit() {
requireAdminSession();
$id = (int)getParam('id', 0);
if ($id <= 0) exit(json_encode(['success' => false, 'message' => '缺少id'], JSON_UNESCAPED_UNICODE));
$db = getDB();
$chk = $db->prepare("SELECT id FROM cashiers WHERE id = :id");
$chk->bindValue(':id', $id, SQLITE3_INTEGER);
if (!$chk->execute()->fetchArray(SQLITE3_ASSOC)) {
exit(json_encode(['success' => false, 'message' => '收银员不存在'], JSON_UNESCAPED_UNICODE));
}
$sets = [];
$params = [':id' => $id];
$newPassword = getParam('password', '');
if ($newPassword !== '') {
$sets[] = "password_hash = :h";
$params[':h'] = password_hash($newPassword, PASSWORD_DEFAULT);
$sets[] = "salt = :s";
$params[':s'] = bin2hex(random_bytes(8));
}
if (getParam('discount_limit_percent') !== null && getParam('discount_limit_percent') !== '') {
$dl = (int)getParam('discount_limit_percent', 0);
if ($dl < 0 || $dl > 100) {
exit(json_encode(['success' => false, 'message' => '折扣上限需在0-100之间'], JSON_UNESCAPED_UNICODE));
}
$sets[] = "discount_limit_percent = :d";
$params[':d'] = $dl;
}
if (empty($sets)) {
exit(json_encode(['success' => false, 'message' => '没有可更新的字段'], JSON_UNESCAPED_UNICODE));
}
$sql = "UPDATE cashiers SET " . implode(', ', $sets) . " WHERE id = :id";
$stmt = $db->prepare($sql);
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v, is_int($v) ? SQLITE3_INTEGER : SQLITE3_TEXT);
}
$stmt->execute();
exit(json_encode(['success' => true, 'message' => '收银员信息已更新'], JSON_UNESCAPED_UNICODE));
}
// ===== 删除收银员(仅管理员)=====
function cashierDelete() {
requireAdminSession();
$id = (int)getParam('id', 0);
if ($id <= 0) exit(json_encode(['success' => false, 'message' => '缺少id'], JSON_UNESCAPED_UNICODE));
$db = getDB();
$stmt = $db->prepare("DELETE FROM cashiers WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
exit(json_encode(['success' => true, 'message' => '收银员已删除'], JSON_UNESCAPED_UNICODE));
}
+79 -16
View File
@@ -298,13 +298,19 @@ function shopBuyCart($token) {
// ★ 双保险验证(与单件购买一致)
$isPreview = true;
$isAdminToken = false;
$isOnline = false;
$isRegistered = false;
$isCashier = false;
$isAdminSession = false;
$operatorDiscountLimit = 0;
$cashierRow = null;
if ($token) {
$tokenInfo = validateToken($token);
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
$isPreview = false;
$isAdminToken = true; // ★ 记录代购令牌身份,避免下方大额复检重复校验已消费的token导致误判
if (validateToken($token)) validateAndUseToken($token);
} else {
$accessResult = validateWebAccess($token, 'buy', $password, $ipAddress);
@@ -323,6 +329,17 @@ function shopBuyCart($token) {
error($accessResult['message'], 401);
}
}
} else {
// 收银员/管理员会话代购通路(独立收银台,无需token)
if (isCashierLoggedIn()) {
$isPreview = false;
$isCashier = true;
$cashierRow = getCurrentCashier();
$operatorDiscountLimit = (int)($cashierRow['discount_limit_percent'] ?? 0);
} elseif (isAdminLoggedIn()) {
$isPreview = false;
$isAdminSession = true;
}
}
// 结算模式对应的折扣/加价系数
@@ -369,32 +386,56 @@ function shopBuyCart($token) {
$totalPrice = (int)round($subtotal * $rate) + $colorFee;
$saved = $subtotal - $totalPrice; // 折扣省下的(潜影盒加价+颜色费时为负)
// ★ 安全检查:服务端计算的总额≥1000时,必须已通过密码验证(防止前端绕过)
// 管理员token(admin/all)在上方已跳过密码验证,此处补检
if ($totalPrice >= 1000 && !$isPreview) {
$tokenInfo = validateToken($token);
$isAdminToken = $tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all');
if ($isAdminToken && (!$password || trim($password) === '')) {
// 管理员代购大额也需密码确认
jsonResponse(['success' => false, 'need_password' => true,
'player' => $player, 'message' => '金额≥1000债券需确认密码'], 401);
// ===== 收银员/管理员手动折扣(服务端强制上限,前端声称不可信)=====
$discountPercent = 0;
$discountAmount = 0;
$finalPrice = $totalPrice;
$canDiscount = ($isCashier || $isAdminToken || $isAdminSession);
if ($canDiscount) {
$reqDiscount = (float)getParam('discount_percent', 0);
if ($reqDiscount < 0) $reqDiscount = 0;
if ($reqDiscount > 100) $reqDiscount = 100;
// 收银员受自身折扣上限限制;管理员无限制(最大100)
$limit = $isCashier ? $operatorDiscountLimit : 100;
if ($reqDiscount > $limit) {
error('折扣超过权限上限(收银员最大可打' . $limit . '% off)', 403);
}
if (!$isAdminToken && !isset($accessResult['ok'])) {
$discountPercent = $reqDiscount;
if ($discountPercent > 0) {
$discountAmount = (int)round($totalPrice * $discountPercent / 100);
$finalPrice = $totalPrice - $discountAmount;
}
}
// ★ 安全检查:服务端计算的实际扣款额≥1000时,必须已通过密码验证(防止前端绕过)
// 管理员代购令牌(admin/all)已在上方标记$isAdminToken并消费,此处不再重新校验token,
// 而是校验代购操作者(管理员)登录密码;普通玩家通路走validateWebAccess已在上方完成验证;
// 收银员通路已通过收银员会话登录,视为已授权,无需额外密码
if ($finalPrice >= 1000 && !$isPreview) {
if ($isCashier) {
// 收银员已登录即授权,无需额外密码
} elseif ($isAdminToken || $isAdminSession) {
// 代购通路:校验管理员登录密码(代购操作者的密码),而非目标玩家游戏密码
if (!$password || trim($password) === '' || $password !== ADMIN_PASS) {
jsonResponse(['success' => false, 'need_password' => true,
'player' => $player, 'message' => '金额≥1000债券需确认管理员密码'], 401);
}
} elseif (!isset($accessResult['ok'])) {
// 非管理员且未通过密码验证 → 拒绝
error('大额交易需验证游戏登录密码', 401);
}
}
// 检查余额
if (!$isPreview && $totalPrice > 0) {
if (!$isPreview && $finalPrice > 0) {
$balanceStmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :player");
$balanceStmt->bindValue(':player', $player, SQLITE3_TEXT);
$balanceResult = $balanceStmt->execute();
$balanceRow = $balanceResult->fetchArray(SQLITE3_ASSOC);
if (!$balanceRow) error('玩家余额查询失败');
$balance = (int)$balanceRow['amount'];
if ($balance < $totalPrice) {
error('余额不足,当前余额: ' . $balance . ' 债券,需要: ' . $totalPrice . ' 债券');
if ($balance < $finalPrice) {
error('余额不足,当前余额: ' . $balance . ' 债券,需要: ' . $finalPrice . ' 债券');
}
}
@@ -435,13 +476,15 @@ function shopBuyCart($token) {
$reasonItems = array_map(function ($l) { return $l['name'] . ' x' . $l['amount']; }, $lines);
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'shop_cart', :amount, :reason, :detail, 'pending', :time)");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':amount', $totalPrice, SQLITE3_INTEGER);
$stmt->bindValue(':amount', $finalPrice, SQLITE3_INTEGER);
$stmt->bindValue(':reason', "购物车结算({$modeName}" . ($colorName ? "-{$colorName}潜影盒" : '') . "): " . implode('、', $reasonItems), SQLITE3_TEXT);
$detailData = [
'settlement' => $settlement,
'rate' => $rate,
'subtotal' => $subtotal,
'total_price' => $totalPrice,
'total_price' => $finalPrice,
'discount_percent' => (int)$discountPercent,
'discount_amount' => (int)$discountAmount,
'items' => $lines
];
if ($settlement === 'shulker') {
@@ -453,6 +496,23 @@ function shopBuyCart($token) {
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
// 记录收银台订单(代购/收银员操作,写入同一事务,复用已开启的$db连接避免触发getDB安全网ROLLBACK)
if ($isCashier || $isAdminToken || $isAdminSession) {
$operatorType = $isCashier ? 'cashier' : 'admin';
$operatorName = $isCashier ? ($cashierRow['username'] ?? '') : 'admin';
recordCashierOrder([
'operator_type' => $operatorType,
'operator_name' => $operatorName,
'player_name' => $player,
'items_detail' => $lines,
'subtotal' => $subtotal,
'total_price' => $finalPrice,
'discount_percent' => (int)$discountPercent,
'discount_amount' => (int)$discountAmount,
'settlement' => $settlement
], $db);
}
// 写入sync_requests
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
$stmt2 = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
@@ -472,7 +532,10 @@ function shopBuyCart($token) {
'rate' => $rate,
'items' => $lines,
'subtotal' => $subtotal,
'total_price' => $totalPrice,
'total_price' => $finalPrice,
'original_price' => $totalPrice,
'discount_percent' => (int)$discountPercent,
'discount_amount' => (int)$discountAmount,
'saved' => $saved,
'player' => $player
], '购物车结算成功');
+577
View File
@@ -0,0 +1,577 @@
<?php
/**
* 独立收银台页面
* 支持管理员登录或收银员登录;具备商品选择、购物车、手动折扣(服务端强制上限)、订单历史。
*/
require_once __DIR__ . '/core.php';
if (session_status() === PHP_SESSION_NONE) session_start();
$loggedIn = isAdminLoggedIn() || isCashierLoggedIn();
$initialRole = 'guest';
$initialName = '';
$initialLimit = 0;
if (isAdminLoggedIn()) {
$initialRole = 'admin';
$initialName = 'admin';
$initialLimit = 100;
} elseif (isCashierLoggedIn()) {
$c = getCurrentCashier();
$initialRole = 'cashier';
$initialName = $c['username'];
$initialLimit = $c['discount_limit_percent'];
}
$initialState = json_encode([
'logged_in' => $loggedIn,
'role' => $initialRole,
'username' => $initialName,
'discount_limit' => $initialLimit
], JSON_UNESCAPED_UNICODE);
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http="Expires" content="0">
<title>SDF1 - 收银台</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; transition:background .25s ease; }
.header { background:linear-gradient(135deg,#1a1e2e,#0d1117); border-bottom:1px solid var(--border); padding:14px 24px; display:flex; justify-content:space-between; align-items:center; position:sticky; top:0; z-index:50; }
.header h1 { font-size:18px; color:var(--accent); letter-spacing:.5px; }
.header .who { font-size:13px; color:var(--dim); }
.header .who b { color:var(--text); }
.btn { padding:8px 16px; border:none; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600; transition:all .2s cubic-bezier(.16,1,.3,1); }
.btn-blue { background:var(--accent); color:#fff; }
.btn-green { background:var(--green); color:#fff; }
.btn-red { background:var(--red); color:#fff; }
.btn-yellow { background:var(--yellow); color:#000; }
.btn-ghost { background:transparent; border:1px solid var(--border); color:var(--dim); }
.btn:hover { opacity:.88; transform:translateY(-1px); box-shadow:0 4px 14px rgba(0,0,0,.35); }
.btn:active { transform:translateY(0); }
.btn:disabled { opacity:.4; cursor:not-allowed; transform:none; box-shadow:none; }
/* 登录页 */
.login-wrap { min-height:100vh; display:flex; align-items:center; justify-content:center; padding:20px;
background:radial-gradient(1200px 600px at 50% -10%, rgba(88,166,255,.12), transparent); }
.login-card { background:var(--card); border:1px solid var(--border); border-radius:16px; padding:36px 32px; width:360px; max-width:100%; box-shadow:0 20px 60px rgba(0,0,0,.5); }
.login-card h2 { text-align:center; margin-bottom:6px; color:var(--accent); font-size:22px; }
.login-card .sub { text-align:center; color:var(--dim); font-size:12px; margin-bottom:24px; }
.login-card label { display:block; font-size:12px; color:var(--dim); margin:14px 0 6px; }
.login-card input { width:100%; padding:11px 12px; background:var(--bg); border:1px solid var(--border); border-radius:8px; color:var(--text); font-size:14px; outline:none; transition:border .2s; }
.login-card input:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(88,166,255,.15); }
.login-card .btn { width:100%; margin-top:22px; padding:12px; font-size:15px; }
/* 主界面 */
.app { display:grid; grid-template-columns: 380px 1fr; gap:16px; padding:16px; align-items:start; }
@media(max-width:900px){ .app { grid-template-columns:1fr; } }
.panel { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:16px; }
.panel h3 { font-size:14px; color:var(--accent); margin-bottom:12px; display:flex; align-items:center; gap:8px; }
.field { margin-bottom:12px; }
.field label { font-size:12px; color:var(--dim); display:block; margin-bottom:5px; }
.field input, .field select { width:100%; padding:9px 11px; background:var(--bg); border:1px solid var(--border); border-radius:7px; color:var(--text); font-size:13px; outline:none; }
.field input:focus, .field select:focus { border-color:var(--accent); }
.player-bar { display:flex; gap:8px; }
.player-bar input { flex:1; }
.balances { display:flex; gap:10px; margin:12px 0; }
.balances .b { flex:1; background:var(--bg); border:1px solid var(--border); border-radius:8px; padding:10px; text-align:center; }
.balances .b .v { font-size:20px; font-weight:700; color:var(--green); }
.balances .b .l { font-size:11px; color:var(--dim); margin-top:3px; }
.online-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:5px; }
.online-dot.on { background:var(--green); box-shadow:0 0 8px var(--green); }
.online-dot.off { background:var(--dim); }
.cart-item { display:flex; align-items:center; gap:10px; padding:10px; background:var(--bg); border:1px solid var(--border); border-radius:9px; margin-bottom:8px; transition:all .2s; }
.cart-item:hover { border-color:var(--accent); }
.cart-item .ci-name { flex:1; font-size:13px; }
.cart-item .ci-name small { color:var(--dim); display:block; font-size:11px; }
.qty { display:flex; align-items:center; gap:6px; }
.qty button { width:26px; height:26px; border-radius:6px; border:1px solid var(--border); background:var(--card); color:var(--text); cursor:pointer; font-size:15px; line-height:1; }
.qty input { width:46px; text-align:center; padding:5px; background:var(--bg); border:1px solid var(--border); border-radius:6px; color:var(--text); font-size:13px; }
.ci-del { color:var(--red); cursor:pointer; font-size:16px; padding:4px; }
.settlement-row { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px; }
.seg { padding:8px 14px; border:1px solid var(--border); border-radius:8px; cursor:pointer; font-size:12px; color:var(--dim); background:var(--bg); transition:all .2s; }
.seg.active { background:var(--accent); color:#fff; border-color:var(--accent); }
.summary-line { display:flex; justify-content:space-between; font-size:13px; padding:6px 0; border-bottom:1px dashed var(--border); }
.summary-line.total { font-size:17px; font-weight:700; border-bottom:none; margin-top:8px; }
.summary-line.total .v { color:var(--green); }
.summary-line .disc { color:var(--yellow); }
.catalog-head { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
.cat-tabs { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:12px; }
.prod-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:10px; max-height:62vh; overflow-y:auto; padding-right:4px; }
.prod { background:var(--bg); border:1px solid var(--border); border-radius:10px; padding:12px; cursor:pointer; transition:all .2s cubic-bezier(.16,1,.3,1); position:relative; overflow:hidden; }
.prod:hover { transform:translateY(-3px); border-color:var(--accent); box-shadow:0 8px 24px rgba(0,0,0,.4); }
.prod .pname { font-size:13px; font-weight:600; margin-bottom:4px; }
.prod .pcat { font-size:10px; color:var(--dim); margin-bottom:8px; }
.prod .pprice { font-size:15px; color:var(--accent); font-weight:700; }
.prod .pstock { font-size:10px; color:var(--dim); position:absolute; top:10px; right:10px; }
.prod.soldout { opacity:.45; cursor:not-allowed; }
.prod.soldout:hover { transform:none; border-color:var(--border); box-shadow:none; }
.toast { position:fixed; top:16px; right:16px; padding:11px 18px; border-radius:8px; font-size:13px; z-index:200; animation:sIn .3s; box-shadow:0 8px 24px rgba(0,0,0,.4); }
.toast.ok { background:var(--green); color:#fff; }
.toast.err { background:var(--red); color:#fff; }
@keyframes sIn { from{transform:translateX(110%);opacity:0} to{transform:translateX(0);opacity:1} }
.orders { margin-top:14px; }
.order-row { font-size:12px; padding:10px; background:var(--bg); border:1px solid var(--border); border-radius:8px; margin-bottom:7px; }
.order-row .ot { display:flex; justify-content:space-between; }
.order-row .ono { color:var(--accent); font-weight:600; }
.order-row .od { color:var(--dim); margin-top:4px; }
.order-row .od .disc { color:var(--yellow); }
.theme-picker { display:flex; gap:8px; align-items:center; margin-top:8px; }
.color-btn { width:28px; height:28px; border-radius:4px; cursor:pointer; border:2px solid transparent; transition:all .2s; }
.color-btn:hover { border-color:var(--accent); transform:scale(1.12); }
.glass-alert-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.45);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);z-index:5000;justify-content:center;align-items:center;animation:glassFadeIn .25s ease}
.glass-alert-overlay.show{display:flex}
.glass-alert-card{background:rgba(22,27,34,0.94);backdrop-filter:blur(24px);-webkit-backdrop-filter:blur(24px);border:1px solid rgba(88,166,255,0.25);border-radius:16px;padding:26px;width:340px;max-width:88%;box-shadow:0 12px 48px rgba(0,0,0,0.5),inset 0 1px 0 rgba(255,255,255,0.05);animation:glassSlideUp .3s ease;text-align:center}
.glass-alert-card .alert-icon{font-size:34px;margin-bottom:10px}
.glass-alert-card .alert-msg{font-size:14px;color:#e6edf3;line-height:1.6;margin-bottom:18px;word-break:break-word}
.glass-alert-card .alert-input{width:100%;padding:10px 12px;background:rgba(13,17,23,0.8);border:1px solid rgba(88,166,255,0.3);border-radius:8px;color:#e6edf3;font-size:13px;outline:none;box-sizing:border-box;text-align:center;margin-bottom:16px}
.glass-alert-card .alert-btns{display:flex;gap:10px;justify-content:center}
.glass-alert-card .alert-btns button{padding:8px 24px;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;transition:all .2s}
.glass-alert-card .alert-btns .ag-ok{background:#58a6ff;color:#fff}
.glass-alert-card .alert-btns .ag-cancel{background:rgba(255,255,255,0.08);color:#8b949e}
@keyframes glassFadeIn{from{opacity:0}to{opacity:1}}
@keyframes glassSlideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
.hidden { display:none !important; }
</style>
</head>
<body>
<?php if (!$loggedIn): ?>
<!-- 登录页 -->
<div class="login-wrap" id="loginWrap">
<div class="login-card">
<h2>🧾 SDF1 收银台</h2>
<div class="sub">管理员或收银员登录</div>
<form id="loginForm" onsubmit="return doLogin(event)">
<label>账号</label>
<input type="text" id="loginUser" autocomplete="username" placeholder="收银员账号 / admin" required>
<label>密码</label>
<input type="password" id="loginPass" autocomplete="current-password" placeholder="登录密码" required>
<button type="submit" class="btn btn-blue" id="loginBtn">登 录</button>
</form>
</div>
</div>
<?php else: ?>
<!-- 主界面 -->
<div class="header">
<div style="display:flex;align-items:center;gap:12px">
<h1>🧾 SDF1 收银台</h1>
<span class="who">操作员:<b id="whoName"></b> <span id="whoRole" class="tag tag-unused"></span></span>
</div>
<div style="display:flex;align-items:center;gap:10px">
<button class="btn btn-yellow" onclick="showThemePicker()">🎨 主题</button>
<button class="btn btn-red" onclick="doLogout()">退出</button>
</div>
</div>
<div class="app">
<!-- 左:玩家 + 购物车 -->
<div>
<div class="panel">
<h3>👤 目标玩家</h3>
<div class="player-bar">
<input type="text" id="playerName" placeholder="输入玩家名" onkeydown="if(event.key==='Enter')checkPlayer()">
<button class="btn btn-blue" onclick="checkPlayer()">查询</button>
</div>
<div class="balances">
<div class="b"><div class="v" id="balVal">-</div><div class="l">债券余额</div></div>
<div class="b"><div class="v" style="font-size:13px;padding-top:5px"><span class="online-dot off" id="onlineDot"></span><span id="onlineTxt">未知</span></div><div class="l">在线状态</div></div>
</div>
</div>
<div class="panel" style="margin-top:16px">
<h3>🛒 购物车</h3>
<div id="cartList"><div style="color:var(--dim);font-size:12px;text-align:center;padding:18px">点击右侧商品加入购物车</div></div>
<div style="margin-top:12px">
<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:6px">结算方式</label>
<div class="settlement-row">
<div class="seg active" data-set="backpack" onclick="setSettlement('backpack')">🎒 塞背包 (98折)</div>
<div class="seg" data-set="shulker" onclick="setSettlement('shulker')">📦 潜影盒 (+2元)</div>
</div>
<div id="shulkerColors" class="hidden" style="margin-bottom:10px">
<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:6px">潜影盒颜色(紫色免费,其它+2元)</label>
<select id="shulkerColor" onchange="recalc()">
<option value="purple">紫色(免费)</option>
<option value="white">白色</option>
<option value="black">黑色</option>
<option value="red">红色</option>
<option value="blue">蓝色</option>
<option value="green">绿色</option>
<option value="yellow">黄色</option>
<option value="orange">橙色</option>
</select>
</div>
<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:6px">手动折扣(%)— 上限 <b id="discLimit">0</b>%</label>
<div style="display:flex;gap:8px;align-items:center">
<input type="number" id="discount" min="0" value="0" oninput="recalc()" style="flex:1">
<span style="font-size:12px;color:var(--dim)">off</span>
</div>
<div id="summary" style="margin-top:14px"></div>
<button class="btn btn-green" id="payBtn" style="width:100%;margin-top:14px;padding:12px;font-size:15px" onclick="confirmPay()" disabled>确认收款</button>
<div id="adminPwWrap" class="field hidden" style="margin-top:12px">
<label>管理员密码(≥1000债券需确认)</label>
<input type="password" id="adminPw" placeholder="输入管理员密码">
</div>
</div>
</div>
</div>
<!-- 右:商品目录 -->
<div class="panel">
<h3>🛍️ 商品目录</h3>
<div class="catalog-head">
<input type="text" id="prodSearch" placeholder="搜索商品名…" oninput="renderCatalog()" style="flex:1">
</div>
<div class="cat-tabs" id="catTabs"></div>
<div class="prod-grid" id="prodGrid"><div style="color:var(--dim);font-size:12px;padding:18px">加载中…</div></div>
<div class="orders">
<h3 style="margin-top:6px">📜 订单记录 <button class="btn btn-ghost" style="float:right;padding:4px 10px;font-size:11px" onclick="loadOrders()">刷新</button></h3>
<div id="orderList"><div style="color:var(--dim);font-size:12px;padding:10px">暂无订单</div></div>
</div>
</div>
</div>
<?php endif; ?>
<div id="toastHost"></div>
<script>
const STATE = <?php echo $initialState; ?>;
let products = [];
let categories = [];
let cart = {}; // id -> qty
let settlement = 'backpack';
let currentPlayer = null;
let playerBalance = 0;
// ===== 工具 =====
function toast(msg, type) {
const t = document.createElement('div');
t.className = 'toast ' + (type || 'ok');
t.textContent = msg;
document.getElementById('toastHost').appendChild(t);
setTimeout(() => t.remove(), 2600);
}
async function api(url, opts) {
const r = await fetch(url, Object.assign({headers:{'X-Requested-With':'cashier'}}, opts));
let data;
try { data = await r.json(); } catch(e) { throw new Error('服务器响应异常'); }
return data;
}
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
// ===== 主题 =====
function showThemePicker() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);display:flex;justify-content:center;align-items:center;z-index:2000';
overlay.innerHTML = `<div style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px;width:460px;max-width:90%">
<h3 style="margin-bottom:12px">🎨 选择背景颜色</h3>
<div class="theme-picker">
<div class="color-btn" style="background:#0d1117" onclick="setTheme('#0d1117')"></div>
<div class="color-btn" style="background:#1a1e2e" onclick="setTheme('#1a1e2e')"></div>
<div class="color-btn" style="background:#0f4c75" onclick="setTheme('#0f4c75')"></div>
<div class="color-btn" style="background:#1b2631" onclick="setTheme('#1b2631')"></div>
<div class="color-btn" style="background:#2c3e50" onclick="setTheme('#2c3e50')"></div>
<div class="color-btn" style="background:#23272a" onclick="setTheme('#23272a')"></div>
</div>
<div style="margin-top:16px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" id="customColor" placeholder="#1a237e" class="color-input" style="flex:1;min-width:120px;padding:7px 10px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:13px">
<button class="btn btn-blue" onclick="applyCustomColor()">应用</button>
</div>
<div style="text-align:right;margin-top:14px"><button class="btn btn-ghost" onclick="this.closest('div').parentElement.remove()">关闭</button></div>
</div>`;
document.body.appendChild(overlay);
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
}
function setTheme(color) {
document.documentElement.style.setProperty('--bg', color);
document.body.style.background = color;
localStorage.setItem('sdf1_cashier_theme', color);
toast('主题已应用', 'ok');
}
function applyCustomColor() {
const v = document.getElementById('customColor').value.trim();
if (/^#?[0-9A-Fa-f]{6}$/.test(v)) { setTheme(v[0]==='#'?v:'#'+v); document.querySelector('.theme-picker')?.closest('div').parentElement.remove(); }
else toast('请输入有效的十六进制颜色', 'err');
}
// ===== 登录/登出 =====
async function doLogin(e) {
e.preventDefault();
const btn = document.getElementById('loginBtn');
btn.disabled = true;
try {
const data = await api('api/cashier.php?action=login', {
method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:'username=' + encodeURIComponent(document.getElementById('loginUser').value) + '&password=' + encodeURIComponent(document.getElementById('loginPass').value)
});
if (data.success) { location.reload(); }
else { toast(data.message || '登录失败', 'err'); btn.disabled = false; }
} catch(err) { toast(err.message, 'err'); btn.disabled = false; }
return false;
}
async function doLogout() {
await api('api/cashier.php?action=logout');
location.reload();
}
// ===== 初始化(已登录)=====
async function initApp() {
document.getElementById('whoName').textContent = STATE.username;
const roleEl = document.getElementById('whoRole');
roleEl.textContent = STATE.role === 'admin' ? '管理员' : '收银员';
roleEl.className = 'tag ' + (STATE.role === 'admin' ? 'tag-unused' : 'tag-used');
document.getElementById('discLimit').textContent = STATE.discount_limit;
// 管理员需密码确认大额
if (STATE.role === 'admin') document.getElementById('adminPwWrap').classList.remove('hidden');
await Promise.all([loadProducts(), loadOrders()]);
}
async function loadProducts() {
try {
const d = await api('api/shop.php?action=list');
products = (d.data && Array.isArray(d.data)) ? d.data : [];
const dc = await api('api/shop.php?action=categories');
categories = (dc.data && Array.isArray(dc.data)) ? dc.data : [];
buildCatTabs();
renderCatalog();
} catch(e) { toast('商品加载失败: ' + e.message, 'err'); }
}
function buildCatTabs() {
const wrap = document.getElementById('catTabs');
let html = '<div class="seg active" data-cat="" onclick="setCat(\'\')">全部</div>';
categories.forEach(c => {
html += '<div class="seg" data-cat="'+esc(c.category)+'" onclick="setCat(\''+esc(c.category)+'\')">'+esc(c.category)+'</div>';
});
wrap.innerHTML = html;
}
let curCat = '';
function setCat(cat) {
curCat = cat;
document.querySelectorAll('#catTabs .seg').forEach(s => s.classList.toggle('active', s.dataset.cat === cat));
renderCatalog();
}
function renderCatalog() {
const q = (document.getElementById('prodSearch').value || '').toLowerCase();
const grid = document.getElementById('prodGrid');
const list = products.filter(p => {
if (curCat && p.category !== curCat) return false;
if (q && !(p.display_name||'').toLowerCase().includes(q)) return false;
return true;
});
if (!list.length) { grid.innerHTML = '<div style="color:var(--dim);font-size:12px;padding:18px">无匹配商品</div>'; return; }
grid.innerHTML = list.map(p => {
const sold = (p.stock === 0);
return `<div class="prod ${sold?'soldout':''}" ${sold?'':`onclick="addToCart('${esc(p.id)}')"`}>
<div class="pstock">${sold?'售罄':('库存'+esc(p.stock))}</div>
<div class="pname">${esc(p.display_name)}</div>
<div class="pcat">${esc(p.category||'')}</div>
<div class="pprice">${esc(p.buy_price)} 债券</div>
</div>`;
}).join('');
}
function addToCart(id) {
cart[id] = (cart[id] || 0) + 1;
renderCart(); recalc();
}
function changeQty(id, d) {
cart[id] = (cart[id] || 0) + d;
if (cart[id] <= 0) delete cart[id];
renderCart(); recalc();
}
function removeFromCart(id) { delete cart[id]; renderCart(); recalc(); }
function renderCart() {
const el = document.getElementById('cartList');
const ids = Object.keys(cart);
if (!ids.length) { el.innerHTML = '<div style="color:var(--dim);font-size:12px;text-align:center;padding:18px">点击右侧商品加入购物车</div>'; return; }
el.innerHTML = ids.map(id => {
const p = products.find(x => String(x.id) === String(id));
if (!p) return '';
return `<div class="cart-item">
<div class="ci-name">${esc(p.display_name)}<small>${esc(p.buy_price)} 债券/个</small></div>
<div class="qty">
<button onclick="changeQty('${esc(id)}',-1)">−</button>
<input type="number" min="1" value="${cart[id]}" onchange="cart['${esc(id)}']=Math.max(1,parseInt(this.value)||1);renderCart();recalc()">
<button onclick="changeQty('${esc(id)}',1)">+</button>
</div>
<span class="ci-del" onclick="removeFromCart('${esc(id)}')">✕</span>
</div>`;
}).join('');
}
function setSettlement(s) {
settlement = s;
document.querySelectorAll('.settlement-row .seg').forEach(x => x.classList.toggle('active', x.dataset.set === s));
document.getElementById('shulkerColors').classList.toggle('hidden', s !== 'shulker');
recalc();
}
// ===== 计算 + 预览 =====
const RATES = { backpack: 0.98, shulker: 1.00 };
async function recalc() {
const ids = Object.keys(cart);
const payBtn = document.getElementById('payBtn');
if (!ids.length || !currentPlayer) {
document.getElementById('summary').innerHTML = '';
payBtn.disabled = true;
return;
}
let subtotal = 0;
ids.forEach(id => {
const p = products.find(x => String(x.id) === String(id));
if (p) subtotal += (parseInt(p.buy_price)||0) * cart[id];
});
const rate = RATES[settlement];
let total = Math.round(subtotal * rate);
let colorFee = 0;
if (settlement === 'shulker' && document.getElementById('shulkerColor').value !== 'purple') colorFee = 2;
total += colorFee;
let discount = parseFloat(document.getElementById('discount').value) || 0;
const limit = STATE.discount_limit;
if (discount > limit) { discount = limit; document.getElementById('discount').value = limit; }
if (discount < 0) discount = 0;
const discAmt = Math.round(total * discount / 100);
const final = total - discAmt;
document.getElementById('summary').innerHTML = `
<div class="summary-line"><span>商品小计</span><span>${subtotal} 债券</span></div>
<div class="summary-line"><span>${settlement==='shulker'?'潜影盒费率':'背包费率'} (${rate*100}%)</span><span>${colorFee?('+'+colorFee):''}</span></div>
<div class="summary-line"><span>应收原价</span><span>${total} 债券</span></div>
<div class="summary-line"><span class="disc">手动折扣 ${discount}%</span><span class="disc">-${discAmt} 债券</span></div>
<div class="summary-line total"><span>实收</span><span class="v">${final} 债券</span></div>`;
payBtn.disabled = false;
}
// ===== 玩家查询 =====
async function checkPlayer() {
const name = document.getElementById('playerName').value.trim();
if (!name) { toast('请输入玩家名', 'err'); return; }
try {
const d = await api('api/cashier.php?action=player_check&player=' + encodeURIComponent(name));
if (!d.success) { toast(d.message, 'err'); return; }
currentPlayer = name;
playerBalance = d.data.balance;
document.getElementById('balVal').textContent = playerBalance;
const dot = document.getElementById('onlineDot');
const txt = document.getElementById('onlineTxt');
if (d.data.online) { dot.className = 'online-dot on'; txt.textContent = '在线'; }
else { dot.className = 'online-dot off'; txt.textContent = '离线'; }
recalc();
} catch(e) { toast('查询失败: ' + e.message, 'err'); }
}
// ===== 确认收款 =====
async function confirmPay() {
const ids = Object.keys(cart);
if (!ids.length) { toast('购物车为空', 'err'); return; }
if (!currentPlayer) { toast('请先查询目标玩家', 'err'); return; }
const discount = parseFloat(document.getElementById('discount').value) || 0;
if (discount > STATE.discount_limit) { toast('折扣超过权限上限('+STATE.discount_limit+'%)', 'err'); return; }
const items = ids.map(id => ({ item_id: id, amount: cart[id] }));
const body = new URLSearchParams();
body.set('items', JSON.stringify(items));
body.set('settlement', settlement);
body.set('shulker_color', document.getElementById('shulkerColor').value);
body.set('player', currentPlayer);
body.set('discount_percent', String(discount));
if (STATE.role === 'admin') body.set('password', document.getElementById('adminPw').value || '');
const btn = document.getElementById('payBtn');
btn.disabled = true;
try {
const d = await api('api/shop.php?action=buy_cart', { method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: body.toString() });
if (d.need_password) {
// 管理员大额需密码:弹窗输入后重试
promptAdminPassword().then(pw => {
if (pw === null) { btn.disabled = false; return; }
body.set('password', pw);
api('api/shop.php?action=buy_cart', { method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: body.toString() })
.then(d2 => finishPay(d2)).catch(e => { toast(e.message,'err'); btn.disabled=false; });
});
return;
}
finishPay(d);
} catch(e) { toast('收款失败: ' + e.message, 'err'); btn.disabled = false; }
}
function finishPay(d) {
const btn = document.getElementById('payBtn');
if (d.success) {
toast('收款成功,实收 ' + (d.data.total_price) + ' 债券', 'ok');
cart = {}; renderCart();
document.getElementById('summary').innerHTML = '';
document.getElementById('discount').value = 0;
btn.disabled = true;
checkPlayer(); loadOrders();
} else {
toast(d.message || '收款失败', 'err');
btn.disabled = false;
}
}
function promptAdminPassword() {
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'glass-alert-overlay show';
overlay.innerHTML = `<div class="glass-alert-card">
<div class="alert-icon">🔐</div>
<div class="alert-msg">金额 ≥ 1000 债券,需确认管理员密码</div>
<input class="alert-input" type="password" id="apw" placeholder="管理员密码" autofocus>
<div class="alert-btns">
<button class="ag-cancel" onclick="this.closest('.glass-alert-overlay').remove();window.__apwResolve(null)">取消</button>
<button class="ag-ok" onclick="const v=document.getElementById('apw').value;this.closest('.glass-alert-overlay').remove();window.__apwResolve(v)">确认</button>
</div>
</div>`;
document.body.appendChild(overlay);
window.__apwResolve = resolve;
document.getElementById('apw').addEventListener('keydown', e => { if (e.key==='Enter'){ const v=e.target.value; overlay.remove(); resolve(v);} });
setTimeout(()=>document.getElementById('apw').focus(), 50);
});
}
// ===== 订单记录 =====
async function loadOrders() {
try {
const d = await api('api/cashier.php?action=order_list&limit=50');
const el = document.getElementById('orderList');
if (!d.success || !d.data || !d.data.length) { el.innerHTML = '<div style="color:var(--dim);font-size:12px;padding:10px">暂无订单</div>'; return; }
el.innerHTML = d.data.map(o => {
const items = (o.items_detail && Array.isArray(o.items_detail)) ? o.items_detail.map(i=>esc(i.name)+'x'+i.amount).join('、') : '';
const disc = (parseInt(o.discount_percent)>0) ? ` <span class="disc">(${o.discount_percent}% off -${o.discount_amount})</span>` : '';
return `<div class="order-row">
<div class="ot"><span class="ono">${esc(o.order_no)}</span><span>${o.operator_type==='admin'?'管理员':'收银员'}:${esc(o.operator_name)}</span></div>
<div class="od">玩家 ${esc(o.player_name)} · ${items}${disc}</div>
<div class="od">实收 <b style="color:var(--green)">${o.total_price}</b> 债券 · ${new Date((o.created_at||0)*1000).toLocaleString('zh-CN')}</div>
</div>`;
}).join('');
} catch(e) { /* 忽略 */ }
}
// ===== 启动 =====
(function(){
const saved = localStorage.getItem('sdf1_cashier_theme');
if (saved) { document.documentElement.style.setProperty('--bg', saved); document.body.style.background = saved; }
if (STATE.logged_in) initApp();
})();
</script>
</body>
</html>
+110
View File
@@ -599,6 +599,34 @@ function initTables(SQLite3 $db) {
synced_at INTEGER DEFAULT 0
)");
// ===== 收银员账号表 =====
$db->exec("CREATE TABLE IF NOT EXISTS cashiers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
salt TEXT DEFAULT '',
discount_limit_percent INTEGER DEFAULT 0,
created_at INTEGER DEFAULT 0,
created_by TEXT DEFAULT ''
)");
// ===== 收银台订单表(代购/收银员操作记录)=====
$db->exec("CREATE TABLE IF NOT EXISTS cashier_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT NOT NULL UNIQUE,
operator_type TEXT DEFAULT 'cashier',
operator_name TEXT DEFAULT '',
player_name TEXT NOT NULL,
items_detail TEXT DEFAULT '',
subtotal INTEGER DEFAULT 0,
total_price INTEGER DEFAULT 0,
discount_percent INTEGER DEFAULT 0,
discount_amount INTEGER DEFAULT 0,
settlement TEXT DEFAULT '',
status TEXT DEFAULT 'completed',
created_at INTEGER DEFAULT 0
)");
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
@@ -892,6 +920,88 @@ function isAdminLoggedIn() {
return isset($_SESSION['admin_auth']) && $_SESSION['admin_auth'];
}
// ===== 收银员认证 =====
/**
* 收银员登录:校验用户名+密码,成功写入会话
*/
function cashierLogin($username, $password) {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$db = getDB();
$stmt = $db->prepare("SELECT * FROM cashiers WHERE username = :u");
$stmt->bindValue(':u', $username, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) return false;
if (!password_verify($password, $row['password_hash'])) return false;
$_SESSION['cashier_auth'] = true;
$_SESSION['cashier_id'] = (int)$row['id'];
$_SESSION['cashier_name'] = $row['username'];
$_SESSION['cashier_discount_limit'] = (int)$row['discount_limit_percent'];
$_SESSION['cashier_login_time'] = time();
return true;
}
function isCashierLoggedIn() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
return isset($_SESSION['cashier_auth']) && $_SESSION['cashier_auth'];
}
function getCurrentCashier() {
if (!isCashierLoggedIn()) return null;
return [
'id' => $_SESSION['cashier_id'] ?? 0,
'username' => $_SESSION['cashier_name'] ?? '',
'discount_limit_percent' => (int)($_SESSION['cashier_discount_limit'] ?? 0)
];
}
/**
* 收银台操作鉴权:管理员或收银员会话均可,返回操作者类型('admin'/'cashier')
*/
function requireCashierOrAdminSession() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (isAdminLoggedIn()) return 'admin';
if (isCashierLoggedIn()) return 'cashier';
error('未登录收银台', 401);
}
function cashierLogout() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
unset($_SESSION['cashier_auth'], $_SESSION['cashier_id'], $_SESSION['cashier_name'], $_SESSION['cashier_discount_limit'], $_SESSION['cashier_login_time']);
}
/**
* 记录一笔收银台订单(必须在调用方已开启的事务内执行,使用同一数据库连接)
* @param SQLite3|null $db 调用方已开启事务的连接;传 null 时自动获取(非事务场景)
* @return string 订单号
*/
function recordCashierOrder($data, $db = null) {
if ($db === null) $db = getDB();
$orderNo = 'C' . date('YmdHis') . str_pad(mt_rand(0, 999), 3, '0', STR_PAD_LEFT);
$stmt = $db->prepare("INSERT INTO cashier_orders (order_no, operator_type, operator_name, player_name, items_detail, subtotal, total_price, discount_percent, discount_amount, settlement, status, created_at) VALUES (:no,:ot,:on,:pn,:det,:sub,:tp,:dp,:da,:st,'completed',:time)");
$stmt->bindValue(':no', $orderNo, SQLITE3_TEXT);
$stmt->bindValue(':ot', $data['operator_type'] ?? 'cashier', SQLITE3_TEXT);
$stmt->bindValue(':on', $data['operator_name'] ?? '', SQLITE3_TEXT);
$stmt->bindValue(':pn', $data['player_name'] ?? '', SQLITE3_TEXT);
$stmt->bindValue(':det', json_encode($data['items_detail'] ?? [], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
$stmt->bindValue(':sub', (int)($data['subtotal'] ?? 0), SQLITE3_INTEGER);
$stmt->bindValue(':tp', (int)($data['total_price'] ?? 0), SQLITE3_INTEGER);
$stmt->bindValue(':dp', (int)($data['discount_percent'] ?? 0), SQLITE3_INTEGER);
$stmt->bindValue(':da', (int)($data['discount_amount'] ?? 0), SQLITE3_INTEGER);
$stmt->bindValue(':st', $data['settlement'] ?? '', SQLITE3_TEXT);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
return $orderNo;
}
// ===== 定时清理(仅清理过期token,每次请求执行一次) =====
function cleanExpiredTokensLazy() {
// 频率限制:每5分钟最多清理一次