Files
Sdf1_login/cashier.php
T
ypsdf1 e9ecb0ae53 修复收银台:背包(环保单)模式尊重游戏内 green_discount
此前背包模式固定按 cart_backpack_rate(0.98=98折)结算,
忽略游戏内 /sdf1_login shop setgreen 设置的折扣。
现改为:green_discount<10 时以游戏折扣为准(如 setgreen 9 -> ×0.9),
覆盖默认98折;未设置(=10)时仍走 cart_backpack_rate 保持环保优惠。
前端预览 RATES.backpack 同步读取 green_discount,避免界面/实付不一致。
2026-07-08 16:35:04 +08:00

799 lines
44 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 独立收银台页面
* 支持管理员登录或收银员登录;具备商品选择、购物车、手动折扣(服务端强制上限)、订单历史。
*/
require_once __DIR__ . '/core.php';
if (session_status() === PHP_SESSION_NONE) session_start();
// 读取收银台配置(★ 单次查询同时获取packmoney、green_discount和费率)
function cashierGetConfig() {
try {
$db = getDB();
$stmt = $db->prepare("SELECT cfg_key, cfg_value FROM shop_config WHERE cfg_key IN ('packmoney','green_discount','cart_backpack_rate','cart_shulker_rate')");
$result = $stmt->execute();
$map = ['packmoney' => 5, 'green_discount' => 10, 'cart_backpack_rate' => '0.98', 'cart_shulker_rate' => '1.00']; // 默认值
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$map[$row['cfg_key']] = $row['cfg_value'];
}
return $map;
} catch (Exception $e) {
return ['packmoney' => 5, 'green_discount' => 10, 'cart_backpack_rate' => '0.98', 'cart_shulker_rate' => '1.00'];
}
}
$cfg = cashierGetConfig();
$packMoney = (float)$cfg['packmoney'];
$greenDiscount = (float)$cfg['green_discount'];
$backpackRate = (float)($cfg['cart_backpack_rate'] ?? 0.98);
$shulkerRate = (float)($cfg['cart_shulker_rate'] ?? 1.00);
$loggedIn = isAdminLoggedIn() || isCashierLoggedIn();
$initialRole = 'guest';
$initialName = '';
$initialLimit = 0;
$initialCanCash = 0; // ★ 默认:游客(未登录)无现金收款权限,避免下方 json_encode 报未定义变量
if (isAdminLoggedIn()) {
$initialRole = 'admin';
$initialName = 'admin';
$initialLimit = 100;
$initialCanCash = 1; // 管理员始终可进行现金收款
} elseif (isCashierLoggedIn()) {
$c = getCurrentCashier();
$initialRole = 'cashier';
$initialName = $c['username'];
$initialLimit = $c['discount_limit_percent'];
$initialCanCash = (int)($c['can_cash'] ?? 0);
}
$initialState = json_encode([
'logged_in' => $loggedIn,
'role' => $initialRole,
'username' => $initialName,
'discount_limit' => $initialLimit,
'cashier_can_cash' => $initialCanCash,
'packmoney' => $packMoney,
'green_discount' => $greenDiscount,
'backpack_rate' => $backpackRate,
'shulker_rate' => $shulkerRate
], 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); }
.tag { display:inline-block; padding:2px 10px; border-radius:999px; font-size:11px; font-weight:600; line-height:1.5; vertical-align:middle; }
.tag-admin { background:rgba(188,140,255,.15); color:var(--purple); border:1px solid rgba(188,140,255,.35); }
.tag-cashier { background:rgba(88,166,255,.15); color:var(--accent); border:1px solid rgba(88,166,255,.35); }
.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); }
/* 统一主界面输入框/下拉框样式:修复深色主题下浏览器默认浅色控件"样式不搭"与布局错位问题 */
.app input, .app select, .app textarea {
padding:9px 11px; background:var(--bg); border:1px solid var(--border);
border-radius:7px; color:var(--text); font-size:13px; outline:none;
font-family:inherit; transition:border .2s, box-shadow .2s;
}
.app select { width:100%; }
.app input:focus, .app select:focus, .app textarea:focus {
border-color:var(--accent); box-shadow:0 0 0 3px rgba(88,166,255,.12);
}
.player-bar input { flex:1; }
.catalog-head input { flex:1; }
.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; }
/* 自定义滚动条:统一深色质感,避免浏览器默认浅色滚动条破坏整体观感 */
*::-webkit-scrollbar { width:10px; height:10px; }
*::-webkit-scrollbar-track { background:transparent; }
*::-webkit-scrollbar-thumb { background:var(--border); border-radius:6px; }
*::-webkit-scrollbar-thumb:hover { background:var(--dim); }
* { scrollbar-width:thin; scrollbar-color:var(--border) transparent; }
/* 小票(打包收据)样式:模拟热敏纸小票 */
#receiptOverlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,.55); z-index:6000; justify-content:center; align-items:flex-start; padding:24px 12px; overflow-y:auto; }
#receiptOverlay.show { display:flex; }
.receipt { background:#fdfdf7; color:#1a1a1a; width:340px; max-width:100%; border-radius:8px; padding:22px 20px; box-shadow:0 16px 48px rgba(0,0,0,.5); font-family:'Courier New',monospace; font-size:13px; line-height:1.5; }
.receipt h2 { text-align:center; font-size:17px; margin-bottom:4px; letter-spacing:1px; }
.receipt .r-sub { text-align:center; font-size:11px; color:#555; margin-bottom:10px; }
.receipt .r-line { display:flex; justify-content:space-between; gap:8px; }
.receipt .r-sep { border-top:1px dashed #999; margin:8px 0; }
.receipt .r-item { font-size:12px; }
.receipt .r-item .ri-name { flex:1; }
.receipt .r-total { font-size:16px; font-weight:700; border-top:2px solid #1a1a1a; margin-top:8px; padding-top:6px; }
.receipt .r-foot { text-align:center; font-size:10px; color:#666; margin-top:12px; }
.receipt .r-actions { display:flex; gap:10px; margin-top:16px; }
.receipt .r-actions button { flex:1; padding:10px; border:none; border-radius:6px; font-size:13px; font-weight:700; cursor:pointer; }
.receipt .r-print { background:#1a1a1a; color:#fff; }
.receipt .r-done { background:#d9d9cf; color:#1a1a1a; }
/* 打印时只显示小票本体 */
@media print {
body * { visibility:hidden !important; }
#receiptOverlay, #receiptOverlay * { visibility:visible !important; }
#receiptOverlay { position:absolute; background:#fff; padding:0; align-items:flex-start; }
.receipt { box-shadow:none; width:80mm; max-width:80mm; margin:0 auto; }
.receipt .r-actions { 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-admin"></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')">🎒 塞背包 (<?= ($greenDiscount < 10) ? $greenDiscount : round($backpackRate * 100) ?>折)</div>
<div class="seg" data-set="shulker" onclick="setSettlement('shulker')">📦 潜影盒(原色免费)</div>
</div>
<!-- ★ 收款模式切换(管理员可用 / 收银员看权限) -->
<div id="payModeWrap" style="margin-top:10px">
<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:6px">收款模式</label>
<div class="settlement-row" id="payModeRow">
<div class="seg active" data-pm="bond" onclick="setPayMode('bond')">💰 债券收款(扣余额)</div>
<div class="seg" data-pm="cash" onclick="setPayMode('cash')" id="cashModeBtn">💵 现金收款(仅记账)</div>
</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="default">原色(免费)</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>
<!-- 订单记录改为独立入口(点击跳转到 orders.php) -->
<div class="orders" style="margin-top:14px">
<h3 style="margin-top:6px;display:flex;align-items:center;justify-content:space-between">
<span>📜 订单记录</span>
<a href="orders.php" id="orderLinkBtn" class="btn btn-ghost" style="padding:4px 14px;font-size:11px;text-decoration:none">查看全部 →</a>
</h3>
<div id="orderList"><div style="color:var(--dim);font-size:12px;padding:10px">加载中…</div></div>
</div>
</div>
</div>
<?php endif; ?>
<!-- 小票(打包收据)弹窗 -->
<div id="receiptOverlay">
<div class="receipt" id="receiptBody"><!-- 由 JS 填充 --></div>
</div>
<div id="toastHost"></div>
<script>
const STATE = <?php echo $initialState; ?>;
// 动态更新打包费提示(与后端 packmoney 配置一致,避免硬编码 +2 元)
(function() {
const lbl = document.querySelector('#shulkerColors label');
if (lbl) lbl.textContent = '潜影盒颜色(原色免费,其它颜色+ ' + (STATE.packmoney || 2) + ' 元打包费)';
})();
let products = [];
let categories = [];
let cart = {}; // id -> qty
let settlement = 'backpack';
let payMode = 'bond'; // bond=债券收款(扣余额), cash=现金收款(仅记账)
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, timeoutMs = 25000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const r = await fetch(url, Object.assign({headers:{'X-Requested-With':'cashier'}}, opts, {signal: ctrl.signal}));
let data;
try { data = await r.json(); } catch(e) { throw new Error('服务器响应异常 (HTTP ' + r.status + ')'); }
return data;
} catch(e) {
if (e.name === 'AbortError') throw new Error('请求超时(服务器繁忙),请稍后重试');
throw e;
} finally {
clearTimeout(timer);
}
}
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-admin' : 'tag-cashier');
document.getElementById('discLimit').textContent = STATE.discount_limit;
// 管理员需密码确认大额
if (STATE.role === 'admin') document.getElementById('adminPwWrap').classList.remove('hidden');
// ★ 收款模式权限控制
const cashModeBtn = document.getElementById('cashModeBtn');
if (STATE.role === 'admin') {
// 管理员始终可用现金模式
cashModeBtn.style.display = '';
document.getElementById('payModeWrap').style.display = '';
} else if (STATE.cashier_can_cash) {
// 收银员:管理员授权了现金收款权限
cashModeBtn.style.display = '';
document.getElementById('payModeWrap').style.display = '';
} else {
// 收银员无现金权限 → 隐藏现金按钮,锁定债券模式
cashModeBtn.style.display = 'none';
// 不隐藏整个 payModeWrap,但只显示债券模式
}
await Promise.all([loadProducts(), loadOrders()]); // 底部保留 5 条最新订单概览;完整记录见 orders.php
}
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();
}
// ★ 收款模式切换
function setPayMode(pm) {
payMode = pm;
document.querySelectorAll('#payModeRow .seg').forEach(x => x.classList.toggle('active', x.dataset.pm === pm));
// 现金模式下提示"仅记账不扣债券"
if (pm === 'cash') {
toast('现金收款模式:仅记账,不扣除玩家债券', 'ok');
}
recalc();
}
// ===== 计算 + 预览 =====
// ★ 费率从后端配置读取,不再硬编码
// 背包(环保单)费率:尊重游戏内 green_discount 设置。
// green_discount=10(未设置游戏折扣)→ 用 cart_backpack_rate(默认0.98=98折);
// green_discount<10(如9=9折)→ 以游戏折扣为准(×0.9),覆盖默认98折。
// 与后端 api/shop.php buy_cart 计算保持一致,避免"界面显示98折、实付却是更低折"。
const backpackRate = (STATE.green_discount != null && STATE.green_discount < 10)
? (STATE.green_discount / 10)
: (STATE.backpack_rate || 0.98);
const RATES = { backpack: backpackRate, shulker: (STATE.shulker_rate || 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];
// ★ rate 已包含背包/潜影盒费率;背包模式下若游戏设置了 green_discount(<10),
// 该折扣已并入 rate(与后端 buy_cart 一致),避免重复打折。
let afterRate = Math.round(subtotal * rate);
let ecoPct = 0;
let ecoAmt = 0;
// 仅在背包模式且游戏设置了绿色折扣时,展示"环保单 X折"提示行(rate 已含折扣,此处仅展示节省额)
if (settlement === 'backpack' && STATE.green_discount != null && STATE.green_discount < 10) {
ecoPct = STATE.green_discount; // 折数(如 9)
ecoAmt = subtotal - afterRate; // 折扣节省额(仅展示用)
}
let colorFee = 0;
const sc = document.getElementById('shulkerColor').value;
if (settlement === 'shulker' && sc !== 'default' && sc !== 'purple') {
// ★ 修复:使用 ?? 而非 ||,避免 packmoney=0 时被误判为 falsy 而回退到默认值2
colorFee = STATE.packmoney ?? 2;
}
let total = afterRate + 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>
${ecoPct>0?`<div class="summary-line"><span class="disc">环保单 ${ecoPct}折</span><span class="disc">-${ecoAmt} 债券</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>
${payMode === 'cash' ? '<div class="summary-line" style="border-color:var(--orange);color:var(--orange)"><span>💵 现金收款模式</span><span>仅记账,不扣债券</span></div>' : ''}
<div class="summary-line total"><span>实收${payMode==='cash'?'(记账)':''}</span><span class="v">${final} ${payMode==='cash'?'债券':''}</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('pay_mode', payMode); // ★ 新增:收款模式(bond/cash)
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) {
// ★ 弹出打包小票(打印收据)
showReceipt(d.data);
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 showReceipt(data) {
if (!data) return;
const items = (data.items && Array.isArray(data.items)) ? data.items : [];
const now = new Date();
const pad = n => String(n).padStart(2,'0');
const ts = now.getFullYear()+'-'+pad(now.getMonth()+1)+'-'+pad(now.getDate())+' '+pad(now.getHours())+':'+pad(now.getMinutes())+':'+pad(now.getSeconds());
const modeName = data.mode_name || (data.settlement==='shulker'?'潜影盒打包':'塞背包');
const payName = (data.payment_method || payMode) === 'cash' ? '现金(记账)' : '债券扣款';
const operator = STATE.role === 'admin' ? '管理员' : (STATE.username || '收银员');
// 明细行
let itemHtml = items.map(it => `
<div class="r-line r-item"><span class="ri-name">${esc(it.name)} x${it.amount}</span><span>${it.line_total} 债券</span></div>`).join('');
// 折扣优惠(背包费率+环保单减免的合计,original_price 已含费率与环保减免)
let promoLine = '';
const promoSaved = (parseInt(data.subtotal) || 0) - (parseInt(data.original_price) || 0);
if (promoSaved > 0) {
promoLine = `<div class="r-line"><span>${data.settlement==='backpack'?'环保/费率优惠':'费率优惠'}</span><span>-${promoSaved} 债券</span></div>`;
}
// 手动折扣
let discLine = '';
if (parseInt(data.discount_percent)>0) {
discLine = `<div class="r-line"><span>手动折扣 ${data.discount_percent}%</span><span>-${data.discount_amount} 债券</span></div>`;
}
const body = document.getElementById('receiptBody');
body.innerHTML = `
<h2>🧾 SDF1 商城</h2>
<div class="r-sub">打包小票 / PACKING RECEIPT</div>
<div class="r-line"><span>订单号</span><span>${esc(data.order_no || '—')}</span></div>
<div class="r-line"><span>时间</span><span>${ts}</span></div>
<div class="r-line"><span>玩家</span><span>${esc(data.player)}</span></div>
<div class="r-line"><span>操作员</span><span>${esc(operator)}</span></div>
<div class="r-line"><span>结算方式</span><span>${esc(modeName)}</span></div>
<div class="r-sep"></div>
${itemHtml}
<div class="r-sep"></div>
<div class="r-line"><span>商品小计</span><span>${data.subtotal} 债券</span></div>
${promoLine}
${discLine}
<div class="r-line"><span>收款方式</span><span>${payName}</span></div>
<div class="r-total r-line"><span>实收合计</span><span>${data.total_price} 债券</span></div>
<div class="r-foot">感谢惠顾 · 请妥善保管小票</div>
<div class="r-actions">
<button class="r-print" onclick="printReceipt()">🖨 打印小票</button>
<button class="r-done" onclick="closeReceipt()">完成</button>
</div>`;
document.getElementById('receiptOverlay').classList.add('show');
}
function printReceipt() { window.print(); }
function closeReceipt() { document.getElementById('receiptOverlay').classList.remove('show'); }
// 点击遮罩空白处关闭
document.getElementById('receiptOverlay').addEventListener('click', e => { if (e.target.id === 'receiptOverlay') closeReceipt(); });
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=5');
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>