收银台双模式收款与打包配置全链路实现

- 收银员新增 can_cash 现金收款权限字段(登录/列表/新增/编辑全链路)
- 购物车结算支持 pay_mode=cash/bond 双模式,现金模式跳过余额校验与扣债券
- 收银台前端读取可配置 packmoney 实时报价,修复此前硬编码+2元与实际收费不符
- 管理后台新增打包费(packmoney)与环保减免(green_discount)配置端口
- 同步接口新增 get/set_shop_config,Java端定时器拉取配置
- 购物小票书塞入首个潜影盒,免费潜影盒订正为原色(SHULKER_BOX)
This commit is contained in:
ypsdf1
2026-07-06 23:48:44 +08:00
parent 86b2d6ebdf
commit f32c7cfe86
7 changed files with 249 additions and 49 deletions
+29 -6
View File
@@ -407,6 +407,8 @@ async function loadShop(el) {
<p style="color:var(--dim);font-size:13px;margin-bottom:14px">配置购物车结算时「塞背包」折扣与「潜影盒打包」加价系数,系数作用于商品原价合计。</p>
<div class="form-row"><label>塞背包折扣系数</label><input id="cfgBackpack" type="number" step="0.01" min="0.01" max="1" oninput="updateCartCfgHint()"></div>
<div class="form-row"><label>潜影盒加价系数</label><input id="cfgShulker" type="number" step="0.01" min="1" max="3" oninput="updateCartCfgHint()"></div>
<div class="form-row"><label>彩色潜影盒打包费</label><input id="cfgPackMoney" type="number" min="0" max="999" value="5"><span style="font-size:12px;color:var(--dim)">彩色潜影盒加收债券数(原色免费)</span></div>
<div class="form-row"><label>环保单减免(%)</label><input id="cfgGreen" type="number" min="0" max="9.99" step="0.01" value="2"><span style="font-size:12px;color:var(--dim)">0 = 不减免;玩家选「不打包」时按比例减免(命令 /sdf1_login shop setgreen 10 亦为不减免)</span></div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<button class="btn btn-green" onclick="saveCartConfig()">保存配置</button>
<span id="cartCfgHint" style="font-size:12px;color:var(--accent)"></span>
@@ -423,8 +425,12 @@ async function loadCartConfig() {
if (r && r.success && r.data) {
const bp = document.getElementById('cfgBackpack');
const sh = document.getElementById('cfgShulker');
const pm = document.getElementById('cfgPackMoney');
const gd = document.getElementById('cfgGreen');
if (bp && r.data.backpack_rate != null) bp.value = r.data.backpack_rate;
if (sh && r.data.shulker_rate != null) sh.value = r.data.shulker_rate;
if (pm && r.data.packmoney != null) pm.value = r.data.packmoney;
if (gd && r.data.green_discount != null) gd.value = r.data.green_discount;
updateCartCfgHint();
}
} catch (e) {}
@@ -448,12 +454,19 @@ function updateCartCfgHint() {
async function saveCartConfig() {
const bpEl = document.getElementById('cfgBackpack');
const shEl = document.getElementById('cfgShulker');
const pmEl = document.getElementById('cfgPackMoney');
const gdEl = document.getElementById('cfgGreen');
const bp = parseFloat(bpEl.value);
const sh = parseFloat(shEl.value);
const pm = parseInt(pmEl.value || '5', 10);
let gd = parseFloat(gdEl.value || '0');
if (gd >= 10) gd = 0; // 10 视为不减免
if (isNaN(bp) || isNaN(sh)) { toast('请输入有效数字', 'err'); return; }
if (bp <= 0 || bp > 1) { toast('塞背包折扣系数应在 0.01 ~ 1.00 之间', 'err'); return; }
if (sh < 1 || sh > 3) { toast('潜影盒加价系数应在 1.00 ~ 3.00 之间', 'err'); return; }
const r = await postApi('save_shop_config', { cart_backpack_rate: bp, cart_shulker_rate: sh });
if (isNaN(pm) || pm < 0 || pm > 999) { toast('打包费应在 0 ~ 999 之间', 'err'); return; }
if (gd < 0 || gd > 9.99) { toast('环保单减免应在 0 ~ 9.99 之间', 'err'); return; }
const r = await postApi('save_shop_config', { cart_backpack_rate: bp, cart_shulker_rate: sh, packmoney: pm, green_discount: gd });
toast(r.message || (r.success ? '已保存' : '保存失败'), r.success ? 'ok' : 'err');
if (r.success) updateCartCfgHint();
}
@@ -3177,11 +3190,12 @@ 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)">
<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>
<div class="form-row"><label>现金收款权限</label><select id="cmCash"><option value="0">否(仅债券收款)</option><option value="1">是(可现金收款)</option></select></div>
<button class="btn btn-green" onclick="cmAdd()">创建收银员</button>
</div>
<div id="cmList" style="margin-top:14px">加载中…</div>
@@ -3195,11 +3209,12 @@ async function cmAdd() {
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; }
const canCash = parseInt(document.getElementById('cmCash').value || '0', 10);
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
body: 'username=' + encodeURIComponent(username) + '&password=' + encodeURIComponent(password) + '&discount_limit_percent=' + limit + '&can_cash=' + canCash
}).then(r => r.json());
if (d.success) { toast('收银员已创建', 'ok'); document.getElementById('cmUser').value = ''; document.getElementById('cmPass').value = ''; cmLoad(); }
else toast(d.message || '创建失败', 'err');
@@ -3213,21 +3228,22 @@ async function cmLoad() {
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 => `
wrap.innerHTML = '<table class="table"><tr><th>ID</th><th>账号</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>${c.can_cash ? '✅ 可现金' : '— 仅债券'}</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-yellow" style="padding:4px 10px;font-size:11px" onclick="cmEdit(${c.id},'${escAdmHtml(c.username)}',${c.discount_limit_percent},${c.can_cash ? 1 : 0})">编辑</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) {
async function cmEdit(id, username, curLimit, curCash) {
const overlay = document.createElement('div');
overlay.className = 'glass-alert-overlay show';
overlay.innerHTML = `<div class="glass-alert-card">
@@ -3237,6 +3253,11 @@ async function cmEdit(id, username, curLimit) {
<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-label">现金收款权限(当前 ${curCash ? '可现金' : '仅债券'})</div>
<select class="alert-input" id="cmNewCash">
<option value="0"${curCash ? '' : ' selected'}>否(仅债券收款)</option>
<option value="1"${curCash ? ' selected' : ''}>是(可现金收款)</option>
</select>
<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>
@@ -3250,10 +3271,12 @@ 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 cash = parseInt(document.getElementById('cmNewCash').value || '0', 10);
const body = new URLSearchParams();
body.set('id', String(id));
if (pw) body.set('password', pw);
body.set('discount_limit_percent', String(limit));
body.set('can_cash', String(cash));
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());
+47 -21
View File
@@ -290,36 +290,62 @@ function adminShopRemove() {
}
}
// ===== 购物车结算折扣/加价配置 =====
// ===== 商店配置(折扣/加价系数 + 打包费 + 环保减免)=====
function adminSaveShopConfig() {
requireAdminSession();
$db = getDB();
try {
$bp = getParam('cart_backpack_rate');
$sh = getParam('cart_shulker_rate');
if ($bp === null || $sh === null) error('缺少配置参数');
$bp = (float)$bp;
$sh = (float)$sh;
// 校验范围:塞背包折扣系数 0.01~1.00;潜影盒加价系数 1.00~3.00
if ($bp <= 0 || $bp > 1.0) error('塞背包折扣系数必须介于 0.01 ~ 1.00 之间');
if ($sh < 1.0 || $sh > 3.0) error('潜影盒加价系数必须介于 1.00 ~ 3.00 之间');
$db->exec("CREATE TABLE IF NOT EXISTS shop_config (cfg_key TEXT PRIMARY KEY, cfg_value TEXT NOT NULL)");
$upsert = $db->prepare("INSERT OR REPLACE INTO shop_config (cfg_key, cfg_value) VALUES (:k, :v)");
$upsert->bindValue(':k', 'cart_backpack_rate', SQLITE3_TEXT);
$upsert->bindValue(':v', number_format($bp, 2, '.', ''), SQLITE3_TEXT);
$upsert->execute();
$upsert->bindValue(':k', 'cart_shulker_rate', SQLITE3_TEXT);
$upsert->bindValue(':v', number_format($sh, 2, '.', ''), SQLITE3_TEXT);
$upsert->execute();
$saved = [];
// 购物车折扣/加价系数(需成对提交)
$bp = getParam('cart_backpack_rate');
$sh = getParam('cart_shulker_rate');
if ($bp !== null || $sh !== null) {
if ($bp === null || $sh === null) error('塞背包与潜影盒系数需同时提交');
$bp = (float)$bp;
$sh = (float)$sh;
if ($bp <= 0 || $bp > 1.0) error('塞背包折扣系数必须介于 0.01 ~ 1.00 之间');
if ($sh < 1.0 || $sh > 3.0) error('潜影盒加价系数必须介于 1.00 ~ 3.00 之间');
$upsert->bindValue(':k', 'cart_backpack_rate', SQLITE3_TEXT);
$upsert->bindValue(':v', number_format($bp, 2, '.', ''), SQLITE3_TEXT);
$upsert->execute();
$upsert->bindValue(':k', 'cart_shulker_rate', SQLITE3_TEXT);
$upsert->bindValue(':v', number_format($sh, 2, '.', ''), SQLITE3_TEXT);
$upsert->execute();
$saved['backpack_rate'] = $bp;
$saved['shulker_rate'] = $sh;
}
// 彩色潜影盒打包费
$pm = getParam('packmoney');
if ($pm !== null) {
$pm = (int)$pm;
if ($pm < 0 || $pm > 999) error('打包费需介于 0 ~ 999 之间');
$upsert->bindValue(':k', 'packmoney', SQLITE3_TEXT);
$upsert->bindValue(':v', (string)$pm, SQLITE3_TEXT);
$upsert->execute();
$saved['packmoney'] = $pm;
}
// 环保单减免百分比(0=不减免;命令 setgreen 10 亦为不减免)
$gd = getParam('green_discount');
if ($gd !== null) {
$gd = (float)$gd;
if ($gd < 0 || $gd > 9.99) error('环保单减免需介于 0 ~ 9.99 之间');
$upsert->bindValue(':k', 'green_discount', SQLITE3_TEXT);
$upsert->bindValue(':v', number_format($gd, 2, '.', ''), SQLITE3_TEXT);
$upsert->execute();
$saved['green_discount'] = $gd;
}
if (empty($saved)) error('没有可保存的配置项');
exit(json_encode([
'success' => true,
'message' => '购物车结算配置已保存',
'data' => [
'backpack_rate' => $bp,
'shulker_rate' => $sh
]
'message' => '商店配置已保存',
'data' => $saved
], JSON_UNESCAPED_UNICODE));
} catch (\Throwable $e) {
@error_log('[save_shop_config] Error: ' . $e->getMessage());
+19 -4
View File
@@ -73,7 +73,8 @@ function cashierDoLogin() {
'success' => true,
'data' => [
'username' => $c['username'],
'discount_limit_percent' => $c['discount_limit_percent']
'discount_limit_percent' => $c['discount_limit_percent'],
'can_cash' => (int)($c['can_cash'] ?? 0)
]
], JSON_UNESCAPED_UNICODE));
}
@@ -105,7 +106,8 @@ function cashierSession() {
'data' => [
'role' => 'cashier',
'username' => $c['username'],
'discount_limit_percent' => $c['discount_limit_percent']
'discount_limit_percent' => $c['discount_limit_percent'],
'can_cash' => (int)($c['can_cash'] ?? 0)
]
], JSON_UNESCAPED_UNICODE));
}
@@ -181,7 +183,7 @@ function cashierOrderList() {
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();
$result = $db->prepare("SELECT id, username, discount_limit_percent, can_cash, created_at, created_by FROM cashiers ORDER BY id ASC")->execute();
$rows = [];
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
$rows[] = $r;
@@ -195,6 +197,7 @@ function cashierAdd() {
$username = trim(getParam('username', ''));
$password = getParam('password', '');
$discountLimit = (int)getParam('discount_limit_percent', 0);
$canCash = (int)getParam('can_cash', 0);
if ($username === '' || $password === '') {
exit(json_encode(['success' => false, 'message' => '账号和密码不能为空'], JSON_UNESCAPED_UNICODE));
}
@@ -204,6 +207,9 @@ function cashierAdd() {
if ($discountLimit < 0 || $discountLimit > 100) {
exit(json_encode(['success' => false, 'message' => '折扣上限需在0-100之间'], JSON_UNESCAPED_UNICODE));
}
if ($canCash < 0 || $canCash > 1) {
exit(json_encode(['success' => false, 'message' => '现金收款权限需为0或1'], JSON_UNESCAPED_UNICODE));
}
$db = getDB();
// 查重
$chk = $db->prepare("SELECT id FROM cashiers WHERE username = :u");
@@ -213,11 +219,12 @@ function cashierAdd() {
}
$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 = $db->prepare("INSERT INTO cashiers (username, password_hash, salt, discount_limit_percent, can_cash, created_at, created_by) VALUES (:u,:h,:s,:d,:c,: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(':c', $canCash, SQLITE3_INTEGER);
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
$stmt->execute();
exit(json_encode(['success' => true, 'message' => '收银员创建成功'], JSON_UNESCAPED_UNICODE));
@@ -251,6 +258,14 @@ function cashierEdit() {
$sets[] = "discount_limit_percent = :d";
$params[':d'] = $dl;
}
if (getParam('can_cash') !== null && getParam('can_cash') !== '') {
$cc = (int)getParam('can_cash', 0);
if ($cc < 0 || $cc > 1) {
exit(json_encode(['success' => false, 'message' => '现金收款权限需为0或1'], JSON_UNESCAPED_UNICODE));
}
$sets[] = "can_cash = :c";
$params[':c'] = $cc;
}
if (empty($sets)) {
exit(json_encode(['success' => false, 'message' => '没有可更新的字段'], JSON_UNESCAPED_UNICODE));
}
+22 -8
View File
@@ -268,7 +268,9 @@ function getShopConfig($key, $default = null) {
function cartConfig() {
success([
'backpack_rate' => (float)getShopConfig('cart_backpack_rate', '0.98'),
'shulker_rate' => (float)getShopConfig('cart_shulker_rate', '1.00')
'shulker_rate' => (float)getShopConfig('cart_shulker_rate', '1.00'),
'packmoney' => (int)getShopConfig('packmoney', '5'),
'green_discount' => (float)getShopConfig('green_discount', '2')
]);
}
@@ -276,7 +278,8 @@ function cartConfig() {
function shopBuyCart($token) {
$rawItems = getParam('items');
$settlement = getParam('settlement', 'backpack'); // backpack=塞背包, shulker=潜影盒打包
$shulkerColor = getParam('shulker_color', 'purple'); // 潜影盒颜色(purple免费,其它+2元)
$shulkerColor = getParam('shulker_color', 'default'); // 潜影盒颜色(default原色免费,其它+2元)
$payMode = getParam('pay_mode', 'bond'); // bond=债券扣款, cash=现金仅记账
$player = getParam('player');
$password = getParam('password');
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
@@ -346,10 +349,11 @@ function shopBuyCart($token) {
if ($settlement === 'shulker') {
$rate = (float)getShopConfig('cart_shulker_rate', '1.00');
$modeName = '潜影盒打包';
// 潜影盒颜色额外收费(紫色免费,其它+2元)
$colorFee = ($shulkerColor !== 'purple') ? 2 : 0;
// 潜影盒颜色额外收费(原色/紫色免费,其它加收打包费,打包费来自配置 packmoney)
$packMoney = (int)getShopConfig('packmoney', '5');
$colorFee = ($shulkerColor !== 'default' && $shulkerColor !== 'purple') ? $packMoney : 0;
// 颜色名称映射
$colorNames = ['purple'=>'紫色','white'=>'白色','black'=>'黑色','red'=>'红色','blue'=>'蓝色','green'=>'绿色','yellow'=>'黄色','orange'=>'橙色'];
$colorNames = ['default'=>'原色','purple'=>'原色','white'=>'白色','black'=>'黑色','red'=>'红色','blue'=>'蓝色','green'=>'绿色','yellow'=>'黄色','orange'=>'橙色'];
$colorName = $colorNames[$shulkerColor] ?? $shulkerColor;
} else {
$settlement = 'backpack';
@@ -426,8 +430,16 @@ function shopBuyCart($token) {
}
}
// 检查余额
if (!$isPreview && $finalPrice > 0) {
// ★ 现金收款权限校验:收银员需管理员授予 can_cash 权限
if ($payMode === 'cash') {
if ($isCashier && empty($cashierRow['can_cash'])) {
error('该收银员无现金收款权限(需管理员在收银员管理中开启)', 403);
}
// 现金模式:仅记账,不扣玩家债券(跳过余额校验与扣款)
}
// 检查余额(现金模式跳过:仅记账)
if (!$isPreview && $finalPrice > 0 && $payMode !== 'cash') {
$balanceStmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :player");
$balanceStmt->bindValue(':player', $player, SQLITE3_TEXT);
$balanceResult = $balanceStmt->execute();
@@ -485,6 +497,7 @@ function shopBuyCart($token) {
'total_price' => $finalPrice,
'discount_percent' => (int)$discountPercent,
'discount_amount' => (int)$discountAmount,
'pay_mode' => $payMode,
'items' => $lines
];
if ($settlement === 'shulker') {
@@ -509,7 +522,8 @@ function shopBuyCart($token) {
'total_price' => $finalPrice,
'discount_percent' => (int)$discountPercent,
'discount_amount' => (int)$discountAmount,
'settlement' => $settlement
'settlement' => $settlement,
'payment_method' => $payMode
], $db);
}
+45
View File
@@ -272,6 +272,12 @@ switch ($action) {
case 'pull_shop_prices':
pullShopPrices();
break;
case 'get_shop_config':
getShopConfigSync();
break;
case 'set_shop_config':
setShopConfigSync();
break;
case 'clear_admin_prices':
clearAdminPrices();
break;
@@ -2789,6 +2795,45 @@ function pullShopPrices() {
success(['items' => $items, 'count' => count($items)]);
}
// ===== 插件拉取商店打包配置(打包费 / 环保单减免)=====
function getShopConfigSync() {
$secret = getParam('secret');
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
success([
'packmoney' => (int)getShopConfig('packmoney', '5'),
'green_discount' => (float)getShopConfig('green_discount', '2')
]);
}
// ===== 插件推送商店配置(Java命令 /sdf1_login set packmoney、shop setgreen 写入)=====
function setShopConfigSync() {
$secret = getParam('secret');
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
$key = getParam('key');
$value = getParam('value');
$allowed = ['packmoney', 'green_discount'];
if (!in_array($key, $allowed, true)) error('不支持的配置项');
if ($value === null) error('缺少value');
if ($key === 'packmoney') {
$v = (int)$value;
if ($v < 0 || $v > 999) error('打包费需介于 0 ~ 999');
} else {
$v = (float)$value;
if ($v < 0 || $v > 9.99) error('环保单减免需介于 0 ~ 9.99');
}
$db = getDB();
$db->exec("CREATE TABLE IF NOT EXISTS shop_config (cfg_key TEXT PRIMARY KEY, cfg_value TEXT NOT NULL)");
$stmt = $db->prepare("INSERT OR REPLACE INTO shop_config (cfg_key, cfg_value) VALUES (:k, :v)");
$stmt->bindValue(':k', $key, SQLITE3_TEXT);
$stmt->bindValue(':v', (string)$v, SQLITE3_TEXT);
$stmt->execute();
success([], '配置已保存: ' . $key . '=' . $v);
}
// ===== 插件确认已应用管理员价格改动 =====
function clearAdminPrices() {
$secret = getParam('secret');
+76 -7
View File
@@ -7,6 +7,21 @@ require_once __DIR__ . '/core.php';
if (session_status() === PHP_SESSION_NONE) session_start();
// 读取打包费配置(彩色潜影盒加收,须与后端 shopBuyCart 保持一致)
function cashierGetPackMoney() {
try {
$db = getDB();
$db->exec("CREATE TABLE IF NOT EXISTS shop_config (cfg_key TEXT PRIMARY KEY, cfg_value TEXT NOT NULL)");
$stmt = $db->prepare("SELECT cfg_value FROM shop_config WHERE cfg_key = 'packmoney'");
$stmt->execute();
$row = $stmt->fetch(SQLITE3_ASSOC);
return $row ? (int)$row['cfg_value'] : 5;
} catch (Exception $e) {
return 5;
}
}
$packMoney = cashierGetPackMoney();
$loggedIn = isAdminLoggedIn() || isCashierLoggedIn();
$initialRole = 'guest';
$initialName = '';
@@ -15,17 +30,21 @@ 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
'discount_limit' => $initialLimit,
'cashier_can_cash' => $initialCanCash,
'packmoney' => $packMoney
], JSON_UNESCAPED_UNICODE);
?>
<!DOCTYPE html>
@@ -222,12 +241,21 @@ $initialState = json_encode([
<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 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>
<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="default">原色(免费)</option>
<option value="white">白色</option>
<option value="black">黑色</option>
<option value="red">红色</option>
@@ -276,10 +304,16 @@ $initialState = json_encode([
<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;
@@ -365,6 +399,22 @@ async function initApp() {
// 管理员需密码确认大额
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()]);
}
@@ -448,6 +498,18 @@ function setSettlement(s) {
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();
}
// ===== 计算 + 预览 =====
const RATES = { backpack: 0.98, shulker: 1.00 };
async function recalc() {
@@ -466,7 +528,8 @@ async function recalc() {
const rate = RATES[settlement];
let total = Math.round(subtotal * rate);
let colorFee = 0;
if (settlement === 'shulker' && document.getElementById('shulkerColor').value !== 'purple') colorFee = 2;
const sc = document.getElementById('shulkerColor').value;
if (settlement === 'shulker' && sc !== 'default' && sc !== 'purple') colorFee = STATE.packmoney || 2;
total += colorFee;
let discount = parseFloat(document.getElementById('discount').value) || 0;
@@ -481,7 +544,8 @@ async function recalc() {
<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>`;
${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;
}
@@ -515,6 +579,7 @@ async function confirmPay() {
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));
@@ -540,7 +605,11 @@ async function confirmPay() {
function finishPay(d) {
const btn = document.getElementById('payBtn');
if (d.success) {
toast('收款成功,实收 ' + (d.data.total_price) + ' 债券', 'ok');
if (payMode === 'cash') {
toast('✅ 现金收款记账成功(订单 #' + (d.data.order_id || '') + ',未扣玩家债券)', 'ok');
} else {
toast('收款成功,实收 ' + (d.data.total_price) + ' 债券', 'ok');
}
cart = {}; renderCart();
document.getElementById('summary').innerHTML = '';
document.getElementById('discount').value = 0;
+11 -3
View File
@@ -609,6 +609,8 @@ function initTables(SQLite3 $db) {
created_at INTEGER DEFAULT 0,
created_by TEXT DEFAULT ''
)");
// 收银员现金收款权限字段迁移(已存在则静默失败)
@$db->exec("ALTER TABLE cashiers ADD COLUMN can_cash INTEGER DEFAULT 0");
// ===== 收银台订单表(代购/收银员操作记录)=====
$db->exec("CREATE TABLE IF NOT EXISTS cashier_orders (
@@ -623,9 +625,12 @@ function initTables(SQLite3 $db) {
discount_percent INTEGER DEFAULT 0,
discount_amount INTEGER DEFAULT 0,
settlement TEXT DEFAULT '',
payment_method TEXT DEFAULT 'bond',
status TEXT DEFAULT 'completed',
created_at INTEGER DEFAULT 0
)");
// 收款模式字段迁移(现金/债券)
@$db->exec("ALTER TABLE cashier_orders ADD COLUMN payment_method TEXT DEFAULT 'bond'");
$db->exec('COMMIT');
} catch (Exception $e) {
@@ -939,6 +944,7 @@ function cashierLogin($username, $password) {
$_SESSION['cashier_id'] = (int)$row['id'];
$_SESSION['cashier_name'] = $row['username'];
$_SESSION['cashier_discount_limit'] = (int)$row['discount_limit_percent'];
$_SESSION['cashier_can_cash'] = (int)($row['can_cash'] ?? 0);
$_SESSION['cashier_login_time'] = time();
return true;
}
@@ -955,7 +961,8 @@ function getCurrentCashier() {
return [
'id' => $_SESSION['cashier_id'] ?? 0,
'username' => $_SESSION['cashier_name'] ?? '',
'discount_limit_percent' => (int)($_SESSION['cashier_discount_limit'] ?? 0)
'discount_limit_percent' => (int)($_SESSION['cashier_discount_limit'] ?? 0),
'can_cash' => (int)($_SESSION['cashier_can_cash'] ?? 0)
];
}
@@ -975,7 +982,7 @@ 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']);
unset($_SESSION['cashier_auth'], $_SESSION['cashier_id'], $_SESSION['cashier_name'], $_SESSION['cashier_discount_limit'], $_SESSION['cashier_can_cash'], $_SESSION['cashier_login_time']);
}
/**
@@ -986,7 +993,7 @@ function cashierLogout() {
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 = $db->prepare("INSERT INTO cashier_orders (order_no, operator_type, operator_name, player_name, items_detail, subtotal, total_price, discount_percent, discount_amount, settlement, payment_method, status, created_at) VALUES (:no,:ot,:on,:pn,:det,:sub,:tp,:dp,:da,:st,:pm,'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);
@@ -997,6 +1004,7 @@ function recordCashierOrder($data, $db = null) {
$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(':pm', $data['payment_method'] ?? 'bond', SQLITE3_TEXT);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
return $orderNo;