功能更新与UI优化:游戏图标移除、用户名片修复、婚礼红包界面重设计

- 移除聊天室右下角浮动游戏图标(占卜、百家乐、赛马、老虎机)
- 用户名片按钮区:修复已婚/已好友时按钮换行问题,统一单行显示
- 婚礼红包弹窗:重设计为喜庆鲜红背景,领取按钮改为圆形米黄样式
- 新增婚礼红包恢复接口(/wedding/pending-envelopes),刷新后自动恢复领取按钮
- 修复 Alpine :style 字符串覆盖静态 style 导致圆形按钮失效的问题
- 撤职后用户等级改为根据经验值重新计算,不再无条件重置为1
- 管理员修改用户经验值后自动重算等级,有职务用户等级锁定
- 娱乐大厅钓鱼游戏按钮直接调用 startFishing() 简化操作流程
- 新增赛马、占卜、百家乐游戏及相关后端逻辑
This commit is contained in:
2026-03-03 23:19:59 +08:00
parent 602dcd7cf1
commit f45483bcba
32 changed files with 3746 additions and 370 deletions

View File

@@ -43,43 +43,34 @@
- **货币来源**`CurrencySource::MYSTERY_BOX` / `MYSTERY_BOX_TRAP`(含 `room_id` 流水记录)
- **后台配置**`game_configs` 表,可配置开关/自动投放间隔/各奖励范围/陷阱概率;支持手动投放三种类型
---
## 🕐 待开发
### 🐎 赛马竞猜Horse Racing
**核心玩法**:定时举办赛马,用户押注马匹,按注池赔率结算,跑马过程 WebSocket 实时播报
**待开发清单:**
- [ ] 数据库`horse_races`(场次)+ `horse_bets`(下注记录
- [ ] 模型`HorseRace` / `HorseBet`
- [ ] 队列 Job`OpenHorseRaceJob`(开赛广播)+ `RunHorseRaceJob`(每秒播报马匹进度)+ `CloseHorseRaceJob`(结算)
- [ ] 事件:`HorseRaceOpened` / `HorseRaceProgress` / `HorseRaceSettled`PresenceChannel
- [ ] 控制器:`HorseRaceController`(当前场次/下注/历史)
- [ ] 调度器:按配置间隔开赛
- [ ] 前端:`chat/partials/horse-race.blade.php`(马匹赛道动画/实时进度条/注池赔率显示)
- [ ] 货币来源:`CurrencySource::HORSE_BET` / `HORSE_WIN`
- [ ] 配置参数:`interval_minutes` / `bet_window_seconds` / `race_duration` / `horse_count` / `min_bet` / `max_bet` / `house_take_percent`
---
- **类型**:定时自动开局(调度器每分钟检查,间隔可配置)
- **数据库**`horse_races` + `horse_bets`
- **模型**`HorseRace` / `HorseBet`
- **队列 Job**`OpenHorseRaceJob`(开赛广播)+ `RunHorseRaceJob`(每秒播报马匹进度 + 确定胜者)+ `CloseHorseRaceJob`(结算)
- **事件**`HorseRaceOpened` / `HorseRaceProgress` / `HorseRaceSettled`PresenceChannel 广播
- **控制器**`HorseRaceController``/horse-race/current` / `/horse-race/bet` / `/horse-race/history`
- **广播**`horse.opened` / `horse.progress` / `horse.settled`
- **前端**`chat/partials/horse-race-panel.blade.php`(倒计时/赛马道动画/实时赔率/可拖动FAB
- **货币来源**`CurrencySource::HORSE_BET` / `HORSE_WIN`
- **后台配置**`game_configs` 表,马匹数量/押注窗口/跨马时长/庄家抓水比例均可配置
### 🔮 神秘占卜Fortune Telling
**核心玩法**:每日免费占卜,系统生成玄学签文并给予当日加成;付费可多次
- **类型**:玩家主动使用(每日免费 N 次,额外次数消耗金币)
- **数据库**`fortune_logs`
- **模型**`FortuneLog`55+ 条签文内嵌在模型中)
- **控制器**`FortuneTellingController``/fortune/today` 查今日 / `/fortune/tell` 占卜 / `/fortune/history` 历史)
- **前端**`chat/partials/fortune-panel.blade.php`(卦象摇动动画/签文卡片/当日加成状态/可拖动FAB
- **每日限制**:免费 N 次(可配置),额外次数消耗金币
- **广播**:暂无实时广播(占卜结果仅展示给本人)
- **货币来源**`CurrencySource::FORTUNE_COST`
- **后台配置**`game_configs` 表,免费次数/额外消耗/各签概率均可配置
**待开发清单:**
---
- [ ] 数据库:`fortune_logs`(占卜记录,含签文和当日 buff 效果)
- [ ] 模型:`FortuneLog`
- [ ] 占卜库:预设 50+ 条签文(上上签/上签/中签/下签/大凶签),带对应加成描述
- [ ] 控制器:`FortuneTellingController``/fortune/today` 查今日 / `/fortune/tell` 占卜)
- [ ] 前端:`chat/partials/fortune-panel.blade.php`(卦象动画/签文卡片/今日加成状态)
- [ ] 每日限制免费1次额外次数扣金币
- [ ] Buff 系统(可选扩展):占卜结果影响当日经验/金币获取倍率(需修改自动存点逻辑)
- [ ] 货币来源:`CurrencySource::FORTUNE_COST`
- [ ] 配置参数:`free_count_per_day` / `extra_cost` / 各签概率
## 🕐 待开发
---
@@ -87,7 +78,7 @@
- [ ] 后台游戏管理页面(`/admin/game-configs`)显示各游戏实时统计数据
- [ ] 各游戏历史记录在后台可查(管理员视角)
- [ ] 生产环境部署:`php artisan db:seed --class=GameConfigSeeder`(初始化游戏配置)
- [ ] 生产环境部署:`php artisan db:seed --class=GameConfigSeeder`(初始化游戏配置) 已经完成了
- [ ] 百家乐/老虎机 全面测试(多用户并发下注)
---

View File

@@ -102,6 +102,15 @@ enum CurrencySource: string
/** 神秘箱子——黑化陷阱(倒扣金币,负数) */
case MYSTERY_BOX_TRAP = 'mystery_box_trap';
/** 赛马竞猜——下注消耗(扣除金币) */
case HORSE_BET = 'horse_bet';
/** 赛马竞猜——中奖赔付(收入金币,含本金返还) */
case HORSE_WIN = 'horse_win';
/** 神秘占卜——额外次数消耗(扣除金币) */
case FORTUNE_COST = 'fortune_cost';
/**
* 返回该来源的中文名称,用于后台统计展示。
*/
@@ -131,10 +140,13 @@ enum CurrencySource: string
self::SLOT_SPIN => '老虎机转动',
self::SLOT_WIN => '老虎机中奖',
self::SLOT_CURSE => '老虎机诅咒',
self::RED_PACKET_RECV => '领取礼包红包(金币)',
self::RED_PACKET_RECV => '领取礼包红包(金币)',
self::RED_PACKET_RECV_EXP => '领取礼包红包(经验)',
self::MYSTERY_BOX => '神秘箱子奖励',
self::MYSTERY_BOX_TRAP => '神秘箱子陷阱',
self::MYSTERY_BOX => '神秘箱子奖励',
self::MYSTERY_BOX_TRAP => '神秘箱子陷阱',
self::HORSE_BET => '赛马下注',
self::HORSE_WIN => '赛马赢钱',
self::FORTUNE_COST => '神秘占卜消耗',
};
}
}

View File

@@ -0,0 +1,67 @@
<?php
/**
* 文件功能:赛马开赛广播事件
*
* 新场次开始押注时广播给房间所有用户,携带场次 ID、
* 参赛马匹信息和押注截止时间,前端展示倒计时押注面板。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\HorseRace;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class HorseRaceOpened implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param HorseRace $race 本场信息
*/
public function __construct(
public readonly HorseRace $race,
) {}
/**
* 广播至房间公共频道。
*
* @return array<\Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
}
/**
* 广播事件名(前端监听 .horse.opened
*/
public function broadcastAs(): string
{
return 'horse.opened';
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'race_id' => $this->race->id,
'horses' => $this->race->horses,
'bet_opens_at' => $this->race->bet_opens_at->toIso8601String(),
'bet_closes_at' => $this->race->bet_closes_at->toIso8601String(),
'bet_seconds' => (int) now()->diffInSeconds($this->race->bet_closes_at),
];
}
}

View File

@@ -0,0 +1,71 @@
<?php
/**
* 文件功能:赛马进行中实时进度广播事件
*
* 跑马过程中每隔1秒广播各马匹当前进度0~100%
* 前端据此实时更新赛道动画。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class HorseRaceProgress implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param int $raceId 场次 ID
* @param array<int, int> $positions 各马匹进度 [horse_id => progress(0~100)]
* @param bool $finished 是否已到终点
* @param int|null $leaderId 当前领跑马匹 ID
*/
public function __construct(
public readonly int $raceId,
public readonly array $positions,
public readonly bool $finished = false,
public readonly ?int $leaderId = null,
) {}
/**
* 广播至房间公共频道。
*
* @return array<\Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
}
/**
* 广播事件名(前端监听 .horse.progress
*/
public function broadcastAs(): string
{
return 'horse.progress';
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'race_id' => $this->raceId,
'positions' => $this->positions,
'finished' => $this->finished,
'leader_id' => $this->leaderId,
];
}
}

View File

@@ -0,0 +1,77 @@
<?php
/**
* 文件功能:赛马结算广播事件
*
* 跑马结束後广播赛果(获胜马匹、赔付金额等)给房间所有用户,
* 前端收到后展示结算面板并更新中奖信息。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\HorseRace;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class HorseRaceSettled implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param HorseRace $race 已结算的场次
*/
public function __construct(
public readonly HorseRace $race,
) {}
/**
* 广播至房间公共频道。
*
* @return array<\Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
}
/**
* 广播事件名(前端监听 .horse.settled
*/
public function broadcastAs(): string
{
return 'horse.settled';
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
// 找出获胜马匹的名称
$horses = $this->race->horses ?? [];
$winnerName = '未知';
foreach ($horses as $horse) {
if (($horse['id'] ?? 0) === $this->race->winner_horse_id) {
$winnerName = ($horse['emoji'] ?? '').' '.($horse['name'] ?? '');
break;
}
}
return [
'race_id' => $this->race->id,
'winner_horse_id' => $this->race->winner_horse_id,
'winner_name' => $winnerName,
'total_pool' => $this->race->total_pool,
'settled_at' => $this->race->settled_at?->toIso8601String(),
];
}
}

View File

@@ -88,7 +88,7 @@ class UserManagerController extends Controller
*/
public function update(Request $request, User $user): JsonResponse|RedirectResponse
{
$targetUser = $user;
$targetUser = $user;
$currentUser = Auth::user();
// 超级管理员专属:仅 id=1 的账号可编辑用户信息
@@ -129,7 +129,24 @@ class UserManagerController extends Controller
);
$targetUser->refresh();
}
// 调整经验后重新计算等级(有职务用户锁定职务等级,无职务用户按经验重算)
$targetUser->load('activePosition.position');
$superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
if ($targetUser->activePosition?->position) {
// 有在职职务:等级锁定为职务级,不受经验影响
$lockedLevel = (int) $targetUser->activePosition->position->level;
if ($lockedLevel > 0 && $targetUser->user_level !== $lockedLevel) {
$targetUser->user_level = $lockedLevel;
}
} elseif ($targetUser->user_level < $superLevel) {
// 无职务普通用户:按经验重算等级(不超过满级阈值)
$newLevel = \App\Models\Sysparam::calculateLevel($targetUser->exp_num ?? 0);
$safeLevel = max(1, min($newLevel, $superLevel - 1));
$targetUser->user_level = $safeLevel;
}
}
if (isset($validated['jjb'])) {
$jjbDiff = $validated['jjb'] - ($targetUser->jjb ?? 0);
if ($jjbDiff !== 0) {
@@ -185,7 +202,7 @@ class UserManagerController extends Controller
*/
public function destroy(Request $request, User $user): RedirectResponse
{
$targetUser = $user;
$targetUser = $user;
$currentUser = Auth::user();
// 超级管理员专属:仅 id=1 的账号可删除用户

View File

@@ -0,0 +1,166 @@
<?php
/**
* 文件功能:神秘占卜前台控制器
*
* 提供用户每日占卜功能:
* - 查询今日占卜状态(已占卜/未占卜/剩余次数)
* - 执行占卜(免费或付费)
* - 查询占卜历史记录
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\FortuneLog;
use App\Models\GameConfig;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class FortuneTellingController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
) {}
/**
* 查询今日占卜状态(用于面板初始化和刷新)。
*/
public function todayStatus(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('fortune_telling')) {
return response()->json(['enabled' => false]);
}
$user = $request->user();
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
$freeCount = (int) ($config['free_count_per_day'] ?? 1);
$extraCost = (int) ($config['extra_cost'] ?? 500);
$todayCount = FortuneLog::todayCount($user->id);
$todayLatest = FortuneLog::todayLatest($user->id);
$freeUsed = FortuneLog::query()
->where('user_id', $user->id)
->where('fortune_date', today())
->where('is_free', true)
->count();
$hasFreeLeft = $freeUsed < $freeCount;
return response()->json([
'enabled' => true,
'today_count' => $todayCount,
'free_count' => $freeCount,
'free_used' => $freeUsed,
'has_free_left' => $hasFreeLeft,
'extra_cost' => $extraCost,
'latest' => $todayLatest ? [
'grade' => $todayLatest->grade,
'grade_label' => $todayLatest->gradeLabel(),
'grade_color' => $todayLatest->gradeColor(),
'text' => $todayLatest->text,
'buff_desc' => $todayLatest->buff_desc,
'created_at' => $todayLatest->created_at->format('H:i'),
] : null,
]);
}
/**
* 执行一次占卜。
*
* 免费次数用完后每次消耗 extra_cost 金币。
*/
public function tell(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('fortune_telling')) {
return response()->json(['ok' => false, 'message' => '神秘占卜当前未开启。']);
}
$user = $request->user();
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
$freeCount = (int) ($config['free_count_per_day'] ?? 1);
$extraCost = (int) ($config['extra_cost'] ?? 500);
// 判断今日免费次数是否已用完
$freeUsed = FortuneLog::query()
->where('user_id', $user->id)
->where('fortune_date', today())
->where('is_free', true)
->count();
$isFree = $freeUsed < $freeCount;
$cost = $isFree ? 0 : $extraCost;
// 检查余额
if (! $isFree && ($user->jjb ?? 0) < $cost) {
return response()->json(['ok' => false, 'message' => "金币不足,额外占卜需要 {$cost} 金币。"]);
}
// 扣费
if (! $isFree && $cost > 0) {
$this->currency->change(
$user,
'gold',
-$cost,
CurrencySource::FORTUNE_COST,
'神秘占卜额外次数消耗',
);
}
// 抽签
$grade = FortuneLog::rollGrade($config);
$fortune = FortuneLog::rollFortune($grade);
// 记录
$log = FortuneLog::create([
'user_id' => $user->id,
'grade' => $grade,
'text' => $fortune['text'],
'buff_desc' => $fortune['buff_desc'] ?? null,
'is_free' => $isFree,
'cost' => $cost,
'fortune_date' => today(),
]);
return response()->json([
'ok' => true,
'grade' => $log->grade,
'grade_label' => $log->gradeLabel(),
'grade_color' => $log->gradeColor(),
'text' => $log->text,
'buff_desc' => $log->buff_desc,
'is_free' => $isFree,
'cost' => $cost,
]);
}
/**
* 查询近20条个人占卜历史记录。
*/
public function history(Request $request): JsonResponse
{
$logs = FortuneLog::query()
->where('user_id', $request->user()->id)
->orderByDesc('id')
->limit(20)
->get(['grade', 'text', 'buff_desc', 'is_free', 'cost', 'fortune_date', 'created_at'])
->map(fn ($log) => [
'grade' => $log->grade,
'grade_label' => $log->gradeLabel(),
'grade_color' => $log->gradeColor(),
'text' => $log->text,
'buff_desc' => $log->buff_desc,
'cost' => $log->cost,
'date' => $log->fortune_date->format('m-d'),
'time' => $log->created_at->format('H:i'),
]);
return response()->json(['history' => $logs]);
}
}

View File

@@ -0,0 +1,224 @@
<?php
/**
* 文件功能:赛马竞猜前台控制器
*
* 提供用户在聊天室内参与赛马的 API 接口:
* - 查询当前场次信息(马匹、注池、赔率)
* - 提交下注(扣除金币 + 写入下注记录)
* - 查询本人下注状态
* - 查询最近历史记录
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\GameConfig;
use App\Models\HorseBet;
use App\Models\HorseRace;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class HorseRaceController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
) {}
/**
* 获取当前进行中的场次信息(前端轮询或事件触发后调用)。
*/
public function currentRace(Request $request): JsonResponse
{
$race = HorseRace::currentRace();
if (! $race) {
return response()->json(['race' => null]);
}
$user = $request->user();
$myBet = HorseBet::query()
->where('race_id', $race->id)
->where('user_id', $user->id)
->first();
// 计算各马匹当前注额
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$houseTake = (int) ($config['house_take_percent'] ?? 5);
$horsePools = HorseBet::query()
->where('race_id', $race->id)
->groupBy('horse_id')
->selectRaw('horse_id, SUM(amount) as pool')
->pluck('pool', 'horse_id')
->toArray();
// 计算实时赔率
$horses = $race->horses ?? [];
$horsesWithBets = array_map(function ($horse) use ($horsePools, $houseTake) {
$horsePool = (int) ($horsePools[$horse['id']] ?? 0);
$totalPool = array_sum(array_values($horsePools));
$netPool = $totalPool * (1 - $houseTake / 100);
$odds = $horsePool > 0 ? round($netPool / $horsePool, 2) : null;
return [
'id' => $horse['id'],
'name' => $horse['name'],
'emoji' => $horse['emoji'],
'pool' => $horsePool,
'odds' => $odds,
];
}, $horses);
return response()->json([
'race' => [
'id' => $race->id,
'status' => $race->status,
'bet_closes_at' => $race->bet_closes_at?->toIso8601String(),
'seconds_left' => $race->status === 'betting'
? max(0, (int) now()->diffInSeconds($race->bet_closes_at, false))
: 0,
'horses' => $horsesWithBets,
'total_pool' => $race->total_pool + array_sum(array_values($horsePools)),
'my_bet' => $myBet ? [
'horse_id' => $myBet->horse_id,
'amount' => $myBet->amount,
] : null,
],
]);
}
/**
* 用户提交下注。
*
* 同一场每人限下一注,下注成功后立即扣除金币。
*/
public function bet(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('horse_racing')) {
return response()->json(['ok' => false, 'message' => '赛马竞猜当前未开启。']);
}
$data = $request->validate([
'race_id' => 'required|integer|exists:horse_races,id',
'horse_id' => 'required|integer|min:1',
'amount' => 'required|integer|min:1',
]);
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$minBet = (int) ($config['min_bet'] ?? 100);
$maxBet = (int) ($config['max_bet'] ?? 100000);
if ($data['amount'] < $minBet || $data['amount'] > $maxBet) {
return response()->json(['ok' => false, 'message' => "押注金额须在 {$minBet}~{$maxBet} 金币之间。"]);
}
$race = HorseRace::find($data['race_id']);
if (! $race || ! $race->isBettingOpen()) {
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
}
// 验证马匹 ID 是否有效
$horses = $race->horses ?? [];
$validIds = array_column($horses, 'id');
if (! in_array($data['horse_id'], $validIds, true)) {
return response()->json(['ok' => false, 'message' => '无效的马匹编号。']);
}
$user = $request->user();
$currency = $this->currency;
// 校验余额
if (($user->jjb ?? 0) < $data['amount']) {
return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']);
}
return DB::transaction(function () use ($user, $race, $data, $currency, $horses): JsonResponse {
// 幂等:同一场只能下一注
$existing = HorseBet::query()
->where('race_id', $race->id)
->where('user_id', $user->id)
->lockForUpdate()
->exists();
if ($existing) {
return response()->json(['ok' => false, 'message' => '本场您已下注,请等待开奖。']);
}
// 找出马匹名称
$horseName = '';
foreach ($horses as $horse) {
if ((int) $horse['id'] === (int) $data['horse_id']) {
$horseName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
break;
}
}
// 扣除金币
$currency->change(
$user,
'gold',
-$data['amount'],
CurrencySource::HORSE_BET,
"赛马 #{$race->id} 押注 {$horseName}",
);
// 写入下注记录
HorseBet::create([
'race_id' => $race->id,
'user_id' => $user->id,
'horse_id' => $data['horse_id'],
'amount' => $data['amount'],
'status' => 'pending',
]);
return response()->json([
'ok' => true,
'message' => "✅ 已押注「{$horseName}{$data['amount']} 金币,等待开跑!",
'amount' => $data['amount'],
'horse_id' => $data['horse_id'],
]);
});
}
/**
* 查询最近10场历史记录前端展示胜负趋势
*/
public function history(): JsonResponse
{
$races = HorseRace::query()
->where('status', 'settled')
->orderByDesc('id')
->limit(10)
->get(['id', 'horses', 'winner_horse_id', 'total_pool', 'total_bets', 'settled_at']);
// 转换获胜马匹名称
$history = $races->map(function ($race) {
$winnerName = '未知';
foreach (($race->horses ?? []) as $horse) {
if (($horse['id'] ?? 0) === (int) $race->winner_horse_id) {
$winnerName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
break;
}
}
return [
'id' => $race->id,
'winner_id' => $race->winner_horse_id,
'winner_name' => $winnerName,
'total_pool' => $race->total_pool,
'total_bets' => $race->total_bets,
'settled_at' => $race->settled_at?->toDateTimeString(),
];
});
return response()->json(['history' => $history]);
}
}

View File

@@ -107,4 +107,36 @@ class WeddingController extends Controller
'expires_at' => $ceremony->expires_at,
]);
}
/**
* 查询当前用户所有未领取且未过期的婚礼红包(页面刷新后恢复领取按钮用)。
*/
public function pendingEnvelopes(Request $request): JsonResponse
{
$userId = $request->user()->id;
// 查询有未领取 claim 的 active 婚礼(未过期)
$claims = \App\Models\WeddingEnvelopeClaim::query()
->where('user_id', $userId)
->where('claimed', false)
->with(['ceremony.marriage.user', 'ceremony.marriage.partner', 'ceremony.tier'])
->get()
->filter(fn ($c) => $c->ceremony
&& in_array($c->ceremony->status, ['active'])
&& (! $c->ceremony->expires_at || $c->ceremony->expires_at->isFuture()))
->values();
return response()->json([
'envelopes' => $claims->map(fn ($c) => [
'ceremony_id' => $c->ceremony_id,
'amount' => $c->amount,
'total_amount' => $c->ceremony->total_amount,
'groom' => $c->ceremony->marriage->user->username ?? '—',
'bride' => $c->ceremony->marriage->partner->username ?? '—',
'tier_name' => $c->ceremony->tier?->name ?? '婚礼',
'tier_icon' => $c->ceremony->tier?->icon ?? '🎊',
'expires_at' => $c->ceremony->expires_at?->toDateTimeString(),
]),
]);
}
}

View File

@@ -0,0 +1,171 @@
<?php
/**
* 文件功能:赛马结算队列任务
*
* 跑马结束后触发,按彩池赔率结算所有下注记录,
* 中奖者获得按注池比例计算的赔付,失败者金币已在下注时扣除。
* 结算完成后广播结果并发公屏公告。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Jobs;
use App\Enums\CurrencySource;
use App\Events\HorseRaceSettled;
use App\Events\MessageSent;
use App\Models\GameConfig;
use App\Models\HorseBet;
use App\Models\HorseRace;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
class CloseHorseRaceJob implements ShouldQueue
{
use Queueable;
/**
* 最大重试次数。
*/
public int $tries = 3;
/**
* @param HorseRace $race 要结算的场次
*/
public function __construct(
public readonly HorseRace $race,
) {}
/**
* 执行结算逻辑。
*/
public function handle(
UserCurrencyService $currency,
ChatStateService $chatState,
): void {
$race = $this->race->fresh();
// 防止重复结算
if (! $race || $race->status !== 'running') {
return;
}
// CAS 改状态为 settled
$updated = HorseRace::query()
->where('id', $race->id)
->where('status', 'running')
->update(['status' => 'settled', 'settled_at' => now()]);
if (! $updated) {
return;
}
$race->refresh();
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$houseTake = (int) ($config['house_take_percent'] ?? 5);
$winnerId = (int) $race->winner_horse_id;
// 按马匹统计各匹下注金额
$horsePools = HorseBet::query()
->where('race_id', $race->id)
->groupBy('horse_id')
->selectRaw('horse_id, SUM(amount) as pool')
->pluck('pool', 'horse_id')
->toArray();
$totalPool = array_sum($horsePools);
$winnerPool = (int) ($horsePools[$winnerId] ?? 0);
$netPool = (int) ($totalPool * (1 - $houseTake / 100));
// 结算:遍历所有下注记录
$bets = HorseBet::query()
->where('race_id', $race->id)
->where('status', 'pending')
->with('user')
->get();
$totalPayout = 0;
DB::transaction(function () use ($bets, $winnerId, $netPool, $winnerPool, $currency, &$totalPayout) {
foreach ($bets as $bet) {
if ((int) $bet->horse_id !== $winnerId) {
// 未中奖(本金已在下注时扣除)
$bet->update(['status' => 'lost', 'payout' => 0]);
continue;
}
// 中奖:按注额比例分配净注池
if ($winnerPool > 0) {
$payout = (int) round($netPool * ($bet->amount / $winnerPool));
} else {
$payout = 0;
}
$bet->update(['status' => 'won', 'payout' => $payout]);
if ($payout > 0 && $bet->user) {
$currency->change(
$bet->user,
'gold',
$payout,
CurrencySource::HORSE_WIN,
"赛马 #{$this->race->id}{$bet->horse_id}号马」中奖",
);
}
$totalPayout += $payout;
}
});
// 公屏公告
$this->pushResultMessage($race, $chatState, $totalPayout);
// 广播结算事件
broadcast(new HorseRaceSettled($race));
}
/**
* 向公屏发送赛果系统消息。
*/
private function pushResultMessage(HorseRace $race, ChatStateService $chatState, int $totalPayout): void
{
// 找出胜利马匹名称
$horses = $race->horses ?? [];
$winnerName = '未知';
foreach ($horses as $horse) {
if (($horse['id'] ?? 0) === (int) $race->winner_horse_id) {
$winnerName = ($horse['emoji'] ?? '').($horse['name'] ?? '');
break;
}
}
$payoutText = $totalPayout > 0
? '共派发 🪙'.number_format($totalPayout).' 金币'
: '本场无人获奖';
$content = "🏆 【赛马】第 #{$race->id} 场结束!冠军:{$winnerName}{$payoutText}";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#f59e0b',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
}
}

View File

@@ -0,0 +1,96 @@
<?php
/**
* 文件功能:赛马开赛队列任务
*
* 由调度器按配置间隔触发,游戏开启时创建新场次,
* 生成参赛马匹,广播开赛事件并安排跑马任务。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Jobs;
use App\Events\HorseRaceOpened;
use App\Events\MessageSent;
use App\Models\GameConfig;
use App\Models\HorseRace;
use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class OpenHorseRaceJob implements ShouldQueue
{
use Queueable;
/**
* 最大重试次数。
*/
public int $tries = 1;
/**
* 执行开赛逻辑。
*/
public function handle(ChatStateService $chatState): void
{
// 检查游戏是否开启
if (! GameConfig::isEnabled('horse_racing')) {
return;
}
// 防止重复开赛(上一场还在进行中)
if (HorseRace::currentRace()) {
return;
}
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$betSeconds = (int) ($config['bet_window_seconds'] ?? 90);
$horseCount = (int) ($config['horse_count'] ?? 4);
$minBet = (int) ($config['min_bet'] ?? 100);
$maxBet = (int) ($config['max_bet'] ?? 100000);
$now = now();
$closesAt = $now->copy()->addSeconds($betSeconds);
// 生成参赛马匹
$horses = HorseRace::generateHorses($horseCount);
// 创建新场次
$race = HorseRace::create([
'status' => 'betting',
'bet_opens_at' => $now,
'bet_closes_at' => $closesAt,
'horses' => $horses,
]);
// 广播开赛事件
broadcast(new HorseRaceOpened($race));
// 公屏系统公告
$horseList = implode(' ', array_map(
fn ($h) => "{$h['emoji']}{$h['name']}",
$horses
));
$content = "🐎 【赛马】第 #{$race->id} 场开始!押注时间 {$betSeconds} 秒,参赛马匹:{$horseList}。押注范围 ".number_format($minBet).'~'.number_format($maxBet).' 金币!';
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#f59e0b',
'action' => '大声宣告',
'sent_at' => $now->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
// 押注截止后触发跑马 & 结算任务
RunHorseRaceJob::dispatch($race)->delay($closesAt);
}
}

View File

@@ -0,0 +1,172 @@
<?php
/**
* 文件功能:赛马跑马动画广播 + 结算队列任务
*
* 押注截止后触发,模拟跑马进度并实时广播,
* 跑完后确定获胜者并调度结算任务。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Jobs;
use App\Events\HorseRaceProgress;
use App\Events\MessageSent;
use App\Models\GameConfig;
use App\Models\HorseBet;
use App\Models\HorseRace;
use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class RunHorseRaceJob implements ShouldQueue
{
use Queueable;
/**
* 最大重试次数。
*/
public int $tries = 2;
/**
* @param HorseRace $race 要运行的场次
*/
public function __construct(
public readonly HorseRace $race,
) {}
/**
* 执行跑马过程并广播进度,最后触发结算。
*/
public function handle(ChatStateService $chatState): void
{
$race = $this->race->fresh();
// 防止重复执行
if (! $race || $race->status !== 'betting') {
return;
}
// 乐观锁CAS 改状态为 running
$updated = HorseRace::query()
->where('id', $race->id)
->where('status', 'betting')
->update([
'status' => 'running',
'race_starts_at' => now(),
]);
if (! $updated) {
return;
}
$race->refresh();
// 公屏通知:跑马开始
$horseList = implode(' ', array_map(
fn ($h) => "{$h['emoji']}{$h['name']}",
$race->horses ?? []
));
$startMsg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🏇 【赛马】第 #{$race->id} 场押注截止!马匹已进入跑道,比赛开始!参赛阵容:{$horseList}",
'is_secret' => false,
'font_color' => '#336699',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $startMsg);
broadcast(new MessageSent(1, $startMsg));
SaveMessageJob::dispatch($startMsg);
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$raceDuration = (int) ($config['race_duration'] ?? 30);
$horses = $race->horses ?? [];
$horseCount = count($horses);
// 初始化各马匹进度0~100每步随机增量
$positions = array_fill_keys(array_column($horses, 'id'), 0);
$speeds = [];
foreach ($horses as $horse) {
// 基础速度:随机值,确保比赛有悬念(均值接近 race_duration 步完成)
$speeds[$horse['id']] = random_int(2, 5);
}
// 跑马循环:模拟进度广播(此 job 为同步阻塞广播,每步 sleep 1 秒)
$step = 0;
$maxSteps = $raceDuration;
$winnerId = null;
while ($step < $maxSteps && $winnerId === null) {
$step++;
foreach ($horses as $horse) {
$horseId = $horse['id'];
// 随机冲刺(小概率加速)
$boost = random_int(0, 10) >= 9 ? random_int(5, 15) : 0;
$positions[$horseId] = min(100, $positions[$horseId] + $speeds[$horseId] + $boost);
}
// 检查是否有马到达终点
$finishedHorses = array_filter($positions, fn ($p) => $p >= 100);
$finished = ! empty($finishedHorses);
if ($finished) {
// 取进度最高的马为冠军(若并列取 id 最小的)
arsort($finishedHorses);
$winnerId = (int) array_key_first($finishedHorses);
}
// 广播当前帧进度
broadcast(new HorseRaceProgress($race->id, $positions, $finished, $winnerId ?? $this->leadingHorse($positions)));
if ($finished) {
break;
}
sleep(1);
}
// 如果时间到还没分出胜负,取最高进度的马为赢家
if ($winnerId === null) {
arsort($positions);
$winnerId = (int) array_key_first($positions);
}
// 更新场次记录
$race->update([
'race_ends_at' => now(),
'winner_horse_id' => $winnerId,
]);
// 计算注池统计
$totalBets = HorseBet::query()->where('race_id', $race->id)->count();
$totalPool = HorseBet::query()->where('race_id', $race->id)->sum('amount');
$race->update([
'total_bets' => $totalBets,
'total_pool' => $totalPool,
]);
// 触发结算任务
CloseHorseRaceJob::dispatch($race->fresh());
}
/**
* 获取当前领跑马匹 ID进度最高
*
* @param array<int, int> $positions
*/
private function leadingHorse(array $positions): int
{
arsort($positions);
return (int) array_key_first($positions);
}
}

224
app/Models/FortuneLog.php Normal file
View File

@@ -0,0 +1,224 @@
<?php
/**
* 文件功能:神秘占卜记录模型
*
* 记录用户每次占卜的签文类型、签文文本和当日加成效果。
* 内嵌 50+ 条签文库,提供当日次数查询和随机抽签方法。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FortuneLog extends Model
{
protected $fillable = [
'user_id',
'grade',
'text',
'buff_desc',
'is_free',
'cost',
'fortune_date',
];
/**
* 属性类型转换。
*/
protected function casts(): array
{
return [
'is_free' => 'boolean',
'cost' => 'integer',
'fortune_date' => 'date',
];
}
/**
* 占卜用户关联。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 查询指定用户今日占卜次数。
*/
public static function todayCount(int $userId): int
{
return static::query()
->where('user_id', $userId)
->where('fortune_date', today())
->count();
}
/**
* 查询指定用户今日最新一条占卜记录。
*/
public static function todayLatest(int $userId): ?static
{
return static::query()
->where('user_id', $userId)
->where('fortune_date', today())
->latest()
->first();
}
/**
* 根据概率配置随机抽取一个签文等级。
*
* @param array $config 游戏配置参数
* @return string 签文等级jackpot | good | normal | bad | curse
*/
public static function rollGrade(array $config): string
{
$jackpotChance = (int) ($config['jackpot_chance'] ?? 5);
$goodChance = (int) ($config['good_chance'] ?? 20);
$badChance = (int) ($config['bad_chance'] ?? 20);
$curseChance = (int) ($config['curse_chance'] ?? 5);
// normal 占剩余概率
$normalChance = max(0, 100 - $jackpotChance - $goodChance - $badChance - $curseChance);
$rand = random_int(1, 100);
return match (true) {
$rand <= $jackpotChance => 'jackpot',
$rand <= $jackpotChance + $goodChance => 'good',
$rand <= $jackpotChance + $goodChance + $normalChance => 'normal',
$rand <= $jackpotChance + $goodChance + $normalChance + $badChance => 'bad',
default => 'curse',
};
}
/**
* 根据签文等级随机抽取签文内容。
*
* @param string $grade 签文等级
* @return array{text: string, buff_desc: string} 签文文字和加成描述
*/
public static function rollFortune(string $grade): array
{
$library = self::fortuneLibrary();
$pool = $library[$grade] ?? $library['normal'];
$entry = $pool[array_rand($pool)];
return $entry;
}
/**
* 签文库:各等级预设签文(共 55 条)。
*
* @return array<string, array<int, array{text: string, buff_desc: string}>>
*/
private static function fortuneLibrary(): array
{
return [
// ─── 上上签5条──────────────────────────────────────
'jackpot' => [
['text' => '龙凤呈祥,万事皆宜。天降鸿运,财源广进!', 'buff_desc' => '✨ 今日金币获取 +30%,经验获取 +20%'],
['text' => '紫气东来,鸿运当头。凡事顺遂,财运亨通!', 'buff_desc' => '✨ 今日金币获取 +30%,魅力增长 +50%'],
['text' => '吉星高照,百事大吉。此乃天赐良机,把握之!', 'buff_desc' => '✨ 今日全属性获取 +25%'],
['text' => '神明庇佑,万难消散。今日出行,无往不利!', 'buff_desc' => '✨ 今日金币获取 +40%'],
['text' => '天时地利人和,三才俱备。大展宏图,正此时也!', 'buff_desc' => '✨ 今日经验获取 +50%,金币 +20%'],
],
// ─── 上签10条──────────────────────────────────────
'good' => [
['text' => '春风得意马蹄疾,一日看尽长安花。好运正来,进取可得!', 'buff_desc' => '🌸 今日金币获取 +15%'],
['text' => '风调雨顺,五谷丰登。诸事顺利,喜事临门。', 'buff_desc' => '🌸 今日经验获取 +20%'],
['text' => '柳暗花明又一村,峰回路转现坦途。坚持便是胜利!', 'buff_desc' => '🌸 今日金币及经验各 +10%'],
['text' => '心想事成,万事如意。凡有所求,皆可如愿。', 'buff_desc' => '🌸 今日金币获取 +15%'],
['text' => '鱼跃龙门,一步登天。今日努力,事半功倍!', 'buff_desc' => '🌸 今日经验获取 +25%'],
['text' => '花好月圆,良辰美景。诸事大吉,百福临门。', 'buff_desc' => '🌸 今日魅力增长 +30%'],
['text' => '云开雾散见朝阳,前路光明万里长。好运常伴,前途无量!', 'buff_desc' => '🌸 今日金币获取 +20%'],
['text' => '锦上添花,好事成双。今日诸事皆宜,勇往直前!', 'buff_desc' => '🌸 今日全属性 +10%'],
['text' => '马到成功,旗开得胜。积极行动,收获满满!', 'buff_desc' => '🌸 今日经验获取 +15%'],
['text' => '福星高照,喜气洋洋。和气生财,贵人相助!', 'buff_desc' => '🌸 今日金币 +18%,魅力 +20%'],
],
// ─── 中签20条──────────────────────────────────────
'normal' => [
['text' => '守株待兔不如主动出击,时机稍纵即逝,把握当下。', 'buff_desc' => null],
['text' => '平平淡淡才是真,安分守己自太平。此乃中签,宜静不宜动。', 'buff_desc' => null],
['text' => '水至清则无鱼,人至察则无徒。凡事保持平常心。', 'buff_desc' => null],
['text' => '船到桥头自然直,车到山前必有路。莫要忧虑,顺其自然。', 'buff_desc' => null],
['text' => '路漫漫其修远兮,吾将上下而求索。持之以恒,终见曙光。', 'buff_desc' => null],
['text' => '千里之行,始于足下。今日无大凶无大吉,稳中求进。', 'buff_desc' => null],
['text' => '谋事在人,成事在天。尽力而为,其余顺其自然。', 'buff_desc' => null],
['text' => '不以物喜,不以己悲。心态平和,自有一番天地。', 'buff_desc' => null],
['text' => '厚积薄发,积柔成刚。今日蓄力,来日爆发。', 'buff_desc' => null],
['text' => '勤能补拙,笨鸟先飞。平庸亦可,持续努力方为上策。', 'buff_desc' => null],
['text' => '事无大小,做好眼前即是。此签平稳,无风雨亦无彩虹。', 'buff_desc' => null],
['text' => '知足者常乐,贪多者多失。今日知足,便是福气。', 'buff_desc' => null],
['text' => '中庸之道,乃处世良方。不争不抢,随缘自在。', 'buff_desc' => null],
['text' => '晴天要备雨伞,好时要留余地。居安思危,防患未然。', 'buff_desc' => null],
['text' => '静水流深,沉默有力。今日低调行事,暗中蓄势。', 'buff_desc' => null],
['text' => '月有阴晴圆缺,人有悲欢离合。此乃常态,坦然接受。', 'buff_desc' => null],
['text' => '三人行,必有我师。今日宜多学习,少出风头。', 'buff_desc' => null],
['text' => '凡事量力而行,不可强求。今日随缘,顺其自然。', 'buff_desc' => null],
['text' => '忍一时风平浪静,退一步海阔天空。宽容待人,必有回报。', 'buff_desc' => null],
['text' => '心静自然凉,此乃中签之意,平安即是福。', 'buff_desc' => null],
],
// ─── 下签10条──────────────────────────────────────
'bad' => [
['text' => '乌云当头,诸事不顺。今日宜静守,切莫轻举妄动。', 'buff_desc' => '😞 今日金币获取 -10%'],
['text' => '时运不济,命途多舛。凡事三思而后行,小心为上。', 'buff_desc' => '😞 今日经验获取 -10%'],
['text' => '逆水行舟,举步维艰。今日诸事宜谨慎,避免重大决策。', 'buff_desc' => '😞 今日金币获取 -10%'],
['text' => '阴云密布,好运暂退。宜韬光养晦,待时机再出。', 'buff_desc' => '😞 今日全属性 -5%'],
['text' => '事与愿违,心力交瘁。今日多加休息,来日再战。', 'buff_desc' => '😞 今日经验获取 -15%'],
['text' => '小人横行,道路坎坷。今日言多必失,祸从口出,慎言!', 'buff_desc' => '😞 今日金币 -10%,魅力 -10%'],
['text' => '财帛散尽,才识运归。今日不宜大额消费,节俭为上。', 'buff_desc' => '😞 今日金币获取 -15%'],
['text' => '阳光总在风雨后,苦尽甘来是常事。今日忍耐,明日可期。', 'buff_desc' => '😞 今日经验获取 -10%'],
['text' => '暗箭难防,处处小心。今日谨慎行事,切勿冒进。', 'buff_desc' => '😞 今日全属性 -8%'],
['text' => '屋漏偏逢连夜雨,行路偏遇顶头风。逆境磨砺,坚持即胜。', 'buff_desc' => '😞 今日金币获取 -12%'],
],
// ─── 大凶签5条──────────────────────────────────────
'curse' => [
['text' => '大凶!鬼门大开,诸事皆凶。今日宜闭门避祸,切忌妄动!', 'buff_desc' => '💀 今日金币获取 -25%,经验 -20%'],
['text' => '凶星临头,百事皆忌。今日万事不顺,速速祈神化解!', 'buff_desc' => '💀 今日金币获取 -30%'],
['text' => '阴煞临身,大凶兆也!今日诸事皆休,静待天命。', 'buff_desc' => '💀 今日全属性 -20%'],
['text' => '问此签,凶也大凶。如遇困境,切勿强行克服,顺势而为!', 'buff_desc' => '💀 今日经验获取 -30%,金币 -20%'],
['text' => '天机不可泄露,然此签示警。今日三灾八难,小心谨慎,化凶为吉!', 'buff_desc' => '💀 今日金币获取 -25%,魅力 -15%'],
],
];
}
/**
* 返回签文等级对应的中文名称。
*/
public function gradeLabel(): string
{
return match ($this->grade) {
'jackpot' => '上上签',
'good' => '上签',
'normal' => '中签',
'bad' => '下签',
'curse' => '大凶签',
default => '未知',
};
}
/**
* 返回签文等级对应的颜色(前端展示用)。
*/
public function gradeColor(): string
{
return match ($this->grade) {
'jackpot' => '#f59e0b', // 金色
'good' => '#10b981', // 翠绿
'normal' => '#6b7280', // 灰色
'bad' => '#f97316', // 橙色
'curse' => '#ef4444', // 红色
default => '#6b7280',
};
}
}

56
app/Models/HorseBet.php Normal file
View File

@@ -0,0 +1,56 @@
<?php
/**
* 文件功能:赛马竞猜下注记录模型
*
* 记录用户在每场赛马中的下注信息、押注马匹、结果和赔付金额。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class HorseBet extends Model
{
protected $fillable = [
'race_id',
'user_id',
'horse_id',
'amount',
'status',
'payout',
];
/**
* 属性类型转换。
*/
protected function casts(): array
{
return [
'horse_id' => 'integer',
'amount' => 'integer',
'payout' => 'integer',
];
}
/**
* 所属场次。
*/
public function race(): BelongsTo
{
return $this->belongsTo(HorseRace::class, 'race_id');
}
/**
* 下注用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

149
app/Models/HorseRace.php Normal file
View File

@@ -0,0 +1,149 @@
<?php
/**
* 文件功能:赛马竞猜局次模型
*
* 代表一场赛马比赛,包含参赛马匹信息、场次状态、
* 注池统计以及赛果信息。提供当前场次查询和赔率计算方法。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class HorseRace extends Model
{
protected $fillable = [
'status',
'bet_opens_at',
'bet_closes_at',
'race_starts_at',
'race_ends_at',
'horses',
'winner_horse_id',
'total_bets',
'total_pool',
'settled_at',
];
/**
* 属性类型转换。
*/
protected function casts(): array
{
return [
'bet_opens_at' => 'datetime',
'bet_closes_at' => 'datetime',
'race_starts_at' => 'datetime',
'race_ends_at' => 'datetime',
'settled_at' => 'datetime',
'horses' => 'array',
'winner_horse_id' => 'integer',
'total_bets' => 'integer',
'total_pool' => 'integer',
];
}
/**
* 本场所有下注记录。
*/
public function bets(): HasMany
{
return $this->hasMany(HorseBet::class, 'race_id');
}
/**
* 判断当前是否在押注时间窗口内。
*/
public function isBettingOpen(): bool
{
return $this->status === 'betting'
&& now()->between($this->bet_opens_at, $this->bet_closes_at);
}
/**
* 查询当前正在进行的场次(状态为 betting 且押注未截止)。
*/
public static function currentRace(): ?static
{
return static::query()
->whereIn('status', ['betting', 'running'])
->latest()
->first();
}
/**
* 生成参赛马匹列表(根据马匹数量随机选名)。
*
* @param int $count 马匹数量
* @return array<int, array{id: int, name: string, emoji: string}>
*/
public static function generateHorses(int $count): array
{
// 可用马匹名池(原版竞技风格)
$namePool = [
['name' => '赤兔', 'emoji' => '🐎'],
['name' => '乌骓', 'emoji' => '🐴'],
['name' => '的卢', 'emoji' => '🎠'],
['name' => '绝影', 'emoji' => '🦄'],
['name' => '紫骍', 'emoji' => '🐎'],
['name' => '爪黄', 'emoji' => '🐴'],
['name' => '汗血', 'emoji' => '🎠'],
['name' => '飞电', 'emoji' => '⚡'],
];
// 随机打乱并取前 N 个
shuffle($namePool);
$selected = array_slice($namePool, 0, $count);
$horses = [];
foreach ($selected as $i => $horse) {
$horses[] = [
'id' => $i + 1,
'name' => $horse['name'],
'emoji' => $horse['emoji'],
];
}
return $horses;
}
/**
* 根据注池计算各马匹实时赔率(彩池制,扣除庄家抽水后按比例分配)。
*
* @param int $horseBetAmounts 各马匹的注额数组 [horse_id => amount]
* @param int $housePercent 庄家抽水百分比
* @return array<int, float> horse_id => 赔率(含本金)
*/
public static function calcOdds(array $horseBetAmounts, int $housePercent): array
{
$totalPool = array_sum($horseBetAmounts);
if ($totalPool <= 0) {
// 尚无下注,返回等额赔率
$count = count($horseBetAmounts);
return array_map(fn () => 1.0, $horseBetAmounts);
}
$netPool = $totalPool * (1 - $housePercent / 100);
$odds = [];
foreach ($horseBetAmounts as $horseId => $amount) {
if ($amount <= 0) {
// 无人押注的马,赔率设为理论最大值
$odds[$horseId] = round($netPool, 2);
} else {
// 赔率 = 净注池 / 该马注额(含本金返还)
$odds[$horseId] = round($netPool / $amount, 2);
}
}
return $odds;
}
}

View File

@@ -1,10 +1,69 @@
<?php
/**
* 文件功能:婚礼红包领取记录模型
*
* 对应 wedding_envelope_claims 表。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WeddingEnvelopeClaim extends Model
{
//
/**
* 该模型不使用 updated_at 字段。
*/
const UPDATED_AT = null;
/**
* 允许批量赋值的属性。
*
* @var list<string>
*/
protected $fillable = [
'ceremony_id',
'user_id',
'amount',
'claimed',
'claimed_at',
'created_at',
];
/**
* 获取属性类型转换。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'claimed' => 'boolean',
'claimed_at' => 'datetime',
'created_at' => 'datetime',
'amount' => 'integer',
];
}
/**
* 关联婚礼仪式。
*/
public function ceremony(): BelongsTo
{
return $this->belongsTo(WeddingCeremony::class, 'ceremony_id');
}
/**
* 关联领取用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -213,7 +213,7 @@ class AppointmentService
->whereNull('logout_at')
->whereDate('login_at', today())
->update([
'logout_at' => now(),
'logout_at' => now(),
'duration_seconds' => DB::raw('TIMESTAMPDIFF(SECOND, login_at, NOW())'),
]);
@@ -222,12 +222,17 @@ class AppointmentService
->whereNull('logout_at')
->whereDate('login_at', '<', today())
->update([
'logout_at' => DB::raw('login_at'),
'logout_at' => DB::raw('login_at'),
'duration_seconds' => 0,
]);
// user_level 归 1由系统经验值自然升级机制重新成长
$target->update(['user_level' => 1]);
// 撤职后:按当前经验值重新计算等级(不再无条件归 1
// 这样用户撤职后能保留正常的经验升级成果
$recalcLevel = \App\Models\Sysparam::calculateLevel($target->exp_num ?? 0);
$superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
// 不超过满级阈值
$safeLevel = max(1, min($recalcLevel, $superLevel - 1));
$target->update(['user_level' => $safeLevel]);
// 写入权限操作日志
$this->logAuthority(

View File

@@ -127,6 +127,15 @@ return [
'path' => storage_path('logs/laravel.log'),
],
// 覆盖 Laravel Boost 默认的 browser 日志频道,改为按天分割存储。
// Boost 在注册时会检查此频道是否已存在,若已定义则直接使用此配置。
'browser' => [
'driver' => 'daily',
'path' => storage_path('logs/browser.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 14,
],
],
];

View File

@@ -0,0 +1,62 @@
<?php
/**
* 文件功能:赛马竞猜局次表迁移
*
* 记录每场赛马比赛的状态、参赛马匹、赢家结果
* 以及下注统计等信息。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建 horse_races 表。
*/
public function up(): void
{
Schema::create('horse_races', function (Blueprint $table) {
$table->id();
// 场次状态betting押注中| running跑马中| settled已结算
$table->string('status', 20)->default('betting')->index();
// 押注时间窗口
$table->timestamp('bet_opens_at')->nullable();
$table->timestamp('bet_closes_at')->nullable();
// 跑马开始和结束时间
$table->timestamp('race_starts_at')->nullable();
$table->timestamp('race_ends_at')->nullable();
// 参赛马匹JSON 数组:[{id, name, emoji, odds}]
$table->json('horses')->nullable();
// 获胜马匹 ID1~N
$table->tinyInteger('winner_horse_id')->nullable();
// 本场统计
$table->unsignedBigInteger('total_bets')->default(0); // 总参与人次
$table->unsignedBigInteger('total_pool')->default(0); // 注池总金额
$table->timestamp('settled_at')->nullable();
$table->timestamps();
});
}
/**
* 回滚。
*/
public function down(): void
{
Schema::dropIfExists('horse_races');
}
};

View File

@@ -0,0 +1,63 @@
<?php
/**
* 文件功能:神秘占卜记录表迁移
*
* 记录每次用户占卜的签文类型、签文内容、
* 当日 buff 效果及消耗金币记录。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建 fortune_logs 表。
*/
public function up(): void
{
Schema::create('fortune_logs', function (Blueprint $table) {
$table->id();
// 占卜用户
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
// 签文类型jackpot上上签| good上签| normal中签| bad下签| curse大凶签
$table->string('grade', 20);
// 签文内容(预设文字)
$table->string('text', 500);
// 当日加成描述(例如:"今日经验+20%"
$table->string('buff_desc', 200)->nullable();
// 是否为免费次数false=付费额外次数)
$table->boolean('is_free')->default(true);
// 占卜消耗金币(免费次数为 0
$table->unsignedInteger('cost')->default(0);
// 占卜日期(用于计算每日首次)
$table->date('fortune_date');
$table->timestamps();
// 加速每日次数查询
$table->index(['user_id', 'fortune_date']);
});
}
/**
* 回滚。
*/
public function down(): void
{
Schema::dropIfExists('fortune_logs');
}
};

View File

@@ -0,0 +1,63 @@
<?php
/**
* 文件功能:赛马竞猜下注记录表迁移
*
* 记录每场比赛中每位用户的下注信息,
* 包括押注的马匹、金额、结果和赔付金额。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建 horse_bets 表。
*/
public function up(): void
{
Schema::create('horse_bets', function (Blueprint $table) {
$table->id();
// 关联赛事
$table->foreignId('race_id')->constrained('horse_races')->cascadeOnDelete();
// 下注用户
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
// 押注的马匹 ID1~N
$table->tinyInteger('horse_id');
// 下注金额
$table->unsignedBigInteger('amount');
// 状态pending待开奖| won中奖| lost未中
$table->string('status', 20)->default('pending');
// 实际赔付金额含本金返还lost 时为 0
$table->unsignedBigInteger('payout')->default(0);
$table->timestamps();
// 每场每人只能押注一次
$table->unique(['race_id', 'user_id']);
// 加速查询
$table->index('status');
});
}
/**
* 回滚。
*/
public function down(): void
{
Schema::dropIfExists('horse_bets');
}
};

View File

@@ -143,6 +143,12 @@
@include('chat.partials.slot-machine')
{{-- ═══════════ 神秘箱子游戏面板 ═══════════ --}}
@include('chat.partials.mystery-box')
{{-- ═══════════ 赛马竞猜游戏面板 ═══════════ --}}
@include('chat.partials.horse-race-panel')
{{-- ═══════════ 神秘占卜游戏面板 ═══════════ --}}
@include('chat.partials.fortune-panel')
{{-- ═══════════ 娱乐游戏大厅弹窗 ═══════════ --}}
@include('chat.partials.game-hall')
{{-- 全屏特效系统:管理员烟花/下雨/雷电/下雪 --}}
<script src="/js/effects/effect-sounds.js"></script>

View File

@@ -8,207 +8,203 @@
- 展示近10局历史趋势
--}}
{{-- ─── 百家乐主面板 ─── --}}
{{-- 百家乐主面板 --}}
<div id="baccarat-panel" x-data="baccaratPanel()" x-show="show" x-cloak>
<div x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
style="position:fixed; inset:0; background:rgba(0,0,0,.7); z-index:9940;
style="position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9940;
display:flex; align-items:center; justify-content:center;">
<div
style="width:480px; max-width:96vw; border-radius:24px; overflow:hidden;
box-shadow:0 24px 80px rgba(139,92,246,.5); font-family:system-ui,sans-serif;">
style="width:460px; max-width:96vw; border-radius:8px; overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,.3); font-family:'Microsoft YaHei',SimSun,sans-serif; background:#fff;">
{{-- ─── 顶部标题 ─── --}}
<div
style="background:linear-gradient(135deg,#4c1d95,#6d28d9,#7c3aed); padding:18px 22px 14px; position:relative;">
<div style="display:flex; align-items:center; justify-content:space-between;">
<div>
<div style="color:#fff; font-weight:900; font-size:18px; letter-spacing:1px;">🎲 百家乐</div>
<div style="color:rgba(255,255,255,.6); font-size:12px; margin-top:2px;">
<span x-text="'#' + roundId"></span>
</div>
</div>
{{-- 倒计时 --}}
<div x-show="phase === 'betting'" style="text-align:center;">
<div style="color:#fbbf24; font-size:32px; font-weight:900; line-height:1;" x-text="countdown">
</div>
<div style="color:rgba(255,255,255,.5); font-size:11px;">秒后截止</div>
</div>
{{-- 骰子结果 --}}
<div x-show="phase === 'settled'" style="display:none; text-align:center;">
<div style="font-size:28px;" x-text="diceEmoji"></div>
<div style="color:#fbbf24; font-size:12px; font-weight:bold; margin-top:2px;"
x-text="resultLabel"></div>
style="background:linear-gradient(135deg,#336699,#5a8fc0); padding:10px 16px;
display:flex; align-items:center; justify-content:space-between;">
<div>
<div style="color:#fff; font-weight:bold; font-size:14px;">🎲 百家乐</div>
<div style="color:rgba(255,255,255,.75); font-size:11px; margin-top:1px;">
<span x-text="'#' + roundId"></span>
</div>
</div>
{{-- 倒计时 --}}
<div x-show="phase === 'betting'" style="text-align:center;">
<div style="color:#fbbf24; font-size:28px; font-weight:900; line-height:1;" x-text="countdown">
</div>
<div style="color:rgba(255,255,255,.7); font-size:11px;">秒后截止</div>
</div>
{{-- 骰子结果 --}}
<div x-show="phase === 'settled'" style="display:none; text-align:center;">
<div style="font-size:24px;" x-text="diceEmoji"></div>
<div style="color:#fbbf24; font-size:12px; font-weight:bold; margin-top:2px;" x-text="resultLabel">
</div>
</div>
</div>
{{-- 进度条 --}}
<div x-show="phase === 'betting'"
style="margin-top:10px; height:4px; background:rgba(255,255,255,.15); border-radius:2px; overflow:hidden;">
<div style="height:100%; background:#fbbf24; border-radius:2px; transition:width 1s linear;"
:style="'width:' + Math.max(0, (countdown / totalSeconds * 100)) + '%'"></div>
</div>
{{-- 进度条 --}}
<div x-show="phase === 'betting'" style="height:3px; background:rgba(255,255,255,.2);">
<div style="height:100%; background:#fbbf24; transition:width 1s linear;"
:style="'width:' + Math.max(0, (countdown / totalSeconds * 100)) + '%'"></div>
</div>
{{-- ─── 历史趋势 ─── --}}
<div
style="background:#1e1b4b; padding:8px 16px; display:flex; gap:6px; align-items:center; flex-wrap:wrap;">
<span style="color:rgba(255,255,255,.4); font-size:11px; margin-right:2px;">近期</span>
style="background:#f0f6ff; padding:7px 14px; display:flex; gap:5px; align-items:center;
flex-wrap:wrap; border-bottom:1px solid #d0e4f5;">
<span style="color:#99b0cc; font-size:11px; margin-right:2px;">近期</span>
<template x-for="h in history" :key="h.id">
<span
style="width:22px; height:22px; border-radius:50%; font-size:11px; font-weight:bold;
display:flex; align-items:center; justify-content:center;"
:style="h.result === 'big' ? 'background:#1d4ed8; color:#fff' :
h.result === 'small' ? 'background:#b45309; color:#fff' :
h.result === 'triple' ? 'background:#7c3aed; color:#fff' :
'background:#374151; color:#9ca3af'"
display:flex; align-items:center; justify-content:center; color:#fff;"
:style="h.result === 'big' ? 'background:#1d4ed8' :
h.result === 'small' ? 'background:#d97706' :
h.result === 'triple' ? 'background:#7c3aed' :
'background:#94a3b8'"
:title="'#' + h.id + ' ' + (h.result === 'big' ? '大' : h.result === 'small' ? '小' : h
.result === 'triple' ? '豹' : '☠')"
x-text="h.result === 'big' ? '大' : h.result === 'small' ? '小' : h.result === 'triple' ? '豹' : '☠'">
</span>
</template>
<span x-show="history.length === 0" style="color:rgba(255,255,255,.3); font-size:11px;">暂无记录</span>
<span x-show="history.length === 0" style="color:#b0c4d8; font-size:11px;">暂无记录</span>
</div>
{{-- ─── 主体内容 ─── --}}
<div style="background:linear-gradient(180deg,#1e1b4b,#1a1035); padding:18px 20px;">
{{-- ─── 主体内容(白底) ─── --}}
<div style="background:#fff; padding:14px 16px;">
{{-- 押注阶段 --}}
<div x-show="phase === 'betting'">
{{-- 当前下注池统计 --}}
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px; margin-bottom:14px;">
<div style="background:rgba(29,78,216,.3); border-radius:10px; padding:8px; text-align:center;">
<div style="color:#60a5fa; font-size:11px;">押大</div>
<div style="color:#fff; font-weight:bold; font-size:13px;"
{{-- 下注池统计 --}}
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px; margin-bottom:12px;">
<div
style="background:#eff6ff; border:1px solid #bfdbfe; border-radius:8px; padding:8px; text-align:center;">
<div style="color:#1d4ed8; font-size:11px; font-weight:bold;">押大</div>
<div style="color:#1e40af; font-weight:bold; font-size:13px;"
x-text="Number(totalBetBig).toLocaleString()"></div>
</div>
<div style="background:rgba(180,83,9,.3); border-radius:10px; padding:8px; text-align:center;">
<div style="color:#fbbf24; font-size:11px;">押小</div>
<div style="color:#fff; font-weight:bold; font-size:13px;"
<div
style="background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:8px; text-align:center;">
<div style="color:#d97706; font-size:11px; font-weight:bold;">押小</div>
<div style="color:#b45309; font-weight:bold; font-size:13px;"
x-text="Number(totalBetSmall).toLocaleString()"></div>
</div>
<div
style="background:rgba(124,58,237,.3); border-radius:10px; padding:8px; text-align:center;">
<div style="color:#c4b5fd; font-size:11px;">押豹子</div>
<div style="color:#fff; font-weight:bold; font-size:13px;"
style="background:#f5f3ff; border:1px solid #ddd6fe; border-radius:8px; padding:8px; text-align:center;">
<div style="color:#7c3aed; font-size:11px; font-weight:bold;">押豹子</div>
<div style="color:#6d28d9; font-weight:bold; font-size:13px;"
x-text="Number(totalBetTriple).toLocaleString()"></div>
</div>
</div>
{{-- 已下注状态 / 下注表单 --}}
{{-- 已下注状态 --}}
<div x-show="myBet">
<div
style="background:rgba(34,197,94,.15); border:1px solid rgba(34,197,94,.3); border-radius:12px;
padding:12px 16px; text-align:center; margin-bottom:12px;">
<div style="color:#4ade80; font-weight:bold; font-size:14px;">
style="background:#f0fdf4; border:1px solid #86efac; border-radius:10px;
padding:10px 14px; text-align:center; margin-bottom:10px;">
<div style="color:#16a34a; font-weight:bold; font-size:13px;">
已押注「<span x-text="betTypeLabel(myBetType)"></span>
<span x-text="Number(myBetAmount).toLocaleString()"></span> 金币
</div>
<div style="color:rgba(255,255,255,.4); font-size:11px; margin-top:4px;">等待开奖中…</div>
<div style="color:#86a896; font-size:11px; margin-top:3px;">等待开奖中…</div>
</div>
</div>
{{-- 下注表单 --}}
<div x-show="!myBet">
{{-- 押注选项 --}}
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px; margin-bottom:12px;">
{{-- --}}
<button x-on:click="selectedType='big'"
style="border:none; border-radius:12px; padding:12px 0; cursor:pointer; transition:all .15s; font-weight:bold;"
:style="selectedType === 'big' ?
'background:#1d4ed8; color:#fff; transform:scale(1.05); box-shadow:0 4px 20px rgba(29,78,216,.5)' :
'background:rgba(29,78,216,.2); color:#93c5fd;'">
<div style="font-size:20px;">🔵</div>
<div style="font-size:13px; margin-top:2px;"></div>
<div style="font-size:10px; opacity:.7;">11~17 1:1</div>
style="border-radius:10px; padding:12px 0; cursor:pointer; transition:all .15s; font-weight:bold; font-family:inherit;"
:style="selectedType === 'big'
?
'border:2px solid #1d4ed8; background:#1d4ed8; color:#fff; transform:scale(1.05); box-shadow:0 4px 14px rgba(29,78,216,.3);' :
'border:2px solid #bfdbfe; background:#eff6ff; color:#1d4ed8;'">
<div style="font-size:22px;">🔵</div>
<div style="font-size:13px; margin-top:4px;"></div>
<div style="font-size:10px; margin-top:2px; opacity:.75;">11~17 1:1</div>
</button>
{{-- --}}
<button x-on:click="selectedType='small'"
style="border:none; border-radius:12px; padding:12px 0; cursor:pointer; transition:all .15s; font-weight:bold;"
:style="selectedType === 'small' ?
'background:#b45309; color:#fff; transform:scale(1.05); box-shadow:0 4px 20px rgba(180,83,9,.5)' :
'background:rgba(180,83,9,.2); color:#fcd34d;'">
<div style="font-size:20px;">🟡</div>
<div style="font-size:13px; margin-top:2px;"></div>
<div style="font-size:10px; opacity:.7;">4~10 1:1</div>
style="border-radius:10px; padding:12px 0; cursor:pointer; transition:all .15s; font-weight:bold; font-family:inherit;"
:style="selectedType === 'small'
?
'border:2px solid #d97706; background:#d97706; color:#fff; transform:scale(1.05); box-shadow:0 4px 14px rgba(217,119,6,.3);' :
'border:2px solid #fde68a; background:#fffbeb; color:#b45309;'">
<div style="font-size:22px;">🟡</div>
<div style="font-size:13px; margin-top:4px;"></div>
<div style="font-size:10px; margin-top:2px; opacity:.75;">4~10 1:1</div>
</button>
{{-- 豹子 --}}
<button x-on:click="selectedType='triple'"
style="border:none; border-radius:12px; padding:12px 0; cursor:pointer; transition:all .15s; font-weight:bold;"
:style="selectedType === 'triple' ?
'background:#7c3aed; color:#fff; transform:scale(1.05); box-shadow:0 4px 20px rgba(124,58,237,.5)' :
'background:rgba(124,58,237,.2); color:#c4b5fd;'">
<div style="font-size:20px;">💥</div>
<div style="font-size:13px; margin-top:2px;">豹子</div>
<div style="font-size:10px; opacity:.7;">三同 1:24</div>
style="border-radius:10px; padding:12px 0; cursor:pointer; transition:all .15s; font-weight:bold; font-family:inherit;"
:style="selectedType === 'triple'
?
'border:2px solid #7c3aed; background:#7c3aed; color:#fff; transform:scale(1.05); box-shadow:0 4px 14px rgba(124,58,237,.3);' :
'border:2px solid #ddd6fe; background:#f5f3ff; color:#7c3aed;'">
<div style="font-size:22px;">💥</div>
<div style="font-size:13px; margin-top:4px;">豹子</div>
<div style="font-size:10px; margin-top:2px; opacity:.75;">三同 1:24</div>
</button>
</div>
{{-- 快捷金额 + 自定义 --}}
<div style="margin-bottom:10px;">
<div style="display:flex; gap:6px; margin-bottom:8px; flex-wrap:wrap;">
<template x-for="preset in [100, 500, 1000, 5000, 10000]" :key="preset">
<button x-on:click="betAmount = preset"
style="flex:1; min-width:50px; border:none; border-radius:8px; padding:6px 4px;
font-size:12px; font-weight:bold; cursor:pointer; transition:all .1s;"
:style="betAmount === preset ?
'background:#fbbf24; color:#1a1035;' :
'background:rgba(255,255,255,.1); color:rgba(255,255,255,.7);'"
x-text="preset >= 1000 ? (preset/1000)+'k' : preset">
</button>
</template>
</div>
<input type="number" x-model.number="betAmount" min="100" placeholder="自定义金额"
style="width:100%; background:rgba(255,255,255,.1); border:1px solid rgba(255,255,255,.15);
border-radius:8px; padding:8px 12px; color:#fff; font-size:13px; box-sizing:border-box;"
x-on:focus="$event.target.select()">
{{-- 快捷金额 --}}
<div style="display:grid; grid-template-columns:repeat(5,1fr); gap:6px; margin-bottom:10px;">
<template x-for="preset in [100, 500, 1000, 5000, 10000]" :key="preset">
<button x-on:click="betAmount = preset"
style="border-radius:20px; padding:8px 2px; font-size:13px; font-weight:bold;
cursor:pointer; transition:all .15s; font-family:inherit; text-align:center;"
:style="betAmount === preset ?
'background:#336699; color:#fff; border:none; box-shadow:0 3px 10px rgba(51,102,153,.35); transform:translateY(-1px);' :
'background:#fff; color:#336699; border:1.5px solid #c0d8ef;'"
x-text="preset >= 1000 ? (preset/1000)+'k' : preset">
</button>
</template>
</div>
{{-- 自定义金额 --}}
<input type="number" x-model.number="betAmount" min="100" placeholder="自定义金额"
style="width:100%; background:#f6faff; border:1.5px solid #d0e4f5;
border-radius:8px; padding:8px 12px; color:#225588; font-size:13px;
box-sizing:border-box; margin-bottom:10px;"
x-on:focus="$event.target.select()">
{{-- 下注按钮 --}}
<button x-on:click="submitBet()" :disabled="!selectedType || betAmount < 100 || submitting"
style="width:100%; border:none; border-radius:12px; padding:14px;
font-size:15px; font-weight:bold; cursor:pointer; transition:all .2s;
letter-spacing:1px;"
:style="(!selectedType || betAmount < 100 || submitting) ? {
background: '#f1f5f9',
color: '#94a3b8',
cursor: 'not-allowed',
boxShadow: 'none'
} : {
background: 'linear-gradient(135deg,#6d28d9,#7c3aed,#8b5cf6)',
color: '#fff',
cursor: 'pointer',
boxShadow: '0 4px 16px rgba(124,58,237,0.5)'
}">
:style="(!selectedType || betAmount < 100 || submitting) ?
'display:block; width:100%; border:none; border-radius:12px; padding:13px 0; font-size:14px; font-weight:bold; cursor:not-allowed; transition:all .2s; background:#e0e8f0; color:#99a8b8; box-shadow:none; font-family:inherit;' :
'display:block; width:100%; border:none; border-radius:12px; padding:13px 0; font-size:14px; font-weight:bold; cursor:pointer; transition:all .2s; background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff; box-shadow:0 4px 14px rgba(51,102,153,.3); font-family:inherit;'">
<span
x-text="submitting ? '提交中…' : (!selectedType ? '请先选择大/小/豹子' : '🎲 押注「' + betTypeLabel(selectedType) + '」 ' + Number(betAmount).toLocaleString() + ' 金币')"></span>
</button>
</div>
{{-- 规则提示 --}}
<div style="margin-top:10px; color:rgba(255,255,255,.3); font-size:10px; text-align:center;">
<div style="margin-top:10px; color:#99b0cc; font-size:10px; text-align:center;">
☠️ 3点或18点为庄家收割全灭无退款。豹子优先于大小判断。
</div>
</div>
{{-- 等待开奖阶段 --}}
<div x-show="phase === 'waiting'" style="display:none; text-align:center; padding:16px 0;">
<div style="font-size:40px; animation:spin 1s linear infinite; display:inline-block;">🎲</div>
<div style="color:rgba(255,255,255,.6); margin-top:8px;">正在摇骰子…</div>
<div x-show="phase === 'waiting'" style="display:none; text-align:center; padding:20px 0;">
<div style="font-size:44px; animation:spin 1s linear infinite; display:inline-block;">🎲</div>
<div style="color:#5a8fc0; margin-top:10px; font-weight:bold;">正在摇骰子…</div>
</div>
{{-- 结算阶段 --}}
<div x-show="phase === 'settled'" style="display:none;">
{{-- 骰子展示(数字方块,跨平台兼容) --}}
<div style="display:flex; justify-content:center; gap:10px; margin-bottom:14px;">
{{-- 骰子展示 --}}
<div style="display:flex; justify-content:center; gap:10px; margin-bottom:16px;">
<template x-for="(d, i) in settledDice" :key="i">
<div style="width:54px; height:54px; border-radius:12px; font-weight:900;
<div style="width:56px; height:56px; border-radius:12px; font-weight:900;
display:flex; align-items:center; justify-content:center;
font-size:26px; box-shadow:0 6px 20px rgba(0,0,0,.5);
animation:dice-pop .4s ease-out both; color:#1e1b4b;
background:linear-gradient(145deg,#fff,#e0e7ff);"
font-size:26px; animation:dice-pop .4s ease-out both;
color:#1e3a5f; background:linear-gradient(145deg,#ffffff,#e8f0fb);
border:1.5px solid #d0e4f5; box-shadow:0 4px 14px rgba(51,102,153,.12);"
:style="'animation-delay:' + (i * 0.18) + 's'" x-text="d">
</div>
</template>
@@ -216,13 +212,13 @@
{{-- 结果标签 --}}
<div style="text-align:center; margin-bottom:14px;">
<div style="font-size:26px; font-weight:900; letter-spacing:2px;"
:style="settledResult === 'big' ? 'color:#60a5fa' :
settledResult === 'small' ? 'color:#fbbf24' :
settledResult === 'triple' ? 'color:#c4b5fd' :
settledResult === 'kill' ? 'color:#f87171' : 'color:#fbbf24'"
<div style="font-size:24px; font-weight:900; letter-spacing:2px;"
:style="settledResult === 'big' ? 'color:#1d4ed8' :
settledResult === 'small' ? 'color:#d97706' :
settledResult === 'triple' ? 'color:#7c3aed' :
settledResult === 'kill' ? 'color:#dc2626' : 'color:#336699'"
x-text="resultLabel"></div>
<div style="color:rgba(255,255,255,.35); font-size:12px; margin-top:4px;"
<div style="color:#99b0cc; font-size:12px; margin-top:4px;"
x-text="'骰子总点数:' + settledTotal + ' 点'"></div>
</div>
@@ -230,15 +226,14 @@
<div x-show="myBet">
{{-- 中奖 --}}
<div x-show="myWon"
style="border-radius:14px; overflow:hidden; margin-bottom:4px;
background:linear-gradient(135deg,rgba(16,185,129,.25),rgba(5,150,105,.15));
border:1px solid rgba(52,211,153,.4);">
style="border-radius:12px; overflow:hidden; margin-bottom:4px;
background:#f0fdf4; border:1px solid #86efac;">
<div style="padding:14px 16px; text-align:center;">
<div style="font-size:32px; margin-bottom:4px;">🎉</div>
<div style="color:#34d399; font-size:18px; font-weight:900;">恭喜中奖!</div>
<div style="color:#6ee7b7; font-size:24px; font-weight:bold; margin:6px 0;"
<div style="color:#16a34a; font-size:18px; font-weight:900;">恭喜中奖!</div>
<div style="color:#15803d; font-size:24px; font-weight:bold; margin:6px 0;"
x-text="'+' + Number(myPayout).toLocaleString() + ' 🪙'"></div>
<div style="color:rgba(255,255,255,.4); font-size:11px;"
<div style="color:#86a896; font-size:11px;"
x-text="'押「' + betTypeLabel(myBetType) + '」' + Number(myBetAmount).toLocaleString() + ' 金币 → 赢得 ' + Number(myPayout).toLocaleString() + ' 金币'">
</div>
</div>
@@ -246,48 +241,46 @@
{{-- 未中奖 --}}
<div x-show="!myWon"
style="border-radius:14px; overflow:hidden; margin-bottom:4px;
background:linear-gradient(135deg,rgba(239,68,68,.15),rgba(185,28,28,.1));
border:1px solid rgba(248,113,113,.25);">
style="border-radius:12px; overflow:hidden; margin-bottom:4px;
background:#fff5f5; border:1px solid #fecaca;">
<div style="padding:14px 16px; text-align:center;">
<div style="font-size:28px; margin-bottom:4px;">😔</div>
<div style="color:#f87171; font-size:16px; font-weight:bold; margin-bottom:6px;">本局未中奖
<div style="color:#dc2626; font-size:16px; font-weight:bold; margin-bottom:8px;">本局未中奖
</div>
<div
style="display:inline-flex; align-items:center; gap:8px;
background:rgba(0,0,0,.2); border-radius:20px; padding:5px 14px;">
<span style="color:rgba(255,255,255,.5); font-size:12px;">你押了</span>
<span style="font-weight:bold; font-size:13px; color:#fbbf24;"
background:#fef2f2; border-radius:20px; padding:5px 14px; border:1px solid #fecaca;">
<span style="color:#64748b; font-size:12px;">你押了</span>
<span style="font-weight:bold; font-size:13px; color:#d97706;"
x-text="betTypeLabel(myBetType)"></span>
<span style="color:rgba(255,255,255,.3); font-size:11px;">·</span>
<span style="color:rgba(255,255,255,.5); font-size:12px;">开了</span>
<span style="color:#d0d5db; font-size:11px;">·</span>
<span style="color:#64748b; font-size:12px;">开了</span>
<span style="font-weight:bold; font-size:13px;"
:style="settledResult === 'big' ? 'color:#60a5fa' :
settledResult === 'small' ? 'color:#fbbf24' :
settledResult === 'triple' ? 'color:#c4b5fd' : 'color:#f87171'"
:style="settledResult === 'big' ? 'color:#1d4ed8' :
settledResult === 'small' ? 'color:#d97706' :
settledResult === 'triple' ? 'color:#7c3aed' : 'color:#dc2626'"
x-text="resultLabel"></span>
</div>
<div style="color:rgba(255,255,255,.3); font-size:11px; margin-top:8px;"
<div style="color:#94a3b8; font-size:11px; margin-top:8px;"
x-text="'损失 ' + Number(myBetAmount).toLocaleString() + ' 金币'"></div>
</div>
</div>
</div>
{{-- 未下注但看结果 --}}
<div x-show="!myBet"
style="text-align:center; color:rgba(255,255,255,.3); font-size:12px; padding:8px 0;">
<div x-show="!myBet" style="text-align:center; color:#99b0cc; font-size:12px; padding:8px 0;">
本局未参与下注
</div>
</div>
</div>
{{-- ─── 底部关闭 ─── --}}
<div style="background:rgba(15,10,40,.95); padding:10px 20px; display:flex; justify-content:center;">
{{-- 底部关闭 --}}
<div
style="background:#fff; border-top:1px solid #d0e4f5; padding:12px 16px; display:flex; justify-content:center;">
<button x-on:click="close()"
style="padding:7px 28px; background:rgba(255,255,255,.08); border:none; border-radius:20px;
font-size:12px; color:rgba(255,255,255,.5); cursor:pointer; transition:all .15s;"
onmouseover="this.style.background='rgba(255,255,255,.15)'"
onmouseout="this.style.background='rgba(255,255,255,.08)'">
style="padding:10px 48px; min-width:140px; background:#f0f6ff; border:1px solid #b0d0ee; border-radius:12px;
font-size:14px; font-weight:bold; color:#336699; cursor:pointer; transition:all .15s; font-family:inherit;"
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">
关闭
</button>
</div>
@@ -295,19 +288,7 @@
</div>
</div>
{{-- ─── 骨骰悬浮入口(游戏开启时常驻,支持拖拽) ─── --}}
<div id="baccarat-fab" x-data="baccaratFab()" x-show="visible" x-cloak
:style="'position:fixed; right:' + posX + 'px; bottom:' + posY + 'px; z-index:9900; touch-action:none; user-select:none;'"
@pointerdown.prevent="startDrag($event)" @pointermove.window="onDrag($event)" @pointerup.window="endDrag($event)"
@pointercancel.window="endDrag($event)">
<button
style="width:52px; height:52px; border-radius:50%; border:none;
background:linear-gradient(135deg,#7c3aed,#4f46e5);
box-shadow:0 4px 20px rgba(124,58,237,.5);
font-size:22px; display:flex; align-items:center; justify-content:center;
animation:pulse-fab 2s infinite; user-select:none;"
:style="dragging ? 'cursor:grabbing;' : 'cursor:grab;'" title="百家乐下注中(可拖动)">🎲</button>
</div>
<script>
/**
@@ -396,11 +377,11 @@
0%,
100% {
box-shadow: 0 4px 20px rgba(124, 58, 237, .5);
box-shadow: 0 4px 20px rgba(51, 102, 153, .4);
}
50% {
box-shadow: 0 4px 30px rgba(124, 58, 237, .9);
box-shadow: 0 4px 30px rgba(51, 102, 153, .8);
}
}
</style>
@@ -528,7 +509,7 @@
this.myBet = true;
this.myBetType = data.bet_type;
this.myBetAmount = data.amount;
window.chatDialog?.alert(data.message, '下注成功', '#7c3aed');
window.chatDialog?.alert(data.message, '下注成功', '#336699');
} else {
window.chatDialog?.alert(data.message || '下注失败', '提示', '#ef4444');
}

View File

@@ -0,0 +1,377 @@
{{--
文件功能:神秘占卜前台弹窗组件
聊天室内神秘占卜面板:
- 点击悬浮 FAB 打开面板
- 展示今日签文(免费次数 / 付费次数)
- 卦象摇动动画 + 签文翻转展示
- 展示近20条历史记录
--}}
{{-- ─── 神秘占卜主面板 ─── --}}
<div id="fortune-panel" x-data="fortunePanel()" x-show="show" x-cloak>
<div x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
style="position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9942;
display:flex; align-items:center; justify-content:center;">
<div
style="width:420px; max-width:96vw; border-radius:8px; overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,.3); font-family:'Microsoft YaHei',SimSun,sans-serif; background:#fff;">
{{-- ─── 顶部标题 ─── --}}
<div
style="background:linear-gradient(135deg,#336699,#5a8fc0); padding:10px 16px; display:flex; align-items:center; gap:10px;">
<div style="font-size:14px; font-weight:bold; color:#fff; flex:1;">🔮 神秘占卜</div>
<div style="font-size:11px; color:rgba(255,255,255,.75);">
今日免费 <span x-text="freeCount"></span> 次,已用 <span x-text="freeUsed"></span>
</div>
<span onclick="Alpine.$data(document.getElementById('fortune-panel')).show = false"
style="cursor:pointer; font-size:18px; color:rgba(255,255,255,.8); line-height:1; transition:opacity .15s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=.8">&times;</span>
</div>
{{-- ─── 主体内容 ─── --}}
<div style="background:#fff; padding:14px 16px;">
{{-- Tab 切换 --}}
<div style="display:flex; gap:6px; margin-bottom:16px;">
<button @click="activeTab = 'tell'"
:style="activeTab === 'tell'
?
'flex:1; border:none; border-radius:6px; padding:8px 0; font-size:13px; font-weight:bold; cursor:pointer; transition:all .2s; background:#336699; color:#fff; box-shadow:0 2px 6px rgba(51,102,153,.25);' :
'flex:1; border:1px solid #d0e4f5; border-radius:6px; padding:8px 0; font-size:13px; font-weight:bold; cursor:pointer; transition:all .2s; background:#f6faff; color:#5a8fc0;'">
🔮 今日占卜
</button>
<button @click="activeTab = 'history'; loadHistory()"
:style="activeTab === 'history'
?
'flex:1; border:none; border-radius:6px; padding:8px 0; font-size:13px; font-weight:bold; cursor:pointer; transition:all .2s; background:#336699; color:#fff; box-shadow:0 2px 6px rgba(51,102,153,.25);' :
'flex:1; border:1px solid #d0e4f5; border-radius:6px; padding:8px 0; font-size:13px; font-weight:bold; cursor:pointer; transition:all .2s; background:#f6faff; color:#5a8fc0;'">
📜 历史记录
</button>
</div>
{{-- ── 占卜 Tab ── --}}
<div x-show="activeTab === 'tell'">
{{-- 占卜动画区 --}}
<div style="text-align:center; padding:10px 0 16px;">
{{-- 未占卜:摇卦动画 --}}
<div x-show="!resultGrade">
<div style="font-size:72px; display:inline-block;"
:style="shaking ? 'animation:fortune-shake .5s ease-in-out;' :
'animation:float-orb 3s ease-in-out infinite;'">
🔮
</div>
<div style="color:#888; font-size:12px; margin-top:8px;">
<span x-show="hasFreeLeft">点击下方按钮,开启今日占卜</span>
<span x-show="!hasFreeLeft">免费次数已用完,可付费继续占卜</span>
</div>
</div>
{{-- 已占卜:展示签文 --}}
<div x-show="resultGrade" x-transition:enter="transition ease-out duration-500"
x-transition:enter-start="opacity-0 scale-75"
x-transition:enter-end="opacity-100 scale-100">
{{-- 签文等级徽章 --}}
<div style="display:inline-block; padding:4px 16px; border-radius:20px; font-weight:900;
font-size:14px; letter-spacing:2px; margin-bottom:12px; color:#fff; text-shadow:0 1px 2px rgba(0,0,0,.3);"
:style="'background:' + resultColor + '; border:1px solid rgba(0,0,0,.1);'"
x-text="resultLabel"></div>
{{-- 签文卡片(白底+左侧彩色装饰条) --}}
<div style="border-radius:6px; padding:12px 16px; margin:0 4px;
border:1px solid #d0e4f5; background:#f6faff; position:relative;"
:style="'border-left:4px solid ' + resultColor + ';'">
<div style="color:#225588; font-size:13px; line-height:1.8; text-align:center; font-style:italic;"
x-text="'「' + resultText + '」'"></div>
</div>
{{-- 当日加成 --}}
<div x-show="resultBuff"
style="margin:10px 8px 0; padding:8px 14px; border-radius:10px;
background:#f0f6ff; color:#336699; font-size:12px; text-align:left; border:1px solid #d0e4f5;"
x-text="resultBuff"></div>
</div>
</div>
{{-- 已有今日签文(只展示最新的,可再次占卜) --}}
<div x-show="todayLatest && !resultGrade"
style="border-radius:6px; padding:10px 12px; background:#f6faff;
border:1px solid #d0e4f5; margin-bottom:10px;">
<div style="color:#336699; font-size:11px; margin-bottom:6px; font-weight:bold;">今日最新签文</div>
<div style="display:flex; align-items:center; gap:6px;">
{{-- 签级标签:改为左彩色竖条 + 圆角徽章文字,不用纯色背景 --}}
<span
style="padding:2px 10px; border-radius:20px; font-size:11px; font-weight:bold; border:1.5px solid;"
:style="todayLatest
?
'color:' + todayLatest.grade_color + '; border-color:' + todayLatest.grade_color +
'; background: transparent;' :
'display:none'"
x-text="todayLatest?.grade_label"></span>
<span style="color:#225588; font-size:12px; flex:1;" x-text="todayLatest?.text"></span>
</div>
<div x-show="todayLatest?.buff_desc" style="color:#888; font-size:11px; margin-top:3px;"
x-text="todayLatest?.buff_desc"></div>
</div>
{{-- 占卜按钮 --}}
<div x-show="!resultGrade">
<button @click="doFortune()" :disabled="loading"
:style="loading
?
'display:block; width:100%; border:none; border-radius:12px; padding:12px 0; font-size:14px; font-weight:bold; cursor:not-allowed; transition:all .2s; background:#e0e8f0; color:#99a8b8; box-shadow:none;' :
hasFreeLeft ?
'display:block; width:100%; border:none; border-radius:12px; padding:12px 0; font-size:14px; font-weight:bold; cursor:pointer; transition:all .2s; background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff; box-shadow:0 4px 12px rgba(51,102,153,.25);' :
'display:block; width:100%; border:none; border-radius:12px; padding:13px 0; font-size:15px; font-weight:bold; cursor:pointer; transition:all .2s; background:linear-gradient(135deg,#1e4d8c,#336699); color:#fff; box-shadow:0 4px 14px rgba(30,77,140,.3); letter-spacing:0.5px;'">
<span
x-text="loading ? '占卜中…' : (hasFreeLeft ? '🔮 免费占卜' : '🔮 付费占卜(' + extraCost + ' 金币)')"></span>
</button>
</div>
{{-- 再占一卦按钮 --}}
<div x-show="resultGrade" style="display:flex; gap:10px;">
<button @click="resultGrade = ''; resultText = ''; resultBuff = null"
style="flex:1; border:1px solid #b0d0ee; border-radius:12px; padding:12px 0;
background:#f0f6ff; color:#336699;
font-size:14px; font-weight:bold; cursor:pointer; transition:all .15s;"
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">
返回
</button>
<button @click="resultGrade = ''; resultText = ''; resultBuff = null; doFortune()"
:disabled="loading"
style="flex:2; border:none; border-radius:12px; padding:12px 0;
background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff; box-shadow:0 4px 12px rgba(51,102,153,.2);
font-size:14px; font-weight:bold; cursor:pointer; transition:all .15s;"
x-text="hasFreeLeft ? '🔮 再占一卦(免费)' : '🔮 再占一卦(' + extraCost + ' 金币)'"></button>
</div>
</div>
{{-- ── 历史 Tab ── --}}
<div x-show="activeTab === 'history'" style="display:none;">
<div style="max-height:280px; overflow-y:auto; display:flex; flex-direction:column; gap:6px;">
<template x-for="(log, i) in historyLogs" :key="i">
<div
style="border-radius:6px; padding:8px 10px; background:#f6faff;
border:1px solid #d0e4f5;">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:4px;">
<span
style="padding:1px 8px; border-radius:20px; font-size:10px; font-weight:bold; color:#fff;"
:style="'background:' + log.grade_color + ';'" x-text="log.grade_label"></span>
<span style="color:#aaa; font-size:10px;" x-text="log.date + ' ' + log.time"></span>
<span x-show="log.cost > 0" style="color:#b45309; font-size:10px; margin-left:auto;"
x-text="'花费 ' + log.cost + '金'"></span>
</div>
<div style="color:#225588; font-size:12px; line-height:1.6;" x-text="log.text"></div>
<div x-show="log.buff_desc" style="color:#888; font-size:11px; margin-top:3px;"
x-text="log.buff_desc"></div>
</div>
</template>
<div x-show="historyLogs.length === 0"
style="text-align:center; color:#aaa; font-size:12px; padding:20px 0;">
尚无占卜记录
</div>
</div>
</div>
</div>
{{-- ─── 底部关闭 ─── --}}
<div
style="background:#fff; border-top:1px solid #d0e4f5; padding:14px 16px; display:flex; justify-content:center;">
<button @click="show = false"
style="padding:10px 48px; min-width:140px; background:#f0f6ff; border:1px solid #b0d0ee; border-radius:12px;
font-size:14px; font-weight:bold; color:#336699; cursor:pointer; transition:all .15s;"
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">
关闭
</button>
</div>
</div>
</div>
</div>
<style>
@keyframes float-orb {
0%,
100% {
transform: translateY(0) scale(1);
filter: drop-shadow(0 0 12px rgba(168, 85, 247, .6));
}
50% {
transform: translateY(-8px) scale(1.05);
filter: drop-shadow(0 0 24px rgba(168, 85, 247, .9));
}
}
@keyframes fortune-shake {
0%,
100% {
transform: rotate(0deg) scale(1.1);
}
20% {
transform: rotate(-15deg) scale(1.15);
}
40% {
transform: rotate(12deg) scale(1.15);
}
60% {
transform: rotate(-8deg) scale(1.12);
}
80% {
transform: rotate(5deg) scale(1.1);
}
}
@keyframes pulse-fortune {
0%,
100% {
box-shadow: 0 4px 20px rgba(168, 85, 247, .5);
}
50% {
box-shadow: 0 4px 30px rgba(168, 85, 247, .9);
}
}
</style>
<script>
/**
* 神秘占卜主面板 Alpine 组件
*/
function fortunePanel() {
return {
show: false,
activeTab: 'tell',
loading: false,
shaking: false,
// 游戏配置
freeCount: 1,
freeUsed: 0,
hasFreeLeft: true,
extraCost: 500,
// 今日最新签文(若已占卜过)
todayLatest: null,
// 本次占卜结果
resultGrade: '',
resultLabel: '',
resultColor: '#a855f7',
resultText: '',
resultBuff: null,
// 历史记录
historyLogs: [],
/**
* 加载今日占卜状态
*/
async loadTodayStatus() {
try {
const res = await fetch('/fortune/today');
const data = await res.json();
if (!data.enabled) return;
this.freeCount = data.free_count || 1;
this.freeUsed = data.free_used || 0;
this.hasFreeLeft = data.has_free_left ?? true;
this.extraCost = data.extra_cost || 500;
this.todayLatest = data.latest || null;
} catch {}
},
/**
* 执行占卜
*/
async doFortune() {
if (this.loading) return;
this.loading = true;
this.shaking = true;
// 摇卦动画
await new Promise(r => setTimeout(r, 600));
this.shaking = false;
try {
const res = await fetch('/fortune/tell', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]')?.content,
},
});
const data = await res.json();
if (data.ok) {
this.resultGrade = data.grade;
this.resultLabel = data.grade_label;
this.resultColor = data.grade_color;
this.resultText = data.text;
this.resultBuff = data.buff_desc;
// 更新今日状态
if (data.is_free) {
this.freeUsed++;
this.hasFreeLeft = this.freeUsed < this.freeCount;
}
} else {
window.chatDialog?.alert(data.message || '占卜失败', '提示', '#ef4444');
}
} catch {
window.chatDialog?.alert('网络异常,请稍后重试。', '错误', '#ef4444');
}
this.loading = false;
},
/**
* 加载历史记录
*/
async loadHistory() {
if (this.historyLogs.length > 0) return; // 已加载过则不重复请求
try {
const res = await fetch('/fortune/history');
const data = await res.json();
this.historyLogs = data.history || [];
} catch {}
},
};
}
/** 页面加载时:检查游戏是否开启,若开启则初始化面板数据 */
document.addEventListener('DOMContentLoaded', async () => {
try {
const res = await fetch('/fortune/today');
const data = await res.json();
if (data.enabled) {
const panel = document.getElementById('fortune-panel');
if (panel) {
const pd = Alpine.$data(panel);
pd.freeCount = data.free_count || 1;
pd.freeUsed = data.free_used || 0;
pd.hasFreeLeft = data.has_free_left ?? true;
pd.extraCost = data.extra_cost || 500;
pd.todayLatest = data.latest || null;
}
}
} catch (e) {
console.warn('[神秘占卜] 初始化失败', e);
}
});
</script>

View File

@@ -0,0 +1,396 @@
{{--
文件功能:娱乐游戏大厅弹窗组件
点击工具栏「娱乐」按钮后弹出,展示所有已开启的游戏:
- 百家乐:当前场次状态 + 倒计时 + 直接参与按钮
- 老虎机:今日限额余量 + 直接打开按钮
- 神秘箱子:已投放数量 + 直接打开按钮
- 赛马竞猜:当前场次状态 + 参与按钮
- 神秘占卜:今日占卜次数 + 直接打开按钮
- 钓鱼:状态 + 打开按钮
@author ChatRoom Laravel
@version 1.0.0
--}}
{{-- ─── 服务端注入各游戏开关状态(避免前端额外请求)─── --}}
@php
$gameEnabled = [
'baccarat' => \App\Models\GameConfig::isEnabled('baccarat'),
'slot_machine' => \App\Models\GameConfig::isEnabled('slot_machine'),
'mystery_box' => \App\Models\GameConfig::isEnabled('mystery_box'),
'horse_racing' => \App\Models\GameConfig::isEnabled('horse_racing'),
'fortune_telling' => \App\Models\GameConfig::isEnabled('fortune_telling'),
'fishing' => \App\Models\GameConfig::isEnabled('fishing'),
];
@endphp
<script>
/** 后台游戏开关状态Blade 服务端注入1分钟缓存 */
window.GAME_ENABLED = @json($gameEnabled);
</script>
{{-- ═══════════ 游戏大厅弹窗遮罩 ═══════════ --}}
<div id="game-hall-modal"
style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.55);
z-index:9998; justify-content:center; align-items:center;">
<div id="game-hall-inner"
style="width:680px; max-width:96vw; max-height:88vh; border-radius:8px; overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,.3); display:flex; flex-direction:column;
background:#fff; font-family:'Microsoft YaHei',SimSun,sans-serif;">
{{-- ─── 标题栏(与商店弹窗同风格)─── --}}
<div
style="background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff;
padding:10px 16px; display:flex; align-items:center; gap:10px; flex-shrink:0;">
<div style="font-size:14px; font-weight:bold; flex:1;">🎮 娱乐大厅</div>
<div
style="font-size:12px; color:#d0e8ff; display:flex; align-items:center; gap:3px;
background:rgba(0,0,0,.2); padding:2px 8px; border-radius:10px;">
🪙 <strong id="game-hall-jjb" style="color:#ffe082; font-size:13px;">--</strong> 金币
</div>
<span onclick="closeGameHall()"
style="cursor:pointer; font-size:18px; opacity:.8; line-height:1; transition:opacity .15s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=.8">&times;</span>
</div>
{{-- ─── 游戏卡片网格 ─── --}}
<div style="flex:1; overflow-y:auto; background:#f6faff; padding:12px;">
{{-- 加载状态 --}}
<div id="game-hall-loading" style="text-align:center; color:#336699; padding:40px 0; font-size:13px;">
<div style="font-size:28px; margin-bottom:8px;"></div>
加载游戏状态中…
</div>
{{-- 游戏卡片容器 --}}
<div id="game-hall-cards" style="display:none; grid-template-columns:1fr 1fr; gap:10px;">
</div>
{{-- 全部未开启提示 --}}
<div id="game-hall-empty"
style="display:none; text-align:center; color:#336699; padding:40px 0; font-size:13px;">
<div style="font-size:28px; margin-bottom:8px;">🔒</div>
暂无开启的游戏,请联系管理员
</div>
</div>
{{-- ─── 底部 ─── --}}
<div
style="background:#fff; border-top:1px solid #d0e4f5; padding:8px 16px;
display:flex; justify-content:center; flex-shrink:0;">
<button onclick="closeGameHall()"
style="padding:5px 24px; background:#f0f6ff; border:1px solid #b0d0ee; border-radius:4px;
font-size:12px; color:#336699; cursor:pointer; transition:all .15s;"
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">关闭</button>
</div>
</div>
</div>
<script>
/** 游戏大厅配置定义ID → 展示配置) */
const GAME_HALL_GAMES = [{
id: 'baccarat',
name: '🎲 百家乐',
desc: '猜骰子大小1:1 赔率,豹子 1:24',
accentColor: '#336699',
fetchUrl: '/baccarat/current',
openFn: () => {
closeGameHall();
const panel = document.getElementById('baccarat-panel');
if (panel) Alpine.$data(panel).show = true;
},
renderStatus: (data) => {
if (!data?.round) return {
badge: '⏸ 等待开局',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '下局即将开始,稍后再来'
};
const r = data.round;
if (r.status === 'betting') {
return {
badge: '🟢 押注中',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: `⏱ 剩余 ${r.seconds_left || 0} 秒 | 注池:${Number((r.total_bet_big||0)+(r.total_bet_small||0)+(r.total_bet_triple||0)).toLocaleString()} 金`
};
}
return {
badge: '⏳ 开奖中',
badgeStyle: 'background:#fef3c7; color:#92400e; border:1px solid #fcd34d',
detail: '正在摇骰子…'
};
},
btnLabel: (data) => data?.round?.status === 'betting' ? '🎲 立即下注' : '📊 查看详情',
},
{
id: 'slot_machine',
name: '🎰 老虎机',
desc: '每日限额旋转,中奖即时到账',
accentColor: '#0891b2',
fetchUrl: null,
openFn: () => {
closeGameHall();
const panel = document.getElementById('slot-panel');
if (panel) Alpine.$data(panel).show = true;
},
renderStatus: () => ({
badge: '✅ 随时可玩',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: '每日限额抽奖,旋转即可'
}),
btnLabel: () => '🎰 开始旋转',
},
{
id: 'mystery_box',
name: '📦 神秘箱子',
desc: '管理员随机投放,抢到即开奖',
accentColor: '#b45309',
fetchUrl: '/mystery-box/status',
openFn: () => {
closeGameHall();
window.dispatchEvent(new CustomEvent('open-mystery-box'));
},
renderStatus: (data) => {
const count = data?.available_count ?? 0;
return count > 0 ? {
badge: `🎁 ${count} 个待领`,
badgeStyle: 'background:#fef3c7; color:#92400e; border:1px solid #fcd34d',
detail: '箱子已投放!快去领取'
} : {
badge: '📭 暂无箱子',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '等待管理员投放'
};
},
btnLabel: (data) => (data?.available_count ?? 0) > 0 ? '🎁 立即领取' : '📭 等待投放',
},
{
id: 'horse_racing',
name: '🐎 赛马竞猜',
desc: '彩池制赛马,押注马匹赢取奖金',
accentColor: '#336699',
fetchUrl: '/horse-race/current',
openFn: () => {
closeGameHall();
const panel = document.getElementById('horse-race-panel');
if (panel) Alpine.$data(panel).openFromHall();
},
renderStatus: (data) => {
if (!data?.race) return {
badge: '⏸ 等待开赛',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '下场赛马即将开始'
};
const r = data.race;
if (r.status === 'betting') {
return {
badge: '🟢 押注中',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: `⏱ 剩余 ${r.seconds_left || 0} 秒 | 注池:${Number(r.total_pool || 0).toLocaleString()} 金`
};
}
if (r.status === 'running') {
return {
badge: '🏇 跑马中',
badgeStyle: 'background:#fef3c7; color:#92400e; border:1px solid #fcd34d',
detail: '比赛进行中…'
};
}
return {
badge: '🏆 已结算',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '下场即将开始'
};
},
btnLabel: (data) => data?.race?.status === 'betting' ? '🐎 立即押注' : '📊 查看赛况',
},
{
id: 'fortune_telling',
name: '🔮 神秘占卜',
desc: '每日签文,开启今日运势加成',
accentColor: '#6d28d9',
fetchUrl: '/fortune/today',
openFn: () => {
closeGameHall();
const panel = document.getElementById('fortune-panel');
if (panel) Alpine.$data(panel).show = true;
},
renderStatus: (data) => {
if (!data?.enabled) return {
badge: '🔒 未开启',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: '此游戏暂未开启'
};
const used = data.free_used ?? 0;
const total = data.free_count ?? 1;
return data.has_free_left ? {
badge: '✨ 免费可占',
badgeStyle: 'background:#ede9fe; color:#5b21b6; border:1px solid #c4b5fd',
detail: `今日已占 ${used}/${total} 次,还有免费次数`
} : {
badge: '💰 付费可占',
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
detail: `今日免费次数已用完(${data.extra_cost} 金/次)`
};
},
btnLabel: (data) => data?.has_free_left ? '🔮 免费占卜' : '🔮 付费占卜',
},
{
id: 'fishing',
name: '🎣 钓鱼',
desc: '消耗鱼饵钓取金币和道具。背包需有鱼饵才能出竿。',
accentColor: '#0d9488',
fetchUrl: null,
openFn: () => {
closeGameHall();
// 直接触发钓鱼,无需手动输入指令
if (typeof startFishing === 'function') {
startFishing();
}
},
renderStatus: () => ({
badge: '🎣 随时可钓',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: '① 点击发言框上方【🎣 钓鱼】按钮 → ② 等待浮漂出现 → ③ 看到 🪝 后立刻点击收竿!'
}),
btnLabel: () => '🎣 去钓鱼',
},
];
/**
* 打开游戏大厅弹窗,加载各游戏状态
*/
window.openGameHall = async function() {
document.getElementById('game-hall-modal').style.display = 'flex';
document.getElementById('game-hall-loading').style.display = 'block';
document.getElementById('game-hall-cards').style.display = 'none';
document.getElementById('game-hall-empty').style.display = 'none';
const jjbEl = document.getElementById('game-hall-jjb');
if (window.chatContext?.userJjb !== undefined) {
jjbEl.textContent = Number(window.chatContext.userJjb).toLocaleString();
}
// 每次打开均实时拉取后台开关状态(避免页面不刷新时开关不同步)
let enabledMap = window.GAME_ENABLED ?? {};
try {
const r = await fetch('/games/enabled', {
headers: {
'Accept': 'application/json'
}
});
if (r.ok) enabledMap = await r.json();
} catch {
/* 网络异常时降级使用页面注入值 */
}
// 过滤出后台已开启的游戏
const enabledGames = GAME_HALL_GAMES.filter(g => enabledMap[g.id] !== false);
// 并行请求有状态接口的游戏
const statuses = {};
await Promise.all(
enabledGames.filter(g => g.fetchUrl).map(async g => {
try {
const res = await fetch(g.fetchUrl);
statuses[g.id] = await res.json();
} catch {
statuses[g.id] = null;
}
})
);
renderGameCards(enabledGames, statuses);
};
/**
* 关闭游戏大厅弹窗
*/
window.closeGameHall = function() {
document.getElementById('game-hall-modal').style.display = 'none';
};
/**
* 渲染所有游戏卡片(海军蓝风格)
*
* @param {Array} games 已过滤的游戏配置列表
* @param {Object} statuses 各游戏的 API 返回数据
*/
function renderGameCards(games, statuses) {
const container = document.getElementById('game-hall-cards');
container.innerHTML = '';
if (games.length === 0) {
document.getElementById('game-hall-loading').style.display = 'none';
document.getElementById('game-hall-empty').style.display = 'block';
return;
}
games.forEach(game => {
const data = statuses[game.id] ?? null;
const status = game.renderStatus ? game.renderStatus(data) : {
badge: '✅ 可用',
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
detail: ''
};
const btnLabel = game.btnLabel ? game.btnLabel(data) : '🎮 进入';
const card = document.createElement('div');
card.style.cssText = `
background:#fff;
border:1px solid #d0e4f5;
border-left:4px solid ${game.accentColor};
border-radius:6px; padding:12px 14px;
cursor:default; transition:border-color .2s, box-shadow .2s;
display:flex; flex-direction:column; gap:8px;
`;
card.innerHTML = `
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:8px;">
<div style="flex:1;">
<div style="color:#225588; font-weight:bold; font-size:13px; margin-bottom:3px;">${game.name}</div>
<div style="color:#666; font-size:11px; line-height:1.4;">${game.desc}</div>
</div>
<span style="padding:2px 8px; border-radius:10px; font-size:10px; font-weight:bold; white-space:nowrap; ${status.badgeStyle}">
${status.badge}
</span>
</div>
<div style="color:#888; font-size:10px; line-height:1.4; min-height:14px; border-top:1px dashed #e0ecf8; padding-top:6px;">${status.detail || '&nbsp;'}</div>
<button
style="width:100%; border:none; border-radius:4px; padding:7px 8px; font-size:12px; font-weight:bold;
cursor:pointer; color:#fff; transition:opacity .15s;
background:linear-gradient(135deg,${game.accentColor},${game.accentColor}cc);"
onmouseover="this.style.opacity='.85'"
onmouseout="this.style.opacity='1'">
${btnLabel}
</button>
`;
card.querySelector('button').addEventListener('click', (e) => {
e.stopPropagation();
game.openFn();
});
card.addEventListener('mouseenter', () => {
card.style.borderColor = game.accentColor;
card.style.boxShadow = `0 2px 8px rgba(51,102,153,.18)`;
});
card.addEventListener('mouseleave', () => {
card.style.borderColor = '#d0e4f5';
card.style.borderLeftColor = game.accentColor;
card.style.boxShadow = '';
});
container.appendChild(card);
});
document.getElementById('game-hall-loading').style.display = 'none';
container.style.display = 'grid';
}
// 点击遮罩关闭弹窗
document.getElementById('game-hall-modal').addEventListener('click', function(e) {
if (e.target === this) closeGameHall();
});
</script>

View File

@@ -0,0 +1,677 @@
{{--
文件功能:赛马竞猜前台弹窗组件
聊天室内赛马竞猜游戏面板:
- 监听 WebSocket horse.opened 事件触发弹窗
- 展示参赛马匹列表和实时注池赔率
- 倒计时押注后进入跑马阶段(动态进度条)
- 监听 horse.progress 更新赛道动画
- 监听 horse.settled 展示结果 + 个人赔付
- 展示近10场历史趋势
--}}
{{-- ─── 赛马主面板 ─── --}}
<div id="horse-race-panel" x-data="horseRacePanel()" x-show="show" x-cloak>
<div x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
style="position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9941;
display:flex; align-items:center; justify-content:center;">
<div
style="width:500px; max-width:96vw; border-radius:8px; overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,.3); font-family:'Microsoft YaHei',SimSun,sans-serif; background:#fff;">
{{-- ─── 标题栏(海军蓝风格)─── --}}
<div
style="background:linear-gradient(135deg,#336699,#5a8fc0); padding:10px 16px; display:flex; align-items:center; gap:10px;">
<div style="font-size:14px; font-weight:bold; color:#fff; flex:1;">
🐎 赛马竞猜
<span style="font-size:11px; font-weight:normal; color:rgba(255,255,255,.6); margin-left:4px;"
x-text="raceId ? '#' + raceId + ' 场' : ''"></span>
</div>
{{-- 倒计时(押注阶段) --}}
<div x-show="phase === 'betting'"
style="font-size:12px; color:#fff; background:rgba(255,255,255,.2); padding:4px 12px; border-radius:20px; border:1px solid rgba(255,255,255,.3); box-shadow:0 2px 4px rgba(0,0,0,.1) inset; display:flex; align-items:center; gap:4px;">
<span x-text="countdown" style="font-weight:bold; color:#fef08a; font-size:14px;"></span>
</div>
{{-- 跑马阶段 --}}
<div x-show="phase === 'running'" style="display:none;"
style="font-size:12px; color:rgba(255,255,255,.8); background:rgba(0,0,0,.2); padding:2px 10px; border-radius:10px;">
🏇 跑马中…
</div>
{{-- 结算 --}}
<div x-show="phase === 'settled'" style="display:none;"
style="font-size:12px; color:#ffe082; font-weight:bold;">🏆 已结算</div>
<span onclick="Alpine.$data(document.getElementById('horse-race-panel')).close()"
style="cursor:pointer; font-size:18px; color:rgba(255,255,255,.8); line-height:1; transition:opacity .15s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=.8">&times;</span>
</div>
{{-- 押注进度条(蓝色风格) --}}
<div x-show="phase === 'betting'" style="height:3px; background:#d0e4f5; overflow:hidden;">
<div style="height:100%; background:#336699; transition:width 1s linear;"
:style="'width:' + Math.max(0, (countdown / totalSeconds * 100)) + '%'"></div>
</div>
{{-- ─── 历史趋势(蓝白色系)─── --}}
<div
style="background:#f6faff; padding:6px 16px; border-bottom:1px solid #d0e4f5;
display:flex; gap:5px; align-items:center; flex-wrap:wrap; min-height:32px;">
<span style="color:#336699; font-size:11px; margin-right:2px; font-weight:bold;">近期冒涨:</span>
<template x-for="h in history" :key="h.id">
<span
style="padding:1px 8px; border-radius:10px; font-size:10px; font-weight:bold;
background:#e8f0f8; color:#336699; border:1px solid #b8d0e8;"
:title="'#' + h.id + ' 冠军:' + h.winner_name" x-text="h.winner_name"></span>
</template>
<span x-show="history.length === 0" style="color:#aaa; font-size:11px;">暂无记录</span>
</div>
{{-- ─── 主体内容(白底)─── --}}
<div style="background:#fff; padding:14px 16px;">
{{-- ── 押注阶段 ── --}}
<div x-show="phase === 'betting'">
{{-- 注池统计 --}}
<div
style="color:#336699; font-size:11px; margin-bottom:8px; text-align:center; background:#e8f0f8; border-radius:4px; padding:4px 0;">
注池总额:<span style="color:#b45309; font-weight:bold;"
x-text="Number(totalPool).toLocaleString() + ' 金币'"></span>
</div>
{{-- 马匹列表 --}}
<div style="display:flex; flex-direction:column; gap:8px; margin-bottom:12px;">
<template x-for="horse in horses" :key="horse.id">
<div style="border-radius:12px; padding:10px 14px; cursor:pointer; transition:all .15s; border:2px solid transparent;"
:style="selectedHorse === horse.id ?
'background:#e8f0f8; border-color:#336699;' :
'background:#f6faff; border-color:#d0e4f5;'"
@click="myBet ? null : selectedHorse = horse.id">
<div style="display:flex; align-items:center; justify-content:space-between;">
<div style="display:flex; align-items:center; gap:10px;">
{{-- 选中勾选 --}}
<div style="width:20px; height:20px; border-radius:50%; border:2px solid; display:flex; align-items:center; justify-content:center; font-size:10px; flex-shrink:0;"
:style="selectedHorse === horse.id ?
'border-color:#336699; background:#336699; color:#fff' :
'border-color:#b0c8e0; color:transparent'">
</div>
<div style="font-size:22px;" x-text="horse.emoji"></div>
<div>
<div style="color:#225588; font-weight:bold; font-size:13px;"
x-text="horse.name"></div>
<div style="color:#888; font-size:10px;">
注池:<span x-text="Number(horse.pool || 0).toLocaleString()"></span>
</div>
</div>
</div>
{{-- 实时赔率 --}}
<div style="text-align:right;">
<div style="color:#b45309; font-weight:900; font-size:15px;"
x-text="horse.odds ? '×' + horse.odds : '—'"></div>
<div style="color:#999; font-size:10px;">赔率</div>
</div>
</div>
</div>
</template>
</div>
{{-- 已下注状态 --}}
<div x-show="myBet">
<div
style="background:#e8fde8; border:1px solid #a3e6b0; border-radius:6px;
padding:10px 14px; text-align:center; margin-bottom:8px;">
<div style="color:#16a34a; font-weight:bold; font-size:13px;">
已押注「<span x-text="myBetHorseName"></span>
<span x-text="Number(myBetAmount).toLocaleString()"></span> 金币
</div>
<div style="color:#888; font-size:11px; margin-top:3px;">等待开跑…</div>
</div>
</div>
{{-- 下注区 --}}
<div x-show="!myBet">
{{-- 快捷金额 --}}
<div style="display:flex; gap:6px; margin-bottom:8px;">
<template x-for="preset in [100, 500, 1000, 5000, 10000]" :key="preset">
<button @click="betAmount = preset"
style="flex:1; border:1px solid #b0d0ee; border-radius:6px; padding:8px 0;
font-size:13px; font-weight:bold; cursor:pointer; transition:all .15s;"
:style="betAmount === preset ?
'background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff; border-color:#2a5580; box-shadow:0 3px 8px rgba(51,102,153,.3);' :
'background:#f6faff; color:#336699;'"
x-text="preset >= 1000 ? (preset/1000)+'k' : preset"></button>
</template>
</div>
<input type="number" x-model.number="betAmount" min="100" placeholder="自定义金额"
style="width:100%; background:#f6faff; border:1px solid #d0e4f5; border-radius:8px;
padding:10px 14px; color:#333; font-size:13px; box-sizing:border-box; margin-bottom:12px; outline:none; transition:all .15s;"
onfocus="this.style.borderColor='#336699'; this.style.background='#fff'; this.style.boxShadow='0 0 0 2px rgba(51,102,153,.1)';"
onblur="this.style.borderColor='#d0e4f5'; this.style.background='#f6faff'; this.style.boxShadow='none';">
{{-- 下注按钮 --}}
<button @click="submitBet()" :disabled="!selectedHorse || betAmount < 100 || submitting"
style="display:block; width:100%; border:none; border-radius:12px; padding:12px 0;
font-size:14px; font-weight:bold; cursor:pointer; transition:all .2s; box-shadow:0 4px 12px rgba(51,102,153,.2);"
:style="(!selectedHorse || betAmount < 100 || submitting) ?
'background:#e0e8f0; color:#99a8b8; cursor:not-allowed; box-shadow:none;' :
'background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff;'">
<span
x-text="submitting ? '提交中…' : (!selectedHorse ? '请先选择马匹' : '🐎 确认押注「' + myBetHorsePreviewName + '」 ' + Number(betAmount).toLocaleString() + ' 金币')"></span>
</button>
</div>
</div>
{{-- ── 跑马阶段 ── --}}
<div x-show="phase === 'running'" style="display:none;">
<div
style="margin-bottom:8px; color:#336699; font-size:11px; font-weight:bold; text-align:center; background:#e8f0f8; border-radius:4px; padding:4px;">
🏁 赛道实况
</div>
<div style="display:flex; flex-direction:column; gap:8px;">
<template x-for="horse in horses" :key="horse.id">
<div style="display:flex; align-items:center; gap:8px;">
<div style="width:30px; text-align:center; font-size:18px;" x-text="horse.emoji"></div>
<div style="flex:1; position:relative;">
{{-- 赛道背景 --}}
<div
style="height:24px; background:#e8f0f8; border-radius:10px; overflow:hidden; position:relative;">
{{-- 进度条 --}}
<div style="height:100%; border-radius:20px; transition:width .9s ease-out;"
:style="'width:' + (positions[horse.id] || 0) + '%; background:' +
(leaderId === horse.id ? '#336699' :
'#b8d0e8')">
</div>
{{-- 马匹图标(跟随进度) --}}
<div style="position:absolute; top:50%; transform:translateY(-50%); font-size:16px; transition:left .9s ease-out; pointer-events:none;"
:style="'left:' + Math.max(0, (positions[horse.id] || 0) - 5) + '%'">
<span x-text="horse.emoji"></span>
</div>
</div>
</div>
{{-- 进度数字 --}}
<div style="width:38px; text-align:right; color:#336699; font-size:10px; font-weight:bold;"
x-text="(positions[horse.id] || 0) + '%'"></div>
</div>
</template>
</div>
<div style="margin-top:10px; text-align:center; color:#aaa; font-size:10px; text-align:center;">
🏁 终点线
</div>
</div>
{{-- ── 结算阶段(蓝白风格)── --}}
<div x-show="phase === 'settled'" style="display:none;">
{{-- 获胜马匹 --}}
<div
style="text-align:center; padding:12px 0 10px; border-bottom:1px solid #d0e4f5; margin-bottom:10px;">
<div style="font-size:36px; margin-bottom:4px;" x-text="winnerEmoji"></div>
<div style="color:#336699; font-size:17px; font-weight:bold;"
x-text="'🏆 ' + winnerName + ' 夺冠!'">
</div>
<div style="color:#888; font-size:11px; margin-top:3px;"
x-text="'注池总额:' + Number(totalPool).toLocaleString() + ' 金币'"></div>
</div>
{{-- 个人结果 --}}
<div x-show="myBet">
{{-- 中奖 --}}
<div x-show="myWon"
style="border-radius:6px; padding:12px 14px; text-align:center; margin-bottom:4px;
background:#e8fde8; border:1px solid #a3e6b0;">
<div style="font-size:24px; margin-bottom:4px;">🎉</div>
<div style="color:#16a34a; font-size:16px; font-weight:bold;">恭喜中奖!</div>
<div style="color:#15803d; font-size:18px; font-weight:bold; margin:4px 0;"
x-text="'+' + Number(myPayout).toLocaleString() + ' 🪙'"></div>
<div style="color:#888; font-size:10px;"
x-text="'押「' + myBetHorseName + '」' + Number(myBetAmount).toLocaleString() + ' 金币 → 赢得 ' + Number(myPayout).toLocaleString() + ' 金币'">
</div>
</div>
{{-- 未中奖 --}}
<div x-show="!myWon"
style="border-radius:6px; padding:12px 14px; text-align:center;
background:#fff0f0; border:1px solid #fca5a5;">
<div style="font-size:20px; margin-bottom:4px;">😔</div>
<div style="color:#dc2626; font-size:13px; font-weight:bold; margin-bottom:4px;">本场未中奖
</div>
<div style="color:#888; font-size:11px;"
x-text="'押了「' + myBetHorseName + '」' + Number(myBetAmount).toLocaleString() + ' 金币,冠军是「' + winnerName + '」'">
</div>
</div>
</div>
<div x-show="!myBet" style="text-align:center; color:#aaa; font-size:12px; padding:8px 0;">
本场未参与下注
</div>
</div>
</div>
{{-- ─── 底部关闭 ─── --}}
<div
style="background:#fff; border-top:1px solid #d0e4f5; padding:14px 16px; display:flex; justify-content:center;">
<button @click="close()"
style="padding:10px 48px; min-width:140px; background:#f0f6ff; border:1px solid #b0d0ee; border-radius:12px;
font-size:14px; font-weight:bold; color:#336699; cursor:pointer; transition:all .15s;"
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">
关闭
</button>
</div>
</div>
</div>
</div>
<style>
@keyframes horse-run {
0% {
transform: translateX(-2px);
}
100% {
transform: translateX(2px);
}
}
@keyframes pulse-horse {
0%,
100% {
box-shadow: 0 4px 20px rgba(245, 158, 11, .5);
}
50% {
box-shadow: 0 4px 32px rgba(245, 158, 11, .9);
}
}
</style>
<script>
/**
* 赛马竞猜悬浮按钮 Alpine 组件(拖动 + localStorage 位置持久化)
*/
function horseRaceFab() {
const STORAGE_KEY = 'horse_race_fab_pos';
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
return {
visible: false,
posX: saved?.x ?? 80,
posY: saved?.y ?? 90,
dragging: false,
_startX: 0,
_startY: 0,
_origX: 0,
_origY: 0,
_moved: false,
startDrag(e) {
this.dragging = true;
this._moved = false;
this._startX = e.clientX;
this._startY = e.clientY;
this._origX = this.posX;
this._origY = this.posY;
e.currentTarget.setPointerCapture?.(e.pointerId);
},
onDrag(e) {
if (!this.dragging) return;
const dx = e.clientX - this._startX;
const dy = e.clientY - this._startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this._moved = true;
// right 定位:往右拖 dx>0 → right 减小bottom 定位:往下拖 dy>0 → bottom 减小
this.posX = Math.max(4, Math.min(window.innerWidth - 132, this._origX - dx));
this.posY = Math.max(4, Math.min(window.innerHeight - 132, this._origY - dy));
},
endDrag(e) {
if (!this.dragging) return;
this.dragging = false;
localStorage.setItem(STORAGE_KEY, JSON.stringify({
x: this.posX,
y: this.posY
}));
if (!this._moved) this.openPanel();
},
openPanel() {
const panel = document.getElementById('horse-race-panel');
if (panel) Alpine.$data(panel).openFromHall();
},
};
}
/**
* 赛马竞猜主面板 Alpine 组件
*/
function horseRacePanel() {
return {
show: false,
phase: 'betting', // betting | running | settled
raceId: null,
totalSeconds: 90,
countdown: 90,
countdownTimer: null,
// 马匹列表(含实时赔率)
horses: [],
positions: {}, // 跑马进度 {horse_id: 0~100}
leaderId: null,
// 注池
totalPool: 0,
// 本人下注
myBet: false,
myBetHorseId: null,
myBetHorseName: '',
myBetAmount: 0,
// 下注表单
selectedHorse: null,
betAmount: 100,
submitting: false,
// 结算结果
winnerName: '',
winnerEmoji: '',
myWon: false,
myPayout: 0,
// 历史记录
history: [],
/**
* 获取当前选中马匹的预览名称(用于按钮文字)
*/
get myBetHorsePreviewName() {
if (!this.selectedHorse) return '';
const h = this.horses.find(h => h.id === this.selectedHorse);
return h ? h.emoji + h.name : '';
},
/**
* 开赛:填充场次数据并开始倒计时
*/
openRace(data) {
this.phase = 'betting';
this.raceId = data.race_id;
this.countdown = data.bet_seconds || 90;
this.totalSeconds = this.countdown;
this.horses = data.horses || [];
this.myBet = false;
this.myBetHorseId = null;
this.myBetHorseName = '';
this.myBetAmount = 0;
this.selectedHorse = null;
this.betAmount = 100;
this.positions = {};
this.leaderId = null;
this.show = true;
this.loadCurrentRace();
this.startCountdown();
this.updateFab(true);
},
/**
* 从接口获取当前场次状态(我的下注、注池赔率)
*/
async loadCurrentRace() {
try {
const res = await fetch('/horse-race/current');
const data = await res.json();
if (data.race) {
this.horses = data.race.horses || this.horses;
this.totalPool = data.race.total_pool || 0;
if (data.race.my_bet) {
this.myBet = true;
this.myBetHorseId = data.race.my_bet.horse_id;
this.myBetAmount = data.race.my_bet.amount;
const h = this.horses.find(h => h.id === this.myBetHorseId);
this.myBetHorseName = h ? h.emoji + h.name : '';
}
}
} catch {}
},
/**
* 启动倒计时
*/
startCountdown() {
clearInterval(this.countdownTimer);
this.countdownTimer = setInterval(() => {
this.countdown--;
if (this.countdown <= 0) {
clearInterval(this.countdownTimer);
this.phase = 'running';
}
}, 1000);
},
/**
* 提交下注
*/
async submitBet() {
if (!this.selectedHorse || this.betAmount < 100 || this.submitting) return;
this.submitting = true;
try {
const res = await fetch('/horse-race/bet', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]')?.content,
},
body: JSON.stringify({
race_id: this.raceId,
horse_id: this.selectedHorse,
amount: this.betAmount,
}),
});
const data = await res.json();
if (data.ok) {
this.myBet = true;
this.myBetHorseId = data.horse_id;
this.myBetAmount = data.amount;
const h = this.horses.find(h => h.id === data.horse_id);
this.myBetHorseName = h ? h.emoji + h.name : '';
window.chatDialog?.alert(data.message, '下注成功', '#f59e0b');
} else {
window.chatDialog?.alert(data.message || '下注失败', '提示', '#ef4444');
}
} catch {
window.chatDialog?.alert('网络异常,请稍后重试。', '错误', '#ef4444');
}
this.submitting = false;
},
/**
* 接收跑马进度更新
*/
updateProgress(data) {
this.phase = 'running';
this.positions = data.positions || {};
this.leaderId = data.leader_id;
},
/**
* 显示结算结果
*/
showResult(data) {
clearInterval(this.countdownTimer);
this.phase = 'settled';
this.show = true;
// 找出获胜马匹信息
const winner = this.horses.find(h => h.id === data.winner_horse_id);
this.winnerName = winner ? winner.emoji + winner.name : data.winner_name || '未知';
this.winnerEmoji = winner ? winner.emoji : '🐎';
// 判断本人是否中奖
if (this.myBet && this.myBetHorseId === data.winner_horse_id) {
this.myWon = true;
// 赔付前端显示估算(实际以后端为准,后端 WebSocket 无返回赔付金额)
this.myPayout = 0; // 无法前端计算,等用户看下一次余额或后端私信
} else {
this.myWon = false;
this.myPayout = 0;
}
this.updateFab(false);
this.loadHistory();
},
/**
* 加载历史记录
*/
async loadHistory() {
try {
const res = await fetch('/horse-race/history');
const data = await res.json();
this.history = (data.history || []).reverse();
} catch {}
},
/**
* 更新悬浮按钮显示状态
*/
updateFab(visible) {
const fab = document.getElementById('horse-race-fab');
if (fab) Alpine.$data(fab).visible = visible;
},
/**
* 关闭面板
*/
close() {
this.show = false;
if (this.phase === 'betting') {
this.updateFab(true);
}
},
/**
* 从游戏大厅入口打开面板:先重新请求当前场次最新状态,再显示面板。
* 解决游戏大厅展示‚押注中‚但面板状态降旧导致提交报错的问题。
*/
async openFromHall() {
try {
const res = await fetch('/horse-race/current');
const data = await res.json();
if (data.race) {
const race = data.race;
this.raceId = race.id;
this.horses = race.horses || [];
this.totalPool = race.total_pool || 0;
// 更新本人下注状态
if (race.my_bet) {
this.myBet = true;
this.myBetHorseId = race.my_bet.horse_id;
this.myBetAmount = race.my_bet.amount;
const h = this.horses.find(h => h.id === race.my_bet.horse_id);
this.myBetHorseName = h ? h.emoji + h.name : '';
} else {
this.myBet = false;
this.myBetHorseId = null;
this.myBetHorseName = '';
this.myBetAmount = 0;
}
// 同步阶段和倒计时
if (race.status === 'betting' && (race.seconds_left ?? 0) > 0) {
this.phase = 'betting';
this.countdown = race.seconds_left;
this.totalSeconds = race.seconds_left;
this.startCountdown();
} else if (race.status === 'running') {
this.phase = 'running';
} else {
this.phase = 'settled';
}
} else {
// 当前无进行中场次,重置状态
this.raceId = null;
this.horses = [];
this.phase = 'betting';
this.countdown = 0;
}
} catch (e) {
console.warn('[\u8d5b\u9a6c] openFromHall 失\u8d25', e);
}
this.show = true;
},
};
}
// ─── WebSocket 监听 ──────────────────────────────────────────────
/** 收到开赛事件:弹出押注面板 */
window.addEventListener('chat:horse.opened', (e) => {
const panel = document.getElementById('horse-race-panel');
if (panel) Alpine.$data(panel).openRace(e.detail);
});
/** 收到跑马进度事件:更新赛道 */
window.addEventListener('chat:horse.progress', (e) => {
const panel = document.getElementById('horse-race-panel');
if (panel) Alpine.$data(panel).updateProgress(e.detail);
});
/** 收到结算事件:展示结果 */
window.addEventListener('chat:horse.settled', (e) => {
const panel = document.getElementById('horse-race-panel');
if (panel) Alpine.$data(panel).showResult(e.detail);
});
/** 页面加载时恢复进行中的场次 */
document.addEventListener('DOMContentLoaded', async () => {
try {
const histRes = await fetch('/horse-race/history');
const histData = await histRes.json();
const panel = document.getElementById('horse-race-panel');
const fab = document.getElementById('horse-race-fab');
if (panel) {
Alpine.$data(panel).history = (histData.history || []).reverse();
}
const curRes = await fetch('/horse-race/current');
const curData = await curRes.json();
// 游戏可访问则常驻显示 FAB与占卜一致
if (fab) Alpine.$data(fab).visible = true;
if (curData.race && panel) {
const race = curData.race;
const seconds = race.seconds_left || 0;
const panelData = Alpine.$data(panel);
panelData.raceId = race.id;
panelData.horses = race.horses || [];
panelData.totalPool = race.total_pool || 0;
if (race.my_bet) {
panelData.myBet = true;
panelData.myBetHorseId = race.my_bet.horse_id;
panelData.myBetAmount = race.my_bet.amount;
const h = panelData.horses.find(h => h.id === race.my_bet.horse_id);
panelData.myBetHorseName = h ? h.emoji + h.name : '';
}
if (race.status === 'betting' && seconds > 0) {
panelData.phase = 'betting';
panelData.countdown = seconds;
} else if (race.status === 'running') {
panelData.phase = 'running';
}
}
} catch (e) {
console.warn('[赛马] 初始化失败', e);
}
});
</script>

View File

@@ -611,49 +611,54 @@
style="position:fixed; inset:0; background:rgba(0,0,0,.6);
z-index:9910; display:flex; align-items:center; justify-content:center;">
<div
style="width:380px; max-width:95vw; background:linear-gradient(160deg,#7c2d12,#9a3412);
border-radius:20px; box-shadow:0 24px 80px rgba(220,38,38,.5); overflow:hidden; text-align:center;
border:1px solid rgba(251,146,60,.3);">
<div style="padding:24px 20px 16px;">
<div style="font-size:52px; margin-bottom:8px; animation:pulse 1.5s infinite;">🧧</div>
<div style="color:#fef3c7; font-weight:bold; font-size:18px; margin-bottom:4px;" x-text="title"></div>
<div style="color:rgba(254,243,199,.7); font-size:12px; margin-bottom:16px;" x-text="subTitle"></div>
style="width:380px; max-width:95vw;
background:linear-gradient(160deg,#c0392b,#e74c3c,#c0392b);
border-radius:20px; overflow:hidden; text-align:center;
box-shadow:0 24px 80px rgba(231,76,60,.65), 0 0 0 1px rgba(255,210,100,.25);
border:1px solid rgba(255,210,100,.3);">
<div style="padding:28px 20px 12px;">
<div style="font-size:56px; margin-bottom:8px; animation:pulse 1.5s infinite;">🧧</div>
<div style="color:#fff8dc; font-weight:bold; font-size:18px; margin-bottom:4px; text-shadow:0 1px 4px rgba(0,0,0,.3);"
x-text="title"></div>
<div style="color:rgba(255,248,220,.75); font-size:12px; margin-bottom:8px;" x-text="subTitle"></div>
{{-- 未领取 --}}
<div x-show="!claimed" style="display:none;">
<div style="color:rgba(254,243,199,.5); font-size:11px; margin-bottom:14px;">
<div style="color:rgba(255,248,220,.55); font-size:11px; margin-bottom:20px;">
红包有效期 <strong style="color:#fcd34d;">24小时</strong>,过期自动消失
</div>
{{-- 仿"同意离婚"按钮:深色外框 + 内部实心颜色按钮 --}}
<div style="background:rgba(0,0,0,.35); border-radius:18px; padding:6px; margin-bottom:4px;">
{{-- 圆形领取按钮(仿「開」按钮,全样式写入 :style 避免 Alpine 覆盖) --}}
<div style="display:flex; justify-content:center; margin-bottom:20px;">
<button x-on:click="doClaim()" :disabled="claiming"
style="display:block; width:100%; padding:14px 0; border:none;
border-radius:13px; font-size:16px; font-weight:bold;
cursor:pointer; transition:all .15s; letter-spacing:1px; color:#fff;"
onmouseover="if(!this.disabled)this.style.transform='scale(1.08)'"
onmouseout="this.style.transform=''"
:style="claiming
?
'background:#b45309; opacity:.65; cursor:not-allowed;' :
'background:#d97706; box-shadow:0 2px 12px rgba(0,0,0,.4);'"
onmouseover="if(!this.disabled) this.style.filter='brightness(1.12)'"
onmouseout="this.style.filter=''">
<span x-text="claiming ? '领取中…' : '🧧 点击领取红包'"></span>
'width:130px; height:130px; border-radius:50%; border:none; cursor:not-allowed; display:flex; align-items:center; justify-content:center; flex-shrink:0; box-sizing:border-box; transition:all .25s; font-size:28px; font-weight:900; letter-spacing:2px; background:#c8b89a; color:rgba(100,50,20,.45); box-shadow:none;' :
'width:130px; height:130px; border-radius:50%; border:none; cursor:pointer; display:flex; align-items:center; justify-content:center; flex-shrink:0; box-sizing:border-box; transition:all .25s; font-size:28px; font-weight:900; letter-spacing:2px; background:#f5e6c8; color:#8b3a1a; box-shadow:0 6px 28px rgba(0,0,0,.35), inset 0 -4px 10px rgba(139,58,26,.12); animation:grab-btn-pulse 1.8s ease-in-out infinite;'">
<span x-text="claiming ? '…' : '领取'"></span>
</button>
</div>
</div>
{{-- 已领取 --}}
<div x-show="claimed" style="display:none;">
<div style="font-size:36px; margin-bottom:6px; color:#fcd34d; font-weight:bold;"
<div x-show="claimed" style="display:none; padding-bottom:8px;">
<div style="font-size:40px; margin-bottom:6px; color:#fcd34d; font-weight:bold;"
x-text="'+' + claimedAmount.toLocaleString() + ' 金'"></div>
<div style="color:#fef3c7; font-size:13px;">🎉 恭喜你领取了红包!</div>
<div style="color:rgba(254,243,199,.6); font-size:11px; margin-top:4px;">金币已自动到账</div>
<div style="color:#fff8dc; font-size:13px;">🎉 恭喜你领取了红包!</div>
<div style="color:rgba(255,248,220,.6); font-size:11px; margin-top:4px;">金币已自动到账</div>
</div>
</div>
<div style="padding:0 20px 20px;">
{{-- 关闭按钮 --}}
<div style="padding:0 20px 24px; display:flex; justify-content:center;">
<button x-on:click="close()"
style="padding:10px 32px; background:rgba(0,0,0,.3); border:none; border-radius:30px;
font-size:12px; color:rgba(254,243,199,.7); cursor:pointer;">
style="width:200px; padding:12px 0; background:rgba(0,0,0,.3); border:none; border-radius:30px;
font-size:13px; color:rgba(255,248,220,.8); cursor:pointer; letter-spacing:1px;
transition:background .15s;"
onmouseover="this.style.background='rgba(0,0,0,.48)'"
onmouseout="this.style.background='rgba(0,0,0,.3)'">
<span x-text="claimed ? '收下啦 ✨' : '关闭'"></span>
</button>
</div>
@@ -661,6 +666,7 @@
</div>
</div>
<style>
@keyframes pulse {
@@ -1412,5 +1418,55 @@
// 延迟初始化,确保 Echo 已就绪
setTimeout(() => window.initMarriagePrivateChannel(userId), 1500);
}
// ── 页面刷新后恢复婚礼红包领取按钮 ─────────────────────────
// 延迟 2 秒以确保聊天框和 Alpine 均已完成初始化
setTimeout(async () => {
try {
const res = await fetch('/wedding/pending-envelopes', {
headers: {
'Accept': 'application/json'
}
});
const data = await res.json();
if (!res.ok || !data.envelopes?.length) return;
// 初始化全局缓存
if (!window._weddingEnvelopes) window._weddingEnvelopes = {};
data.envelopes.forEach(env => {
const ceremonyId = env.ceremony_id;
// 注入 detail 到全局 Map供领取弹窗使用
window._weddingEnvelopes[ceremonyId] = {
ceremony_id: ceremonyId,
total_amount: env.total_amount,
tier_name: env.tier_name,
tier_icon: env.tier_icon,
user: {
username: env.groom
},
partner: {
username: env.bride
},
};
// 在包厢窗口追加提示 + 领取按钮
if (typeof appendSystemMessage === 'function') {
const claimBtn = `<button onclick="(function(){var d=window._weddingEnvelopes[${ceremonyId}];var el=document.getElementById('wedding-envelope-modal');if(el&&d)Alpine.$data(el).open(d);})()"
style="display:inline-block; margin-left:10px; padding:4px 14px; border-radius:20px;
background:#d97706; color:#fff;
border:none; font-size:12px; font-weight:bold; cursor:pointer;
vertical-align:middle; line-height:1.8; box-shadow:0 2px 8px rgba(0,0,0,.3);"
title="点击领取婚礼红包">🧧 点击领取红包</button>`;
appendSystemMessage(
`⚠️ 您有来自 ${env.tier_icon} ${env.groom} 与 ${env.bride}【${env.tier_name}】的婚礼红包未领取!${claimBtn}`
);
}
});
} catch (e) {
console.warn('[婚礼红包] 恢复待领取按钮失败', e);
}
}, 2000);
});
</script>

View File

@@ -9,157 +9,140 @@
- 最近记录展示
--}}
{{-- ─── 老虎机悬浮按钮(可拖动) ─── --}}
<div id="slot-fab"
x-data="slotFab()"
x-show="visible"
x-cloak
:style="'position:fixed; right:' + posX + 'px; bottom:' + posY + 'px; z-index:9900; touch-action:none; user-select:none;'"
@pointerdown.prevent="startDrag($event)"
@pointermove.window="onDrag($event)"
@pointerup.window="endDrag($event)"
@pointercancel.window="endDrag($event)">
<button
style="width:52px; height:52px; border-radius:50%; border:none;
background:linear-gradient(135deg,#d97706,#f59e0b);
box-shadow:0 4px 20px rgba(245,158,11,.5);
font-size:22px; display:flex; align-items:center; justify-content:center;
animation:slot-pulse 2s infinite;"
:style="dragging ? 'cursor:grabbing;' : 'cursor:grab;'"
title="老虎机(可拖动)">🎰</button>
</div>
{{-- ─── 老虎机主面板 ─── --}}
{{-- \u2500\u2500\u2500 \u8001\u864e\u673a\u4e3b\u9762\u677f \u2500\u2500\u2500 --}}
<div id="slot-panel" x-data="slotPanel()" x-show="show" x-cloak>
<div x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
style="position:fixed; inset:0; background:rgba(0,0,0,.75); z-index:9950;
style="position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9950;
display:flex; align-items:center; justify-content:center;">
<div
style="width:400px; max-width:96vw; border-radius:24px; overflow:hidden;
box-shadow:0 24px 80px rgba(245,158,11,.4); font-family:system-ui,sans-serif;">
style="width:420px; max-width:96vw; border-radius:8px; overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,.3); font-family:'Microsoft YaHei',SimSun,sans-serif; background:#fff;">
{{-- ─── 顶部标题 ─── --}}
<div style="background:linear-gradient(135deg,#78350f,#b45309,#d97706); padding:16px 20px 12px;">
<div style="display:flex; align-items:center; justify-content:space-between;">
<div>
<div style="color:#fff; font-weight:900; font-size:18px;">🎰 老虎机</div>
<div style="color:rgba(255,255,255,.6); font-size:11px; margin-top:2px;">
每次消耗 <span x-text="costPerSpin" style="color:#fbbf24; font-weight:bold;"></span> 金币
</div>
</div>
<div style="text-align:right;">
<div style="color:#fbbf24; font-size:18px; font-weight:900;">🪙 <span
x-text="Number(balance).toLocaleString()"></span></div>
<div x-show="dailyLimit > 0" style="color:rgba(255,255,255,.5); font-size:11px;"
x-text="'今日剩余 ' + remaining + ' 次'"></div>
</div>
{{-- \u2500\u2500\u2500 \u6807\u9898\u680f\uff08\u6d77\u519b\u84dd\u98ce\u683c\uff09\u2500\u2500\u2500 --}}
<div
style="background:linear-gradient(135deg,#336699,#5a8fc0); padding:10px 16px; display:flex; align-items:center; gap:10px;">
<div style="font-size:14px; font-weight:bold; color:#fff; flex:1;">🎰 老虎机</div>
<div
style="font-size:12px; color:rgba(255,255,255,.8); background:rgba(0,0,0,.15); padding:2px 10px; border-radius:10px; display:flex; align-items:center; gap:4px;">
🪙 <span x-text="Number(balance).toLocaleString()" style="color:#fef08a; font-weight:bold;"></span>
</div>
<div x-show="dailyLimit > 0"
style="font-size:11px; color:rgba(255,255,255,.7); background:rgba(255,255,255,.15); padding:2px 8px; border-radius:10px;"
x-text="'剩余 ' + remaining + ' 次'"></div>
<span onclick="Alpine.$data(document.getElementById('slot-panel')).close()"
style="cursor:pointer; font-size:18px; color:rgba(255,255,255,.8); line-height:1; transition:opacity .15s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=.8">&times;</span>
</div>
{{-- ─── 滚轮区域 ─── --}}
<div style="background:linear-gradient(180deg,#1c1007,#292012); padding:20px;">
{{-- \u2500\u2500\u2500 \u8de8\u8d39\u8bf4\u660e\u6761 \u2500\u2500\u2500 --}}
<div
style="background:#f6faff; border-bottom:1px solid #d0e4f5; padding:4px 16px; font-size:11px; color:#5a8fc0; text-align:center;">
每次消耗 <span x-text="costPerSpin" style="color:#336699; font-weight:bold;"></span> 金币
</div>
{{-- 三列转轮 --}}
{{-- \u2500\u2500\u2500 \u4e3b\u4f53\u5185\u5bb9\uff08\u767d\u5e95\uff09\u2500\u2500\u2500 --}}
<div style="background:#fff; padding:16px 16px 12px;">
{{-- \u4e09\u5217\u8f6c\u8f6e --}}
<div
style="background:#0f0a02; border-radius:16px; padding:16px 12px; margin-bottom:16px;
border:2px solid rgba(245,158,11,.3); box-shadow:inset 0 0 30px rgba(0,0,0,.5);">
style="background:#f0f6ff; border-radius:10px; padding:14px 12px; margin-bottom:14px;
border:1px solid #d0e4f5; box-shadow:inset 0 2px 6px rgba(51,102,153,.06);">
<div style="display:flex; gap:8px; justify-content:center; align-items:center;">
{{-- 第一列 --}}
<div
style="flex:1; background:linear-gradient(180deg,#1a1000,#2d1f00); border-radius:12px;
height:90px; display:flex; align-items:center; justify-content:center;
border:1px solid rgba(245,158,11,.2); overflow:hidden; position:relative;">
<div id="slot-reel-0" style="font-size:44px; transition:all .15s; user-select:none;"
x-text="spinning ? spinEmojis[0] : resultEmojis[0]"
:style="spinning && !reel1Stopped ? 'animation:reel-spin .1s linear infinite' : ''">
style="flex:1; background:#fff; border-radius:8px;
height:120px; display:flex; align-items:center; justify-content:center;
border:1.5px solid #b8d0e8; box-shadow:0 2px 8px rgba(51,102,153,.1);">
<div id="slot-reel-0" x-text="spinning ? spinEmojis[0] : resultEmojis[0]"
:style="'font-size:80px; line-height:1; transition:all .15s; user-select:none; ' + (spinning &&
!reel1Stopped ? 'animation:reel-spin .1s linear infinite' : '')">
</div>
</div>
{{-- 分隔 --}}
<div style="color:rgba(245,158,11,.4); font-size:20px; font-weight:900;">|</div>
<div style="color:#b8d0e8; font-size:20px; font-weight:900;">|</div>
{{-- 第二列 --}}
<div
style="flex:1; background:linear-gradient(180deg,#1a1000,#2d1f00); border-radius:12px;
height:90px; display:flex; align-items:center; justify-content:center;
border:1px solid rgba(245,158,11,.2); overflow:hidden; position:relative;">
<div id="slot-reel-1" style="font-size:44px; transition:all .15s; user-select:none;"
x-text="spinning ? spinEmojis[1] : resultEmojis[1]"
:style="spinning && !reel2Stopped ? 'animation:reel-spin .12s linear infinite' : ''">
style="flex:1; background:#fff; border-radius:8px;
height:120px; display:flex; align-items:center; justify-content:center;
border:1.5px solid #b8d0e8; box-shadow:0 2px 8px rgba(51,102,153,.1);">
<div id="slot-reel-1" x-text="spinning ? spinEmojis[1] : resultEmojis[1]"
:style="'font-size:80px; line-height:1; transition:all .15s; user-select:none; ' + (spinning &&
!reel2Stopped ? 'animation:reel-spin .12s linear infinite' : '')">
</div>
</div>
{{-- 分隔 --}}
<div style="color:rgba(245,158,11,.4); font-size:20px; font-weight:900;">|</div>
<div style="color:#b8d0e8; font-size:20px; font-weight:900;">|</div>
{{-- 第三列 --}}
<div
style="flex:1; background:linear-gradient(180deg,#1a1000,#2d1f00); border-radius:12px;
height:90px; display:flex; align-items:center; justify-content:center;
border:1px solid rgba(245,158,11,.2); overflow:hidden; position:relative;">
<div id="slot-reel-2" style="font-size:44px; transition:all .15s; user-select:none;"
x-text="spinning ? spinEmojis[2] : resultEmojis[2]"
:style="spinning && !reel3Stopped ? 'animation:reel-spin .14s linear infinite' : ''">
style="flex:1; background:#fff; border-radius:8px;
height:120px; display:flex; align-items:center; justify-content:center;
border:1.5px solid #b8d0e8; box-shadow:0 2px 8px rgba(51,102,153,.1);">
<div id="slot-reel-2" x-text="spinning ? spinEmojis[2] : resultEmojis[2]"
:style="'font-size:80px; line-height:1; transition:all .15s; user-select:none; ' + (spinning &&
!reel3Stopped ? 'animation:reel-spin .14s linear infinite' : '')">
</div>
</div>
</div>
{{-- 中间射线指示条 --}}
<div
style="height:2px; background:linear-gradient(90deg,transparent,rgba(245,158,11,.6),transparent);
margin-top:8px; border-radius:1px;">
style="height:2px; background:linear-gradient(90deg,transparent,rgba(51,102,153,.3),transparent);
margin-top:10px; border-radius:1px;">
</div>
</div>
{{-- 结果提示 --}}
<div style="text-align:center; min-height:36px; margin-bottom:12px;">
{{-- \u7ed3\u679c\u63d0\u793a --}}
<div style="text-align:center; min-height:34px; margin-bottom:10px;">
<div x-show="!spinning && resultLabel" x-transition
style="display:inline-block; padding:5px 18px; border-radius:20px; font-weight:bold; font-size:14px;"
style="display:inline-block; padding:4px 18px; border-radius:20px; font-weight:bold; font-size:13px;"
:style="resultType === 'jackpot' ?
'background:linear-gradient(135deg,#fbbf24,#f59e0b); color:#1c1007; box-shadow:0 0 20px rgba(251,191,36,.5);' :
'background:linear-gradient(135deg,#fbbf24,#f59e0b); color:#fff; box-shadow:0 0 16px rgba(251,191,36,.4);' :
resultType === 'triple_gem' ?
'background:rgba(167,139,250,.2); color:#c4b5fd; border:1px solid rgba(167,139,250,.3);' :
'background:linear-gradient(135deg,#7c3aed,#a78bfa); color:#fff; box-shadow:0 0 12px rgba(124,58,237,.3);' :
resultType === 'triple' ?
'background:rgba(52,211,153,.15); color:#6ee7b7; border:1px solid rgba(52,211,153,.25);' :
'background:linear-gradient(135deg,#059669,#34d399); color:#fff; box-shadow:0 0 10px rgba(52,211,153,.3);' :
resultType === 'pair' ?
'background:rgba(96,165,250,.15); color:#93c5fd; border:1px solid rgba(96,165,250,.25);' :
'background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff; box-shadow:0 0 10px rgba(51,102,153,.2);' :
resultType === 'curse' ?
'background:rgba(239,68,68,.15); color:#f87171; border:1px solid rgba(239,68,68,.25);' :
'background:rgba(255,255,255,.06); color:rgba(255,255,255,.4); '"
'background:linear-gradient(135deg,#dc2626,#ef4444); color:#fff; box-shadow:0 0 10px rgba(220,38,38,.3);' :
'background:#f0f6ff; color:#5a8fc0; border:1px solid #d0e4f5;'"
x-text="resultLabel">
</div>
<div x-show="spinning"
style="color:rgba(255,255,255,.4); font-size:13px; animation:blink .6s infinite;">
<div x-show="spinning" style="color:#5a8fc0; font-size:13px; animation:blink .6s infinite;">
正在转动中…
</div>
</div>
{{-- 盈亏显示 --}}
{{-- \u76c8\u4e8f\u663e\u793a --}}
<div x-show="!spinning && resultType" style="text-align:center; margin-bottom:12px;">
<div x-show="netChange > 0" style="color:#34d399; font-size:20px; font-weight:bold;"
<div x-show="netChange > 0" style="color:#16a34a; font-size:22px; font-weight:bold;"
x-text="'+' + Number(netChange).toLocaleString() + ' 🪙'">
</div>
<div x-show="netChange < 0" style="color:#f87171; font-size:16px; font-weight:bold;"
<div x-show="netChange < 0" style="color:#dc2626; font-size:16px; font-weight:bold;"
x-text="Number(netChange).toLocaleString() + ' 🪙'">
</div>
<div x-show="netChange === 0 && resultType === 'miss'"
style="color:rgba(255,255,255,.3); font-size:13px;">
<div x-show="netChange === 0 && resultType === 'miss'" style="color:#999; font-size:13px;">
损失 <span x-text="costPerSpin"></span> 金币
</div>
</div>
{{-- 转动按钮 --}}
{{-- \u65cb\u8f6c\u6309\u94ae --}}
<button x-on:click="doSpin()" :disabled="spinning || (dailyLimit > 0 && remaining <= 0)"
style="width:100%; border:none; border-radius:14px; padding:14px; font-size:16px;
font-weight:900; cursor:pointer; transition:all .2s; letter-spacing:2px;"
style="display:block; width:100%; border:none; border-radius:12px; padding:12px 0;
font-size:15px; font-weight:bold; cursor:pointer; transition:all .2s; letter-spacing:1px;"
:style="(spinning || (dailyLimit > 0 && remaining <= 0)) ? {
background: '#292012',
color: 'rgba(255,255,255,.3)',
cursor: 'not-allowed'
background: '#e0e8f0',
color: '#99a8b8',
cursor: 'not-allowed',
boxShadow: 'none'
} : {
background: 'linear-gradient(135deg,#b45309,#d97706,#f59e0b)',
background: 'linear-gradient(135deg,#336699,#5a8fc0)',
color: '#fff',
boxShadow: '0 4px 20px rgba(245,158,11,.5)',
transform: 'scale(1)'
boxShadow: '0 4px 14px rgba(51,102,153,.35)'
}">
<span
x-text="spinning ? '🎰 转动中…' :
@@ -167,40 +150,78 @@
'🎰 SPIN'"></span>
</button>
{{-- 赔率说明 --}}
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:4px; margin-top:10px;">
{{-- \u8d54\u7387\u8bf4\u660e --}}
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:6px; margin-top:12px;">
<div
style="background:rgba(245,158,11,.1); border-radius:6px; padding:4px; text-align:center; font-size:10px;">
style="background:#fffbeb; border:1px solid #fde68a; border-radius:6px; padding:5px; text-align:center; font-size:10px;">
<div>7⃣7⃣7</div>
<div style="color:#fbbf24; font-weight:bold;">×100</div>
<div style="color:#d97706; font-weight:bold; margin-top:2px;">×100</div>
</div>
<div
style="background:rgba(167,139,250,.1); border-radius:6px; padding:4px; text-align:center; font-size:10px;">
style="background:#f5f3ff; border:1px solid #ddd6fe; border-radius:6px; padding:5px; text-align:center; font-size:10px;">
<div>💎💎💎</div>
<div style="color:#c4b5fd; font-weight:bold;">×50</div>
<div style="color:#7c3aed; font-weight:bold; margin-top:2px;">×50</div>
</div>
<div
style="background:rgba(52,211,153,.1); border-radius:6px; padding:4px; text-align:center; font-size:10px;">
style="background:#f0fdf4; border:1px solid #bbf7d0; border-radius:6px; padding:5px; text-align:center; font-size:10px;">
<div>三同</div>
<div style="color:#6ee7b7; font-weight:bold;">×10</div>
<div style="color:#059669; font-weight:bold; margin-top:2px;">×10</div>
</div>
<div
style="background:rgba(96,165,250,.1); border-radius:6px; padding:4px; text-align:center; font-size:10px;">
style="background:#f0f6ff; border:1px solid #d0e4f5; border-radius:6px; padding:5px; text-align:center; font-size:10px;">
<div>两同</div>
<div style="color:#93c5fd; font-weight:bold;">×2</div>
<div style="color:#336699; font-weight:bold; margin-top:2px;">×2</div>
</div>
</div>
{{-- 玩法说明(可折叠) --}}
<div x-data="{ open: false }" style="margin-top:12px;">
<button @click="open = !open"
style="width:100%; background:#f6faff; border:1px solid #d0e4f5; border-radius:8px;
padding:7px 12px; font-size:11px; color:#5a8fc0; cursor:pointer; transition:all .15s;
display:flex; align-items:center; justify-content:space-between;"
onmouseover="this.style.background='#eaf3ff'" onmouseout="this.style.background='#f6faff'">
<span>📖 玩法说明</span>
<span x-text="open ? '▲ 收起' : '▼ 展开'" style="font-size:10px; color:#99b0cc;"></span>
</button>
<div x-show="open" x-transition
style="margin-top:6px; background:#f6faff; border:1px solid #d0e4f5; border-radius:8px;
padding:10px 12px; font-size:11px; color:#446688; line-height:1.8;">
<div style="font-weight:bold; color:#336699; margin-bottom:6px; font-size:12px;">🎰 如何游玩</div>
<div> 点击 <strong>SPIN</strong> 按钮消耗金币抽奖,系统随机确定三列图案</div>
<div> 图案组合决定奖励倍率,奖励金币 = 消耗金币 × 倍率</div>
<div> 每日有次数上限,用完须等次日 0 点重置</div>
<div style="height:1px; background:#d0e4f5; margin:8px 0;"></div>
<div style="font-weight:bold; color:#336699; margin-bottom:6px; font-size:12px;">💎 图案赔率表</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:4px;">
<div>🎰 7⃣7⃣7 <strong style="color:#d97706;">×100</strong> 大奖全服广播</div>
<div>💎💎💎 <strong style="color:#7c3aed;">×50</strong> 三钻大奖</div>
<div>任意三同 <strong style="color:#059669;">×10</strong> 三倍以上</div>
<div>任意两同 <strong style="color:#336699;">×2</strong> 回本加成</div>
<div>💀 骷髅 <strong style="color:#dc2626;">另有惩罚</strong></div>
<div>其余组合 <strong style="color:#999;">未中奖</strong></div>
</div>
<div style="height:1px; background:#d0e4f5; margin:8px 0;"></div>
<div style="font-weight:bold; color:#336699; margin-bottom:4px; font-size:12px;">⚠️ 注意事项</div>
<div> 奖励直接到账金币,无需额外领取</div>
<div> 三个 7️⃣ 为聊天室大奖,系统会全服广播</div>
<div> 未中奖只损失本次消耗的旋转费用</div>
</div>
</div>
{{-- 历史记录 --}}
<div x-show="history.length > 0" style="margin-top:10px;">
<div style="color:rgba(255,255,255,.3); font-size:10px; margin-bottom:4px;">最近记录</div>
<div x-show="history.length > 0" style="margin-top:12px;">
<div style="color:#99b0cc; font-size:10px; margin-bottom:5px;">最近记录</div>
<div style="display:flex; gap:4px; flex-wrap:wrap;">
<template x-for="h in history" :key="h.created_at + h.result_label">
<div style="background:rgba(255,255,255,.06); border-radius:6px; padding:3px 8px;
<div style="background:#f0f6ff; border:1px solid #d0e4f5; border-radius:6px; padding:3px 8px;
font-size:11px; display:flex; align-items:center; gap:4px;"
:title="h.result_label + ' ' + (h.payout > 0 ? '+' : '') + h.payout">
<span x-text="h.emojis.join('')"></span>
<span :style="h.payout > 0 ? 'color:#4ade80' : 'color:#f87171'"
<span :style="h.payout > 0 ? 'color:#16a34a; font-weight:bold;' : 'color:#dc2626;'"
x-text="(h.payout > 0 ? '+' : '') + h.payout"></span>
</div>
</template>
@@ -208,13 +229,13 @@
</div>
</div>
{{-- ─── 底部关闭 ─── --}}
<div style="background:rgba(15,8,0,.95); padding:8px 20px; display:flex; justify-content:center;">
{{-- \u2500\u2500\u2500 \u5e95\u90e8\u5173\u95ed \u2500\u2500\u2500 --}}
<div
style="background:#fff; border-top:1px solid #d0e4f5; padding:14px 16px; display:flex; justify-content:center;">
<button x-on:click="close()"
style="padding:6px 28px; background:rgba(255,255,255,.06); border:none; border-radius:20px;
font-size:12px; color:rgba(255,255,255,.4); cursor:pointer; transition:all .15s;"
onmouseover="this.style.background='rgba(255,255,255,.12)'"
onmouseout="this.style.background='rgba(255,255,255,.06)'">
style="padding:10px 48px; min-width:140px; background:#f0f6ff; border:1px solid #b0d0ee; border-radius:12px;
font-size:14px; font-weight:bold; color:#336699; cursor:pointer; transition:all .15s;"
onmouseover="this.style.background='#ddeeff'" onmouseout="this.style.background='#f0f6ff'">
关闭
</button>
</div>
@@ -270,17 +291,19 @@
const STORAGE_KEY = 'slot_fab_pos';
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
return {
visible: false,
posX: saved?.x ?? 18,
posY: saved?.y ?? 150,
visible: false,
posX: saved?.x ?? 18,
posY: saved?.y ?? 150,
dragging: false,
_startX: 0, _startY: 0,
_origX: 0, _origY: 0,
_moved: false,
_startX: 0,
_startY: 0,
_origX: 0,
_origY: 0,
_moved: false,
async init() {
try {
const res = await fetch('/slot/info');
const res = await fetch('/slot/info');
const data = await res.json();
this.visible = data.enabled === true;
} catch {}
@@ -288,11 +311,11 @@
startDrag(e) {
this.dragging = true;
this._moved = false;
this._startX = e.clientX;
this._startY = e.clientY;
this._origX = this.posX;
this._origY = this.posY;
this._moved = false;
this._startX = e.clientX;
this._startY = e.clientY;
this._origX = this.posX;
this._origY = this.posY;
e.currentTarget.setPointerCapture?.(e.pointerId);
},
@@ -301,14 +324,17 @@
const dx = e.clientX - this._startX;
const dy = e.clientY - this._startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this._moved = true;
this.posX = Math.max(4, Math.min(window.innerWidth - 60, this._origX - dx));
this.posX = Math.max(4, Math.min(window.innerWidth - 60, this._origX - dx));
this.posY = Math.max(4, Math.min(window.innerHeight - 60, this._origY + dy));
},
endDrag(e) {
if (!this.dragging) return;
this.dragging = false;
localStorage.setItem(STORAGE_KEY, JSON.stringify({ x: this.posX, y: this.posY }));
localStorage.setItem(STORAGE_KEY, JSON.stringify({
x: this.posX,
y: this.posY
}));
if (!this._moved) this.openPanel();
},
@@ -355,6 +381,18 @@
_spinInterval: null,
_stopTimers: [],
/**
* Alpine 初始化: 监听 show 变化自动加载数据(解决从游戏大厅入口不调用 open() 时历史不刷新的问题)
*/
init() {
this.$watch('show', async (val) => {
if (val) {
await this.loadInfo();
await this.loadHistory();
}
});
},
/**
* 打开面板并加载数据
*/

View File

@@ -17,7 +17,7 @@
<div class="chat-toolbar" id="toolbar-strip">
<div class="tool-btn" onclick="openShopModal()" title="购买道具">商店</div>
<div class="tool-btn" onclick="saveExp()" title="手动存经验点">存点</div>
<div class="tool-btn" onclick="alert('🚧 娱乐功能开发中,敬请期待!')" title="娱乐(待开发)">娱乐</div>
<div class="tool-btn" onclick="openGameHall()" title="娱乐游戏大厅">娱乐</div>
<div class="tool-btn" onclick="alert('🚧 银行功能开发中,敬请期待!')" title="银行(待开发)">银行</div>
<div class="tool-btn" onclick="openMarriageStatusModal()" title="婚姻状态">婚姻</div>
<div class="tool-btn" onclick="openFriendPanel()" title="好友列表">好友</div>

View File

@@ -193,7 +193,8 @@
});
if (cfgRes.ok) divorceConfig = await cfgRes.json();
} catch (e) {
/* 网络异常则使用默认值 */ }
/* 网络异常则使用默认值 */
}
// 打开专属离婚确认弹窗
const modal = document.getElementById('divorce-confirm-modal');
@@ -721,7 +722,7 @@
{{-- 操作按钮区:加好友 + 送礼物 + 送金币(有职务且有奖励权限时显示) --}}
<div x-data="{ showGiftPanel: false, showRewardPanel: false }" x-show="userInfo.username !== window.chatContext.username">
<div class="modal-actions" style="margin-bottom: 0; display: flex; gap: 6px; flex-wrap: wrap;">
<div class="modal-actions" style="margin-bottom: 0; display: flex; gap: 6px;">
{{-- 加好友 / 删好友 --}}
<button x-on:click="toggleFriend()" :disabled="friendLoading"
:style="is_friend
@@ -730,7 +731,7 @@
'background: linear-gradient(135deg,#16a34a,#22c55e); color:#fff; border:none;'"
style="flex:1; padding: 7px 10px; border-radius: 5px; font-size: 12px;
cursor: pointer; font-weight: bold; transition: opacity .15s;"
x-text="friendLoading ? '处理中…' : (is_friend ? '✅ 已是好友' : ' 加好友')"></button>
x-text="friendLoading ? '处理中…' : (is_friend ? '✅ 好友' : ' 加好友')"></button>
{{-- 送礼物按钮 --}}
<button class="btn-whisper" style="flex:1;"
@@ -774,11 +775,11 @@
{{-- 对方已婚时显示提示(非伴侣) --}}
<div x-show="!marriageLoading && targetMarriage && targetMarriage.status === 'married' && !targetMarriage.is_my_partner"
:title="'与 ' + (targetMarriage?.partner_name || '—') + ' 已婚'"
style="flex:1; display:flex; align-items:center; justify-content:center;
padding:7px 10px; border-radius:5px; font-size:11px; background:#fff1f2;
border:1px solid #fecdd3; color:#f43f5e; font-weight:bold;">
💑 <span x-text="'与 ' + (targetMarriage?.partner_name || '—') + ' 已婚'"
style="margin-left:3px;"></span>
padding:7px 10px; border-radius:5px; font-size:12px; background:#fff1f2;
border:1px solid #fecdd3; color:#f43f5e; font-weight:bold; white-space:nowrap;">
💑 已婚
</div>
{{-- 如果对方是自己的伴侣,显示离婚按钮 --}}

View File

@@ -95,11 +95,35 @@ Schedule::call(function () {
$rand = random_int(1, 100);
$boxType = match (true) {
$rand <= $trapChance => 'trap',
$rand <= $trapChance => 'trap',
$rand <= $trapChance + 15 => 'rare',
default => 'normal',
default => 'normal',
};
\App\Jobs\DropMysteryBoxJob::dispatch($boxType);
})->everyMinute()->name('mystery-box:auto-drop')->withoutOverlapping();
// ──────────── 赛马竞猜定时任务 ─────────────────────────────────
// 每分钟:检查是否应开启新一场赛马
Schedule::call(function () {
if (! \App\Models\GameConfig::isEnabled('horse_racing')) {
return;
}
// 当前已有进行中的场次(押注中/跑马中),跳过
if (\App\Models\HorseRace::currentRace()) {
return;
}
$config = \App\Models\GameConfig::forGame('horse_racing')?->params ?? [];
$interval = (int) ($config['interval_minutes'] ?? 30);
// 检查距上一场触发时间是否已达到间隔
$lastRace = \App\Models\HorseRace::latest()->first();
if ($lastRace && $lastRace->created_at->diffInMinutes(now()) < $interval) {
return;
}
\App\Jobs\OpenHorseRaceJob::dispatch();
})->everyMinute()->name('horse-race:open-race')->withoutOverlapping();

View File

@@ -103,8 +103,10 @@ Route::middleware(['chat.auth'])->group(function () {
Route::post('/{marriage}/setup', [\App\Http\Controllers\WeddingController::class, 'setup'])->name('setup');
// 领取婚礼红包
Route::post('/ceremony/{ceremony}/claim', [\App\Http\Controllers\WeddingController::class, 'claim'])->name('claim');
// 查询是否有待领取红包
// 查询是否有待领取红包(单个婚礼)
Route::get('/ceremony/{ceremony}/envelope', [\App\Http\Controllers\WeddingController::class, 'envelopeStatus'])->name('envelope-status');
// 查询当前用户所有待领取婚礼红包(页面加载时恢复按钮)
Route::get('/pending-envelopes', [\App\Http\Controllers\WeddingController::class, 'pendingEnvelopes'])->name('pending-envelopes');
});
// ── 节日福利(前台)──────────────────────────────────────────────
@@ -140,6 +142,38 @@ Route::middleware(['chat.auth'])->group(function () {
Route::post('/claim', [\App\Http\Controllers\MysteryBoxController::class, 'claim'])->name('claim');
});
// ── 赛马竞猜(前台)─────────────────────────────────────────
Route::prefix('horse-race')->name('horse-race.')->group(function () {
// 获取当前赛事信息(马匹、注池、赔率)
Route::get('/current', [\App\Http\Controllers\HorseRaceController::class, 'currentRace'])->name('current');
// 提交下注
Route::post('/bet', [\App\Http\Controllers\HorseRaceController::class, 'bet'])->name('bet');
// 最近10场历史记录
Route::get('/history', [\App\Http\Controllers\HorseRaceController::class, 'history'])->name('history');
});
// ── 神秘占卜(前台)─────────────────────────────────────────
Route::prefix('fortune')->name('fortune.')->group(function () {
// 查询今日占卜状态(已占卜签文 / 剩余免费次数)
Route::get('/today', [\App\Http\Controllers\FortuneTellingController::class, 'todayStatus'])->name('today');
// 执行占卜(免费或付费)
Route::post('/tell', [\App\Http\Controllers\FortuneTellingController::class, 'tell'])->name('tell');
// 个人历史记录
Route::get('/history', [\App\Http\Controllers\FortuneTellingController::class, 'history'])->name('history');
});
// ── 游戏大厅:实时开关状态接口 ────────────────────────────────────
Route::get('/games/enabled', function () {
return response()->json([
'baccarat' => \App\Models\GameConfig::isEnabled('baccarat'),
'slot_machine' => \App\Models\GameConfig::isEnabled('slot_machine'),
'mystery_box' => \App\Models\GameConfig::isEnabled('mystery_box'),
'horse_racing' => \App\Models\GameConfig::isEnabled('horse_racing'),
'fortune_telling' => \App\Models\GameConfig::isEnabled('fortune_telling'),
'fishing' => \App\Models\GameConfig::isEnabled('fishing'),
]);
})->name('games.enabled');
// ---- 第五阶段:具体房间内部聊天核心 ----
// 进入具体房间界面的初始化
Route::get('/room/{id}', [ChatController::class, 'init'])->name('chat.room');