From a2b09da73011f4c53b435f4d187849a41d0f8ec4 Mon Sep 17 00:00:00 2001 From: pllx Date: Tue, 28 Apr 2026 13:07:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E4=B8=AA=E6=80=A7?= =?UTF-8?q?=E8=A3=85=E6=89=AE=E6=94=AF=E6=8C=81=E5=A4=9A=E4=BB=BD=E8=B4=AD?= =?UTF-8?q?=E4=B9=B0=EF=BC=8C=E5=90=8C=E6=AC=BE=E7=BB=AD=E8=B4=AD=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8F=A0=E5=8A=A0=E5=A4=A9=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Services/DecorationService.php | 43 +++++++++++++++++------ app/Services/ShopService.php | 8 ++--- resources/js/chat-room/shop-controls.js | 46 +++++++++++++++++++++---- 3 files changed, 77 insertions(+), 20 deletions(-) diff --git a/app/Services/DecorationService.php b/app/Services/DecorationService.php index dc29ae1..3ee2d29 100644 --- a/app/Services/DecorationService.php +++ b/app/Services/DecorationService.php @@ -52,12 +52,14 @@ class DecorationService * 购买装扮:扣金币、写购买记录、更新 users.active_decorations。 * * 同槽位的旧装扮会被新购买覆盖(旧装扮不退款),不同槽位可并行持有。 + * 若购买的是已激活的同款样式,则自动叠加天数而非覆盖重置。 * * @param User $user 购买用户 * @param ShopItem $item 装扮商品 + * @param int $quantity 购买份数 * @return array{ok:bool, message:string, balance_after?:int, slot?:string, style?:string, expires_at?:string} */ - public function purchase(User $user, ShopItem $item): array + public function purchase(User $user, ShopItem $item, int $quantity = 1): array { // 根据商品类型映射到对应槽位 $slot = self::TYPE_TO_SLOT[$item->type] ?? null; @@ -65,14 +67,29 @@ class DecorationService return ['ok' => false, 'message' => '未知装扮类型']; } + $totalPrice = $item->price * $quantity; + // 校验金币余额 - if ($user->jjb < $item->price) { - return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"]; + if ($user->jjb < $totalPrice) { + return ['ok' => false, 'message' => "金币不足,购买 {$quantity} 份 [{$item->name}] 需要 {$totalPrice} 金币,当前仅有 {$user->jjb} 金币。"]; } // 计算过期时间(至少 1 天) $days = max(1, (int) ($item->duration_days ?? 1)); - $expiresAt = Carbon::now()->addDays($days); + $totalDays = $days * $quantity; + + // 检查同一槽位是否已激活相同样式 → 叠加天数 + $decorations = $this->getActiveDecorations($user); + $isSameActive = ! empty($decorations[$slot]) + && ($decorations[$slot]['style'] ?? '') === $item->slug; + + if ($isSameActive) { + // 在现有到期时间上追加天数 + $existingExpires = Carbon::parse($decorations[$slot]['expires_at']); + $expiresAt = $existingExpires->copy()->addDays($totalDays); + } else { + $expiresAt = Carbon::now()->addDays($totalDays); + } // 按装扮类型使用不同的流水来源标识,便于后台按类型筛选消费记录 $source = match ($item->type) { @@ -84,11 +101,11 @@ class DecorationService }; // 事务包裹:扣金币、写购买记录、更新激活状态三步原子操作 - DB::transaction(function () use ($user, $item, $slot, $days, $expiresAt, $source) { + DB::transaction(function () use ($user, $item, $slot, $totalPrice, $totalDays, $expiresAt, $source) { // ① 通过统一积分服务扣除金币(含流水记录) $this->currencyService->change( - $user, 'gold', -$item->price, $source, - "购买装扮:{$item->name}({$days}天)" + $user, 'gold', -$totalPrice, $source, + "购买装扮:{$item->name}({$totalDays}天)" ); // ② 写入购买记录(用于后台统计与用户回溯) @@ -96,11 +113,11 @@ class DecorationService 'user_id' => $user->id, 'shop_item_id' => $item->id, 'status' => 'active', - 'price_paid' => $item->price, + 'price_paid' => $totalPrice, 'expires_at' => $expiresAt, ]); - // ③ 更新用户 active_decorations JSON 字段(同槽位覆盖,不同槽位合并) + // ③ 更新用户 active_decorations JSON 字段(同槽位合并,不同槽位追加) $decorations = $this->getActiveDecorations($user); $decorations[$slot] = [ 'style' => $item->slug, @@ -113,13 +130,19 @@ class DecorationService // 重新读取最新余额,避免缓存脏数据 $balanceAfter = (int) $user->fresh()->jjb; + // 计算叠加后的总天数显示(如果是续费,显示累计总天数) + $displayDays = $isSameActive + ? (int) Carbon::now()->diffInDays(Carbon::parse($expiresAt), false) + 1 + : $totalDays; + return [ 'ok' => true, - 'message' => "购买成功!{$item->icon} {$item->name} 已激活({$days}天有效)", + 'message' => "购买成功!{$item->icon} {$item->name} 已激活({$displayDays}天有效)", 'balance_after' => $balanceAfter, 'slot' => $slot, 'style' => $item->slug, 'expires_at' => $expiresAt->toIso8601String(), + 'quantity' => $quantity, ]; } diff --git a/app/Services/ShopService.php b/app/Services/ShopService.php index 2e1322e..167a0aa 100644 --- a/app/Services/ShopService.php +++ b/app/Services/ShopService.php @@ -49,10 +49,10 @@ class ShopService 'auto_fishing' => $this->buyAutoFishingCard($user, $item), ShopItem::TYPE_SIGN_REPAIR => $this->buySignRepairCard($user, $item, $quantity), // ── 个人装扮购买(委托给 DecorationService)─────────────── - 'msg_bubble' => $this->decorationService->purchase($user, $item), - 'msg_name_color' => $this->decorationService->purchase($user, $item), - 'msg_text_color' => $this->decorationService->purchase($user, $item), - 'avatar_frame' => $this->decorationService->purchase($user, $item), + 'msg_bubble' => $this->decorationService->purchase($user, $item, $quantity), + 'msg_name_color' => $this->decorationService->purchase($user, $item, $quantity), + 'msg_text_color' => $this->decorationService->purchase($user, $item, $quantity), + 'avatar_frame' => $this->decorationService->purchase($user, $item, $quantity), default => ['ok' => false, 'message' => '未知商品类型'], }; } diff --git a/resources/js/chat-room/shop-controls.js b/resources/js/chat-room/shop-controls.js index fb1714e..2134954 100644 --- a/resources/js/chat-room/shop-controls.js +++ b/resources/js/chat-room/shop-controls.js @@ -336,10 +336,10 @@ export function renderDecorations(data) { const items = Array.isArray(data.items) ? data.items : []; list.innerHTML = ""; - // 购买说明明确同类型替换规则,避免用户误以为可以叠加多款气泡或头像框。 + // 购买说明:已激活同款支持叠加天数,不同款式替换时旧装扮作废不退款。 const note = document.createElement("div"); note.className = "decoration-note"; - note.innerHTML = "📌 购买说明:每个类型只生效一个,购买同类型新装扮后,旧装扮自动作废且不退款。"; + note.innerHTML = "📌 购买说明:同类型只生效一款;已激活的同款续购自动叠加天数,无需一次买满;购买不同款式时旧装扮自动作废且不退款。"; list.appendChild(note); DECORATION_GROUPS.forEach((group) => { @@ -548,11 +548,16 @@ async function confirmAndBuyItem(item) { } } + // 个性装扮支持多份购买(叠加天数),弹出数量选择 + if (DECORATION_TYPE_TO_SLOT[item.type] && item.type !== "sign_repair") { + quantity = await window.promptQuantity?.(`购买多份【${item.name}】可叠加天数\n已激活的同款续购自动延长,无需一次买满`, 1, 99) ?? 1; + if (quantity === null || quantity === undefined) { + return; + } + } + const validityText = buildValidityText(item); - const replacementText = DECORATION_TYPE_TO_SLOT[item.type] - ? "\n购买说明:同类型只生效最新购买,原有同类型装扮会自动作废且不退款。" - : ""; - const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n【${item.name}】${quantity > 1 ? ` × ${quantity}` : ""}${validityText ? `\n${validityText}` : ""}${replacementText}\n\n确定购买吗?`; + const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n【${item.name}】${quantity > 1 ? ` × ${quantity}` : ""}${validityText ? `\n${validityText}` : ""}\n\n确定购买吗?`; const confirmed = await confirmShopPurchase(confirmMessage); if (confirmed) { @@ -560,6 +565,35 @@ async function confirmAndBuyItem(item) { } } +/** + * 通用数量输入弹窗。 + * + * @param {string} hint 提示文案 + * @param {number} [minVal=1] 最小值 + * @param {number} [maxVal=99] 最大值 + * @returns {Promise} 返回数量,用户取消返回 null + */ +window.promptQuantity = async (hint, minVal = 1, maxVal = 99) => { + if (window.chatDialog?.prompt) { + const result = await window.chatDialog.prompt(hint, "1", "购买数量"); + if (result === null || result === undefined) return null; + const val = parseInt(result, 10); + if (isNaN(val) || val < minVal || val > maxVal) { + window.chatDialog?.alert?.(`请输入 ${minVal}~${maxVal} 之间的整数`); + return null; + } + return val; + } + const result = window.prompt(`${hint}\n数量(${minVal}~${maxVal}):`, "1"); + if (result === null) return null; + const val = parseInt(result, 10); + if (isNaN(val) || val < minVal || val > maxVal) { + alert(`请输入 ${minVal}~${maxVal} 之间的整数`); + return null; + } + return val; +}; + /** * 兼容全局弹窗组件缺失时的原生确认。 *