Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1409d16bb | |||
| 2ea84ed93e | |||
| dce0a52750 | |||
| ce6d71a4f2 | |||
| a0a189867f | |||
| 3c7cefe447 | |||
| 74e4803bc2 | |||
| b8feab34a6 | |||
| 0c9e7baca2 | |||
| da0846c7ab | |||
| 8c1b0b0840 | |||
| 1b062f67ea | |||
| 41522393de | |||
| 645fe2a830 | |||
| 64945a973e | |||
| 725a38eac3 | |||
| 11a882bd8e | |||
| a65827c5d9 | |||
| 9b993e487c | |||
| 6225a0fb45 | |||
| b3eebd286e | |||
| fdd4f8a179 | |||
| 82dbc19319 | |||
| ffd8789e67 | |||
| ee525f049e | |||
| f354516869 | |||
| 92e3dd0cdf | |||
| 9764961519 | |||
| 4af4468fc4 | |||
| a6e50c36d7 | |||
| b21f583fe5 | |||
| 8c7b1086ff | |||
| 59a417bd10 | |||
| 0fe003a773 | |||
| 06864a9cec | |||
| 622bc94377 | |||
| 575e92e03f | |||
| 522eea72f6 | |||
| fc7930046d | |||
| 7ba7b34ca7 | |||
| 3eaf37a648 | |||
| 221f629ec2 | |||
| 18acd7d890 | |||
| 09a2b0d85f | |||
| b60f3615c1 | |||
| 363c45a140 | |||
| 181cc6a0b0 | |||
| 3c95478097 |
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "chatroom-local-marketplace",
|
||||||
|
"interface": {
|
||||||
|
"displayName": "Chatroom Local Plugins"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "chatroom-ride-development",
|
||||||
|
"source": {
|
||||||
|
"source": "local",
|
||||||
|
"path": "./plugins/chatroom-ride-development"
|
||||||
|
},
|
||||||
|
"policy": {
|
||||||
|
"installation": "AVAILABLE",
|
||||||
|
"authentication": "ON_INSTALL"
|
||||||
|
},
|
||||||
|
"category": "Productivity"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
/.agents
|
/.agents
|
||||||
/.codex
|
/.codex
|
||||||
/.hermes
|
/.hermes
|
||||||
|
/.reasonix
|
||||||
|
/.understand-anything
|
||||||
/auth.json
|
/auth.json
|
||||||
/node_modules
|
/node_modules
|
||||||
/public/build
|
/public/build
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ namespace App\Console\Commands;
|
|||||||
|
|
||||||
use App\Enums\CurrencySource;
|
use App\Enums\CurrencySource;
|
||||||
use App\Events\MessageSent;
|
use App\Events\MessageSent;
|
||||||
|
use App\Jobs\AiFishingJob;
|
||||||
use App\Jobs\SaveMessageJob;
|
use App\Jobs\SaveMessageJob;
|
||||||
use App\Models\Autoact;
|
use App\Models\Autoact;
|
||||||
use App\Models\DailySignIn;
|
use App\Models\DailySignIn;
|
||||||
|
use App\Models\GameConfig;
|
||||||
use App\Models\Sysparam;
|
use App\Models\Sysparam;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\AiFinanceService;
|
use App\Services\AiFinanceService;
|
||||||
@@ -61,11 +63,15 @@ class AiHeartbeatCommand extends Command
|
|||||||
*/
|
*/
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
|
||||||
// 1. 检查总开关
|
// 1. 检查总开关
|
||||||
if (Sysparam::getValue('chatbot_enabled', '0') !== '1') {
|
if (Sysparam::getValue('chatbot_enabled', '0') !== '1') {
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$config = $this->heartbeatConfig();
|
||||||
|
|
||||||
// 2. 获取 AI 实体
|
// 2. 获取 AI 实体
|
||||||
$user = User::where('username', 'AI小班长')->first();
|
$user = User::where('username', 'AI小班长')->first();
|
||||||
if (! $user) {
|
if (! $user) {
|
||||||
@@ -73,21 +79,26 @@ class AiHeartbeatCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 心跳开始前,若手上金币已高于 100 万,则先把超出的部分转入银行。
|
// 心跳开始前,若手上金币已高于 100 万,则先把超出的部分转入银行。
|
||||||
$this->aiFinance->bankExcessGold($user);
|
if ((int) ($user->jjb ?? 0) > AiFinanceService::AVAILABLE_GOLD_RESERVE) {
|
||||||
|
$this->aiFinance->bankExcessGold($user);
|
||||||
|
}
|
||||||
|
|
||||||
// 2.5 自动每日签到(今日已签时 claim() 幂等返回,不重复发奖)
|
// 2.5 自动每日签到(今日已签时 claim() 幂等返回,不重复发奖)
|
||||||
$this->performDailySignIn($user);
|
if ($this->performDailySignIn($user)) {
|
||||||
|
// 签到可能发放经验、金币或魅力,后续心跳计算必须基于最新余额。
|
||||||
|
$user->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 常规心跳经验与金币发放
|
// 3. 常规心跳经验与金币发放
|
||||||
// (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励)
|
// (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励)
|
||||||
$expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1'));
|
$expGain = $this->parseRewardValue($config['exp_per_heartbeat']);
|
||||||
if ($expGain > 0) {
|
if ($expGain > 0) {
|
||||||
$expMultiplier = $this->vipService->getExpMultiplier($user);
|
$expMultiplier = $this->vipService->getExpMultiplier($user);
|
||||||
$actualExpGain = (int) round($expGain * $expMultiplier);
|
$actualExpGain = (int) round($expGain * $expMultiplier);
|
||||||
$user->exp_num += $actualExpGain;
|
$user->exp_num += $actualExpGain;
|
||||||
}
|
}
|
||||||
|
|
||||||
$jjbGain = $this->parseRewardValue(Sysparam::getValue('jjb_per_heartbeat', '0'));
|
$jjbGain = $this->parseRewardValue($config['jjb_per_heartbeat']);
|
||||||
if ($jjbGain > 0) {
|
if ($jjbGain > 0) {
|
||||||
$jjbMultiplier = $this->vipService->getJjbMultiplier($user);
|
$jjbMultiplier = $this->vipService->getJjbMultiplier($user);
|
||||||
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
|
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
|
||||||
@@ -95,30 +106,35 @@ class AiHeartbeatCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
$user->save();
|
$user->save();
|
||||||
$user->refresh();
|
|
||||||
|
|
||||||
// 4. 重算等级(基础心跳升级)
|
// 4. 重算等级(基础心跳升级)
|
||||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
$superLevel = (int) $config['superlevel'];
|
||||||
$leveledUp = $this->calculateNewLevel($user, $superLevel);
|
$leveledUp = $this->calculateNewLevel($user, $superLevel);
|
||||||
|
|
||||||
// 5. 随机事件触发
|
// 5. 随机事件触发
|
||||||
$eventChance = (int) Sysparam::getValue('auto_event_chance', '10');
|
$eventChance = (int) $config['auto_event_chance'];
|
||||||
if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
|
if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
|
||||||
$autoEvent = Autoact::randomEvent();
|
$autoEvent = Autoact::randomEvent();
|
||||||
if ($autoEvent) {
|
if ($autoEvent) {
|
||||||
|
$hasCurrencyChange = false;
|
||||||
|
|
||||||
// 执行随机事件的金钱经验惩奖
|
// 执行随机事件的金钱经验惩奖
|
||||||
if ($autoEvent->exp_change !== 0) {
|
if ($autoEvent->exp_change !== 0) {
|
||||||
$this->currencyService->change(
|
$this->currencyService->change(
|
||||||
$user, 'exp', $autoEvent->exp_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
|
$user, 'exp', $autoEvent->exp_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
|
||||||
);
|
);
|
||||||
|
$hasCurrencyChange = true;
|
||||||
}
|
}
|
||||||
if ($autoEvent->jjb_change !== 0) {
|
if ($autoEvent->jjb_change !== 0) {
|
||||||
$this->currencyService->change(
|
$this->currencyService->change(
|
||||||
$user, 'gold', $autoEvent->jjb_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
|
$user, 'gold', $autoEvent->jjb_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
|
||||||
);
|
);
|
||||||
|
$hasCurrencyChange = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->refresh();
|
if ($hasCurrencyChange) {
|
||||||
|
$user->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
// 重新计算等级
|
// 重新计算等级
|
||||||
if ($this->calculateNewLevel($user, $superLevel)) {
|
if ($this->calculateNewLevel($user, $superLevel)) {
|
||||||
@@ -149,39 +165,68 @@ class AiHeartbeatCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. 钓鱼小游戏随机参与逻辑
|
// 7. 钓鱼小游戏随机参与逻辑
|
||||||
$fishingEnabled = Sysparam::getValue('chatbot_fishing_enabled', '0') === '1';
|
$fishingEnabled = $config['chatbot_fishing_enabled'] === '1';
|
||||||
$fishingChance = (int) Sysparam::getValue('chatbot_fishing_chance', '100'); // 默认 5% 概率
|
$fishingChance = (int) $config['chatbot_fishing_chance']; // 默认 100% 概率,保持原有配置默认值。
|
||||||
if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance && \App\Models\GameConfig::isEnabled('fishing')) {
|
if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance) {
|
||||||
$cost = (int) (\App\Models\GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5'));
|
$fishingConfig = GameConfig::forGame('fishing');
|
||||||
// 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。
|
|
||||||
if ($this->aiFinance->prepareSpend($user, $cost)) {
|
|
||||||
// 先扣除费用
|
|
||||||
$this->currencyService->change(
|
|
||||||
$user, 'gold', -$cost,
|
|
||||||
CurrencySource::FISHING_COST,
|
|
||||||
"AI小班长钓鱼抛竿消耗 {$cost} 金币",
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 模拟玩家等待时间
|
if ($fishingConfig?->enabled) {
|
||||||
$waitMin = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8'));
|
$cost = (int) ($fishingConfig->params['fishing_cost'] ?? $config['fishing_cost']);
|
||||||
$waitMax = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
|
// 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。
|
||||||
$waitTime = rand($waitMin, $waitMax);
|
if ($this->aiFinance->prepareSpend($user, $cost)) {
|
||||||
|
// 先扣除抛竿费用,再派发延迟收竿任务,避免当前心跳等待钓鱼结果。
|
||||||
|
$this->currencyService->change(
|
||||||
|
$user, 'gold', -$cost,
|
||||||
|
CurrencySource::FISHING_COST,
|
||||||
|
"AI小班长钓鱼抛竿消耗 {$cost} 金币",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
// 延迟派发收竿事件(AI目前统一将事件播报到房间 1,或者拿 active room ids)
|
// 模拟玩家等待时间
|
||||||
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
|
$waitMin = (int) ($fishingConfig->params['fishing_wait_min'] ?? $config['fishing_wait_min']);
|
||||||
$roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1;
|
$waitMax = (int) ($fishingConfig->params['fishing_wait_max'] ?? $config['fishing_wait_max']);
|
||||||
|
$waitTime = rand($waitMin, $waitMax);
|
||||||
|
|
||||||
\App\Jobs\AiFishingJob::dispatch($user, $roomId)->delay(now()->addSeconds($waitTime));
|
// 延迟派发收竿事件(AI目前统一将事件播报到房间 1,或者拿 active room ids)
|
||||||
|
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
|
||||||
|
$roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1;
|
||||||
|
|
||||||
|
AiFishingJob::dispatch($user, $roomId)->delay(now()->addSeconds($waitTime));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 心跳结束后,若新增金币让手上金额超过 100 万,则把超出的部分重新转回银行。
|
// 心跳结束后,若新增金币让手上金额超过 100 万,则把超出的部分重新转回银行。
|
||||||
$this->aiFinance->bankExcessGold($user);
|
if ((int) ($user->jjb ?? 0) > AiFinanceService::AVAILABLE_GOLD_RESERVE) {
|
||||||
|
$this->aiFinance->bankExcessGold($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
$elapsedMs = (int) round((microtime(true) - $startedAt) * 1000);
|
||||||
|
$this->info("AI心跳完成,耗时 {$elapsedMs}ms。");
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取本轮心跳需要的系统配置,避免命令流程中重复触发配置读取。
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function heartbeatConfig(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'exp_per_heartbeat' => Sysparam::getValue('exp_per_heartbeat', '1'),
|
||||||
|
'jjb_per_heartbeat' => Sysparam::getValue('jjb_per_heartbeat', '0'),
|
||||||
|
'superlevel' => Sysparam::getValue('superlevel', '100'),
|
||||||
|
'auto_event_chance' => Sysparam::getValue('auto_event_chance', '10'),
|
||||||
|
'chatbot_fishing_enabled' => Sysparam::getValue('chatbot_fishing_enabled', '0'),
|
||||||
|
'chatbot_fishing_chance' => Sysparam::getValue('chatbot_fishing_chance', '100'),
|
||||||
|
'fishing_cost' => Sysparam::getValue('fishing_cost', '5'),
|
||||||
|
'fishing_wait_min' => Sysparam::getValue('fishing_wait_min', '8'),
|
||||||
|
'fishing_wait_max' => Sysparam::getValue('fishing_wait_max', '15'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算并更新用户等级
|
* 计算并更新用户等级
|
||||||
*/
|
*/
|
||||||
@@ -222,7 +267,7 @@ class AiHeartbeatCommand extends Command
|
|||||||
/**
|
/**
|
||||||
* 尝试为 AI小班长 执行今日签到,成功时广播签到通知。
|
* 尝试为 AI小班长 执行今日签到,成功时广播签到通知。
|
||||||
*/
|
*/
|
||||||
private function performDailySignIn(User $user): void
|
private function performDailySignIn(User $user): bool
|
||||||
{
|
{
|
||||||
// 先检查今日是否已签,避免每分钟都调用事务
|
// 先检查今日是否已签,避免每分钟都调用事务
|
||||||
$alreadySigned = DailySignIn::query()
|
$alreadySigned = DailySignIn::query()
|
||||||
@@ -231,7 +276,7 @@ class AiHeartbeatCommand extends Command
|
|||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
if ($alreadySigned) {
|
if ($alreadySigned) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取活跃房间作为签到归属(默认房间 1)
|
// 获取活跃房间作为签到归属(默认房间 1)
|
||||||
@@ -242,7 +287,7 @@ class AiHeartbeatCommand extends Command
|
|||||||
|
|
||||||
// 仅当本次心跳实际完成签到时才广播(幂等保护)
|
// 仅当本次心跳实际完成签到时才广播(幂等保护)
|
||||||
if (! $dailySignIn->wasRecentlyCreated) {
|
if (! $dailySignIn->wasRecentlyCreated) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$rewardParts = [];
|
$rewardParts = [];
|
||||||
@@ -265,6 +310,8 @@ class AiHeartbeatCommand extends Command
|
|||||||
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。';
|
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。';
|
||||||
|
|
||||||
$this->broadcastSystemMessage('系统传音', $content, '#0f766e');
|
$this->broadcastSystemMessage('系统传音', $content, '#0f766e');
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use App\Enums\CurrencySource;
|
|||||||
use App\Events\MessageSent;
|
use App\Events\MessageSent;
|
||||||
use App\Jobs\SaveMessageJob;
|
use App\Jobs\SaveMessageJob;
|
||||||
use App\Models\PositionDutyLog;
|
use App\Models\PositionDutyLog;
|
||||||
|
use App\Models\Room;
|
||||||
use App\Models\Sysparam;
|
use App\Models\Sysparam;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ChatStateService;
|
use App\Services\ChatStateService;
|
||||||
@@ -27,6 +28,7 @@ use App\Services\ChatUserPresenceService;
|
|||||||
use App\Services\UserCurrencyService;
|
use App\Services\UserCurrencyService;
|
||||||
use App\Services\VipService;
|
use App\Services\VipService;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Redis;
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
|
||||||
@@ -65,6 +67,8 @@ class AutoSaveExp extends Command
|
|||||||
*/
|
*/
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
|
||||||
// 读取奖励配置
|
// 读取奖励配置
|
||||||
$expGainRaw = Sysparam::getValue('exp_per_heartbeat', '1');
|
$expGainRaw = Sysparam::getValue('exp_per_heartbeat', '1');
|
||||||
$jjbGainRaw = Sysparam::getValue('jjb_per_heartbeat', '0');
|
$jjbGainRaw = Sysparam::getValue('jjb_per_heartbeat', '0');
|
||||||
@@ -81,15 +85,22 @@ class AutoSaveExp extends Command
|
|||||||
|
|
||||||
// 统计本次处理总人次(一个用户在多个房间会被计算多次)
|
// 统计本次处理总人次(一个用户在多个房间会被计算多次)
|
||||||
$totalProcessed = 0;
|
$totalProcessed = 0;
|
||||||
|
$usersByUsername = $this->preloadOnlineUsers($roomMap);
|
||||||
|
|
||||||
foreach ($roomMap as $roomId => $usernames) {
|
foreach ($roomMap as $roomId => $usernames) {
|
||||||
foreach ($usernames as $username) {
|
foreach ($usernames as $username) {
|
||||||
$this->processUser($username, $roomId, $expGainRaw, $jjbGainRaw, $superLevel);
|
$user = $usersByUsername->get($username);
|
||||||
|
if (! $user) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->processUser($user, $roomId, $expGainRaw, $jjbGainRaw, $superLevel);
|
||||||
$totalProcessed++;
|
$totalProcessed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("自动存点完成,共处理 {$totalProcessed} 个在线用户。");
|
$elapsedMs = (int) round((microtime(true) - $startedAt) * 1000);
|
||||||
|
$this->info('自动存点完成,共扫描 '.count($roomMap)." 个在线房间,处理 {$totalProcessed} 个在线用户,耗时 {$elapsedMs}ms。");
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
@@ -108,7 +119,7 @@ class AutoSaveExp extends Command
|
|||||||
$roomMap = [];
|
$roomMap = [];
|
||||||
|
|
||||||
// 从数据库取出所有房间 ID
|
// 从数据库取出所有房间 ID
|
||||||
$roomIds = \App\Models\Room::pluck('id');
|
$roomIds = Room::pluck('id');
|
||||||
|
|
||||||
foreach ($roomIds as $roomId) {
|
foreach ($roomIds as $roomId) {
|
||||||
// Laravel 的 Redis facade 会自动加配置的前缀,与 ChatStateService 存入时完全一致
|
// Laravel 的 Redis facade 会自动加配置的前缀,与 ChatStateService 存入时完全一致
|
||||||
@@ -121,27 +132,46 @@ class AutoSaveExp extends Command
|
|||||||
return $roomMap;
|
return $roomMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预加载所有在线用户名对应的用户资料与身份关系,避免循环内逐个查询用户和身份信息。
|
||||||
|
*
|
||||||
|
* @param array<int, array<string>> $roomMap 在线房间与用户名映射
|
||||||
|
* @return Collection<string, User> 以用户名为键的用户集合
|
||||||
|
*/
|
||||||
|
private function preloadOnlineUsers(array $roomMap): Collection
|
||||||
|
{
|
||||||
|
$usernames = collect($roomMap)
|
||||||
|
->flatten()
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($usernames->isEmpty()) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return User::query()
|
||||||
|
->with(['activePosition.position.department', 'vipLevel'])
|
||||||
|
->whereIn('username', $usernames)
|
||||||
|
->get()
|
||||||
|
->keyBy('username');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 为单个在线用户执行存点逻辑,并在其所在房间推送存点通知。
|
* 为单个在线用户执行存点逻辑,并在其所在房间推送存点通知。
|
||||||
*
|
*
|
||||||
* @param string $username 用户名
|
* @param User $user 已预加载身份关系的在线用户
|
||||||
* @param int $roomId 所在房间ID
|
* @param int $roomId 所在房间ID
|
||||||
* @param string $expGainRaw 经验奖励原始配置(支持 "1" 或 "1-10" 范围)
|
* @param string $expGainRaw 经验奖励原始配置(支持 "1" 或 "1-10" 范围)
|
||||||
* @param string $jjbGainRaw 金币奖励原始配置
|
* @param string $jjbGainRaw 金币奖励原始配置
|
||||||
* @param int $superLevel 管理员等级阈值
|
* @param int $superLevel 管理员等级阈值
|
||||||
*/
|
*/
|
||||||
private function processUser(
|
private function processUser(
|
||||||
string $username,
|
User $user,
|
||||||
int $roomId,
|
int $roomId,
|
||||||
string $expGainRaw,
|
string $expGainRaw,
|
||||||
string $jjbGainRaw,
|
string $jjbGainRaw,
|
||||||
int $superLevel
|
int $superLevel
|
||||||
): void {
|
): void {
|
||||||
$user = User::where('username', $username)->first();
|
|
||||||
if (! $user) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 计算奖励量(经验/金币 均支持 VIP 倍率)
|
// 1. 计算奖励量(经验/金币 均支持 VIP 倍率)
|
||||||
$expGain = $this->parseRewardValue($expGainRaw);
|
$expGain = $this->parseRewardValue($expGainRaw);
|
||||||
$expMultiplier = $this->vipService->getExpMultiplier($user);
|
$expMultiplier = $this->vipService->getExpMultiplier($user);
|
||||||
@@ -165,8 +195,11 @@ class AutoSaveExp extends Command
|
|||||||
$user, 'gold', $actualJjbGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId,
|
$user, 'gold', $actualJjbGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
$user->refresh(); // 刷新获取最新属性(service 已原子更新)
|
if ($actualExpGain > 0 || $actualJjbGain > 0) {
|
||||||
$user->load(['activePosition.position.department', 'vipLevel']); // 存点通知需要展示部门、职务与会员身份。
|
// 刷新获取最新属性(service 已原子更新),同时保留后续通知需要展示的身份关系。
|
||||||
|
$user->refresh();
|
||||||
|
$user->load(['activePosition.position.department', 'vipLevel']);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 自动升降级逻辑
|
// 3. 自动升降级逻辑
|
||||||
// - 有在职职务的用户:等级固定为职务对应等级,不随经验变化
|
// - 有在职职务的用户:等级固定为职务对应等级,不随经验变化
|
||||||
@@ -178,11 +211,17 @@ class AutoSaveExp extends Command
|
|||||||
$activeUP = $user->activePosition; // 已在 refresh 后加载
|
$activeUP = $user->activePosition; // 已在 refresh 后加载
|
||||||
|
|
||||||
if ($activeUP?->position) {
|
if ($activeUP?->position) {
|
||||||
// 有在职职务:等级锁定为职务设定值,确保不被经验系统覆盖
|
// 有在职职务:职务等级是最低要求,允许用户通过经验值超越职务等级
|
||||||
$requiredLevel = (int) $activeUP->position->level;
|
$requiredLevel = (int) $activeUP->position->level;
|
||||||
if ($requiredLevel > 0 && $user->user_level !== $requiredLevel) {
|
if ($requiredLevel > 0 && $user->user_level < $requiredLevel) {
|
||||||
$user->user_level = $requiredLevel;
|
$user->user_level = $requiredLevel;
|
||||||
}
|
}
|
||||||
|
// 职务成员也可以按经验超越职务等级
|
||||||
|
$expLevel = Sysparam::calculateLevel($user->exp_num);
|
||||||
|
if ($expLevel > $user->user_level && $expLevel < $superLevel) {
|
||||||
|
$user->user_level = $expLevel;
|
||||||
|
$leveledUp = ($expLevel > $oldLevel);
|
||||||
|
}
|
||||||
} elseif ($oldLevel < $superLevel) {
|
} elseif ($oldLevel < $superLevel) {
|
||||||
// 普通用户:按经验计算并更新等级
|
// 普通用户:按经验计算并更新等级
|
||||||
$newLevel = Sysparam::calculateLevel($user->exp_num);
|
$newLevel = Sysparam::calculateLevel($user->exp_num);
|
||||||
@@ -241,7 +280,7 @@ class AutoSaveExp extends Command
|
|||||||
'id' => $this->chatState->nextMessageId($roomId),
|
'id' => $this->chatState->nextMessageId($roomId),
|
||||||
'room_id' => $roomId,
|
'room_id' => $roomId,
|
||||||
'from_user' => '系统',
|
'from_user' => '系统',
|
||||||
'to_user' => $username, // 定向推送给本人
|
'to_user' => $user->username, // 定向推送给本人
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
'is_secret' => true, // 私信模式:前端过滤,只有收件人才能看到
|
'is_secret' => true, // 私信模式:前端过滤,只有收件人才能看到
|
||||||
'font_color' => '#16a34a', // 草绿色
|
'font_color' => '#16a34a', // 草绿色
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
/**
|
/**
|
||||||
* 文件功能:定期清理聊天记录 Artisan 命令
|
* 文件功能:定期清理聊天记录 Artisan 命令
|
||||||
*
|
*
|
||||||
* 每天自动清理超过指定天数的聊天记录,保持数据库体积可控。
|
* 用户聊天记录永久保留;仅清理可过期的游戏通知、进出播报等噪音消息。
|
||||||
* 保留天数可通过 sysparam 表的 message_retention_days 配置,默认 30 天。
|
* 通知保留天数可通过 sysparam 表的 game_message_retention_days 配置,默认 30 天。
|
||||||
*
|
*
|
||||||
* @author ChatRoom Laravel
|
* @author ChatRoom Laravel
|
||||||
*
|
*
|
||||||
@@ -31,7 +31,7 @@ class PurgeOldMessages extends Command
|
|||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $signature = 'messages:purge
|
protected $signature = 'messages:purge
|
||||||
{--days= : 覆盖默认保留天数}
|
{--days= : 覆盖通知消息默认保留天数}
|
||||||
{--image-days=3 : 聊天图片单独保留天数}
|
{--image-days=3 : 聊天图片单独保留天数}
|
||||||
{--dry-run : 仅预览不实际删除}';
|
{--dry-run : 仅预览不实际删除}';
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ class PurgeOldMessages extends Command
|
|||||||
*
|
*
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $description = '清理过期聊天记录,并额外清理 3 天前的聊天图片文件';
|
protected $description = '清理过期游戏/临时通知,并额外清理 3 天前的聊天图片文件';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行命令
|
* 执行命令
|
||||||
@@ -49,9 +49,9 @@ class PurgeOldMessages extends Command
|
|||||||
*/
|
*/
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
// 保留天数:命令行参数 > sysparam 配置 > 默认 30 天
|
// 通知保留天数:命令行参数 > sysparam 配置 > 默认 30 天;普通用户聊天不再按时间删除。
|
||||||
$days = (int) ($this->option('days')
|
$days = (int) ($this->option('days')
|
||||||
?: Sysparam::getValue('message_retention_days', '30'));
|
?: Sysparam::getValue('game_message_retention_days', '30'));
|
||||||
$imageDays = max(0, (int) $this->option('image-days'));
|
$imageDays = max(0, (int) $this->option('image-days'));
|
||||||
|
|
||||||
$cutoff = Carbon::now()->subDays($days);
|
$cutoff = Carbon::now()->subDays($days);
|
||||||
@@ -59,22 +59,22 @@ class PurgeOldMessages extends Command
|
|||||||
|
|
||||||
$this->cleanupExpiredImages($imageDays, $isDryRun);
|
$this->cleanupExpiredImages($imageDays, $isDryRun);
|
||||||
|
|
||||||
// 统计待清理数量
|
$expiredNoticeQuery = $this->expiredNoticeQuery($cutoff);
|
||||||
$totalCount = Message::where('sent_at', '<', $cutoff)->count();
|
$totalCount = (clone $expiredNoticeQuery)->count();
|
||||||
|
|
||||||
if ($totalCount === 0) {
|
if ($totalCount === 0) {
|
||||||
$this->info("✅ 没有超过 {$days} 天的聊天记录需要清理。");
|
$this->info("✅ 没有超过 {$days} 天的游戏/临时通知需要清理,用户聊天记录已永久保留。");
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($isDryRun) {
|
if ($isDryRun) {
|
||||||
$this->warn("🔍 [预览模式] 将删除 {$totalCount} 条超过 {$days} 天的聊天记录(截止 {$cutoff->toDateTimeString()})");
|
$this->warn("🔍 [预览模式] 将删除 {$totalCount} 条超过 {$days} 天的游戏/临时通知(截止 {$cutoff->toDateTimeString()})");
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("🧹 开始清理超过 {$days} 天的聊天记录(截止 {$cutoff->toDateTimeString()})...");
|
$this->info("🧹 开始清理超过 {$days} 天的游戏/临时通知(截止 {$cutoff->toDateTimeString()})...");
|
||||||
$this->info(" 待清理数量:{$totalCount} 条");
|
$this->info(" 待清理数量:{$totalCount} 条");
|
||||||
|
|
||||||
// 分批删除,每批 1000 条,避免长时间锁表
|
// 分批删除,每批 1000 条,避免长时间锁表
|
||||||
@@ -82,7 +82,7 @@ class PurgeOldMessages extends Command
|
|||||||
$batchSize = 1000;
|
$batchSize = 1000;
|
||||||
|
|
||||||
do {
|
do {
|
||||||
$batch = Message::where('sent_at', '<', $cutoff)
|
$batch = $this->expiredNoticeQuery($cutoff)
|
||||||
->limit($batchSize)
|
->limit($batchSize)
|
||||||
->delete();
|
->delete();
|
||||||
|
|
||||||
@@ -93,11 +93,31 @@ class PurgeOldMessages extends Command
|
|||||||
}
|
}
|
||||||
} while ($batch === $batchSize);
|
} while ($batch === $batchSize);
|
||||||
|
|
||||||
$this->info("✅ 清理完成!共删除 {$deleted} 条聊天记录。");
|
$this->info("✅ 清理完成!共删除 {$deleted} 条游戏/临时通知,用户聊天记录未删除。");
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造过期通知清理查询,兼容新增字段前已经落库的旧通知。
|
||||||
|
*/
|
||||||
|
private function expiredNoticeQuery(Carbon $cutoff): \Illuminate\Database\Eloquent\Builder
|
||||||
|
{
|
||||||
|
return Message::query()
|
||||||
|
->where('sent_at', '<', $cutoff)
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->whereIn('retention_type', Message::purgableRetentionTypes())
|
||||||
|
->orWhere(function ($legacyQuery) {
|
||||||
|
// 兼容迁移前默认归为 user_chat 的旧通知,避免历史游戏播报继续堆积。
|
||||||
|
$legacyQuery->where('retention_type', Message::RETENTION_USER_CHAT)
|
||||||
|
->where(function ($noticeQuery) {
|
||||||
|
$noticeQuery->whereIn('from_user', ['钓鱼播报', '星海小博士', '进出播报', '座驾播报'])
|
||||||
|
->orWhereIn('action', ['fishing_result', 'idiom_result', 'riddle_result', 'system_welcome', 'vip_presence', 'ride_presence', 'auto_save_exp']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清理超过图片保留天数的聊天图片文件,并把消息改成过期占位。
|
* 清理超过图片保留天数的聊天图片文件,并把消息改成过期占位。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:扫描并补算用户成就的 Artisan 命令。
|
||||||
|
*
|
||||||
|
* 支持单用户、全量与最近活跃用户三种扫描方式,便于定时任务和后台补算复用。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\AchievementService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:通过命令行批量检查用户成就进度并写入解锁记录。
|
||||||
|
*/
|
||||||
|
class ScanAchievementsCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 命令签名。
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'achievements:scan
|
||||||
|
{--user= : 指定用户 ID 或用户名}
|
||||||
|
{--all : 扫描全部用户}
|
||||||
|
{--notify : 解锁时向用户推送本人可见通知}
|
||||||
|
{--dry-run : 仅预览,不写入成就记录}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 命令描述。
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = '扫描聊天室用户成就进度并补齐解锁记录';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建命令依赖。
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly AchievementService $achievementService,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行成就扫描命令。
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$notify = (bool) $this->option('notify');
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
if ($this->option('user')) {
|
||||||
|
$user = $this->resolveUser((string) $this->option('user'));
|
||||||
|
if (! $user) {
|
||||||
|
$this->error('未找到指定用户。');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->achievementService->scanUser($user, $notify, $dryRun);
|
||||||
|
$this->info("已扫描用户 {$user->username}:检查 {$result['checked']} 项,解锁 {$result['unlocked']} 项,更新 {$result['updated']} 项。");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = User::query()->orderBy('id');
|
||||||
|
if (! $this->option('all')) {
|
||||||
|
// 默认只扫最近活跃用户,避免定时任务每次全表扫描。
|
||||||
|
$query->where('updated_at', '>=', now()->subDay())->limit(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = ['users' => 0, 'checked' => 0, 'unlocked' => 0, 'updated' => 0, 'dry_run' => $dryRun];
|
||||||
|
$query->chunkById(100, function ($users) use (&$summary, $notify, $dryRun): void {
|
||||||
|
$chunkSummary = $this->achievementService->scanUsers($users, $notify, $dryRun);
|
||||||
|
$summary['users'] += $chunkSummary['users'];
|
||||||
|
$summary['checked'] += $chunkSummary['checked'];
|
||||||
|
$summary['unlocked'] += $chunkSummary['unlocked'];
|
||||||
|
$summary['updated'] += $chunkSummary['updated'];
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info("成就扫描完成:用户 {$summary['users']} 人,检查 {$summary['checked']} 项,解锁 {$summary['unlocked']} 项,更新 {$summary['updated']} 项。");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 ID 或用户名解析用户。
|
||||||
|
*/
|
||||||
|
private function resolveUser(string $value): ?User
|
||||||
|
{
|
||||||
|
return User::query()
|
||||||
|
->when(is_numeric($value), fn ($query) => $query->where('id', (int) $value), fn ($query) => $query->where('username', $value))
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,9 @@ enum CurrencySource: string
|
|||||||
/** 商城购买消耗(扣除金币) */
|
/** 商城购买消耗(扣除金币) */
|
||||||
case SHOP_BUY = 'shop_buy';
|
case SHOP_BUY = 'shop_buy';
|
||||||
|
|
||||||
|
/** 购买聊天室座驾消耗(扣除金币) */
|
||||||
|
case RIDE_BUY = 'ride_buy';
|
||||||
|
|
||||||
/** 管理员手动调整(后台直接修改经验/金币/魅力) */
|
/** 管理员手动调整(后台直接修改经验/金币/魅力) */
|
||||||
case ADMIN_ADJUST = 'admin_adjust';
|
case ADMIN_ADJUST = 'admin_adjust';
|
||||||
|
|
||||||
@@ -161,6 +164,18 @@ enum CurrencySource: string
|
|||||||
/** 猜谜活动奖励 */
|
/** 猜谜活动奖励 */
|
||||||
case GAME_REWARD = 'game_reward';
|
case GAME_REWARD = 'game_reward';
|
||||||
|
|
||||||
|
/** 收到用户赠送的金币 */
|
||||||
|
case GOLD_GIFT_RECV = 'gold_gift_recv';
|
||||||
|
|
||||||
|
/** 聊天互动魅力奖励 */
|
||||||
|
case CHAT_CHARM = 'chat_charm';
|
||||||
|
|
||||||
|
/** 存入银行(jjb → bank_jjb) */
|
||||||
|
case BANK_DEPOSIT = 'bank_deposit';
|
||||||
|
|
||||||
|
/** 从银行取款(bank_jjb → jjb) */
|
||||||
|
case BANK_WITHDRAW = 'bank_withdraw';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 返回该来源的中文名称,用于后台统计展示。
|
* 返回该来源的中文名称,用于后台统计展示。
|
||||||
*/
|
*/
|
||||||
@@ -174,6 +189,7 @@ enum CurrencySource: string
|
|||||||
self::RECV_GIFT => '收到礼物',
|
self::RECV_GIFT => '收到礼物',
|
||||||
self::NEWBIE_BONUS => '新人礼包',
|
self::NEWBIE_BONUS => '新人礼包',
|
||||||
self::SHOP_BUY => '商城购买',
|
self::SHOP_BUY => '商城购买',
|
||||||
|
self::RIDE_BUY => '座驾购买(金币)',
|
||||||
self::ADMIN_ADJUST => '管理员调整',
|
self::ADMIN_ADJUST => '管理员调整',
|
||||||
self::POSITION_REWARD => '职务奖励',
|
self::POSITION_REWARD => '职务奖励',
|
||||||
self::SIGN_IN => '每日签到',
|
self::SIGN_IN => '每日签到',
|
||||||
@@ -214,6 +230,10 @@ enum CurrencySource: string
|
|||||||
self::MSG_DECORATION_BUY => '消息装扮购买(旧)',
|
self::MSG_DECORATION_BUY => '消息装扮购买(旧)',
|
||||||
self::AVATAR_FRAME_BUY => '头像框购买',
|
self::AVATAR_FRAME_BUY => '头像框购买',
|
||||||
self::GAME_REWARD => '猜谜活动奖励',
|
self::GAME_REWARD => '猜谜活动奖励',
|
||||||
|
self::GOLD_GIFT_RECV => '收到赠送金币',
|
||||||
|
self::CHAT_CHARM => '聊天魅力奖励',
|
||||||
|
self::BANK_DEPOSIT => '存入银行',
|
||||||
|
self::BANK_WITHDRAW => '银行取款',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
|||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:广播聊天室全屏特效播放指令,并携带操作者与定向接收者信息。
|
||||||
|
*/
|
||||||
class EffectBroadcast implements ShouldBroadcastNow
|
class EffectBroadcast implements ShouldBroadcastNow
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
@@ -26,16 +29,19 @@ class EffectBroadcast implements ShouldBroadcastNow
|
|||||||
/**
|
/**
|
||||||
* 支持的特效类型列表(用于校验)
|
* 支持的特效类型列表(用于校验)
|
||||||
*/
|
*/
|
||||||
public const TYPES = ['fireworks', 'rain', 'lightning', 'snow', 'sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies'];
|
public const TYPES = ['fireworks', 'rain', 'lightning', 'snow', 'sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies', 'j35', '99a', 'df5c', 'fujian'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构造函数
|
* 构造函数
|
||||||
*
|
*
|
||||||
* @param int $roomId 房间 ID
|
* @param int $roomId 房间 ID
|
||||||
* @param string $type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies
|
* @param string $type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies / j35 / 99a / df5c / fujian
|
||||||
* @param string $operator 触发特效的用户名(购买者)
|
* @param string $operator 触发特效的用户名(购买者)
|
||||||
* @param string|null $targetUsername 接收者用户名(null = 全员)
|
* @param string|null $targetUsername 接收者用户名(null = 全员)
|
||||||
* @param string|null $giftMessage 附带赠言
|
* @param string|null $giftMessage 附带赠言
|
||||||
|
* @param string|null $effectTitle 特效画面标题
|
||||||
|
* @param string|null $rideName 座驾名称
|
||||||
|
* @param string|null $effectUserInfo 特效画面用户身份信息
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly int $roomId,
|
public readonly int $roomId,
|
||||||
@@ -43,6 +49,9 @@ class EffectBroadcast implements ShouldBroadcastNow
|
|||||||
public readonly string $operator,
|
public readonly string $operator,
|
||||||
public readonly ?string $targetUsername = null,
|
public readonly ?string $targetUsername = null,
|
||||||
public readonly ?string $giftMessage = null,
|
public readonly ?string $giftMessage = null,
|
||||||
|
public readonly ?string $effectTitle = null,
|
||||||
|
public readonly ?string $rideName = null,
|
||||||
|
public readonly ?string $effectUserInfo = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,6 +79,9 @@ class EffectBroadcast implements ShouldBroadcastNow
|
|||||||
'operator' => $this->operator,
|
'operator' => $this->operator,
|
||||||
'target_username' => $this->targetUsername, // null = 全员
|
'target_username' => $this->targetUsername, // null = 全员
|
||||||
'gift_message' => $this->giftMessage,
|
'gift_message' => $this->giftMessage,
|
||||||
|
'effect_title' => $this->effectTitle,
|
||||||
|
'ride_name' => $this->rideName,
|
||||||
|
'effect_user_info' => $this->effectUserInfo,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:前台用户成就展示控制器。
|
||||||
|
*
|
||||||
|
* 展示当前登录用户的成就分类、解锁状态和进度。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Services\AchievementService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:提供“我的成就”页面数据。
|
||||||
|
*/
|
||||||
|
class AchievementController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 创建成就控制器依赖。
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly AchievementService $achievementService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展示当前登录用户的成就总览。
|
||||||
|
*/
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
$this->achievementService->scanUser($user);
|
||||||
|
$achievementData = $this->achievementService->displayForUser($user);
|
||||||
|
$activeTab = in_array($request->query('status'), ['unlocked', 'locked'], true)
|
||||||
|
? $request->query('status')
|
||||||
|
: 'all';
|
||||||
|
$allAchievements = $achievementData['achievements'];
|
||||||
|
|
||||||
|
// 页面 tab 只影响展示列表,不影响顶部总进度统计。
|
||||||
|
$achievementTabs = [
|
||||||
|
'all' => [
|
||||||
|
'label' => '全部',
|
||||||
|
'count' => $allAchievements->count(),
|
||||||
|
'url' => route('achievements.index'),
|
||||||
|
],
|
||||||
|
'unlocked' => [
|
||||||
|
'label' => '已完成',
|
||||||
|
'count' => $allAchievements->where('unlocked', true)->count(),
|
||||||
|
'url' => route('achievements.index', ['status' => 'unlocked']),
|
||||||
|
],
|
||||||
|
'locked' => [
|
||||||
|
'label' => '未达成',
|
||||||
|
'count' => $allAchievements->where('unlocked', false)->count(),
|
||||||
|
'url' => route('achievements.index', ['status' => 'locked']),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$achievementData['achievements'] = match ($activeTab) {
|
||||||
|
'unlocked' => $allAchievements->where('unlocked', true)->values(),
|
||||||
|
'locked' => $allAchievements->where('unlocked', false)->values(),
|
||||||
|
default => $allAchievements,
|
||||||
|
};
|
||||||
|
|
||||||
|
return view('achievements.index', [
|
||||||
|
'user' => $user,
|
||||||
|
'active_tab' => $activeTab,
|
||||||
|
'achievement_tabs' => $achievementTabs,
|
||||||
|
...$achievementData,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:后台成就记录查询控制器。
|
||||||
|
*
|
||||||
|
* 提供固定成就目录的解锁统计与用户成就记录只读查询。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\UserAchievement;
|
||||||
|
use App\Support\AchievementCatalog;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:展示后台成就总览、解锁记录与按成就分组统计。
|
||||||
|
*/
|
||||||
|
class AchievementController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 展示成就记录总览。
|
||||||
|
*/
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$definitions = AchievementCatalog::definitions();
|
||||||
|
$query = UserAchievement::query()
|
||||||
|
->with('user:id,username')
|
||||||
|
->whereNotNull('achieved_at')
|
||||||
|
->latest('achieved_at');
|
||||||
|
|
||||||
|
if ($request->filled('username')) {
|
||||||
|
$query->whereHas('user', function ($userQuery) use ($request): void {
|
||||||
|
$userQuery->where('username', 'like', '%'.$request->string('username')->toString().'%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('achievement_key')) {
|
||||||
|
$query->where('achievement_key', $request->string('achievement_key')->toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
$records = $query->paginate(30)->withQueryString();
|
||||||
|
$summary = [
|
||||||
|
'total_definitions' => count($definitions),
|
||||||
|
'unlocked_records' => UserAchievement::query()->whereNotNull('achieved_at')->count(),
|
||||||
|
'unlocked_users' => UserAchievement::query()->whereNotNull('achieved_at')->distinct('user_id')->count('user_id'),
|
||||||
|
];
|
||||||
|
$topAchievements = UserAchievement::query()
|
||||||
|
->whereNotNull('achieved_at')
|
||||||
|
->select('achievement_key', DB::raw('count(*) as unlocked_count'))
|
||||||
|
->groupBy('achievement_key')
|
||||||
|
->orderByDesc('unlocked_count')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('admin.achievements.index', compact('definitions', 'records', 'summary', 'topAchievements'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:后台座驾独立管理控制器。
|
||||||
|
*
|
||||||
|
* 提供座驾列表、新增、编辑、上下架切换与删除能力,不依赖商店商品模块。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreRideRequest;
|
||||||
|
use App\Http\Requests\UpdateRideRequest;
|
||||||
|
use App\Models\Ride;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台座驾管理控制器
|
||||||
|
* 负责独立 rides 表的后台管理流程。
|
||||||
|
*/
|
||||||
|
class RideController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 显示座驾管理列表页。
|
||||||
|
*/
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
$rides = Ride::query()
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('admin.rides.index', compact('rides'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增座驾(仅 id=1 超级站长)。
|
||||||
|
*/
|
||||||
|
public function store(StoreRideRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
Ride::create($data);
|
||||||
|
|
||||||
|
return redirect()->route('admin.rides.index')->with('success', '座驾「'.$data['name'].'」创建成功!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新座驾信息。
|
||||||
|
*/
|
||||||
|
public function update(UpdateRideRequest $request, Ride $ride): RedirectResponse
|
||||||
|
{
|
||||||
|
$ride->update($request->validated());
|
||||||
|
|
||||||
|
return redirect()->route('admin.rides.index')->with('success', '座驾「'.$ride->name.'」更新成功!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换座驾上下架状态。
|
||||||
|
*/
|
||||||
|
public function toggle(Ride $ride): RedirectResponse
|
||||||
|
{
|
||||||
|
$ride->update(['is_active' => ! $ride->is_active]);
|
||||||
|
$status = $ride->is_active ? '上架' : '下架';
|
||||||
|
|
||||||
|
return redirect()->route('admin.rides.index')->with('success', "「{$ride->name}」已{$status}。");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除座驾(仅 id=1 超级站长)。
|
||||||
|
*/
|
||||||
|
public function destroy(Ride $ride): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless(Auth::id() === 1, 403);
|
||||||
|
|
||||||
|
$name = $ride->name;
|
||||||
|
$ride->delete();
|
||||||
|
|
||||||
|
return redirect()->route('admin.rides.index')->with('success', "「{$name}」已删除。");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,10 @@
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreShopItemRequest;
|
||||||
|
use App\Http\Requests\UpdateShopItemRequest;
|
||||||
use App\Models\ShopItem;
|
use App\Models\ShopItem;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
@@ -35,11 +36,9 @@ class ShopItemController extends Controller
|
|||||||
/**
|
/**
|
||||||
* 新增商品(仅 id=1 超级站长)
|
* 新增商品(仅 id=1 超级站长)
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(StoreShopItemRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_unless(Auth::id() === 1, 403);
|
$data = $request->validated();
|
||||||
|
|
||||||
$data = $this->validateItem($request);
|
|
||||||
ShopItem::create($data);
|
ShopItem::create($data);
|
||||||
|
|
||||||
return redirect()->route('admin.shop.index')->with('success', '商品「'.$data['name'].'」创建成功!');
|
return redirect()->route('admin.shop.index')->with('success', '商品「'.$data['name'].'」创建成功!');
|
||||||
@@ -50,9 +49,9 @@ class ShopItemController extends Controller
|
|||||||
*
|
*
|
||||||
* @param ShopItem $shopItem 路由模型自动注入
|
* @param ShopItem $shopItem 路由模型自动注入
|
||||||
*/
|
*/
|
||||||
public function update(Request $request, ShopItem $shopItem): RedirectResponse
|
public function update(UpdateShopItemRequest $request, ShopItem $shopItem): RedirectResponse
|
||||||
{
|
{
|
||||||
$data = $this->validateItem($request, $shopItem);
|
$data = $request->validated();
|
||||||
$shopItem->update($data);
|
$shopItem->update($data);
|
||||||
|
|
||||||
return redirect()->route('admin.shop.index')->with('success', '商品「'.$shopItem->name.'」更新成功!');
|
return redirect()->route('admin.shop.index')->with('success', '商品「'.$shopItem->name.'」更新成功!');
|
||||||
@@ -85,29 +84,4 @@ class ShopItemController extends Controller
|
|||||||
|
|
||||||
return redirect()->route('admin.shop.index')->with('success', "「{$name}」已删除。");
|
return redirect()->route('admin.shop.index')->with('success', "「{$name}」已删除。");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一验证商品表单(新增/编辑共用)
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function validateItem(Request $request, ?ShopItem $item = null): array
|
|
||||||
{
|
|
||||||
return $request->validate([
|
|
||||||
'name' => 'required|string|max:100',
|
|
||||||
'slug' => ['required', 'string', 'max:100',
|
|
||||||
\Illuminate\Validation\Rule::unique('shop_items', 'slug')->ignore($item?->id),
|
|
||||||
],
|
|
||||||
'icon' => 'required|string|max:20',
|
|
||||||
'description' => 'nullable|string|max:500',
|
|
||||||
'price' => 'required|integer|min:0',
|
|
||||||
'type' => 'required|in:instant,duration,one_time,ring,auto_fishing,sign_repair,msg_bubble,msg_name_color,msg_text_color,avatar_frame',
|
|
||||||
'duration_days' => 'nullable|integer|min:0',
|
|
||||||
'duration_minutes' => 'nullable|integer|min:0',
|
|
||||||
'intimacy_bonus' => 'nullable|integer|min:0',
|
|
||||||
'charm_bonus' => 'nullable|integer|min:0',
|
|
||||||
'sort_order' => 'required|integer|min:0',
|
|
||||||
'is_active' => 'boolean',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,8 +457,9 @@ class AdminCommandController extends Controller
|
|||||||
abort(403, '仅站长可查看私信');
|
abort(403, '仅站长可查看私信');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询最近 50 条悄悄话(发送或接收)
|
// 查询最近 50 条用户之间的悄悄话,系统发给用户的私信通知不展示到管理查看里。
|
||||||
$messages = Message::where('is_secret', true)
|
$messages = Message::where('is_secret', true)
|
||||||
|
->where('from_user', 'not like', '系统%')
|
||||||
->where(function ($q) use ($username) {
|
->where(function ($q) use ($username) {
|
||||||
$q->where('from_user', $username)
|
$q->where('from_user', $username)
|
||||||
->orWhere('to_user', $username);
|
->orWhere('to_user', $username);
|
||||||
@@ -1023,7 +1024,7 @@ class AdminCommandController extends Controller
|
|||||||
'message' => "<b>{$admin->username}</b>({$positionName})向你发放了 <b>{$amount}</b> 枚金币!",
|
'message' => "<b>{$admin->username}</b>({$positionName})向你发放了 <b>{$amount}</b> 枚金币!",
|
||||||
'icon' => '💰',
|
'icon' => '💰',
|
||||||
'color' => '#f59e0b',
|
'color' => '#f59e0b',
|
||||||
'duration' => 8000,
|
'duration' => 3000,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
$this->chatState->pushMessage($roomId, $msg);
|
$this->chatState->pushMessage($roomId, $msg);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use App\Models\BaccaratBet;
|
|||||||
use App\Models\BaccaratRound;
|
use App\Models\BaccaratRound;
|
||||||
use App\Models\GameConfig;
|
use App\Models\GameConfig;
|
||||||
use App\Services\BaccaratLossCoverService;
|
use App\Services\BaccaratLossCoverService;
|
||||||
|
use App\Services\GameBetBroadcastService;
|
||||||
use App\Services\GameRoomScopeService;
|
use App\Services\GameRoomScopeService;
|
||||||
use App\Services\UserCurrencyService;
|
use App\Services\UserCurrencyService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -35,6 +36,7 @@ class BaccaratController extends Controller
|
|||||||
private readonly UserCurrencyService $currency,
|
private readonly UserCurrencyService $currency,
|
||||||
private readonly BaccaratLossCoverService $lossCoverService,
|
private readonly BaccaratLossCoverService $lossCoverService,
|
||||||
private readonly GameRoomScopeService $roomScopeService,
|
private readonly GameRoomScopeService $roomScopeService,
|
||||||
|
private readonly GameBetBroadcastService $betBroadcastService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -192,27 +194,7 @@ class BaccaratController extends Controller
|
|||||||
'big' => '大', 'small' => '小', default => '豹子'
|
'big' => '大', 'small' => '小', default => '豹子'
|
||||||
};
|
};
|
||||||
|
|
||||||
// 发送系统传音到聊天室,公示该用户的押注信息
|
$this->betBroadcastService->baccarat((int) ($round->room_id ?? 1), $user->username, (int) $data['amount'], $betLabel);
|
||||||
$chatState = app(\App\Services\ChatStateService::class);
|
|
||||||
$formattedAmount = number_format($data['amount']);
|
|
||||||
$roomId = $round->room_id ?? 1;
|
|
||||||
|
|
||||||
// 格式:🌟 🎲 娜姐 押注了 119 金币(大)!✨
|
|
||||||
$content = "🎲 <b> 【百家乐】【{$user->username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$betLabel})!✨";
|
|
||||||
$msg = [
|
|
||||||
'id' => $chatState->nextMessageId($roomId),
|
|
||||||
'room_id' => $roomId,
|
|
||||||
'from_user' => '系统传音',
|
|
||||||
'to_user' => '大家',
|
|
||||||
'content' => $content,
|
|
||||||
'is_secret' => false,
|
|
||||||
'font_color' => '#d97706',
|
|
||||||
'action' => '',
|
|
||||||
'sent_at' => now()->toDateTimeString(),
|
|
||||||
];
|
|
||||||
$chatState->pushMessage($roomId, $msg);
|
|
||||||
event(new \App\Events\MessageSent($roomId, $msg));
|
|
||||||
\App\Jobs\SaveMessageJob::dispatch($msg);
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
|
|||||||
@@ -13,9 +13,12 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\CurrencySource;
|
||||||
use App\Models\BankLog;
|
use App\Models\BankLog;
|
||||||
use App\Models\Sysparam;
|
use App\Models\Sysparam;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\UserCurrencyLog;
|
||||||
|
use App\Services\UserCurrencyService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -26,6 +29,9 @@ use Illuminate\Support\Facades\DB;
|
|||||||
*/
|
*/
|
||||||
class BankController extends Controller
|
class BankController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserCurrencyService $currencyService,
|
||||||
|
) {}
|
||||||
/**
|
/**
|
||||||
* 查询银行余额及最近20条流水记录
|
* 查询银行余额及最近20条流水记录
|
||||||
*/
|
*/
|
||||||
@@ -107,7 +113,8 @@ class BankController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
DB::transaction(function () use ($user, $amount): void {
|
DB::transaction(function () use ($user, $amount): void {
|
||||||
$user->decrement('jjb', $amount);
|
$this->currencyService->change($user, 'gold', -$amount, CurrencySource::BANK_DEPOSIT, "存入银行 {$amount} 金币");
|
||||||
|
|
||||||
$user->increment('bank_jjb', $amount);
|
$user->increment('bank_jjb', $amount);
|
||||||
|
|
||||||
BankLog::create([
|
BankLog::create([
|
||||||
@@ -151,7 +158,8 @@ class BankController extends Controller
|
|||||||
|
|
||||||
DB::transaction(function () use ($user, $amount): void {
|
DB::transaction(function () use ($user, $amount): void {
|
||||||
$user->decrement('bank_jjb', $amount);
|
$user->decrement('bank_jjb', $amount);
|
||||||
$user->increment('jjb', $amount);
|
|
||||||
|
$this->currencyService->change($user, 'gold', $amount, CurrencySource::BANK_WITHDRAW, "取出银行存款 {$amount} 金币");
|
||||||
|
|
||||||
BankLog::create([
|
BankLog::create([
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ use App\Models\User;
|
|||||||
use App\Services\AppointmentService;
|
use App\Services\AppointmentService;
|
||||||
use App\Services\ChatStateService;
|
use App\Services\ChatStateService;
|
||||||
use App\Services\ChatUserPresenceService;
|
use App\Services\ChatUserPresenceService;
|
||||||
|
use App\Services\DailyGameProfitLeaderboardService;
|
||||||
use App\Services\MessageFilterService;
|
use App\Services\MessageFilterService;
|
||||||
use App\Services\PositionPermissionService;
|
use App\Services\PositionPermissionService;
|
||||||
|
use App\Services\RideService;
|
||||||
use App\Services\RoomBroadcastService;
|
use App\Services\RoomBroadcastService;
|
||||||
use App\Services\UserCurrencyService;
|
use App\Services\UserCurrencyService;
|
||||||
use App\Services\VipService;
|
use App\Services\VipService;
|
||||||
@@ -62,9 +64,11 @@ class ChatController extends Controller
|
|||||||
private readonly VipService $vipService,
|
private readonly VipService $vipService,
|
||||||
private readonly \App\Services\ShopService $shopService,
|
private readonly \App\Services\ShopService $shopService,
|
||||||
private readonly UserCurrencyService $currencyService,
|
private readonly UserCurrencyService $currencyService,
|
||||||
|
private readonly DailyGameProfitLeaderboardService $dailyGameProfitLeaderboardService,
|
||||||
private readonly AppointmentService $appointmentService,
|
private readonly AppointmentService $appointmentService,
|
||||||
private readonly RoomBroadcastService $broadcast,
|
private readonly RoomBroadcastService $broadcast,
|
||||||
private readonly PositionPermissionService $positionPermissionService,
|
private readonly PositionPermissionService $positionPermissionService,
|
||||||
|
private readonly RideService $rideService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,6 +120,8 @@ class ChatController extends Controller
|
|||||||
|
|
||||||
// 3. 广播和初始化欢迎(仅限初次进入)
|
// 3. 广播和初始化欢迎(仅限初次进入)
|
||||||
$newbieEffect = null;
|
$newbieEffect = null;
|
||||||
|
$initialRideEffect = null;
|
||||||
|
$initialRideEffectOptions = null;
|
||||||
$initialPresenceTheme = null;
|
$initialPresenceTheme = null;
|
||||||
$initialWelcomeMessage = null;
|
$initialWelcomeMessage = null;
|
||||||
$initialWelcomeMessages = [];
|
$initialWelcomeMessages = [];
|
||||||
@@ -192,40 +198,84 @@ class ChatController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 统一走通用进场播报逻辑,管理员不再发送单独的特殊登录提示。
|
// 统一走通用进场播报逻辑,管理员不再发送单独的特殊登录提示。
|
||||||
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
|
$ridePresencePayload = $this->rideService->buildPresencePayload($user);
|
||||||
$vipPresencePayload = $this->broadcast->buildVipPresencePayload($user, 'join');
|
if (! $ridePresencePayload) {
|
||||||
|
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
|
||||||
|
$vipPresencePayload = $this->broadcast->buildVipPresencePayload($user, 'join');
|
||||||
|
|
||||||
$generalWelcomeMsg = [
|
$generalWelcomeMsg = [
|
||||||
'id' => $this->chatState->nextMessageId($id),
|
'id' => $this->chatState->nextMessageId($id),
|
||||||
'room_id' => $id,
|
'room_id' => $id,
|
||||||
'from_user' => '进出播报',
|
'from_user' => '进出播报',
|
||||||
'to_user' => '大家',
|
'to_user' => '大家',
|
||||||
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>",
|
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>",
|
||||||
'is_secret' => false,
|
'is_secret' => false,
|
||||||
'font_color' => $color,
|
'font_color' => $color,
|
||||||
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
|
'action' => empty($vipPresencePayload) ? 'system_welcome' : 'vip_presence',
|
||||||
'welcome_user' => $user->username,
|
'welcome_user' => $user->username,
|
||||||
'welcome_kind' => 'entry_broadcast',
|
'welcome_kind' => 'entry_broadcast',
|
||||||
'sent_at' => now()->toDateTimeString(),
|
'sent_at' => now()->toDateTimeString(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。
|
// 当会员等级带有专属主题时,把横幅与特效字段并入系统消息,供前端展示豪华进场效果。
|
||||||
if (! empty($vipPresencePayload)) {
|
if (! empty($vipPresencePayload)) {
|
||||||
$generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload);
|
$generalWelcomeMsg = array_merge($generalWelcomeMsg, $vipPresencePayload);
|
||||||
$initialPresenceTheme = $vipPresencePayload;
|
$initialPresenceTheme = $vipPresencePayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
|
||||||
|
$initialWelcomeMessage = $generalWelcomeMsg;
|
||||||
|
$initialWelcomeMessages[] = $generalWelcomeMsg;
|
||||||
|
|
||||||
|
$this->chatState->pushMessage($id, $generalWelcomeMsg);
|
||||||
|
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
|
||||||
|
broadcast(new MessageSent($id, $generalWelcomeMsg));
|
||||||
|
|
||||||
|
// 会员专属特效需要单独广播给其他在线成员,自己则在页面初始化后本地补播。
|
||||||
|
if (! empty($vipPresencePayload['presence_effect'])) {
|
||||||
|
broadcast(new \App\Events\EffectBroadcast($id, $vipPresencePayload['presence_effect'], $user->username))->toOthers();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把当前这次进房生成的欢迎消息带回前端,确保用户自己也一定能看到。
|
if ($ridePresencePayload) {
|
||||||
$initialWelcomeMessage = $generalWelcomeMsg;
|
$rideWelcomeMsg = [
|
||||||
$initialWelcomeMessages[] = $generalWelcomeMsg;
|
'id' => $this->chatState->nextMessageId($id),
|
||||||
|
'room_id' => $id,
|
||||||
|
'from_user' => '座驾播报',
|
||||||
|
'to_user' => '大家',
|
||||||
|
'content' => "<span style=\"color:#0f766e;font-weight:bold;\">{$ridePresencePayload['ride_icon']} {$ridePresencePayload['identity_text']} · {$ridePresencePayload['welcome_text']}</span>",
|
||||||
|
'is_secret' => false,
|
||||||
|
'font_color' => '#0f766e',
|
||||||
|
'action' => 'ride_presence',
|
||||||
|
'welcome_user' => $user->username,
|
||||||
|
'welcome_kind' => 'ride_presence',
|
||||||
|
'ride_key' => $ridePresencePayload['ride_key'],
|
||||||
|
'ride_name' => $ridePresencePayload['ride_name'],
|
||||||
|
'effect_title' => $ridePresencePayload['effect_title'],
|
||||||
|
'effect_user_info' => $ridePresencePayload['effect_user_info'],
|
||||||
|
'sent_at' => now()->toDateTimeString(),
|
||||||
|
];
|
||||||
|
|
||||||
$this->chatState->pushMessage($id, $generalWelcomeMsg);
|
// 座驾进场独立追加一条播报,并广播全屏特效给其他在线用户。
|
||||||
// 修复:之前使用了 ->toOthers() 导致自己看不到自己的进场提示
|
$this->chatState->pushMessage($id, $rideWelcomeMsg);
|
||||||
broadcast(new MessageSent($id, $generalWelcomeMsg));
|
broadcast(new MessageSent($id, $rideWelcomeMsg));
|
||||||
|
broadcast(new \App\Events\EffectBroadcast(
|
||||||
|
$id,
|
||||||
|
$ridePresencePayload['ride_key'],
|
||||||
|
$user->username,
|
||||||
|
effectTitle: $ridePresencePayload['effect_title'],
|
||||||
|
rideName: $ridePresencePayload['ride_name'],
|
||||||
|
effectUserInfo: $ridePresencePayload['effect_user_info'],
|
||||||
|
))->toOthers();
|
||||||
|
|
||||||
// 会员专属特效需要单独广播给其他在线成员,自己则在页面初始化后本地补播。
|
$initialRideEffect = $ridePresencePayload['ride_key'];
|
||||||
if (! empty($vipPresencePayload['presence_effect'])) {
|
$initialRideEffectOptions = [
|
||||||
broadcast(new \App\Events\EffectBroadcast($id, $vipPresencePayload['presence_effect'], $user->username))->toOthers();
|
'effect_title' => $ridePresencePayload['effect_title'],
|
||||||
|
'effect_user_info' => $ridePresencePayload['effect_user_info'],
|
||||||
|
'ride_name' => $ridePresencePayload['ride_name'],
|
||||||
|
'operator' => $user->username,
|
||||||
|
];
|
||||||
|
$initialWelcomeMessages[] = $rideWelcomeMsg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +364,8 @@ class ChatController extends Controller
|
|||||||
'user' => $user,
|
'user' => $user,
|
||||||
'weekEffect' => $this->shopService->getActiveWeekEffect($user),
|
'weekEffect' => $this->shopService->getActiveWeekEffect($user),
|
||||||
'newbieEffect' => $newbieEffect,
|
'newbieEffect' => $newbieEffect,
|
||||||
|
'initialRideEffect' => $initialRideEffect,
|
||||||
|
'initialRideEffectOptions' => $initialRideEffectOptions,
|
||||||
'initialPresenceTheme' => $initialPresenceTheme,
|
'initialPresenceTheme' => $initialPresenceTheme,
|
||||||
'initialWelcomeMessage' => $initialWelcomeMessage,
|
'initialWelcomeMessage' => $initialWelcomeMessage,
|
||||||
'initialWelcomeMessages' => $initialWelcomeMessages,
|
'initialWelcomeMessages' => $initialWelcomeMessages,
|
||||||
@@ -322,6 +374,7 @@ class ChatController extends Controller
|
|||||||
'pendingDivorce' => $pendingDivorceData,
|
'pendingDivorce' => $pendingDivorceData,
|
||||||
'roomPermissionMap' => $roomPermissionMap,
|
'roomPermissionMap' => $roomPermissionMap,
|
||||||
'hasRoomManagementPermission' => in_array(true, $roomPermissionMap, true),
|
'hasRoomManagementPermission' => in_array(true, $roomPermissionMap, true),
|
||||||
|
'dailyGameProfitLeaders' => $this->dailyGameProfitLeaderboardService->topThree(),
|
||||||
'dailyStatusCatalog' => ChatDailyStatusCatalog::groupedOptions(),
|
'dailyStatusCatalog' => ChatDailyStatusCatalog::groupedOptions(),
|
||||||
'activeDailyStatus' => $this->chatUserPresenceService->currentDailyStatus($user),
|
'activeDailyStatus' => $this->chatUserPresenceService->currentDailyStatus($user),
|
||||||
]);
|
]);
|
||||||
@@ -456,7 +509,7 @@ class ChatController extends Controller
|
|||||||
'message' => strip_tags($pureContent),
|
'message' => strip_tags($pureContent),
|
||||||
'icon' => '👋',
|
'icon' => '👋',
|
||||||
'color' => '#e11d48',
|
'color' => '#e11d48',
|
||||||
'duration' => 8000,
|
'duration' => 3000,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1038,12 +1091,9 @@ class ChatController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 扣除金币、增加对方魅力
|
// 扣除金币、增加对方魅力(含流水日志)
|
||||||
$user->jjb = ($user->jjb ?? 0) - $totalCost;
|
$this->currencyService->change($user, 'gold', -$totalCost, CurrencySource::SEND_GIFT, "送花:送给 {$toUsername} {$count} 份【{$gift->name}】,魅力 +{$totalCharm}", $roomId);
|
||||||
$user->save();
|
$this->currencyService->change($toUser, 'charm', $totalCharm, CurrencySource::RECV_GIFT, "收到 {$user->username} 赠送的 {$count} 份【{$gift->name}】", $roomId);
|
||||||
|
|
||||||
$toUser->meili = ($toUser->meili ?? 0) + $totalCharm;
|
|
||||||
$toUser->save();
|
|
||||||
|
|
||||||
// 构建礼物图片 URL
|
// 构建礼物图片 URL
|
||||||
$giftImageUrl = $gift->image ? "/images/gifts/{$gift->image}" : '';
|
$giftImageUrl = $gift->image ? "/images/gifts/{$gift->image}" : '';
|
||||||
@@ -1075,8 +1125,8 @@ class ChatController extends Controller
|
|||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'message' => "送花成功!花费 {$totalCost} 金币,{$toUsername} 魅力 +{$totalCharm}",
|
'message' => "送花成功!花费 {$totalCost} 金币,{$toUsername} 魅力 +{$totalCharm}",
|
||||||
'data' => [
|
'data' => [
|
||||||
'my_jjb' => $user->jjb,
|
'my_jjb' => $user->fresh()->jjb,
|
||||||
'target_charm' => $toUser->meili,
|
'target_charm' => $toUser->fresh()->meili,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -1129,9 +1179,8 @@ class ChatController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送者获得魅力
|
// 发送者获得魅力(含流水日志)
|
||||||
$sender->meili = ($sender->meili ?? 0) + $charmGain;
|
$this->currencyService->change($sender, 'charm', $charmGain, CurrencySource::CHAT_CHARM, "与 {$toUsername} 聊天获得魅力奖励");
|
||||||
$sender->save();
|
|
||||||
|
|
||||||
// 更新 Redis 计数器(1 小时过期)
|
// 更新 Redis 计数器(1 小时过期)
|
||||||
Redis::incrby($capKey, $charmGain);
|
Redis::incrby($capKey, $charmGain);
|
||||||
@@ -1354,9 +1403,9 @@ class ChatController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行转账(直接操作字段,与 sendFlower 保持一致风格)
|
// 执行转账(含流水日志)
|
||||||
$sender->decrement('jjb', $amount);
|
$this->currencyService->change($sender, 'gold', -$amount, CurrencySource::GIFT_SENT, "赠送金币给 {$toName}", $roomId);
|
||||||
$receiver->increment('jjb', $amount);
|
$this->currencyService->change($receiver, 'gold', $amount, CurrencySource::GOLD_GIFT_RECV, "收到 {$sender->username} 赠送的金币", $roomId);
|
||||||
|
|
||||||
// 写入真正的私聊消息,避免其他旁观用户在公屏看到赠金币通知。
|
// 写入真正的私聊消息,避免其他旁观用户在公屏看到赠金币通知。
|
||||||
$giftMsg = [
|
$giftMsg = [
|
||||||
@@ -1375,7 +1424,7 @@ class ChatController extends Controller
|
|||||||
'message' => '<b>'.ChatContentSanitizer::htmlText($sender->username)."</b> 向你赠送了 <b>{$amount}</b> 枚金币!",
|
'message' => '<b>'.ChatContentSanitizer::htmlText($sender->username)."</b> 向你赠送了 <b>{$amount}</b> 枚金币!",
|
||||||
'icon' => '💰',
|
'icon' => '💰',
|
||||||
'color' => '#f59e0b',
|
'color' => '#f59e0b',
|
||||||
'duration' => 8000,
|
'duration' => 3000,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,10 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Enums\CurrencySource;
|
use App\Enums\CurrencySource;
|
||||||
use App\Events\MessageSent;
|
|
||||||
use App\Jobs\SaveMessageJob;
|
|
||||||
use App\Models\GameConfig;
|
use App\Models\GameConfig;
|
||||||
use App\Models\HorseBet;
|
use App\Models\HorseBet;
|
||||||
use App\Models\HorseRace;
|
use App\Models\HorseRace;
|
||||||
use App\Services\ChatStateService;
|
use App\Services\GameBetBroadcastService;
|
||||||
use App\Services\GameRoomScopeService;
|
use App\Services\GameRoomScopeService;
|
||||||
use App\Services\UserCurrencyService;
|
use App\Services\UserCurrencyService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -40,6 +38,7 @@ class HorseRaceController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserCurrencyService $currency,
|
private readonly UserCurrencyService $currency,
|
||||||
private readonly GameRoomScopeService $roomScopeService,
|
private readonly GameRoomScopeService $roomScopeService,
|
||||||
|
private readonly GameBetBroadcastService $betBroadcastService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -221,23 +220,7 @@ class HorseRaceController extends Controller
|
|||||||
'status' => 'pending',
|
'status' => 'pending',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$chatState = app(ChatStateService::class);
|
$this->betBroadcastService->horseRace((int) $race->room_id, $user->username, (int) $data['amount'], $horseName);
|
||||||
$formattedAmount = number_format($data['amount']);
|
|
||||||
$content = "🐎 <b>【赛马】【{$user->username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$horseName})!✨";
|
|
||||||
$msg = [
|
|
||||||
'id' => $chatState->nextMessageId((int) $race->room_id),
|
|
||||||
'room_id' => (int) $race->room_id,
|
|
||||||
'from_user' => '系统传音',
|
|
||||||
'to_user' => '大家',
|
|
||||||
'content' => $content,
|
|
||||||
'is_secret' => false,
|
|
||||||
'font_color' => '#d97706',
|
|
||||||
'action' => '',
|
|
||||||
'sent_at' => now()->toDateTimeString(),
|
|
||||||
];
|
|
||||||
$chatState->pushMessage((int) $race->room_id, $msg);
|
|
||||||
event(new MessageSent((int) $race->room_id, $msg));
|
|
||||||
SaveMessageJob::dispatch($msg);
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use App\Models\RedPacketClaim;
|
|||||||
use App\Models\RedPacketEnvelope;
|
use App\Models\RedPacketEnvelope;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ChatStateService;
|
use App\Services\ChatStateService;
|
||||||
|
use App\Services\GameBetBroadcastService;
|
||||||
use App\Services\PositionPermissionService;
|
use App\Services\PositionPermissionService;
|
||||||
use App\Services\UserCurrencyService;
|
use App\Services\UserCurrencyService;
|
||||||
use App\Support\PositionPermissionRegistry;
|
use App\Support\PositionPermissionRegistry;
|
||||||
@@ -58,6 +59,7 @@ class RedPacketController extends Controller
|
|||||||
private readonly ChatStateService $chatState,
|
private readonly ChatStateService $chatState,
|
||||||
private readonly UserCurrencyService $currencyService,
|
private readonly UserCurrencyService $currencyService,
|
||||||
private readonly PositionPermissionService $positionPermissionService,
|
private readonly PositionPermissionService $positionPermissionService,
|
||||||
|
private readonly GameBetBroadcastService $betBroadcastService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -357,23 +359,9 @@ class RedPacketController extends Controller
|
|||||||
type: $envelopeType,
|
type: $envelopeType,
|
||||||
));
|
));
|
||||||
|
|
||||||
// 在聊天室发送领取播报(所有人可见)
|
// 在聊天室发送领取播报并附带右下角通知,提醒房间内所有在线人员。
|
||||||
$typeLabel = $envelopeType === 'exp' ? '经验' : '金币';
|
$typeLabel = $envelopeType === 'exp' ? '经验' : '金币';
|
||||||
$typeIcon = $envelopeType === 'exp' ? '✨' : '💰';
|
$this->betBroadcastService->redPacketClaimed($roomId, $user->username, $amount, $envelopeType);
|
||||||
$claimedMsg = [
|
|
||||||
'id' => $this->chatState->nextMessageId($roomId),
|
|
||||||
'room_id' => $roomId,
|
|
||||||
'from_user' => '系统传音',
|
|
||||||
'to_user' => '',
|
|
||||||
'content' => "🧧 <b>{$user->username}</b> 抢到了 <b>{$amount}</b> {$typeLabel}礼包!{$typeIcon}",
|
|
||||||
'is_secret' => false,
|
|
||||||
'font_color' => $envelopeType === 'exp' ? '#6d28d9' : '#d97706',
|
|
||||||
'action' => '',
|
|
||||||
'sent_at' => now()->toDateTimeString(),
|
|
||||||
];
|
|
||||||
$this->chatState->pushMessage($roomId, $claimedMsg);
|
|
||||||
broadcast(new MessageSent($roomId, $claimedMsg));
|
|
||||||
SaveMessageJob::dispatch($claimedMsg);
|
|
||||||
|
|
||||||
$balanceField = $envelopeType === 'exp' ? 'exp_num' : 'jjb';
|
$balanceField = $envelopeType === 'exp' ? 'exp_num' : 'jjb';
|
||||||
$balanceNow = $user->fresh()->$balanceField;
|
$balanceNow = $user->fresh()->$balanceField;
|
||||||
|
|||||||
@@ -218,6 +218,8 @@ class RiddleQuizController extends Controller
|
|||||||
'data' => [
|
'data' => [
|
||||||
'quiz_type' => $round->quiz_type,
|
'quiz_type' => $round->quiz_type,
|
||||||
'quiz_type_label' => $quizTypeLabel,
|
'quiz_type_label' => $quizTypeLabel,
|
||||||
|
'round_id' => $round->id,
|
||||||
|
'quiz_round_id' => $round->id,
|
||||||
'answer' => (string) $round->idiom?->answer,
|
'answer' => (string) $round->idiom?->answer,
|
||||||
'quiz_answer' => (string) $round->idiom?->answer,
|
'quiz_answer' => (string) $round->idiom?->answer,
|
||||||
'reward_gold' => $round->reward_gold,
|
'reward_gold' => $round->reward_gold,
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:聊天室座驾前台接口控制器。
|
||||||
|
*
|
||||||
|
* 提供座驾列表、用户当前座驾、购买记录与购买座驾接口。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Events\MessageSent;
|
||||||
|
use App\Http\Requests\BuyRideRequest;
|
||||||
|
use App\Jobs\SaveMessageJob;
|
||||||
|
use App\Models\Ride;
|
||||||
|
use App\Models\Room;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ChatStateService;
|
||||||
|
use App\Services\RideService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天室座驾控制器
|
||||||
|
* 负责前台座驾页面的数据读取与购买操作。
|
||||||
|
*/
|
||||||
|
class RideController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 构造座驾控制器依赖。
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly RideService $rideService,
|
||||||
|
private readonly ChatStateService $chatState,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取座驾页面需要的商品、当前座驾和购买记录。
|
||||||
|
*/
|
||||||
|
public function items(): JsonResponse
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'items' => $this->rideService->activeItems()
|
||||||
|
->map(fn (Ride $item) => $this->rideService->formatItem($item))
|
||||||
|
->values(),
|
||||||
|
'current_ride' => $this->rideService->formatCurrentRide($user),
|
||||||
|
'purchases' => $this->rideService->purchaseRecords($user),
|
||||||
|
'user_jjb' => $user->jjb ?? 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 购买座驾并返回最新金币和当前座驾状态。
|
||||||
|
*/
|
||||||
|
public function buy(BuyRideRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
$roomId = (int) $request->integer('room_id');
|
||||||
|
$room = Room::query()->findOrFail($roomId);
|
||||||
|
|
||||||
|
if (! $room->canUserEnter($user) || ! $this->chatState->isUserInRoom($roomId, $user->username)) {
|
||||||
|
return response()->json(['status' => 'error', 'message' => '请先进入当前房间后再购买座驾。'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = Ride::query()->findOrFail((int) $request->integer('item_id'));
|
||||||
|
$result = $this->rideService->buy($user, $item, $roomId);
|
||||||
|
|
||||||
|
if (! $result['ok']) {
|
||||||
|
return response()->json(['status' => 'error', 'message' => $result['message']], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pushRidePurchaseNotice($user, $item, $roomId);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => $result['message'],
|
||||||
|
'current_ride' => $result['current_ride'] ?? null,
|
||||||
|
'purchases' => $this->rideService->purchaseRecords($user->fresh()),
|
||||||
|
'jjb' => $user->fresh()->jjb,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向当前房间广播座驾购买成功通知,方便其他用户快速打开座驾页面。
|
||||||
|
*/
|
||||||
|
private function pushRidePurchaseNotice(User $user, Ride $item, int $roomId): void
|
||||||
|
{
|
||||||
|
$button = '<button onclick="openRideModal()">购买座驾</button>';
|
||||||
|
$content = sprintf(
|
||||||
|
'🚀 【座驾】 <b>%s</b> 购买了 <b>%s</b>,有效期 <b>%d 天</b>,排面已安排!%s',
|
||||||
|
e($user->username),
|
||||||
|
e($item->name),
|
||||||
|
(int) $item->duration_days,
|
||||||
|
$button,
|
||||||
|
);
|
||||||
|
|
||||||
|
$message = [
|
||||||
|
'id' => $this->chatState->nextMessageId($roomId),
|
||||||
|
'room_id' => $roomId,
|
||||||
|
'from_user' => '系统传音',
|
||||||
|
'to_user' => '大家',
|
||||||
|
'content' => $content,
|
||||||
|
'is_secret' => false,
|
||||||
|
'font_color' => '#0f766e',
|
||||||
|
'action' => 'ride_purchase',
|
||||||
|
'sent_at' => now()->toDateTimeString(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 购买通知需要写入房间消息缓存、实时广播并落库,刷新后仍可追溯。
|
||||||
|
$this->chatState->pushMessage($roomId, $message);
|
||||||
|
broadcast(new MessageSent($roomId, $message));
|
||||||
|
SaveMessageJob::dispatch($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,19 +40,23 @@ class ShopController extends Controller
|
|||||||
public function items(): JsonResponse
|
public function items(): JsonResponse
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
$items = ShopItem::active()->map(fn ($item) => [
|
$items = ShopItem::query()
|
||||||
'id' => $item->id,
|
->where('is_active', true)
|
||||||
'name' => $item->name,
|
->orderBy('sort_order')
|
||||||
'slug' => $item->slug,
|
->get()
|
||||||
'description' => $item->description,
|
->map(fn ($item) => [
|
||||||
'icon' => $item->icon,
|
'id' => $item->id,
|
||||||
'price' => $item->price,
|
'name' => $item->name,
|
||||||
'type' => $item->type,
|
'slug' => $item->slug,
|
||||||
'duration_days' => $item->duration_days,
|
'description' => $item->description,
|
||||||
'duration_minutes' => $item->duration_minutes,
|
'icon' => $item->icon,
|
||||||
'intimacy_bonus' => $item->intimacy_bonus,
|
'price' => $item->price,
|
||||||
'charm_bonus' => $item->charm_bonus,
|
'type' => $item->type,
|
||||||
]);
|
'duration_days' => $item->duration_days,
|
||||||
|
'duration_minutes' => $item->duration_minutes,
|
||||||
|
'intimacy_bonus' => $item->intimacy_bonus,
|
||||||
|
'charm_bonus' => $item->charm_bonus,
|
||||||
|
]);
|
||||||
|
|
||||||
$signRepairCard = $items->firstWhere('type', ShopItem::TYPE_SIGN_REPAIR);
|
$signRepairCard = $items->firstWhere('type', ShopItem::TYPE_SIGN_REPAIR);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use App\Http\Requests\UpdateDailyStatusRequest;
|
|||||||
use App\Http\Requests\UpdateProfileRequest;
|
use App\Http\Requests\UpdateProfileRequest;
|
||||||
use App\Models\Sysparam;
|
use App\Models\Sysparam;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\AchievementService;
|
||||||
use App\Services\ChatStateService;
|
use App\Services\ChatStateService;
|
||||||
use App\Services\ChatUserPresenceService;
|
use App\Services\ChatUserPresenceService;
|
||||||
use App\Services\PositionPermissionService;
|
use App\Services\PositionPermissionService;
|
||||||
@@ -58,6 +59,7 @@ class UserController extends Controller
|
|||||||
private readonly ChatStateService $chatState,
|
private readonly ChatStateService $chatState,
|
||||||
private readonly ChatUserPresenceService $chatUserPresenceService,
|
private readonly ChatUserPresenceService $chatUserPresenceService,
|
||||||
private readonly UserCurrencyService $currencyService,
|
private readonly UserCurrencyService $currencyService,
|
||||||
|
private readonly AchievementService $achievementService,
|
||||||
private readonly PositionPermissionService $positionPermissionService,
|
private readonly PositionPermissionService $positionPermissionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -83,6 +85,7 @@ class UserController extends Controller
|
|||||||
$activePosition = $targetUser->activePosition?->load('position.department')->position;
|
$activePosition = $targetUser->activePosition?->load('position.department')->position;
|
||||||
$data = [
|
$data = [
|
||||||
'username' => $targetUser->username,
|
'username' => $targetUser->username,
|
||||||
|
'previous_name' => $targetUser->previous_name,
|
||||||
'sex' => match ((int) $targetUser->sex) {
|
'sex' => match ((int) $targetUser->sex) {
|
||||||
1 => '男', 2 => '女', default => ''
|
1 => '男', 2 => '女', default => ''
|
||||||
},
|
},
|
||||||
@@ -159,6 +162,8 @@ class UserController extends Controller
|
|||||||
'expires_at' => $signIdentity->expires_at?->toIso8601String(),
|
'expires_at' => $signIdentity->expires_at?->toIso8601String(),
|
||||||
] : null,
|
] : null,
|
||||||
];
|
];
|
||||||
|
// 名片弹窗只读取已缓存的成就摘要,避免双击用户时同步扫描全量日志造成卡顿。
|
||||||
|
$data['achievements'] = $this->achievementService->profileSummaryForUser($targetUser);
|
||||||
|
|
||||||
// 管理员网络信息仅对站长或拥有「封IP」职务权限的操作者展示。
|
// 管理员网络信息仅对站长或拥有「封IP」职务权限的操作者展示。
|
||||||
$canViewNetworkInfo = $operator
|
$canViewNetworkInfo = $operator
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:前台座驾购买请求验证。
|
||||||
|
*
|
||||||
|
* 校验用户购买座驾时传入的座驾与房间上下文,避免未进房直接购买聊天室座驾。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 座驾购买请求
|
||||||
|
* 负责校验座驾 ID 与当前房间 ID。
|
||||||
|
*/
|
||||||
|
class BuyRideRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 判断当前用户是否允许购买座驾。
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取座驾购买请求验证规则。
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'item_id' => ['required', 'integer', 'exists:rides,id'],
|
||||||
|
'room_id' => ['required', 'integer', 'exists:rooms,id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取座驾购买请求中文错误提示。
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'item_id.required' => '请选择要购买的座驾。',
|
||||||
|
'item_id.exists' => '座驾不存在或已被删除。',
|
||||||
|
'room_id.required' => '请先进入聊天室后再购买座驾。',
|
||||||
|
'room_id.exists' => '当前房间不存在。',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:后台新增座驾请求验证。
|
||||||
|
*
|
||||||
|
* 校验座驾独立模块的名称、特效 key、价格、使用天数、欢迎语和上下架状态。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台新增座驾请求
|
||||||
|
* 负责新增座驾时的权限与字段校验。
|
||||||
|
*/
|
||||||
|
class StoreRideRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 判断当前用户是否允许新增座驾。
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->id === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取新增座驾验证规则。
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'slug' => ['required', 'string', 'max:100', 'regex:/^ride_[a-z0-9_]+$/', Rule::unique('rides', 'slug')],
|
||||||
|
'effect_key' => ['required', 'string', 'max:50', 'regex:/^[a-z0-9_]+$/', Rule::unique('rides', 'effect_key')],
|
||||||
|
'icon' => ['required', 'string', 'max:20'],
|
||||||
|
'description' => ['nullable', 'string', 'max:500'],
|
||||||
|
'price' => ['required', 'integer', 'min:0'],
|
||||||
|
'duration_days' => ['required', 'integer', 'min:1'],
|
||||||
|
'welcome_message' => ['nullable', 'string', 'max:255'],
|
||||||
|
'sort_order' => ['required', 'integer', 'min:0'],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取新增座驾中文错误提示。
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'slug.regex' => '座驾标识必须使用 ride_ 开头,例如 ride_j35。',
|
||||||
|
'effect_key.regex' => '特效 key 只能包含小写字母、数字和下划线。',
|
||||||
|
'duration_days.min' => '使用天数至少为 1 天。',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:后台新增商店商品请求验证。
|
||||||
|
*
|
||||||
|
* 统一校验商店商品字段。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\ShopItem;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台新增商店商品请求
|
||||||
|
* 负责新增商品时的权限与字段校验。
|
||||||
|
*/
|
||||||
|
class StoreShopItemRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 判断当前用户是否允许新增商品。
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->id === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取新增商品验证规则。
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'slug' => ['required', 'string', 'max:100', Rule::unique('shop_items', 'slug')],
|
||||||
|
'icon' => ['required', 'string', 'max:20'],
|
||||||
|
'description' => ['nullable', 'string', 'max:500'],
|
||||||
|
'price' => ['required', 'integer', 'min:0'],
|
||||||
|
'type' => ['required', Rule::in($this->allowedTypes())],
|
||||||
|
'duration_days' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'duration_minutes' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'intimacy_bonus' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'charm_bonus' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'sort_order' => ['required', 'integer', 'min:0'],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取允许后台配置的商品类型。
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
protected function allowedTypes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'instant',
|
||||||
|
'duration',
|
||||||
|
'one_time',
|
||||||
|
'ring',
|
||||||
|
'auto_fishing',
|
||||||
|
ShopItem::TYPE_SIGN_REPAIR,
|
||||||
|
'msg_bubble',
|
||||||
|
'msg_name_color',
|
||||||
|
'msg_text_color',
|
||||||
|
'avatar_frame',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ class UpdateProfileRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'previous_name' => ['nullable', 'string', 'max:50'],
|
||||||
'sex' => ['required', 'in:0,1,2'],
|
'sex' => ['required', 'in:0,1,2'],
|
||||||
'headface' => ['required', 'string', 'max:50'], // 比如存放 01.gif - 50.gif
|
'headface' => ['required', 'string', 'max:50'], // 比如存放 01.gif - 50.gif
|
||||||
'sign' => ['nullable', 'string', 'max:255'],
|
'sign' => ['nullable', 'string', 'max:255'],
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:后台更新座驾请求验证。
|
||||||
|
*
|
||||||
|
* 校验座驾编辑时的唯一标识、价格、使用天数和欢迎语配置。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台更新座驾请求
|
||||||
|
* 负责编辑座驾时的权限与字段校验。
|
||||||
|
*/
|
||||||
|
class UpdateRideRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 判断当前用户是否允许编辑座驾。
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取更新座驾验证规则。
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$ride = $this->route('ride');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'slug' => ['required', 'string', 'max:100', 'regex:/^ride_[a-z0-9_]+$/', Rule::unique('rides', 'slug')->ignore($ride?->id)],
|
||||||
|
'effect_key' => ['required', 'string', 'max:50', 'regex:/^[a-z0-9_]+$/', Rule::unique('rides', 'effect_key')->ignore($ride?->id)],
|
||||||
|
'icon' => ['required', 'string', 'max:20'],
|
||||||
|
'description' => ['nullable', 'string', 'max:500'],
|
||||||
|
'price' => ['required', 'integer', 'min:0'],
|
||||||
|
'duration_days' => ['required', 'integer', 'min:1'],
|
||||||
|
'welcome_message' => ['nullable', 'string', 'max:255'],
|
||||||
|
'sort_order' => ['required', 'integer', 'min:0'],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取更新座驾中文错误提示。
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'slug.regex' => '座驾标识必须使用 ride_ 开头,例如 ride_j35。',
|
||||||
|
'effect_key.regex' => '特效 key 只能包含小写字母、数字和下划线。',
|
||||||
|
'duration_days.min' => '使用天数至少为 1 天。',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:后台更新商店商品请求验证。
|
||||||
|
*
|
||||||
|
* 统一校验商店商品编辑字段。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\ShopItem;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台更新商店商品请求
|
||||||
|
* 负责编辑商品时的权限与字段校验。
|
||||||
|
*/
|
||||||
|
class UpdateShopItemRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 判断当前用户是否允许编辑商品。
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取更新商品验证规则。
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$shopItem = $this->route('shopItem');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'slug' => ['required', 'string', 'max:100', Rule::unique('shop_items', 'slug')->ignore($shopItem?->id)],
|
||||||
|
'icon' => ['required', 'string', 'max:20'],
|
||||||
|
'description' => ['nullable', 'string', 'max:500'],
|
||||||
|
'price' => ['required', 'integer', 'min:0'],
|
||||||
|
'type' => ['required', Rule::in($this->allowedTypes())],
|
||||||
|
'duration_days' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'duration_minutes' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'intimacy_bonus' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'charm_bonus' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'sort_order' => ['required', 'integer', 'min:0'],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取允许后台配置的商品类型。
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
protected function allowedTypes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'instant',
|
||||||
|
'duration',
|
||||||
|
'one_time',
|
||||||
|
'ring',
|
||||||
|
'auto_fishing',
|
||||||
|
ShopItem::TYPE_SIGN_REPAIR,
|
||||||
|
'msg_bubble',
|
||||||
|
'msg_name_color',
|
||||||
|
'msg_text_color',
|
||||||
|
'avatar_frame',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ class SaveMessageJob implements ShouldQueue
|
|||||||
'image_path' => $this->messageData['image_path'] ?? null,
|
'image_path' => $this->messageData['image_path'] ?? null,
|
||||||
'image_thumb_path' => $this->messageData['image_thumb_path'] ?? null,
|
'image_thumb_path' => $this->messageData['image_thumb_path'] ?? null,
|
||||||
'image_original_name' => $this->messageData['image_original_name'] ?? null,
|
'image_original_name' => $this->messageData['image_original_name'] ?? null,
|
||||||
|
'retention_type' => Message::resolveRetentionType($this->messageData),
|
||||||
// 恢复 Carbon 时间对象
|
// 恢复 Carbon 时间对象
|
||||||
'sent_at' => Carbon::parse($this->messageData['sent_at']),
|
'sent_at' => Carbon::parse($this->messageData['sent_at']),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ class TriggerHolidayEventJob implements ShouldQueue
|
|||||||
|
|
||||||
$now = now();
|
$now = now();
|
||||||
$scheduledFor = $this->manual ? $now->copy() : $event->send_at;
|
$scheduledFor = $this->manual ? $now->copy() : $event->send_at;
|
||||||
|
$expiresAt = $this->manual
|
||||||
|
? $now->copy()->addMinutes($event->expire_minutes)
|
||||||
|
: $scheduledFor?->copy()->addMinutes($event->expire_minutes);
|
||||||
|
|
||||||
if (! $this->manual) {
|
if (! $this->manual) {
|
||||||
// 定时触发只允许处理真正到期且仍处于 pending 的模板。
|
// 定时触发只允许处理真正到期且仍处于 pending 的模板。
|
||||||
@@ -131,12 +134,23 @@ class TriggerHolidayEventJob implements ShouldQueue
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$validScheduledFor = $scheduleService->skipExpiredOccurrences($event, $now);
|
||||||
|
if ($validScheduledFor === null || ! $validScheduledFor->equalTo($scheduledFor)) {
|
||||||
|
// 漏跑且已过期的批次只推进模板,不生成领取批次和聊天室公告。
|
||||||
|
$event->update([
|
||||||
|
'send_at' => $validScheduledFor,
|
||||||
|
'status' => $validScheduledFor ? 'pending' : 'completed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$nextSendAt = $scheduleService->advanceAfterTrigger($event);
|
$nextSendAt = $scheduleService->advanceAfterTrigger($event);
|
||||||
$event->update([
|
$event->update([
|
||||||
'send_at' => $nextSendAt,
|
'send_at' => $nextSendAt,
|
||||||
'status' => $nextSendAt ? 'pending' : 'completed',
|
'status' => $nextSendAt ? 'pending' : 'completed',
|
||||||
'triggered_at' => $now,
|
'triggered_at' => $now,
|
||||||
'expires_at' => $now->copy()->addMinutes($event->expire_minutes),
|
'expires_at' => $expiresAt,
|
||||||
'claimed_count' => 0,
|
'claimed_count' => 0,
|
||||||
'claimed_amount' => 0,
|
'claimed_amount' => 0,
|
||||||
]);
|
]);
|
||||||
@@ -163,7 +177,7 @@ class TriggerHolidayEventJob implements ShouldQueue
|
|||||||
'repeat_type' => $event->repeat_type,
|
'repeat_type' => $event->repeat_type,
|
||||||
'scheduled_for' => $scheduledFor,
|
'scheduled_for' => $scheduledFor,
|
||||||
'triggered_at' => $now,
|
'triggered_at' => $now,
|
||||||
'expires_at' => $now->copy()->addMinutes($event->expire_minutes),
|
'expires_at' => $expiresAt,
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
'audience_count' => 0,
|
'audience_count' => 0,
|
||||||
'claimed_count' => 0,
|
'claimed_count' => 0,
|
||||||
|
|||||||
@@ -20,6 +20,120 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
*/
|
*/
|
||||||
class Message extends Model
|
class Message extends Model
|
||||||
{
|
{
|
||||||
|
public const RETENTION_USER_CHAT = 'user_chat';
|
||||||
|
|
||||||
|
public const RETENTION_SYSTEM_NOTICE = 'system_notice';
|
||||||
|
|
||||||
|
public const RETENTION_GAME_NOTICE = 'game_notice';
|
||||||
|
|
||||||
|
public const RETENTION_EPHEMERAL_NOTICE = 'ephemeral_notice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可按过期策略清理的消息保留类型。
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function purgableRetentionTypes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::RETENTION_GAME_NOTICE,
|
||||||
|
self::RETENTION_EPHEMERAL_NOTICE,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据广播消息载荷推断数据库保留类型。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $messageData 聊天室消息载荷
|
||||||
|
*/
|
||||||
|
public static function resolveRetentionType(array $messageData): string
|
||||||
|
{
|
||||||
|
$explicitType = (string) ($messageData['retention_type'] ?? '');
|
||||||
|
if (in_array($explicitType, [
|
||||||
|
self::RETENTION_USER_CHAT,
|
||||||
|
self::RETENTION_SYSTEM_NOTICE,
|
||||||
|
self::RETENTION_GAME_NOTICE,
|
||||||
|
self::RETENTION_EPHEMERAL_NOTICE,
|
||||||
|
], true)) {
|
||||||
|
return $explicitType;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fromUser = (string) ($messageData['from_user'] ?? '');
|
||||||
|
$action = (string) ($messageData['action'] ?? '');
|
||||||
|
$messageType = (string) ($messageData['message_type'] ?? 'text');
|
||||||
|
|
||||||
|
if (self::isEphemeralNotice($fromUser, $action)) {
|
||||||
|
return self::RETENTION_EPHEMERAL_NOTICE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isGameNotice($fromUser, $action, $messageType, $messageData)) {
|
||||||
|
return self::RETENTION_GAME_NOTICE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isSystemNotice($fromUser)) {
|
||||||
|
return self::RETENTION_SYSTEM_NOTICE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::RETENTION_USER_CHAT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断消息是否属于可短期保留的进出场类通知。
|
||||||
|
*/
|
||||||
|
public static function isEphemeralNotice(string $fromUser, string $action = ''): bool
|
||||||
|
{
|
||||||
|
return in_array($fromUser, ['进出播报', '座驾播报'], true)
|
||||||
|
|| in_array($action, ['system_welcome', 'vip_presence', 'ride_presence', 'auto_save_exp'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断消息是否属于游戏或玩法通知。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $messageData 聊天室消息载荷
|
||||||
|
*/
|
||||||
|
public static function isGameNotice(string $fromUser, string $action, string $messageType = 'text', array $messageData = []): bool
|
||||||
|
{
|
||||||
|
$gameSenders = ['钓鱼播报', '星海小博士'];
|
||||||
|
$gameActions = [
|
||||||
|
'fishing_result',
|
||||||
|
'idiom_result',
|
||||||
|
'riddle_result',
|
||||||
|
'ride_purchase',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (in_array($fromUser, $gameSenders, true) || in_array($action, $gameActions, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($messageData['toast_notification'])) {
|
||||||
|
$title = (string) data_get($messageData, 'toast_notification.title', '');
|
||||||
|
|
||||||
|
return str_contains($title, '下注')
|
||||||
|
|| str_contains($title, '赛马')
|
||||||
|
|| str_contains($title, '百家乐')
|
||||||
|
|| str_contains($title, '双色球')
|
||||||
|
|| str_contains($title, '红包')
|
||||||
|
|| str_contains($title, '结算');
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($messageType, ['game_notice'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断消息是否来自系统发送者。
|
||||||
|
*/
|
||||||
|
public static function isSystemNotice(string $fromUser): bool
|
||||||
|
{
|
||||||
|
return in_array($fromUser, [
|
||||||
|
'系统',
|
||||||
|
'系统公告',
|
||||||
|
'系统传音',
|
||||||
|
'系统播报',
|
||||||
|
'送花播报',
|
||||||
|
'AI小班长',
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
*
|
*
|
||||||
@@ -37,6 +151,7 @@ class Message extends Model
|
|||||||
'image_path',
|
'image_path',
|
||||||
'image_thumb_path',
|
'image_thumb_path',
|
||||||
'image_original_name',
|
'image_original_name',
|
||||||
|
'retention_type',
|
||||||
'sent_at',
|
'sent_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:聊天室座驾模型。
|
||||||
|
*
|
||||||
|
* 对应 rides 表,保存座驾名称、特效 key、价格、使用天数、欢迎语与上下架状态。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天室座驾模型
|
||||||
|
* 负责提供座驾定义、全屏特效 key 和购买记录关系。
|
||||||
|
*/
|
||||||
|
class Ride extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'name', 'slug', 'effect_key', 'icon', 'description', 'price',
|
||||||
|
'duration_days', 'welcome_message', 'sort_order', 'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取座驾对应的所有购买记录。
|
||||||
|
*/
|
||||||
|
public function purchases(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserRidePurchase::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取座驾全屏特效 key。
|
||||||
|
*/
|
||||||
|
public function rideKey(): string
|
||||||
|
{
|
||||||
|
return $this->effect_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有上架座驾。
|
||||||
|
*
|
||||||
|
* @return Collection<int, self>
|
||||||
|
*/
|
||||||
|
public static function active(): Collection
|
||||||
|
{
|
||||||
|
return static::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
class ShopItem extends Model
|
class ShopItem extends Model
|
||||||
{
|
{
|
||||||
public const TYPE_SIGN_REPAIR = 'sign_repair';
|
public const TYPE_SIGN_REPAIR = 'sign_repair';
|
||||||
|
|
||||||
public const DECORATION_TYPES = ['msg_bubble', 'msg_name_color', 'msg_text_color', 'avatar_frame'];
|
public const DECORATION_TYPES = ['msg_bubble', 'msg_name_color', 'msg_text_color', 'avatar_frame'];
|
||||||
|
|
||||||
protected $table = 'shop_items';
|
protected $table = 'shop_items';
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class User extends Authenticatable
|
|||||||
*/
|
*/
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'username',
|
'username',
|
||||||
|
'previous_name',
|
||||||
'password',
|
'password',
|
||||||
'email',
|
'email',
|
||||||
'sex',
|
'sex',
|
||||||
@@ -261,6 +262,22 @@ class User extends Authenticatable
|
|||||||
return $this->hasMany(DailySignIn::class, 'user_id')->latest('sign_in_date');
|
return $this->hasMany(DailySignIn::class, 'user_id')->latest('sign_in_date');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联:用户已解锁和进行中的成就记录。
|
||||||
|
*/
|
||||||
|
public function achievements(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserAchievement::class, 'user_id')->latest('achieved_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联:用户各成就的最新进度快照。
|
||||||
|
*/
|
||||||
|
public function achievementProgress(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserAchievementProgress::class, 'user_id')->latest('last_scanned_at');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关联:用户全部身份徽章。
|
* 关联:用户全部身份徽章。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:用户成就解锁记录模型。
|
||||||
|
*
|
||||||
|
* 保存每个用户在固定成就目录中的进度快照、达成时间与通知状态。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:封装用户成就记录字段、类型转换与用户关联。
|
||||||
|
*/
|
||||||
|
class UserAchievement extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\UserAchievementFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 允许批量赋值的字段。
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'achievement_key',
|
||||||
|
'progress_value',
|
||||||
|
'achieved_at',
|
||||||
|
'notified_at',
|
||||||
|
'metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 属性类型转换。
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'progress_value' => 'integer',
|
||||||
|
'achieved_at' => 'datetime',
|
||||||
|
'notified_at' => 'datetime',
|
||||||
|
'metadata' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联:成就记录所属用户。
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:用户成就进度模型。
|
||||||
|
*
|
||||||
|
* 保存用户在每个固定成就上的最新进度快照,解锁状态由 user_achievements 单独记录。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:封装用户成就进度字段、类型转换与用户关联。
|
||||||
|
*/
|
||||||
|
class UserAchievementProgress extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\UserAchievementProgressFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对应的数据表名。
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $table = 'user_achievement_progress';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 允许批量赋值的字段。
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'achievement_key',
|
||||||
|
'progress_value',
|
||||||
|
'threshold_value',
|
||||||
|
'last_scanned_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 属性类型转换。
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'progress_value' => 'integer',
|
||||||
|
'threshold_value' => 'integer',
|
||||||
|
'last_scanned_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联:进度记录所属用户。
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:用户座驾购买记录模型。
|
||||||
|
*
|
||||||
|
* 对应 user_ride_purchases 表,追踪用户座驾购买、续期、替换和过期状态。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户座驾购买记录模型
|
||||||
|
* 负责连接用户与座驾,并判断当前记录是否仍有效。
|
||||||
|
*/
|
||||||
|
class UserRidePurchase extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id', 'ride_id', 'status', 'price_paid', 'expires_at', 'used_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'used_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取购买记录所属用户。
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取购买记录对应座驾。
|
||||||
|
*/
|
||||||
|
public function ride(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Ride::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断座驾购买记录是否仍然有效。
|
||||||
|
*/
|
||||||
|
public function isAlive(): bool
|
||||||
|
{
|
||||||
|
if ($this->status !== 'active') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->expires_at && $this->expires_at->isPast()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:用户成就扫描与授予服务。
|
||||||
|
*
|
||||||
|
* 基于聊天室已有日志表聚合用户进度,并写入固定成就目录的解锁状态。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\CurrencySource;
|
||||||
|
use App\Events\MessageSent;
|
||||||
|
use App\Jobs\SaveMessageJob;
|
||||||
|
use App\Models\BaccaratBet;
|
||||||
|
use App\Models\DailySignIn;
|
||||||
|
use App\Models\GomokuGame;
|
||||||
|
use App\Models\HorseBet;
|
||||||
|
use App\Models\LotteryTicket;
|
||||||
|
use App\Models\Marriage;
|
||||||
|
use App\Models\Message;
|
||||||
|
use App\Models\PositionAuthorityLog;
|
||||||
|
use App\Models\PositionDutyLog;
|
||||||
|
use App\Models\RedPacketClaim;
|
||||||
|
use App\Models\RedPacketEnvelope;
|
||||||
|
use App\Models\SlotMachineLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserAchievement;
|
||||||
|
use App\Models\UserAchievementProgress;
|
||||||
|
use App\Models\UserCurrencyLog;
|
||||||
|
use App\Models\UserPosition;
|
||||||
|
use App\Support\AchievementCatalog;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:计算成就进度、创建解锁记录并推送本人通知。
|
||||||
|
*/
|
||||||
|
class AchievementService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 创建成就服务依赖。
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly ChatStateService $chatState,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描单个用户的所有固定成就。
|
||||||
|
*
|
||||||
|
* @return array{checked: int, unlocked: int, updated: int, dry_run: bool}
|
||||||
|
*/
|
||||||
|
public function scanUser(User $user, bool $notify = false, bool $dryRun = false): array
|
||||||
|
{
|
||||||
|
$progress = $this->progressForUser($user);
|
||||||
|
$checked = 0;
|
||||||
|
$unlocked = 0;
|
||||||
|
$updated = 0;
|
||||||
|
|
||||||
|
foreach (AchievementCatalog::definitions() as $definition) {
|
||||||
|
$checked++;
|
||||||
|
$value = (int) ($progress[$definition['metric']] ?? 0);
|
||||||
|
$achievement = UserAchievement::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('achievement_key', $definition['key'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
if ($value >= $definition['threshold'] && ! $achievement?->achieved_at) {
|
||||||
|
$unlocked++;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->storeProgress($user, $definition, $value);
|
||||||
|
|
||||||
|
if (! $achievement) {
|
||||||
|
$achievement = UserAchievement::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'achievement_key' => $definition['key'],
|
||||||
|
'progress_value' => $value,
|
||||||
|
'metadata' => ['threshold' => $definition['threshold']],
|
||||||
|
]);
|
||||||
|
$updated++;
|
||||||
|
} elseif ($achievement->progress_value !== $value) {
|
||||||
|
$achievement->forceFill(['progress_value' => $value])->save();
|
||||||
|
$updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value < $definition['threshold'] || $achievement->achieved_at) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$achievement->forceFill([
|
||||||
|
'progress_value' => $value,
|
||||||
|
'achieved_at' => now(),
|
||||||
|
'metadata' => ['threshold' => $definition['threshold']],
|
||||||
|
])->save();
|
||||||
|
$unlocked++;
|
||||||
|
|
||||||
|
if ($notify) {
|
||||||
|
$this->notifyUnlocked($user, $achievement, $definition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'checked' => $checked,
|
||||||
|
'unlocked' => $unlocked,
|
||||||
|
'updated' => $updated,
|
||||||
|
'dry_run' => $dryRun,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量扫描用户成就。
|
||||||
|
*
|
||||||
|
* @return array{users: int, checked: int, unlocked: int, updated: int, dry_run: bool}
|
||||||
|
*/
|
||||||
|
public function scanUsers(iterable $users, bool $notify = false, bool $dryRun = false): array
|
||||||
|
{
|
||||||
|
$summary = ['users' => 0, 'checked' => 0, 'unlocked' => 0, 'updated' => 0, 'dry_run' => $dryRun];
|
||||||
|
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$result = $this->scanUser($user, $notify, $dryRun);
|
||||||
|
$summary['users']++;
|
||||||
|
$summary['checked'] += $result['checked'];
|
||||||
|
$summary['unlocked'] += $result['unlocked'];
|
||||||
|
$summary['updated'] += $result['updated'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装用户成就展示数据。
|
||||||
|
*
|
||||||
|
* @return array{categories: array<string, string>, achievements: Collection<int, array<string, mixed>>, unlocked_count: int, total_count: int}
|
||||||
|
*/
|
||||||
|
public function displayForUser(User $user): array
|
||||||
|
{
|
||||||
|
$progress = $this->progressForUser($user);
|
||||||
|
$records = UserAchievement::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->get()
|
||||||
|
->keyBy('achievement_key');
|
||||||
|
|
||||||
|
$achievements = collect(AchievementCatalog::definitions())
|
||||||
|
->sortBy('sort')
|
||||||
|
->map(function (array $definition) use ($progress, $records): array {
|
||||||
|
$record = $records->get($definition['key']);
|
||||||
|
$value = max((int) ($record?->progress_value ?? 0), (int) ($progress[$definition['metric']] ?? 0));
|
||||||
|
$threshold = (int) $definition['threshold'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
...$definition,
|
||||||
|
'progress_value' => $value,
|
||||||
|
'progress_percent' => $threshold > 0 ? min(100, (int) floor($value / $threshold * 100)) : 100,
|
||||||
|
'achieved_at' => $record?->achieved_at,
|
||||||
|
'unlocked' => (bool) $record?->achieved_at,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'categories' => AchievementCatalog::categories(),
|
||||||
|
'achievements' => $achievements,
|
||||||
|
'unlocked_count' => $achievements->where('unlocked', true)->count(),
|
||||||
|
'total_count' => $achievements->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取用户最近解锁成就。
|
||||||
|
*
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function recentUnlockedForUser(User $user, int $limit = 5): Collection
|
||||||
|
{
|
||||||
|
return UserAchievement::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->whereNotNull('achieved_at')
|
||||||
|
->latest('achieved_at')
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->map(function (UserAchievement $achievement): array {
|
||||||
|
$definition = AchievementCatalog::find($achievement->achievement_key);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'key' => $achievement->achievement_key,
|
||||||
|
'name' => $definition['name'] ?? $achievement->achievement_key,
|
||||||
|
'icon' => $definition['icon'] ?? '🏅',
|
||||||
|
'description' => $definition['description'] ?? '',
|
||||||
|
'achieved_at' => $achievement->achieved_at?->toDateTimeString(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取用户资料卡使用的成就摘要。
|
||||||
|
*
|
||||||
|
* @return array{unlocked_count: int, total_count: int, recent: array<int, array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
public function profileSummaryForUser(User $user): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'unlocked_count' => (int) UserAchievement::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->whereNotNull('achieved_at')
|
||||||
|
->count(),
|
||||||
|
'total_count' => count(AchievementCatalog::definitions()),
|
||||||
|
'recent' => $this->recentUnlockedForUser($user, 5)->values()->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聚合单个用户所有成就进度。
|
||||||
|
*
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
public function progressForUser(User $user): array
|
||||||
|
{
|
||||||
|
$username = (string) $user->username;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'chat_messages' => $this->chatMessageCount($username),
|
||||||
|
'welcome_messages' => $this->welcomeMessageCount($username),
|
||||||
|
'total_sign_ins' => (int) DailySignIn::query()->where('user_id', $user->id)->count(),
|
||||||
|
'sign_in_streak' => (int) DailySignIn::query()->where('user_id', $user->id)->max('streak_days'),
|
||||||
|
'makeup_sign_ins' => (int) DailySignIn::query()->where('user_id', $user->id)->where('is_makeup', true)->count(),
|
||||||
|
'exp_gain' => $this->currencyGain($user->id, 'exp'),
|
||||||
|
'gold_gain' => $this->currencyGain($user->id, 'gold'),
|
||||||
|
'charm_gain' => $this->currencyGain($user->id, 'charm'),
|
||||||
|
'gold_assets' => max(0, (int) $user->jjb + (int) $user->bank_jjb),
|
||||||
|
'bank_balance' => max(0, (int) $user->bank_jjb),
|
||||||
|
'game_gold_won' => $this->gameGoldWon($user->id),
|
||||||
|
'game_gold_lost' => $this->gameGoldLost($user->id),
|
||||||
|
'baccarat_bets' => (int) BaccaratBet::query()->where('user_id', $user->id)->count(),
|
||||||
|
'horse_bets' => (int) HorseBet::query()->where('user_id', $user->id)->count(),
|
||||||
|
'lottery_tickets' => (int) LotteryTicket::query()->where('user_id', $user->id)->count(),
|
||||||
|
'slot_spins' => (int) SlotMachineLog::query()->where('user_id', $user->id)->count(),
|
||||||
|
'gomoku_wins' => $this->gomokuWinCount($user->id),
|
||||||
|
'fishing_times' => $this->currencySourceCount($user->id, CurrencySource::FISHING_COST->value),
|
||||||
|
'riddle_wins' => $this->currencySourceCount($user->id, CurrencySource::GAME_REWARD->value),
|
||||||
|
'red_packets_sent' => (int) RedPacketEnvelope::query()->where('sender_id', $user->id)->count(),
|
||||||
|
'red_packets_claimed' => (int) RedPacketClaim::query()->where('user_id', $user->id)->count(),
|
||||||
|
'marriages' => (int) Marriage::query()->where('status', 'married')->where(fn ($query) => $query->where('user_id', $user->id)->orWhere('partner_id', $user->id))->count(),
|
||||||
|
'marriage_intimacy' => (int) Marriage::query()->where(fn ($query) => $query->where('user_id', $user->id)->orWhere('partner_id', $user->id))->max('intimacy'),
|
||||||
|
'gifts_sent' => $this->currencySourceCount($user->id, CurrencySource::SEND_GIFT->value),
|
||||||
|
'gifts_received' => $this->currencySourceCount($user->id, CurrencySource::RECV_GIFT->value),
|
||||||
|
'positions' => (int) UserPosition::query()->where('user_id', $user->id)->count(),
|
||||||
|
'duty_minutes' => (int) floor((int) PositionDutyLog::query()->where('user_id', $user->id)->sum('duration_seconds') / 60),
|
||||||
|
'authority_actions' => (int) PositionAuthorityLog::query()->where('user_id', $user->id)->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计普通用户聊天消息数量。
|
||||||
|
*/
|
||||||
|
private function chatMessageCount(string $username): int
|
||||||
|
{
|
||||||
|
return (int) Message::query()
|
||||||
|
->where('from_user', $username)
|
||||||
|
->whereIn('message_type', ['text', 'image', 'expired_image'])
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->where('retention_type', Message::RETENTION_USER_CHAT)
|
||||||
|
->orWhereNull('retention_type');
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计用户发出的欢迎动作次数。
|
||||||
|
*/
|
||||||
|
private function welcomeMessageCount(string $username): int
|
||||||
|
{
|
||||||
|
return (int) Message::query()
|
||||||
|
->where('from_user', $username)
|
||||||
|
->where('action', '欢迎')
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计指定货币的累计正向获得量。
|
||||||
|
*/
|
||||||
|
private function currencyGain(int $userId, string $currency): int
|
||||||
|
{
|
||||||
|
return (int) UserCurrencyLog::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('currency', $currency)
|
||||||
|
->where('amount', '>', 0)
|
||||||
|
->sum('amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计指定流水来源次数。
|
||||||
|
*/
|
||||||
|
private function currencySourceCount(int $userId, string $source): int
|
||||||
|
{
|
||||||
|
return (int) UserCurrencyLog::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('source', $source)
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计用户通过游戏相关流水累计赢取的金币。
|
||||||
|
*/
|
||||||
|
private function gameGoldWon(int $userId): int
|
||||||
|
{
|
||||||
|
return (int) UserCurrencyLog::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('currency', 'gold')
|
||||||
|
->where('amount', '>', 0)
|
||||||
|
->whereIn('source', $this->gameWinSources())
|
||||||
|
->sum('amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计用户在游戏相关流水中累计输掉或消耗的金币。
|
||||||
|
*/
|
||||||
|
private function gameGoldLost(int $userId): int
|
||||||
|
{
|
||||||
|
return abs((int) UserCurrencyLog::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('currency', 'gold')
|
||||||
|
->where('amount', '<', 0)
|
||||||
|
->whereIn('source', $this->gameLossSources())
|
||||||
|
->sum('amount'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回游戏赢钱来源,用于游戏赢取类成就聚合。
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function gameWinSources(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CurrencySource::BACCARAT_WIN->value,
|
||||||
|
CurrencySource::BACCARAT_LOSS_COVER_CLAIM->value,
|
||||||
|
CurrencySource::HORSE_WIN->value,
|
||||||
|
CurrencySource::LOTTERY_WIN->value,
|
||||||
|
CurrencySource::SLOT_WIN->value,
|
||||||
|
CurrencySource::FISHING_GAIN->value,
|
||||||
|
CurrencySource::MYSTERY_BOX->value,
|
||||||
|
CurrencySource::GOMOKU_WIN->value,
|
||||||
|
CurrencySource::GAME_REWARD->value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回游戏输钱来源,用于游戏输钱类成就聚合。
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function gameLossSources(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CurrencySource::BACCARAT_BET->value,
|
||||||
|
CurrencySource::HORSE_BET->value,
|
||||||
|
CurrencySource::LOTTERY_BUY->value,
|
||||||
|
CurrencySource::SLOT_SPIN->value,
|
||||||
|
CurrencySource::SLOT_CURSE->value,
|
||||||
|
CurrencySource::FISHING_COST->value,
|
||||||
|
CurrencySource::FORTUNE_COST->value,
|
||||||
|
CurrencySource::GOMOKU_ENTRY_FEE->value,
|
||||||
|
CurrencySource::MYSTERY_BOX_TRAP->value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计五子棋胜利次数。
|
||||||
|
*/
|
||||||
|
private function gomokuWinCount(int $userId): int
|
||||||
|
{
|
||||||
|
return (int) GomokuGame::query()
|
||||||
|
->where('status', 'finished')
|
||||||
|
->where(function ($query) use ($userId) {
|
||||||
|
$query->where(fn ($inner) => $inner->where('player_black_id', $userId)->where('winner', 1))
|
||||||
|
->orWhere(fn ($inner) => $inner->where('player_white_id', $userId)->where('winner', 2));
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入用户成就进度快照。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $definition 成就定义
|
||||||
|
*/
|
||||||
|
private function storeProgress(User $user, array $definition, int $value): void
|
||||||
|
{
|
||||||
|
UserAchievementProgress::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'achievement_key' => $definition['key'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'progress_value' => $value,
|
||||||
|
'threshold_value' => (int) $definition['threshold'],
|
||||||
|
'last_scanned_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 给用户推送成就解锁通知。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $definition 成就定义
|
||||||
|
*/
|
||||||
|
private function notifyUnlocked(User $user, UserAchievement $achievement, array $definition): void
|
||||||
|
{
|
||||||
|
if ($achievement->notified_at) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roomId = (int) ($user->room_id ?: 1);
|
||||||
|
$message = [
|
||||||
|
'id' => $this->chatState->nextMessageId($roomId),
|
||||||
|
'room_id' => $roomId,
|
||||||
|
'from_user' => '系统公告',
|
||||||
|
'to_user' => $user->username,
|
||||||
|
'content' => "🏅 恭喜解锁成就:{$definition['icon']} {$definition['name']} <span style=\"color:#64748b;\">{$definition['description']}</span>",
|
||||||
|
'is_secret' => true,
|
||||||
|
'font_color' => '#ca8a04',
|
||||||
|
'action' => 'achievement_unlocked',
|
||||||
|
'retention_type' => Message::RETENTION_SYSTEM_NOTICE,
|
||||||
|
'toast_notification' => [
|
||||||
|
'title' => '🏅 成就解锁',
|
||||||
|
'message' => "{$definition['icon']} {$definition['name']}",
|
||||||
|
'icon' => '🏅',
|
||||||
|
'color' => '#ca8a04',
|
||||||
|
'duration' => 3000,
|
||||||
|
],
|
||||||
|
'sent_at' => now()->toDateTimeString(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->chatState->pushMessage($roomId, $message);
|
||||||
|
broadcast(new MessageSent($roomId, $message));
|
||||||
|
SaveMessageJob::dispatch($message);
|
||||||
|
|
||||||
|
$achievement->forceFill(['notified_at' => now()])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,13 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\CurrencySource;
|
||||||
use App\Events\MessageSent;
|
use App\Events\MessageSent;
|
||||||
use App\Jobs\SaveMessageJob;
|
use App\Jobs\SaveMessageJob;
|
||||||
use App\Models\BankLog;
|
use App\Models\BankLog;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\UserCurrencyLog;
|
||||||
|
use App\Services\UserCurrencyService;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,6 +44,7 @@ class AiFinanceService
|
|||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ChatStateService $chatState,
|
private readonly ChatStateService $chatState,
|
||||||
|
private readonly UserCurrencyService $currencyService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,15 +111,15 @@ class AiFinanceService
|
|||||||
$depositAmount = $walletGold - self::AVAILABLE_GOLD_RESERVE;
|
$depositAmount = $walletGold - self::AVAILABLE_GOLD_RESERVE;
|
||||||
|
|
||||||
// 只把超过 100 万的部分转入银行,手上保留不高于 100 万的常规活动资金。
|
// 只把超过 100 万的部分转入银行,手上保留不高于 100 万的常规活动资金。
|
||||||
$lockedUser->jjb = self::AVAILABLE_GOLD_RESERVE;
|
$this->currencyService->change($lockedUser, 'gold', -$depositAmount, CurrencySource::BANK_DEPOSIT, "自动存入银行 {$depositAmount} 金币");
|
||||||
$lockedUser->bank_jjb = $bankBefore + $depositAmount;
|
|
||||||
$lockedUser->save();
|
$lockedUser->increment('bank_jjb', $depositAmount);
|
||||||
|
|
||||||
BankLog::create([
|
BankLog::create([
|
||||||
'user_id' => $lockedUser->id,
|
'user_id' => $lockedUser->id,
|
||||||
'type' => 'deposit',
|
'type' => 'deposit',
|
||||||
'amount' => $depositAmount,
|
'amount' => $depositAmount,
|
||||||
'balance_after' => (int) $lockedUser->bank_jjb,
|
'balance_after' => (int) $lockedUser->fresh()->bank_jjb,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return array_values(array_filter(
|
return array_values(array_filter(
|
||||||
@@ -162,15 +166,15 @@ class AiFinanceService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 优先把银行金币提回手上,保证 AI 的即时可用余额尽量回到目标线。
|
// 优先把银行金币提回手上,保证 AI 的即时可用余额尽量回到目标线。
|
||||||
$lockedUser->jjb = $walletGold + $withdrawAmount;
|
$this->currencyService->change($lockedUser, 'gold', $withdrawAmount, CurrencySource::BANK_WITHDRAW, "从银行自动取款 {$withdrawAmount} 金币");
|
||||||
$lockedUser->bank_jjb = $bankGold - $withdrawAmount;
|
|
||||||
$lockedUser->save();
|
$lockedUser->decrement('bank_jjb', $withdrawAmount);
|
||||||
|
|
||||||
BankLog::create([
|
BankLog::create([
|
||||||
'user_id' => $lockedUser->id,
|
'user_id' => $lockedUser->id,
|
||||||
'type' => 'withdraw',
|
'type' => 'withdraw',
|
||||||
'amount' => $withdrawAmount,
|
'amount' => $withdrawAmount,
|
||||||
'balance_after' => (int) $lockedUser->bank_jjb,
|
'balance_after' => (int) $lockedUser->fresh()->bank_jjb,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (int) $lockedUser->jjb >= $targetWalletGold;
|
return (int) $lockedUser->jjb >= $targetWalletGold;
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:每日游戏净盈利前三榜读服务
|
||||||
|
*
|
||||||
|
* 聚合百家乐与赛马当天金币流水,给聊天室顶部悬浮榜提供轻量数据。
|
||||||
|
*
|
||||||
|
* @author ChatRoom Laravel
|
||||||
|
*
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\CurrencySource;
|
||||||
|
use App\Models\UserCurrencyLog;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:查询百家乐与赛马每日净盈利前三用户。
|
||||||
|
*/
|
||||||
|
class DailyGameProfitLeaderboardService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 每日榜单固定称号。
|
||||||
|
*/
|
||||||
|
private const TITLES = [
|
||||||
|
1 => '金库爆破王',
|
||||||
|
2 => '马桌双修财神',
|
||||||
|
3 => '金币收割机',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 参与净盈利统计的游戏流水来源。
|
||||||
|
*/
|
||||||
|
private const GAME_PROFIT_SOURCES = [
|
||||||
|
CurrencySource::BACCARAT_BET,
|
||||||
|
CurrencySource::BACCARAT_WIN,
|
||||||
|
CurrencySource::HORSE_BET,
|
||||||
|
CurrencySource::HORSE_WIN,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定日期的游戏净盈利前三榜。
|
||||||
|
*
|
||||||
|
* @return Collection<int, object{rank:int,title:string,user_id:int,username:string,headface_url:string,net_profit:int}>
|
||||||
|
*/
|
||||||
|
public function topThree(?string $date = null): Collection
|
||||||
|
{
|
||||||
|
$statsDate = CarbonImmutable::parse($date ?? today()->toDateString())->startOfDay();
|
||||||
|
$cacheKey = 'daily_game_profit_leaderboard:v2:'.$statsDate->toDateString();
|
||||||
|
|
||||||
|
return Cache::remember($cacheKey, 300, function () use ($statsDate) {
|
||||||
|
$rangeStart = $statsDate;
|
||||||
|
$rangeEnd = $statsDate->addDay();
|
||||||
|
|
||||||
|
return UserCurrencyLog::query()
|
||||||
|
->join('users', 'users.id', '=', 'user_currency_logs.user_id')
|
||||||
|
->where('user_currency_logs.currency', 'gold')
|
||||||
|
->whereIn('user_currency_logs.source', array_map(
|
||||||
|
fn (CurrencySource $source): string => $source->value,
|
||||||
|
self::GAME_PROFIT_SOURCES
|
||||||
|
))
|
||||||
|
->where('user_currency_logs.created_at', '>=', $rangeStart)
|
||||||
|
->where('user_currency_logs.created_at', '<', $rangeEnd)
|
||||||
|
->where('users.username', '!=', 'AI小班长')
|
||||||
|
->groupBy('user_currency_logs.user_id', 'users.username', 'users.usersf')
|
||||||
|
->havingRaw('SUM(user_currency_logs.amount) > 0')
|
||||||
|
->orderByRaw('SUM(user_currency_logs.amount) DESC')
|
||||||
|
->orderBy('user_currency_logs.user_id')
|
||||||
|
->limit(3)
|
||||||
|
->selectRaw('user_currency_logs.user_id, users.username, users.usersf, SUM(user_currency_logs.amount) as net_profit')
|
||||||
|
->get()
|
||||||
|
->values()
|
||||||
|
->map(function (object $row, int $index): object {
|
||||||
|
$rank = $index + 1;
|
||||||
|
|
||||||
|
return (object) [
|
||||||
|
'rank' => $rank,
|
||||||
|
'title' => self::TITLES[$rank],
|
||||||
|
'user_id' => (int) $row->user_id,
|
||||||
|
'username' => (string) $row->username,
|
||||||
|
'headface_url' => $this->resolveHeadfaceUrl((string) ($row->usersf ?: '1.gif')),
|
||||||
|
'net_profit' => (int) $row->net_profit,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析榜单头像地址。
|
||||||
|
*/
|
||||||
|
private function resolveHeadfaceUrl(string $headface): string
|
||||||
|
{
|
||||||
|
if (str_starts_with($headface, 'storage/')) {
|
||||||
|
return '/'.$headface;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/images/headface/'.strtolower($headface);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:游戏下注与奖励公屏右下角通知广播服务
|
||||||
|
*
|
||||||
|
* 统一处理百家乐、赛马、双色球等游戏下注或奖励领取成功后的公屏消息、
|
||||||
|
* 右下角 Toast 通知载荷和异步落库,避免各玩法重复拼装广播结构。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Events\MessageSent;
|
||||||
|
use App\Jobs\SaveMessageJob;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:为游戏下注和奖励领取成功事件生成并广播全员可见通知。
|
||||||
|
*/
|
||||||
|
class GameBetBroadcastService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ChatStateService $chatState,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广播百家乐下注成功通知。
|
||||||
|
*/
|
||||||
|
public function baccarat(int $roomId, string $username, int $amount, string $betLabel): void
|
||||||
|
{
|
||||||
|
$formattedAmount = number_format($amount);
|
||||||
|
|
||||||
|
$this->pushBetMessage(
|
||||||
|
roomId: $roomId,
|
||||||
|
content: "🎲 <b>【百家乐】【{$username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$betLabel})!✨",
|
||||||
|
fontColor: '#d97706',
|
||||||
|
toastTitle: '🎲 有人下注百家乐',
|
||||||
|
toastMessage: "<b>{$username}</b> 押注 <b>{$formattedAmount}</b> 金币({$betLabel})",
|
||||||
|
toastIcon: '🎲',
|
||||||
|
toastColor: '#d97706',
|
||||||
|
toastActorUsername: $username,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广播赛马下注成功通知。
|
||||||
|
*/
|
||||||
|
public function horseRace(int $roomId, string $username, int $amount, string $horseName): void
|
||||||
|
{
|
||||||
|
$formattedAmount = number_format($amount);
|
||||||
|
|
||||||
|
$this->pushBetMessage(
|
||||||
|
roomId: $roomId,
|
||||||
|
content: "🐎 <b>【赛马】【{$username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$horseName})!✨",
|
||||||
|
fontColor: '#d97706',
|
||||||
|
toastTitle: '🐎 有人下注赛马',
|
||||||
|
toastMessage: "<b>{$username}</b> 押注 <b>{$formattedAmount}</b> 金币({$horseName})",
|
||||||
|
toastIcon: '🐎',
|
||||||
|
toastColor: '#d97706',
|
||||||
|
toastActorUsername: $username,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广播双色球购票成功通知。
|
||||||
|
*/
|
||||||
|
public function lottery(int $roomId, string $username, string $issueNo, string $numbersLabel, int $ticketCount): void
|
||||||
|
{
|
||||||
|
$moreText = $ticketCount > 1 ? "等 {$ticketCount} 注号码" : '';
|
||||||
|
|
||||||
|
$this->pushBetMessage(
|
||||||
|
roomId: $roomId,
|
||||||
|
content: "🎟️ 【{$username}】购买 {$issueNo} 期 {$numbersLabel} {$moreText}",
|
||||||
|
fontColor: '#dc2626',
|
||||||
|
toastTitle: '🎟️ 有人购买双色球',
|
||||||
|
toastMessage: "<b>{$username}</b> 购买 {$issueNo} 期 {$numbersLabel} {$moreText}",
|
||||||
|
toastIcon: '🎟️',
|
||||||
|
toastColor: '#dc2626',
|
||||||
|
action: '大声宣告',
|
||||||
|
toastActorUsername: $username,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广播红包领取成功通知。
|
||||||
|
*/
|
||||||
|
public function redPacketClaimed(int $roomId, string $username, int $amount, string $type): void
|
||||||
|
{
|
||||||
|
$typeLabel = $type === 'exp' ? '经验' : '金币';
|
||||||
|
$typeIcon = $type === 'exp' ? '✨' : '💰';
|
||||||
|
$toastColor = $type === 'exp' ? '#6d28d9' : '#d97706';
|
||||||
|
$formattedAmount = number_format($amount);
|
||||||
|
|
||||||
|
$this->pushBetMessage(
|
||||||
|
roomId: $roomId,
|
||||||
|
content: "🧧 <b>{$username}</b> 抢到了 <b>{$formattedAmount}</b> {$typeLabel}礼包!{$typeIcon}",
|
||||||
|
fontColor: $toastColor,
|
||||||
|
toastTitle: '🧧 有人领取红包',
|
||||||
|
toastMessage: "<b>{$username}</b> 抢到 <b>{$formattedAmount}</b> {$typeLabel}礼包",
|
||||||
|
toastIcon: '🧧',
|
||||||
|
toastColor: $toastColor,
|
||||||
|
toastActorUsername: $username,
|
||||||
|
skipToastForActor: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送带右下角通知载荷的公屏游戏消息。
|
||||||
|
*/
|
||||||
|
private function pushBetMessage(
|
||||||
|
int $roomId,
|
||||||
|
string $content,
|
||||||
|
string $fontColor,
|
||||||
|
string $toastTitle,
|
||||||
|
string $toastMessage,
|
||||||
|
string $toastIcon,
|
||||||
|
string $toastColor,
|
||||||
|
string $action = '',
|
||||||
|
?string $toastActorUsername = null,
|
||||||
|
bool $skipToastForActor = false,
|
||||||
|
): void {
|
||||||
|
$toastNotification = [
|
||||||
|
'title' => $toastTitle,
|
||||||
|
'message' => $toastMessage,
|
||||||
|
'icon' => $toastIcon,
|
||||||
|
'color' => $toastColor,
|
||||||
|
'duration' => 3000,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($toastActorUsername !== null) {
|
||||||
|
// 记录触发人用于前端去重,避免本人同时看到本地到账提示和公屏领取提示。
|
||||||
|
$toastNotification['actor_username'] = $toastActorUsername;
|
||||||
|
$toastNotification['skip_for_actor'] = $skipToastForActor;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = [
|
||||||
|
'id' => $this->chatState->nextMessageId($roomId),
|
||||||
|
'room_id' => $roomId,
|
||||||
|
'from_user' => '系统传音',
|
||||||
|
'to_user' => '大家',
|
||||||
|
'content' => $content,
|
||||||
|
'is_secret' => false,
|
||||||
|
'font_color' => $fontColor,
|
||||||
|
'action' => $action,
|
||||||
|
'sent_at' => now()->toDateTimeString(),
|
||||||
|
'toast_notification' => $toastNotification,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 下注通知必须进房间 Presence 频道,确保当前房间所有在线人员都能看到右下角提示。
|
||||||
|
$this->chatState->pushMessage($roomId, $message);
|
||||||
|
event(new MessageSent($roomId, $message));
|
||||||
|
SaveMessageJob::dispatch($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,39 @@ class HolidayEventScheduleService
|
|||||||
|
|
||||||
$currentSendAt = CarbonImmutable::instance($event->send_at);
|
$currentSendAt = CarbonImmutable::instance($event->send_at);
|
||||||
|
|
||||||
|
return $this->nextOccurrenceAfter($event, $currentSendAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跳过已经超过领取窗口的历史计划点。
|
||||||
|
*/
|
||||||
|
public function skipExpiredOccurrences(HolidayEvent $event, CarbonInterface $reference): ?CarbonImmutable
|
||||||
|
{
|
||||||
|
if ($event->send_at === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = CarbonImmutable::instance($event->send_at);
|
||||||
|
$referenceTime = CarbonImmutable::instance($reference);
|
||||||
|
$expireMinutes = max(0, (int) $event->expire_minutes);
|
||||||
|
|
||||||
|
while ($candidate->addMinutes($expireMinutes)->lessThanOrEqualTo($referenceTime)) {
|
||||||
|
// 历史批次的领取窗口已经结束,只推进调度指针,不能补发金币。
|
||||||
|
$candidate = $this->nextOccurrenceAfter($event, $candidate);
|
||||||
|
|
||||||
|
if ($candidate === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算指定计划点之后的下一次触发时间。
|
||||||
|
*/
|
||||||
|
private function nextOccurrenceAfter(HolidayEvent $event, CarbonImmutable $currentSendAt): ?CarbonImmutable
|
||||||
|
{
|
||||||
return match ($event->repeat_type) {
|
return match ($event->repeat_type) {
|
||||||
'daily' => $currentSendAt->addDay(),
|
'daily' => $currentSendAt->addDay(),
|
||||||
'weekly' => $currentSendAt->addWeek(),
|
'weekly' => $currentSendAt->addWeek(),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class LotteryService
|
|||||||
private readonly UserCurrencyService $currency,
|
private readonly UserCurrencyService $currency,
|
||||||
private readonly ChatStateService $chatState,
|
private readonly ChatStateService $chatState,
|
||||||
private readonly GameRoomScopeService $roomScopeService,
|
private readonly GameRoomScopeService $roomScopeService,
|
||||||
|
private readonly GameBetBroadcastService $betBroadcastService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─── 购票 ─────────────────────────────────────────────────────────
|
// ─── 购票 ─────────────────────────────────────────────────────────
|
||||||
@@ -139,8 +140,7 @@ class LotteryService
|
|||||||
// 用户成功购买后,发送系统传音广播(大家都能看到他买了彩票)
|
// 用户成功购买后,发送系统传音广播(大家都能看到他买了彩票)
|
||||||
$firstTicket = $tickets[0];
|
$firstTicket = $tickets[0];
|
||||||
$numsStr = $firstTicket->numbersLabel();
|
$numsStr = $firstTicket->numbersLabel();
|
||||||
$moreStr = $buyCount > 1 ? "等 {$buyCount} 注号码" : '';
|
$this->betBroadcastService->lottery((int) $issue->room_id, $user->username, $issue->issue_no, $numsStr, $buyCount);
|
||||||
$this->pushSystemMessage("🎟️ 【{$user->username}】购买 {$issue->issue_no} 期 {$numsStr} {$moreStr}", (int) $issue->room_id);
|
|
||||||
|
|
||||||
return $tickets;
|
return $tickets;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:聊天室座驾业务服务。
|
||||||
|
*
|
||||||
|
* 统一管理座驾商品列表、购买续期、当前激活座驾、购买记录和入场欢迎语载荷。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\CurrencySource;
|
||||||
|
use App\Models\Ride;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserRidePurchase;
|
||||||
|
use App\Support\ChatContentSanitizer;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天室座驾服务
|
||||||
|
* 负责通过 rides 与 user_ride_purchases 完成座驾购买、续期、替换与进房展示。
|
||||||
|
*/
|
||||||
|
class RideService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 构造座驾服务依赖。
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserCurrencyService $currencyService,
|
||||||
|
private readonly ChatUserPresenceService $chatUserPresenceService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全部上架座驾商品。
|
||||||
|
*
|
||||||
|
* @return Collection<int, Ride>
|
||||||
|
*/
|
||||||
|
public function activeItems(): Collection
|
||||||
|
{
|
||||||
|
return Ride::active();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化座驾商品,供前端页面直接渲染。
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function formatItem(Ride $item): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $item->id,
|
||||||
|
'name' => $item->name,
|
||||||
|
'slug' => $item->slug,
|
||||||
|
'ride_key' => $item->rideKey(),
|
||||||
|
'description' => $item->description,
|
||||||
|
'icon' => $item->icon,
|
||||||
|
'price' => $item->price,
|
||||||
|
'duration_days' => (int) ($item->duration_days ?? 0),
|
||||||
|
'welcome_message' => $item->welcome_message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户当前有效座驾,若已过期则自动标记为 expired。
|
||||||
|
*/
|
||||||
|
public function currentRide(User $user): ?UserRidePurchase
|
||||||
|
{
|
||||||
|
$purchase = UserRidePurchase::query()
|
||||||
|
->with('ride')
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('status', 'active')
|
||||||
|
->orderByDesc('expires_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $purchase) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($purchase->expires_at && $purchase->expires_at->isPast()) {
|
||||||
|
// 过期座驾必须及时落库,避免后续进房继续播放旧特效。
|
||||||
|
$purchase->update(['status' => 'expired']);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $purchase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化用户当前座驾。
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function formatCurrentRide(User $user): ?array
|
||||||
|
{
|
||||||
|
$purchase = $this->currentRide($user);
|
||||||
|
if (! $purchase || ! $purchase->ride) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->formatPurchase($purchase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户最近座驾购买记录。
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function purchaseRecords(User $user, int $limit = 20): array
|
||||||
|
{
|
||||||
|
return UserRidePurchase::query()
|
||||||
|
->with('ride')
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->latest()
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->map(fn (UserRidePurchase $purchase) => $this->formatPurchase($purchase))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 购买座驾:同款续期,不同款替换旧座驾且不退款。
|
||||||
|
*
|
||||||
|
* @return array{ok:bool, message:string, current_ride?:array<string, mixed>}
|
||||||
|
*/
|
||||||
|
public function buy(User $user, Ride $item, ?int $roomId = null): array
|
||||||
|
{
|
||||||
|
if (! $item->is_active) {
|
||||||
|
return ['ok' => false, 'message' => '该座驾暂未上架。'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$days = (int) ($item->duration_days ?? 0);
|
||||||
|
if ($days <= 0) {
|
||||||
|
return ['ok' => false, 'message' => '该座驾使用天数配置异常,请联系管理员。'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->jjb < $item->price) {
|
||||||
|
return ['ok' => false, 'message' => "金币不足,购买【{$item->name}】需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchased = DB::transaction(function () use ($user, $item, $days, $roomId): bool {
|
||||||
|
$now = Carbon::now();
|
||||||
|
|
||||||
|
// 先清理已过期的 active 座驾,避免旧状态影响替换判断。
|
||||||
|
UserRidePurchase::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('status', 'active')
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '<=', $now)
|
||||||
|
->update(['status' => 'expired']);
|
||||||
|
|
||||||
|
$activeRide = UserRidePurchase::query()
|
||||||
|
->with('ride')
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('status', 'active')
|
||||||
|
->orderByDesc('expires_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$balanceAfter = $this->currencyService->deductGoldIfEnough(
|
||||||
|
$user,
|
||||||
|
(int) $item->price,
|
||||||
|
CurrencySource::RIDE_BUY,
|
||||||
|
"购买聊天室座驾:{$item->name}",
|
||||||
|
$roomId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($balanceAfter === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($activeRide && (int) $activeRide->ride_id === (int) $item->id) {
|
||||||
|
$baseTime = $activeRide->expires_at && $activeRide->expires_at->greaterThan($now)
|
||||||
|
? $activeRide->expires_at
|
||||||
|
: $now;
|
||||||
|
|
||||||
|
// 同款续购先取消旧 active,再创建新 active,既保留购买记录,又保持当前座驾唯一。
|
||||||
|
$activeRide->update(['status' => 'cancelled']);
|
||||||
|
UserRidePurchase::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'ride_id' => $item->id,
|
||||||
|
'status' => 'active',
|
||||||
|
'price_paid' => $item->price,
|
||||||
|
'expires_at' => $baseTime->copy()->addDays($days),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($activeRide) {
|
||||||
|
// 不同座驾替换旧座驾,旧记录保留为 cancelled 供后台追溯。
|
||||||
|
$activeRide->update(['status' => 'cancelled']);
|
||||||
|
}
|
||||||
|
|
||||||
|
UserRidePurchase::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'ride_id' => $item->id,
|
||||||
|
'status' => 'active',
|
||||||
|
'price_paid' => $item->price,
|
||||||
|
'expires_at' => $now->copy()->addDays($days),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (! $purchased) {
|
||||||
|
return ['ok' => false, 'message' => "金币不足,购买【{$item->name}】需要 {$item->price} 金币,当前仅有 {$user->fresh()->jjb} 金币。"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$days}天有效)。",
|
||||||
|
'current_ride' => $this->formatCurrentRide($user->fresh()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建进房座驾欢迎语与特效载荷。
|
||||||
|
*
|
||||||
|
* @return array<string, string>|null
|
||||||
|
*/
|
||||||
|
public function buildPresencePayload(User $user): ?array
|
||||||
|
{
|
||||||
|
$purchase = $this->currentRide($user);
|
||||||
|
$item = $purchase?->ride;
|
||||||
|
$rideKey = $item?->rideKey();
|
||||||
|
|
||||||
|
if (! $purchase || ! $item || ! $rideKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$template = trim((string) ($item->welcome_message ?: '【{name}】驾驶【{ride}】震撼入场,全场请注意!'));
|
||||||
|
$rendered = strtr($template, [
|
||||||
|
'{name}' => $user->username,
|
||||||
|
'{ride}' => $item->name,
|
||||||
|
]);
|
||||||
|
$identitySummary = $this->chatUserPresenceService->buildIdentitySummary($user);
|
||||||
|
$effectUserInfo = "用户 {$user->username} · {$identitySummary['inline']}";
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ride_key' => $rideKey,
|
||||||
|
'ride_name' => $item->name,
|
||||||
|
'ride_icon' => (string) ($item->icon ?? '🚘'),
|
||||||
|
'effect_title' => "乘坐【{$item->name}】闪亮登场",
|
||||||
|
'effect_user_info' => $effectUserInfo,
|
||||||
|
'identity_text' => ChatContentSanitizer::htmlText($identitySummary['inline']),
|
||||||
|
'welcome_text' => ChatContentSanitizer::htmlText($rendered),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化单条座驾购买记录。
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function formatPurchase(UserRidePurchase $purchase): array
|
||||||
|
{
|
||||||
|
$item = $purchase->ride;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $purchase->id,
|
||||||
|
'status' => $purchase->status,
|
||||||
|
'price_paid' => (int) $purchase->price_paid,
|
||||||
|
'expires_at' => $purchase->expires_at?->toDateTimeString(),
|
||||||
|
'used_at' => $purchase->used_at?->toDateTimeString(),
|
||||||
|
'created_at' => $purchase->created_at?->toDateTimeString(),
|
||||||
|
'item' => $item ? $this->formatItem($item) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:聊天室固定成就目录。
|
||||||
|
*
|
||||||
|
* 第一版成就规则全部写在代码里,避免过早引入后台规则引擎。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:集中提供成就定义、分类与展示文案。
|
||||||
|
*/
|
||||||
|
class AchievementCatalog
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 返回全部成就定义。
|
||||||
|
*
|
||||||
|
* @return array<string, array{key: string, category: string, name: string, icon: string, description: string, metric: string, threshold: int, sort: int, hidden?: bool}>
|
||||||
|
*/
|
||||||
|
public static function definitions(): array
|
||||||
|
{
|
||||||
|
$definitions = [
|
||||||
|
['key' => 'chat_first_message', 'category' => 'chat', 'name' => '初来乍到', 'icon' => '💬', 'description' => '发送第一条聊天消息', 'metric' => 'chat_messages', 'threshold' => 1, 'sort' => 10],
|
||||||
|
['key' => 'chat_100_messages', 'category' => 'chat', 'name' => '百句达人', 'icon' => '🗣️', 'description' => '累计发送 100 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 100, 'sort' => 20],
|
||||||
|
['key' => 'chat_500_messages', 'category' => 'chat', 'name' => '话题熟客', 'icon' => '📢', 'description' => '累计发送 500 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 500, 'sort' => 30],
|
||||||
|
['key' => 'chat_1000_messages', 'category' => 'chat', 'name' => '千句常驻', 'icon' => '📣', 'description' => '累计发送 1000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 1000, 'sort' => 40],
|
||||||
|
['key' => 'chat_5000_messages', 'category' => 'chat', 'name' => '五千热聊', 'icon' => '🔥', 'description' => '累计发送 5000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 5000, 'sort' => 50],
|
||||||
|
['key' => 'chat_10000_messages', 'category' => 'chat', 'name' => '万句元老', 'icon' => '🏛️', 'description' => '累计发送 10000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 10000, 'sort' => 60],
|
||||||
|
['key' => 'chat_50000_messages', 'category' => 'chat', 'name' => '五万传声', 'icon' => '📡', 'description' => '累计发送 50000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 50000, 'sort' => 70],
|
||||||
|
['key' => 'chat_100000_messages', 'category' => 'chat', 'name' => '十万回响', 'icon' => '🌌', 'description' => '累计发送 100000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 100000, 'sort' => 80],
|
||||||
|
['key' => 'chat_welcome_10', 'category' => 'chat', 'name' => '迎新助手', 'icon' => '🙋', 'description' => '累计欢迎他人 10 次', 'metric' => 'welcome_messages', 'threshold' => 10, 'sort' => 90],
|
||||||
|
['key' => 'chat_welcome_50', 'category' => 'chat', 'name' => '欢迎达人', 'icon' => '👋', 'description' => '累计欢迎他人 50 次', 'metric' => 'welcome_messages', 'threshold' => 50, 'sort' => 100],
|
||||||
|
['key' => 'chat_welcome_100', 'category' => 'chat', 'name' => '迎宾队长', 'icon' => '🎉', 'description' => '累计欢迎他人 100 次', 'metric' => 'welcome_messages', 'threshold' => 100, 'sort' => 110],
|
||||||
|
['key' => 'chat_welcome_500', 'category' => 'chat', 'name' => '满堂迎客', 'icon' => '🏮', 'description' => '累计欢迎他人 500 次', 'metric' => 'welcome_messages', 'threshold' => 500, 'sort' => 120],
|
||||||
|
|
||||||
|
['key' => 'signin_total_1', 'category' => 'sign_in', 'name' => '首次打卡', 'icon' => '☀️', 'description' => '累计签到 1 天', 'metric' => 'total_sign_ins', 'threshold' => 1, 'sort' => 130],
|
||||||
|
['key' => 'signin_total_7', 'category' => 'sign_in', 'name' => '一周到场', 'icon' => '🗓️', 'description' => '累计签到 7 天', 'metric' => 'total_sign_ins', 'threshold' => 7, 'sort' => 140],
|
||||||
|
['key' => 'signin_total_30', 'category' => 'sign_in', 'name' => '月度出勤', 'icon' => '📆', 'description' => '累计签到 30 天', 'metric' => 'total_sign_ins', 'threshold' => 30, 'sort' => 150],
|
||||||
|
['key' => 'signin_total_100', 'category' => 'sign_in', 'name' => '百日足迹', 'icon' => '👣', 'description' => '累计签到 100 天', 'metric' => 'total_sign_ins', 'threshold' => 100, 'sort' => 160],
|
||||||
|
['key' => 'signin_total_365', 'category' => 'sign_in', 'name' => '年度常客', 'icon' => '🏅', 'description' => '累计签到 365 天', 'metric' => 'total_sign_ins', 'threshold' => 365, 'sort' => 170],
|
||||||
|
['key' => 'signin_3_streak', 'category' => 'sign_in', 'name' => '三日连到', 'icon' => '✅', 'description' => '连续签到 3 天', 'metric' => 'sign_in_streak', 'threshold' => 3, 'sort' => 180],
|
||||||
|
['key' => 'signin_7_streak', 'category' => 'sign_in', 'name' => '七日不断', 'icon' => '☑️', 'description' => '连续签到 7 天', 'metric' => 'sign_in_streak', 'threshold' => 7, 'sort' => 190],
|
||||||
|
['key' => 'signin_15_streak', 'category' => 'sign_in', 'name' => '半月不断', 'icon' => '🌙', 'description' => '连续签到 15 天', 'metric' => 'sign_in_streak', 'threshold' => 15, 'sort' => 200],
|
||||||
|
['key' => 'signin_30_streak', 'category' => 'sign_in', 'name' => '月度全勤', 'icon' => '📅', 'description' => '连续签到 30 天', 'metric' => 'sign_in_streak', 'threshold' => 30, 'sort' => 210],
|
||||||
|
['key' => 'signin_60_streak', 'category' => 'sign_in', 'name' => '双月坚守', 'icon' => '🔥', 'description' => '连续签到 60 天', 'metric' => 'sign_in_streak', 'threshold' => 60, 'sort' => 220],
|
||||||
|
['key' => 'signin_100_streak', 'category' => 'sign_in', 'name' => '百日坚持', 'icon' => '💯', 'description' => '连续签到 100 天', 'metric' => 'sign_in_streak', 'threshold' => 100, 'sort' => 230],
|
||||||
|
['key' => 'signin_365_streak', 'category' => 'sign_in', 'name' => '全年不断', 'icon' => '🏆', 'description' => '连续签到 365 天', 'metric' => 'sign_in_streak', 'threshold' => 365, 'sort' => 240],
|
||||||
|
['key' => 'signin_makeup_used', 'category' => 'sign_in', 'name' => '补签救场', 'icon' => '🧩', 'description' => '使用过 1 次补签', 'metric' => 'makeup_sign_ins', 'threshold' => 1, 'sort' => 250],
|
||||||
|
['key' => 'signin_makeup_5', 'category' => 'sign_in', 'name' => '补签老手', 'icon' => '🪄', 'description' => '累计使用 5 次补签', 'metric' => 'makeup_sign_ins', 'threshold' => 5, 'sort' => 260],
|
||||||
|
['key' => 'signin_makeup_20', 'category' => 'sign_in', 'name' => '断线重连', 'icon' => '🔁', 'description' => '累计使用 20 次补签', 'metric' => 'makeup_sign_ins', 'threshold' => 20, 'sort' => 270],
|
||||||
|
|
||||||
|
['key' => 'growth_exp_10000', 'category' => 'growth', 'name' => '小有所成', 'icon' => '✨', 'description' => '累计获得 10000 经验', 'metric' => 'exp_gain', 'threshold' => 10000, 'sort' => 210],
|
||||||
|
['key' => 'growth_gold_100000', 'category' => 'growth', 'name' => '金币新贵', 'icon' => '💰', 'description' => '累计获得 100000 金币', 'metric' => 'gold_gain', 'threshold' => 100000, 'sort' => 220],
|
||||||
|
['key' => 'growth_charm_1000', 'category' => 'growth', 'name' => '魅力初显', 'icon' => '🌸', 'description' => '累计获得 1000 魅力', 'metric' => 'charm_gain', 'threshold' => 1000, 'sort' => 230],
|
||||||
|
['key' => 'growth_assets_1000000', 'category' => 'growth', 'name' => '百万身家', 'icon' => '💎', 'description' => '金币资产达到 1000000', 'metric' => 'gold_assets', 'threshold' => 1000000, 'sort' => 240],
|
||||||
|
['key' => 'growth_assets_10000000', 'category' => 'growth', 'name' => '千万富豪', 'icon' => '👑', 'description' => '金币资产达到 10000000', 'metric' => 'gold_assets', 'threshold' => 10000000, 'sort' => 250],
|
||||||
|
['key' => 'growth_assets_100000000', 'category' => 'growth', 'name' => '亿级资产', 'icon' => '🏆', 'description' => '金币资产达到 100000000', 'metric' => 'gold_assets', 'threshold' => 100000000, 'sort' => 260],
|
||||||
|
['key' => 'growth_bank_500000', 'category' => 'growth', 'name' => '存款达人', 'icon' => '🏦', 'description' => '银行存款达到 500000 金币', 'metric' => 'bank_balance', 'threshold' => 500000, 'sort' => 270],
|
||||||
|
['key' => 'growth_bank_1000000', 'category' => 'growth', 'name' => '百万存款', 'icon' => '🏧', 'description' => '银行存款达到 1000000 金币', 'metric' => 'bank_balance', 'threshold' => 1000000, 'sort' => 280],
|
||||||
|
['key' => 'growth_bank_10000000', 'category' => 'growth', 'name' => '金库存户', 'icon' => '🔐', 'description' => '银行存款达到 10000000 金币', 'metric' => 'bank_balance', 'threshold' => 10000000, 'sort' => 290],
|
||||||
|
|
||||||
|
['key' => 'game_baccarat_20', 'category' => 'game', 'name' => '百家乐入门', 'icon' => '🎲', 'description' => '累计参与百家乐下注 20 次', 'metric' => 'baccarat_bets', 'threshold' => 20, 'sort' => 310],
|
||||||
|
['key' => 'game_horse_20', 'category' => 'game', 'name' => '赛马看客', 'icon' => '🐎', 'description' => '累计参与赛马下注 20 次', 'metric' => 'horse_bets', 'threshold' => 20, 'sort' => 320],
|
||||||
|
['key' => 'game_lottery_20', 'category' => 'game', 'name' => '双色球常客', 'icon' => '🎟️', 'description' => '累计购买双色球 20 注', 'metric' => 'lottery_tickets', 'threshold' => 20, 'sort' => 330],
|
||||||
|
['key' => 'game_slot_20', 'category' => 'game', 'name' => '老虎机试手', 'icon' => '🎰', 'description' => '累计转动老虎机 20 次', 'metric' => 'slot_spins', 'threshold' => 20, 'sort' => 340],
|
||||||
|
['key' => 'game_gomoku_win', 'category' => 'game', 'name' => '五子棋首胜', 'icon' => '♟️', 'description' => '获得 1 次五子棋胜利', 'metric' => 'gomoku_wins', 'threshold' => 1, 'sort' => 350],
|
||||||
|
['key' => 'game_fishing_20', 'category' => 'game', 'name' => '垂钓小能手', 'icon' => '🎣', 'description' => '累计抛竿钓鱼 20 次', 'metric' => 'fishing_times', 'threshold' => 20, 'sort' => 360],
|
||||||
|
['key' => 'game_riddle_win', 'category' => 'game', 'name' => '猜谜破题', 'icon' => '🧠', 'description' => '成功答对 1 次猜谜/猜成语', 'metric' => 'riddle_wins', 'threshold' => 1, 'sort' => 370],
|
||||||
|
['key' => 'game_win_1000', 'category' => 'game', 'name' => '小赚一笔', 'icon' => '🪙', 'description' => '游戏累计赢取 1000 金币', 'metric' => 'game_gold_won', 'threshold' => 1000, 'sort' => 380],
|
||||||
|
['key' => 'game_win_10000', 'category' => 'game', 'name' => '手气渐热', 'icon' => '💵', 'description' => '游戏累计赢取 10000 金币', 'metric' => 'game_gold_won', 'threshold' => 10000, 'sort' => 390],
|
||||||
|
['key' => 'game_win_100000', 'category' => 'game', 'name' => '十万进账', 'icon' => '💰', 'description' => '游戏累计赢取 100000 金币', 'metric' => 'game_gold_won', 'threshold' => 100000, 'sort' => 400],
|
||||||
|
['key' => 'game_win_1000000', 'category' => 'game', 'name' => '百万赢家', 'icon' => '🏆', 'description' => '游戏累计赢取 1000000 金币', 'metric' => 'game_gold_won', 'threshold' => 1000000, 'sort' => 410],
|
||||||
|
['key' => 'game_win_10000000', 'category' => 'game', 'name' => '千万胜手', 'icon' => '👑', 'description' => '游戏累计赢取 10000000 金币', 'metric' => 'game_gold_won', 'threshold' => 10000000, 'sort' => 420],
|
||||||
|
['key' => 'game_loss_1000', 'category' => 'game', 'name' => '小输当练', 'icon' => '🧾', 'description' => '游戏累计输掉 1000 金币', 'metric' => 'game_gold_lost', 'threshold' => 1000, 'sort' => 430],
|
||||||
|
['key' => 'game_loss_10000', 'category' => 'game', 'name' => '万金试炼', 'icon' => '📉', 'description' => '游戏累计输掉 10000 金币', 'metric' => 'game_gold_lost', 'threshold' => 10000, 'sort' => 440],
|
||||||
|
['key' => 'game_loss_100000', 'category' => 'game', 'name' => '十万学费', 'icon' => '🎒', 'description' => '游戏累计输掉 100000 金币', 'metric' => 'game_gold_lost', 'threshold' => 100000, 'sort' => 450],
|
||||||
|
['key' => 'game_loss_1000000', 'category' => 'game', 'name' => '百万沉浮', 'icon' => '🌊', 'description' => '游戏累计输掉 1000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 1000000, 'sort' => 460],
|
||||||
|
['key' => 'game_loss_10000000', 'category' => 'game', 'name' => '千万历练', 'icon' => '🗿', 'description' => '游戏累计输掉 10000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 10000000, 'sort' => 470],
|
||||||
|
|
||||||
|
['key' => 'social_red_packet_sent', 'category' => 'social', 'name' => '慷慨发包', 'icon' => '🧧', 'description' => '发送过 1 次红包', 'metric' => 'red_packets_sent', 'threshold' => 1, 'sort' => 410],
|
||||||
|
['key' => 'social_red_packet_claimed', 'category' => 'social', 'name' => '手气不错', 'icon' => '🙌', 'description' => '领取过 1 次红包', 'metric' => 'red_packets_claimed', 'threshold' => 1, 'sort' => 420],
|
||||||
|
['key' => 'social_married', 'category' => 'social', 'name' => '情定聊天室', 'icon' => '💍', 'description' => '完成一次结婚', 'metric' => 'marriages', 'threshold' => 1, 'sort' => 430],
|
||||||
|
['key' => 'social_intimacy_1000', 'category' => 'social', 'name' => '亲密搭档', 'icon' => '💞', 'description' => '婚姻亲密度达到 1000', 'metric' => 'marriage_intimacy', 'threshold' => 1000, 'sort' => 440],
|
||||||
|
['key' => 'social_gift_sent', 'category' => 'social', 'name' => '赠礼之友', 'icon' => '🎁', 'description' => '送出过 1 次礼物', 'metric' => 'gifts_sent', 'threshold' => 1, 'sort' => 450],
|
||||||
|
['key' => 'social_gift_received', 'category' => 'social', 'name' => '人气收礼', 'icon' => '💐', 'description' => '收到过 1 次礼物', 'metric' => 'gifts_received', 'threshold' => 1, 'sort' => 460],
|
||||||
|
|
||||||
|
['key' => 'duty_first_position', 'category' => 'duty', 'name' => '首次任命', 'icon' => '🎖️', 'description' => '获得过 1 次职务任命', 'metric' => 'positions', 'threshold' => 1, 'sort' => 510],
|
||||||
|
['key' => 'duty_60_minutes', 'category' => 'duty', 'name' => '勤务一小时', 'icon' => '⏱️', 'description' => '累计值班 60 分钟', 'metric' => 'duty_minutes', 'threshold' => 60, 'sort' => 520],
|
||||||
|
['key' => 'duty_admin_action', 'category' => 'duty', 'name' => '管理出手', 'icon' => '🛡️', 'description' => '执行过 1 次职务管理操作', 'metric' => 'authority_actions', 'threshold' => 1, 'sort' => 530],
|
||||||
|
];
|
||||||
|
$definitions = array_merge($definitions, self::extendedTierDefinitions());
|
||||||
|
|
||||||
|
return collect($definitions)->keyBy('key')->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回长期运营需要的扩展阶梯成就。
|
||||||
|
*
|
||||||
|
* @return array<int, array{key: string, category: string, name: string, icon: string, description: string, metric: string, threshold: int, sort: int}>
|
||||||
|
*/
|
||||||
|
private static function extendedTierDefinitions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['key' => 'chat_2000_messages', 'category' => 'chat', 'name' => '两千连珠', 'icon' => '🧵', 'description' => '累计发送 2000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 2000, 'sort' => 45],
|
||||||
|
['key' => 'chat_20000_messages', 'category' => 'chat', 'name' => '两万谈资', 'icon' => '🛰️', 'description' => '累计发送 20000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 20000, 'sort' => 65],
|
||||||
|
['key' => 'chat_200000_messages', 'category' => 'chat', 'name' => '二十万长谈', 'icon' => '🌠', 'description' => '累计发送 200000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 200000, 'sort' => 85],
|
||||||
|
['key' => 'chat_300000_messages', 'category' => 'chat', 'name' => '三十万星河', 'icon' => '🌌', 'description' => '累计发送 300000 条聊天消息', 'metric' => 'chat_messages', 'threshold' => 300000, 'sort' => 86],
|
||||||
|
['key' => 'chat_welcome_1000', 'category' => 'chat', 'name' => '千次迎客', 'icon' => '🎊', 'description' => '累计欢迎他人 1000 次', 'metric' => 'welcome_messages', 'threshold' => 1000, 'sort' => 121],
|
||||||
|
['key' => 'chat_welcome_3000', 'category' => 'chat', 'name' => '迎宾长明灯', 'icon' => '🏵️', 'description' => '累计欢迎他人 3000 次', 'metric' => 'welcome_messages', 'threshold' => 3000, 'sort' => 122],
|
||||||
|
|
||||||
|
['key' => 'signin_total_60', 'category' => 'sign_in', 'name' => '两月足迹', 'icon' => '📍', 'description' => '累计签到 60 天', 'metric' => 'total_sign_ins', 'threshold' => 60, 'sort' => 155],
|
||||||
|
['key' => 'signin_total_180', 'category' => 'sign_in', 'name' => '半年到场', 'icon' => '🧭', 'description' => '累计签到 180 天', 'metric' => 'total_sign_ins', 'threshold' => 180, 'sort' => 165],
|
||||||
|
['key' => 'signin_total_730', 'category' => 'sign_in', 'name' => '两年常驻', 'icon' => '🏕️', 'description' => '累计签到 730 天', 'metric' => 'total_sign_ins', 'threshold' => 730, 'sort' => 171],
|
||||||
|
['key' => 'signin_total_1000', 'category' => 'sign_in', 'name' => '千日留名', 'icon' => '📜', 'description' => '累计签到 1000 天', 'metric' => 'total_sign_ins', 'threshold' => 1000, 'sort' => 172],
|
||||||
|
['key' => 'signin_180_streak', 'category' => 'sign_in', 'name' => '半年不断', 'icon' => '🧱', 'description' => '连续签到 180 天', 'metric' => 'sign_in_streak', 'threshold' => 180, 'sort' => 241],
|
||||||
|
['key' => 'signin_730_streak', 'category' => 'sign_in', 'name' => '两年不断', 'icon' => '🗻', 'description' => '连续签到 730 天', 'metric' => 'sign_in_streak', 'threshold' => 730, 'sort' => 242],
|
||||||
|
['key' => 'signin_makeup_50', 'category' => 'sign_in', 'name' => '时光修补匠', 'icon' => '🧵', 'description' => '累计使用 50 次补签', 'metric' => 'makeup_sign_ins', 'threshold' => 50, 'sort' => 271],
|
||||||
|
|
||||||
|
['key' => 'growth_exp_50000', 'category' => 'growth', 'name' => '经验老练', 'icon' => '🌟', 'description' => '累计获得 50000 经验', 'metric' => 'exp_gain', 'threshold' => 50000, 'sort' => 211],
|
||||||
|
['key' => 'growth_exp_100000', 'category' => 'growth', 'name' => '十万经验', 'icon' => '🎓', 'description' => '累计获得 100000 经验', 'metric' => 'exp_gain', 'threshold' => 100000, 'sort' => 212],
|
||||||
|
['key' => 'growth_exp_500000', 'category' => 'growth', 'name' => '经验厚积', 'icon' => '📚', 'description' => '累计获得 500000 经验', 'metric' => 'exp_gain', 'threshold' => 500000, 'sort' => 213],
|
||||||
|
['key' => 'growth_exp_1000000', 'category' => 'growth', 'name' => '百万经验', 'icon' => '🏫', 'description' => '累计获得 1000000 经验', 'metric' => 'exp_gain', 'threshold' => 1000000, 'sort' => 214],
|
||||||
|
['key' => 'growth_gold_500000', 'category' => 'growth', 'name' => '半百万进账', 'icon' => '💴', 'description' => '累计获得 500000 金币', 'metric' => 'gold_gain', 'threshold' => 500000, 'sort' => 221],
|
||||||
|
['key' => 'growth_gold_1000000', 'category' => 'growth', 'name' => '百万进账', 'icon' => '💵', 'description' => '累计获得 1000000 金币', 'metric' => 'gold_gain', 'threshold' => 1000000, 'sort' => 222],
|
||||||
|
['key' => 'growth_gold_5000000', 'category' => 'growth', 'name' => '五百万进账', 'icon' => '💶', 'description' => '累计获得 5000000 金币', 'metric' => 'gold_gain', 'threshold' => 5000000, 'sort' => 223],
|
||||||
|
['key' => 'growth_gold_10000000', 'category' => 'growth', 'name' => '千万进账', 'icon' => '💷', 'description' => '累计获得 10000000 金币', 'metric' => 'gold_gain', 'threshold' => 10000000, 'sort' => 224],
|
||||||
|
['key' => 'growth_gold_100000000', 'category' => 'growth', 'name' => '亿级进账', 'icon' => '🪙', 'description' => '累计获得 100000000 金币', 'metric' => 'gold_gain', 'threshold' => 100000000, 'sort' => 225],
|
||||||
|
['key' => 'growth_charm_5000', 'category' => 'growth', 'name' => '魅力上扬', 'icon' => '🌺', 'description' => '累计获得 5000 魅力', 'metric' => 'charm_gain', 'threshold' => 5000, 'sort' => 231],
|
||||||
|
['key' => 'growth_charm_10000', 'category' => 'growth', 'name' => '万点魅力', 'icon' => '💐', 'description' => '累计获得 10000 魅力', 'metric' => 'charm_gain', 'threshold' => 10000, 'sort' => 232],
|
||||||
|
['key' => 'growth_charm_50000', 'category' => 'growth', 'name' => '魅力满堂', 'icon' => '🪷', 'description' => '累计获得 50000 魅力', 'metric' => 'charm_gain', 'threshold' => 50000, 'sort' => 233],
|
||||||
|
['key' => 'growth_charm_100000', 'category' => 'growth', 'name' => '十万魅力', 'icon' => '👒', 'description' => '累计获得 100000 魅力', 'metric' => 'charm_gain', 'threshold' => 100000, 'sort' => 234],
|
||||||
|
['key' => 'growth_assets_5000000', 'category' => 'growth', 'name' => '五百万身家', 'icon' => '💍', 'description' => '金币资产达到 5000000', 'metric' => 'gold_assets', 'threshold' => 5000000, 'sort' => 245],
|
||||||
|
['key' => 'growth_assets_50000000', 'category' => 'growth', 'name' => '五千万资产', 'icon' => '🏦', 'description' => '金币资产达到 50000000', 'metric' => 'gold_assets', 'threshold' => 50000000, 'sort' => 255],
|
||||||
|
['key' => 'growth_assets_500000000', 'category' => 'growth', 'name' => '五亿资产', 'icon' => '🏛️', 'description' => '金币资产达到 500000000', 'metric' => 'gold_assets', 'threshold' => 500000000, 'sort' => 261],
|
||||||
|
['key' => 'growth_assets_1000000000', 'category' => 'growth', 'name' => '十亿传说', 'icon' => '🚀', 'description' => '金币资产达到 1000000000', 'metric' => 'gold_assets', 'threshold' => 1000000000, 'sort' => 262],
|
||||||
|
['key' => 'growth_bank_5000000', 'category' => 'growth', 'name' => '五百万存款', 'icon' => '🧱', 'description' => '银行存款达到 5000000 金币', 'metric' => 'bank_balance', 'threshold' => 5000000, 'sort' => 285],
|
||||||
|
['key' => 'growth_bank_50000000', 'category' => 'growth', 'name' => '五千万金库', 'icon' => '🏦', 'description' => '银行存款达到 50000000 金币', 'metric' => 'bank_balance', 'threshold' => 50000000, 'sort' => 291],
|
||||||
|
['key' => 'growth_bank_100000000', 'category' => 'growth', 'name' => '亿级金库', 'icon' => '🔒', 'description' => '银行存款达到 100000000 金币', 'metric' => 'bank_balance', 'threshold' => 100000000, 'sort' => 292],
|
||||||
|
|
||||||
|
['key' => 'game_baccarat_100', 'category' => 'game', 'name' => '百局百家乐', 'icon' => '🎲', 'description' => '累计参与百家乐下注 100 次', 'metric' => 'baccarat_bets', 'threshold' => 100, 'sort' => 311],
|
||||||
|
['key' => 'game_baccarat_500', 'category' => 'game', 'name' => '百家乐熟手', 'icon' => '🃏', 'description' => '累计参与百家乐下注 500 次', 'metric' => 'baccarat_bets', 'threshold' => 500, 'sort' => 312],
|
||||||
|
['key' => 'game_baccarat_1000', 'category' => 'game', 'name' => '千局庄闲', 'icon' => '🎴', 'description' => '累计参与百家乐下注 1000 次', 'metric' => 'baccarat_bets', 'threshold' => 1000, 'sort' => 313],
|
||||||
|
['key' => 'game_horse_100', 'category' => 'game', 'name' => '百场赛马', 'icon' => '🏇', 'description' => '累计参与赛马下注 100 次', 'metric' => 'horse_bets', 'threshold' => 100, 'sort' => 321],
|
||||||
|
['key' => 'game_horse_500', 'category' => 'game', 'name' => '马场熟客', 'icon' => '🎠', 'description' => '累计参与赛马下注 500 次', 'metric' => 'horse_bets', 'threshold' => 500, 'sort' => 322],
|
||||||
|
['key' => 'game_horse_1000', 'category' => 'game', 'name' => '千场观赛', 'icon' => '🏁', 'description' => '累计参与赛马下注 1000 次', 'metric' => 'horse_bets', 'threshold' => 1000, 'sort' => 323],
|
||||||
|
['key' => 'game_lottery_100', 'category' => 'game', 'name' => '百注双色球', 'icon' => '🎟️', 'description' => '累计购买双色球 100 注', 'metric' => 'lottery_tickets', 'threshold' => 100, 'sort' => 331],
|
||||||
|
['key' => 'game_lottery_500', 'category' => 'game', 'name' => '彩池常客', 'icon' => '🔵', 'description' => '累计购买双色球 500 注', 'metric' => 'lottery_tickets', 'threshold' => 500, 'sort' => 332],
|
||||||
|
['key' => 'game_lottery_1000', 'category' => 'game', 'name' => '千注追梦', 'icon' => '🔴', 'description' => '累计购买双色球 1000 注', 'metric' => 'lottery_tickets', 'threshold' => 1000, 'sort' => 333],
|
||||||
|
['key' => 'game_slot_100', 'category' => 'game', 'name' => '百转老虎机', 'icon' => '🎰', 'description' => '累计转动老虎机 100 次', 'metric' => 'slot_spins', 'threshold' => 100, 'sort' => 341],
|
||||||
|
['key' => 'game_slot_500', 'category' => 'game', 'name' => '转轮熟手', 'icon' => '⚙️', 'description' => '累计转动老虎机 500 次', 'metric' => 'slot_spins', 'threshold' => 500, 'sort' => 342],
|
||||||
|
['key' => 'game_slot_1000', 'category' => 'game', 'name' => '千转不歇', 'icon' => '🔔', 'description' => '累计转动老虎机 1000 次', 'metric' => 'slot_spins', 'threshold' => 1000, 'sort' => 343],
|
||||||
|
['key' => 'game_gomoku_5_wins', 'category' => 'game', 'name' => '五子五胜', 'icon' => '⚫', 'description' => '获得 5 次五子棋胜利', 'metric' => 'gomoku_wins', 'threshold' => 5, 'sort' => 351],
|
||||||
|
['key' => 'game_gomoku_20_wins', 'category' => 'game', 'name' => '棋盘强手', 'icon' => '⚪', 'description' => '获得 20 次五子棋胜利', 'metric' => 'gomoku_wins', 'threshold' => 20, 'sort' => 352],
|
||||||
|
['key' => 'game_gomoku_100_wins', 'category' => 'game', 'name' => '百胜棋手', 'icon' => '♟️', 'description' => '获得 100 次五子棋胜利', 'metric' => 'gomoku_wins', 'threshold' => 100, 'sort' => 353],
|
||||||
|
['key' => 'game_fishing_100', 'category' => 'game', 'name' => '百竿垂钓', 'icon' => '🎣', 'description' => '累计抛竿钓鱼 100 次', 'metric' => 'fishing_times', 'threshold' => 100, 'sort' => 361],
|
||||||
|
['key' => 'game_fishing_500', 'category' => 'game', 'name' => '鱼塘熟手', 'icon' => '🐟', 'description' => '累计抛竿钓鱼 500 次', 'metric' => 'fishing_times', 'threshold' => 500, 'sort' => 362],
|
||||||
|
['key' => 'game_fishing_1000', 'category' => 'game', 'name' => '千竿钓客', 'icon' => '🐠', 'description' => '累计抛竿钓鱼 1000 次', 'metric' => 'fishing_times', 'threshold' => 1000, 'sort' => 363],
|
||||||
|
['key' => 'game_riddle_10_wins', 'category' => 'game', 'name' => '十题小成', 'icon' => '🧠', 'description' => '成功答对 10 次猜谜/猜成语', 'metric' => 'riddle_wins', 'threshold' => 10, 'sort' => 371],
|
||||||
|
['key' => 'game_riddle_50_wins', 'category' => 'game', 'name' => '破题熟手', 'icon' => '💡', 'description' => '成功答对 50 次猜谜/猜成语', 'metric' => 'riddle_wins', 'threshold' => 50, 'sort' => 372],
|
||||||
|
['key' => 'game_riddle_200_wins', 'category' => 'game', 'name' => '谜面克星', 'icon' => '📘', 'description' => '成功答对 200 次猜谜/猜成语', 'metric' => 'riddle_wins', 'threshold' => 200, 'sort' => 373],
|
||||||
|
['key' => 'game_win_5000', 'category' => 'game', 'name' => '五千到手', 'icon' => '🪙', 'description' => '游戏累计赢取 5000 金币', 'metric' => 'game_gold_won', 'threshold' => 5000, 'sort' => 385],
|
||||||
|
['key' => 'game_win_50000', 'category' => 'game', 'name' => '五万好运', 'icon' => '💵', 'description' => '游戏累计赢取 50000 金币', 'metric' => 'game_gold_won', 'threshold' => 50000, 'sort' => 395],
|
||||||
|
['key' => 'game_win_500000', 'category' => 'game', 'name' => '半百万赢家', 'icon' => '💰', 'description' => '游戏累计赢取 500000 金币', 'metric' => 'game_gold_won', 'threshold' => 500000, 'sort' => 405],
|
||||||
|
['key' => 'game_win_5000000', 'category' => 'game', 'name' => '五百万胜手', 'icon' => '🏆', 'description' => '游戏累计赢取 5000000 金币', 'metric' => 'game_gold_won', 'threshold' => 5000000, 'sort' => 415],
|
||||||
|
['key' => 'game_win_50000000', 'category' => 'game', 'name' => '五千万战绩', 'icon' => '👑', 'description' => '游戏累计赢取 50000000 金币', 'metric' => 'game_gold_won', 'threshold' => 50000000, 'sort' => 421],
|
||||||
|
['key' => 'game_win_100000000', 'category' => 'game', 'name' => '亿级赢家', 'icon' => '🌟', 'description' => '游戏累计赢取 100000000 金币', 'metric' => 'game_gold_won', 'threshold' => 100000000, 'sort' => 422],
|
||||||
|
['key' => 'game_loss_5000', 'category' => 'game', 'name' => '五千试水', 'icon' => '🧾', 'description' => '游戏累计输掉 5000 金币', 'metric' => 'game_gold_lost', 'threshold' => 5000, 'sort' => 435],
|
||||||
|
['key' => 'game_loss_50000', 'category' => 'game', 'name' => '五万起伏', 'icon' => '📉', 'description' => '游戏累计输掉 50000 金币', 'metric' => 'game_gold_lost', 'threshold' => 50000, 'sort' => 445],
|
||||||
|
['key' => 'game_loss_500000', 'category' => 'game', 'name' => '半百万沉浮', 'icon' => '🌊', 'description' => '游戏累计输掉 500000 金币', 'metric' => 'game_gold_lost', 'threshold' => 500000, 'sort' => 455],
|
||||||
|
['key' => 'game_loss_5000000', 'category' => 'game', 'name' => '五百万历练', 'icon' => '🗿', 'description' => '游戏累计输掉 5000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 5000000, 'sort' => 465],
|
||||||
|
['key' => 'game_loss_50000000', 'category' => 'game', 'name' => '五千万风浪', 'icon' => '🌪️', 'description' => '游戏累计输掉 50000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 50000000, 'sort' => 471],
|
||||||
|
['key' => 'game_loss_100000000', 'category' => 'game', 'name' => '亿级沉浮', 'icon' => '🕳️', 'description' => '游戏累计输掉 100000000 金币', 'metric' => 'game_gold_lost', 'threshold' => 100000000, 'sort' => 472],
|
||||||
|
|
||||||
|
['key' => 'social_red_packet_sent_10', 'category' => 'social', 'name' => '十次发包', 'icon' => '🧧', 'description' => '累计发送 10 次红包', 'metric' => 'red_packets_sent', 'threshold' => 10, 'sort' => 411],
|
||||||
|
['key' => 'social_red_packet_sent_50', 'category' => 'social', 'name' => '红包常客', 'icon' => '🎁', 'description' => '累计发送 50 次红包', 'metric' => 'red_packets_sent', 'threshold' => 50, 'sort' => 412],
|
||||||
|
['key' => 'social_red_packet_sent_100', 'category' => 'social', 'name' => '百包散财', 'icon' => '🏮', 'description' => '累计发送 100 次红包', 'metric' => 'red_packets_sent', 'threshold' => 100, 'sort' => 413],
|
||||||
|
['key' => 'social_red_packet_claimed_10', 'category' => 'social', 'name' => '十次手气', 'icon' => '🙌', 'description' => '累计领取 10 次红包', 'metric' => 'red_packets_claimed', 'threshold' => 10, 'sort' => 421],
|
||||||
|
['key' => 'social_red_packet_claimed_50', 'category' => 'social', 'name' => '抢包熟手', 'icon' => '🫴', 'description' => '累计领取 50 次红包', 'metric' => 'red_packets_claimed', 'threshold' => 50, 'sort' => 422],
|
||||||
|
['key' => 'social_red_packet_claimed_100', 'category' => 'social', 'name' => '百包入手', 'icon' => '🧧', 'description' => '累计领取 100 次红包', 'metric' => 'red_packets_claimed', 'threshold' => 100, 'sort' => 423],
|
||||||
|
['key' => 'social_intimacy_5000', 'category' => 'social', 'name' => '亲密升温', 'icon' => '💞', 'description' => '婚姻亲密度达到 5000', 'metric' => 'marriage_intimacy', 'threshold' => 5000, 'sort' => 441],
|
||||||
|
['key' => 'social_intimacy_10000', 'category' => 'social', 'name' => '万点亲密', 'icon' => '💕', 'description' => '婚姻亲密度达到 10000', 'metric' => 'marriage_intimacy', 'threshold' => 10000, 'sort' => 442],
|
||||||
|
['key' => 'social_intimacy_50000', 'category' => 'social', 'name' => '情深五万', 'icon' => '💖', 'description' => '婚姻亲密度达到 50000', 'metric' => 'marriage_intimacy', 'threshold' => 50000, 'sort' => 443],
|
||||||
|
['key' => 'social_gift_sent_10', 'category' => 'social', 'name' => '十礼相赠', 'icon' => '🎁', 'description' => '累计送出 10 次礼物', 'metric' => 'gifts_sent', 'threshold' => 10, 'sort' => 451],
|
||||||
|
['key' => 'social_gift_sent_50', 'category' => 'social', 'name' => '赠礼熟手', 'icon' => '🎀', 'description' => '累计送出 50 次礼物', 'metric' => 'gifts_sent', 'threshold' => 50, 'sort' => 452],
|
||||||
|
['key' => 'social_gift_sent_100', 'category' => 'social', 'name' => '百礼往来', 'icon' => '💝', 'description' => '累计送出 100 次礼物', 'metric' => 'gifts_sent', 'threshold' => 100, 'sort' => 453],
|
||||||
|
['key' => 'social_gift_received_10', 'category' => 'social', 'name' => '十礼入怀', 'icon' => '💐', 'description' => '累计收到 10 次礼物', 'metric' => 'gifts_received', 'threshold' => 10, 'sort' => 461],
|
||||||
|
['key' => 'social_gift_received_50', 'category' => 'social', 'name' => '人气渐盛', 'icon' => '🌹', 'description' => '累计收到 50 次礼物', 'metric' => 'gifts_received', 'threshold' => 50, 'sort' => 462],
|
||||||
|
['key' => 'social_gift_received_100', 'category' => 'social', 'name' => '百礼人气', 'icon' => '🌷', 'description' => '累计收到 100 次礼物', 'metric' => 'gifts_received', 'threshold' => 100, 'sort' => 463],
|
||||||
|
|
||||||
|
['key' => 'duty_3_positions', 'category' => 'duty', 'name' => '多职历练', 'icon' => '🎖️', 'description' => '累计获得 3 次职务任命', 'metric' => 'positions', 'threshold' => 3, 'sort' => 511],
|
||||||
|
['key' => 'duty_10_positions', 'category' => 'duty', 'name' => '十任履历', 'icon' => '📌', 'description' => '累计获得 10 次职务任命', 'metric' => 'positions', 'threshold' => 10, 'sort' => 512],
|
||||||
|
['key' => 'duty_300_minutes', 'category' => 'duty', 'name' => '勤务五小时', 'icon' => '⏱️', 'description' => '累计值班 300 分钟', 'metric' => 'duty_minutes', 'threshold' => 300, 'sort' => 521],
|
||||||
|
['key' => 'duty_600_minutes', 'category' => 'duty', 'name' => '勤务十小时', 'icon' => '🕰️', 'description' => '累计值班 600 分钟', 'metric' => 'duty_minutes', 'threshold' => 600, 'sort' => 522],
|
||||||
|
['key' => 'duty_3000_minutes', 'category' => 'duty', 'name' => '值班老手', 'icon' => '📋', 'description' => '累计值班 3000 分钟', 'metric' => 'duty_minutes', 'threshold' => 3000, 'sort' => 523],
|
||||||
|
['key' => 'duty_10000_minutes', 'category' => 'duty', 'name' => '万分钟勤务', 'icon' => '🏢', 'description' => '累计值班 10000 分钟', 'metric' => 'duty_minutes', 'threshold' => 10000, 'sort' => 524],
|
||||||
|
['key' => 'duty_10_admin_actions', 'category' => 'duty', 'name' => '十次管理', 'icon' => '🛡️', 'description' => '累计执行 10 次职务管理操作', 'metric' => 'authority_actions', 'threshold' => 10, 'sort' => 531],
|
||||||
|
['key' => 'duty_100_admin_actions', 'category' => 'duty', 'name' => '百次管理', 'icon' => '⚖️', 'description' => '累计执行 100 次职务管理操作', 'metric' => 'authority_actions', 'threshold' => 100, 'sort' => 532],
|
||||||
|
['key' => 'duty_1000_admin_actions', 'category' => 'duty', 'name' => '千次执勤', 'icon' => '🏛️', 'description' => '累计执行 1000 次职务管理操作', 'metric' => 'authority_actions', 'threshold' => 1000, 'sort' => 533],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回成就分类标题。
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function categories(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'chat' => '聊天',
|
||||||
|
'sign_in' => '签到',
|
||||||
|
'growth' => '成长',
|
||||||
|
'game' => '游戏',
|
||||||
|
'social' => '社交',
|
||||||
|
'duty' => '职务',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 key 获取单个成就定义。
|
||||||
|
*
|
||||||
|
* @return array{key: string, category: string, name: string, icon: string, description: string, metric: string, threshold: int, sort: int, hidden?: bool}|null
|
||||||
|
*/
|
||||||
|
public static function find(string $key): ?array
|
||||||
|
{
|
||||||
|
return self::definitions()[$key] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-17
@@ -1,5 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:Laravel 应用启动配置。
|
||||||
|
* 负责注册路由、中间件别名、代理信任规则与全局异常响应格式。
|
||||||
|
*/
|
||||||
|
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
@@ -38,8 +43,12 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
$middleware->redirectGuestsTo('/');
|
$middleware->redirectGuestsTo('/');
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$isChatAjaxRequest = static function (Request $request): bool {
|
$isJsonSessionRequest = static function (Request $request): bool {
|
||||||
return $request->expectsJson() && $request->is(
|
if ($request->expectsJson() || $request->ajax()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $request->is(
|
||||||
'room/*/send',
|
'room/*/send',
|
||||||
'room/*/heartbeat',
|
'room/*/heartbeat',
|
||||||
'room/*/leave',
|
'room/*/leave',
|
||||||
@@ -51,25 +60,28 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 聊天室 AJAX 接口:CSRF token 过期(419)时,返回 JSON 提示而非重定向
|
$expiredSessionResponse = static function () {
|
||||||
// 防止浏览器收到 302 后以 GET 方式重请求只允许 POST 的路由,产生 405 错误
|
return response()->json([
|
||||||
$exceptions->render(function (TokenMismatchException $e, Request $request) use ($isChatAjaxRequest) {
|
'status' => 'error',
|
||||||
if ($isChatAjaxRequest($request)) {
|
'code' => 'SESSION_EXPIRED',
|
||||||
return response()->json([
|
'message' => '登录状态已失效,请刷新页面后重新登录。',
|
||||||
'status' => 'error',
|
'reload' => true,
|
||||||
'message' => '页面已过期,请刷新后重试。',
|
'login_url' => route('home'),
|
||||||
], 419);
|
], 419);
|
||||||
|
};
|
||||||
|
|
||||||
|
// CSRF token 失效通常意味着页面还停留在旧会话里;JSON 请求统一返回业务提示,避免泄露框架异常堆栈。
|
||||||
|
$exceptions->render(function (TokenMismatchException $e, Request $request) use ($expiredSessionResponse, $isJsonSessionRequest) {
|
||||||
|
if ($isJsonSessionRequest($request)) {
|
||||||
|
return $expiredSessionResponse();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Laravel 在某些环境下会先把 TokenMismatchException 包装成 419 HttpException,
|
// Laravel 在某些环境下会先把 TokenMismatchException 包装成 419 HttpException,
|
||||||
// 这里补一层兜底,确保聊天接口始终返回稳定的 JSON,而不是默认 HTML 错误页。
|
// 这里补一层兜底,确保接口始终返回稳定 JSON,而不是默认异常结构。
|
||||||
$exceptions->render(function (HttpExceptionInterface $e, Request $request) use ($isChatAjaxRequest) {
|
$exceptions->render(function (HttpExceptionInterface $e, Request $request) use ($expiredSessionResponse, $isJsonSessionRequest) {
|
||||||
if ($e->getStatusCode() === 419 && $isChatAjaxRequest($request)) {
|
if ($e->getStatusCode() === 419 && $isJsonSessionRequest($request)) {
|
||||||
return response()->json([
|
return $expiredSessionResponse();
|
||||||
'status' => 'error',
|
|
||||||
'message' => '页面已过期,请刷新后重试。',
|
|
||||||
], 419);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})->create();
|
})->create();
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:用户成就测试工厂。
|
||||||
|
*
|
||||||
|
* 为成就相关 Feature Test 快速生成解锁或进度记录。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserAchievement;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:生成用户成就模型的默认测试数据。
|
||||||
|
*
|
||||||
|
* @extends Factory<UserAchievement>
|
||||||
|
*/
|
||||||
|
class UserAchievementFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 定义模型的默认测试状态。
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => User::factory(),
|
||||||
|
'achievement_key' => 'chat_first_message',
|
||||||
|
'progress_value' => 1,
|
||||||
|
'metadata' => ['threshold' => 1],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:用户成就进度测试工厂。
|
||||||
|
*
|
||||||
|
* 为成就进度相关测试生成默认记录。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserAchievementProgress;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:生成用户成就进度模型的默认测试数据。
|
||||||
|
*
|
||||||
|
* @extends Factory<UserAchievementProgress>
|
||||||
|
*/
|
||||||
|
class UserAchievementProgressFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 定义模型的默认测试状态。
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => User::factory(),
|
||||||
|
'achievement_key' => 'chat_first_message',
|
||||||
|
'progress_value' => 0,
|
||||||
|
'threshold_value' => 1,
|
||||||
|
'last_scanned_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:创建聊天室座驾独立数据表。
|
||||||
|
*
|
||||||
|
* 座驾定义和用户座驾购买记录独立于商店模块,支持后台单独配置价格、使用天数和欢迎语。
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 方法功能:创建 rides 与 user_ride_purchases 并预置默认座驾。
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('rides', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name', 100)->comment('座驾名称');
|
||||||
|
$table->string('slug', 100)->unique()->comment('座驾唯一标识,格式 ride_key');
|
||||||
|
$table->string('effect_key', 50)->unique()->comment('全屏特效 key');
|
||||||
|
$table->string('icon', 20)->default('🚘')->comment('座驾图标');
|
||||||
|
$table->text('description')->nullable()->comment('座驾说明');
|
||||||
|
$table->unsignedInteger('price')->default(0)->comment('购买价格');
|
||||||
|
$table->unsignedInteger('duration_days')->default(7)->comment('使用天数');
|
||||||
|
$table->string('welcome_message', 255)->nullable()->comment('入场欢迎语模板');
|
||||||
|
$table->unsignedInteger('sort_order')->default(0)->comment('排序权重');
|
||||||
|
$table->boolean('is_active')->default(true)->comment('是否上架');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('user_ride_purchases', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('ride_id')->constrained('rides')->cascadeOnDelete();
|
||||||
|
$table->enum('status', ['active', 'expired', 'cancelled'])->default('active')->comment('座驾状态');
|
||||||
|
$table->unsignedInteger('price_paid')->default(0)->comment('实际支付金币');
|
||||||
|
$table->timestamp('expires_at')->nullable()->comment('到期时间');
|
||||||
|
$table->timestamp('used_at')->nullable()->comment('首次使用时间');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'status', 'expires_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->seedDefaultRides();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 方法功能:删除座驾独立数据表。
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_ride_purchases');
|
||||||
|
Schema::dropIfExists('rides');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 方法功能:写入当前默认四个全屏座驾。
|
||||||
|
*/
|
||||||
|
private function seedDefaultRides(): void
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
$rides = [
|
||||||
|
[
|
||||||
|
'name' => '歼-35隐身战机',
|
||||||
|
'slug' => 'ride_j35',
|
||||||
|
'effect_key' => 'j35',
|
||||||
|
'description' => '驾驶歼-35划破长空入场,附带全屏战机掠过特效。',
|
||||||
|
'icon' => '🛩️',
|
||||||
|
'price' => 18888,
|
||||||
|
'duration_days' => 7,
|
||||||
|
'sort_order' => 80,
|
||||||
|
'welcome_message' => '【{name}】驾驶【{ride}】划破长空,震撼降临聊天室!',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => '99A主战坦克',
|
||||||
|
'slug' => 'ride_99a',
|
||||||
|
'effect_key' => '99a',
|
||||||
|
'description' => '驾驶 99A 主战坦克重装入场,附带履带尘土与炮击冲击特效。',
|
||||||
|
'icon' => '🛡️',
|
||||||
|
'price' => 18888,
|
||||||
|
'duration_days' => 7,
|
||||||
|
'sort_order' => 81,
|
||||||
|
'welcome_message' => '【{name}】驾驶【{ride}】重装入场,地面都为之一震!',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => '东风-5C战略导弹',
|
||||||
|
'slug' => 'ride_df5c',
|
||||||
|
'effect_key' => 'df5c',
|
||||||
|
'description' => '乘东风-5C 发射升空入场,附带尾焰、烟尘和雷达 HUD 特效。',
|
||||||
|
'icon' => '🚀',
|
||||||
|
'price' => 28888,
|
||||||
|
'duration_days' => 7,
|
||||||
|
'sort_order' => 82,
|
||||||
|
'welcome_message' => '【{name}】乘【{ride}】点火升空,战略级排面拉满!',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => '福建舰航母',
|
||||||
|
'slug' => 'ride_fujian',
|
||||||
|
'effect_key' => 'fujian',
|
||||||
|
'description' => '乘福建舰破浪入场,附带海浪、舰载机和甲板 HUD 特效。',
|
||||||
|
'icon' => '⚓',
|
||||||
|
'price' => 28888,
|
||||||
|
'duration_days' => 7,
|
||||||
|
'sort_order' => 83,
|
||||||
|
'welcome_message' => '【{name}】乘【{ride}】破浪而来,全场列队欢迎!',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($rides as $ride) {
|
||||||
|
DB::table('rides')->insert($ride + [
|
||||||
|
'is_active' => true,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:清理旧版商店座驾数据。
|
||||||
|
*
|
||||||
|
* 如果环境曾跑过“座驾复用商店”的旧迁移,本迁移会移除商店里的座驾字段和残留记录。
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 方法功能:删除 shop_items 中旧座驾记录并移除欢迎语字段。
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('shop_items')->where('slug', 'like', 'ride_%')->delete();
|
||||||
|
|
||||||
|
if (DB::getDriverName() === 'mysql' && $this->shopItemTypeContainsRide()) {
|
||||||
|
DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` = 'ride'");
|
||||||
|
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame','msg_text_color') NOT NULL COMMENT '道具类型'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasColumn('shop_items', 'welcome_message')) {
|
||||||
|
Schema::table('shop_items', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('welcome_message');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 方法功能:清理迁移不恢复旧商店座驾结构。
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 方法功能:判断当前 shop_items.type 枚举是否包含旧座驾类型。
|
||||||
|
*/
|
||||||
|
private function shopItemTypeContainsRide(): bool
|
||||||
|
{
|
||||||
|
$column = DB::selectOne("SHOW COLUMNS FROM `shop_items` LIKE 'type'");
|
||||||
|
|
||||||
|
return $column && str_contains((string) $column->Type, "'ride'");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 执行迁移:给聊天消息增加保留类型字段。
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('messages', function (Blueprint $table) {
|
||||||
|
$table->string('retention_type', 30)
|
||||||
|
->default('user_chat')
|
||||||
|
->index()
|
||||||
|
->comment('消息保留类型:user_chat/system_notice/game_notice/ephemeral_notice');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回滚迁移:移除聊天消息保留类型字段。
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('messages', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('retention_type');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 执行迁移:创建用户成就解锁记录表。
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_achievements', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('用户ID');
|
||||||
|
$table->string('achievement_key', 80)->comment('成就唯一标识');
|
||||||
|
$table->unsignedBigInteger('progress_value')->default(0)->comment('当前进度快照');
|
||||||
|
$table->timestamp('achieved_at')->nullable()->index()->comment('达成时间');
|
||||||
|
$table->timestamp('notified_at')->nullable()->comment('通知时间');
|
||||||
|
$table->json('metadata')->nullable()->comment('成就解锁附加信息');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['user_id', 'achievement_key']);
|
||||||
|
$table->index(['achievement_key', 'achieved_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回滚迁移:删除用户成就解锁记录表。
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_achievements');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 执行迁移:创建用户成就进度快照表。
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_achievement_progress', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('用户ID');
|
||||||
|
$table->string('achievement_key', 80)->comment('成就唯一标识');
|
||||||
|
$table->unsignedBigInteger('progress_value')->default(0)->comment('当前进度值');
|
||||||
|
$table->unsignedBigInteger('threshold_value')->default(0)->comment('达成门槛快照');
|
||||||
|
$table->timestamp('last_scanned_at')->nullable()->index()->comment('最近扫描时间');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['user_id', 'achievement_key']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回滚迁移:删除用户成就进度快照表。
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_achievement_progress');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('previous_name', 50)->nullable()->after('username')->comment('曾用名');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('previous_name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+74
-6
@@ -40,11 +40,62 @@ fi
|
|||||||
PHP_VERSION=$("$PHP_BIN" -r 'echo PHP_VERSION;' 2>/dev/null)
|
PHP_VERSION=$("$PHP_BIN" -r 'echo PHP_VERSION;' 2>/dev/null)
|
||||||
echo -e "${BLUE}使用 PHP:$PHP_BIN (版本:${PHP_VERSION:-unknown})${NC}"
|
echo -e "${BLUE}使用 PHP:$PHP_BIN (版本:${PHP_VERSION:-unknown})${NC}"
|
||||||
|
|
||||||
|
frontend_build_required() {
|
||||||
|
local file="$1"
|
||||||
|
|
||||||
|
case "$file" in
|
||||||
|
resources/js/*|resources/css/*|resources/views/*.blade.php|resources/views/*/*.blade.php|resources/views/*/*/*.blade.php|resources/views/*/*/*/*.blade.php)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
package.json|package-lock.json|vite.config.*|tailwind.config.*|postcss.config.*)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# 1. Git Pull(先重置 lock 文件,避免服务器环境差异导致冲突)
|
# 1. Git Pull(先重置 lock 文件,避免服务器环境差异导致冲突)
|
||||||
echo -e "${YELLOW}[1/8] 拉取代码...${NC}"
|
echo -e "${YELLOW}[1/8] 拉取代码...${NC}"
|
||||||
|
BEFORE_REV=$(git rev-parse HEAD 2>/dev/null || echo "")
|
||||||
git checkout -- composer.lock package-lock.json 2>/dev/null || true
|
git checkout -- composer.lock package-lock.json 2>/dev/null || true
|
||||||
git fetch origin && git pull origin master
|
git fetch origin && git pull origin master
|
||||||
if [ $? -ne 0 ]; then echo -e "${RED}❌ Git 失败${NC}"; exit 1; fi
|
if [ $? -ne 0 ]; then echo -e "${RED}❌ Git 失败${NC}"; exit 1; fi
|
||||||
|
AFTER_REV=$(git rev-parse HEAD 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ -n "$BEFORE_REV" ] && [ -n "$AFTER_REV" ] && [ "$BEFORE_REV" = "$AFTER_REV" ]; then
|
||||||
|
echo -e "${GREEN}✅ 当前代码已是最新版本,本次没有可升级内容,跳过后续部署步骤。${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
FRONTEND_BUILD_NEEDED=0
|
||||||
|
FRONTEND_CHANGED_FILES=""
|
||||||
|
MIGRATION_NEEDED=0
|
||||||
|
NEW_MIGRATION_FILES=""
|
||||||
|
if [ -z "$BEFORE_REV" ] || [ -z "$AFTER_REV" ]; then
|
||||||
|
FRONTEND_BUILD_NEEDED=1
|
||||||
|
FRONTEND_CHANGED_FILES="无法识别更新前后版本,保险起见执行构建"
|
||||||
|
MIGRATION_NEEDED=1
|
||||||
|
NEW_MIGRATION_FILES="无法识别更新前后版本,保险起见执行迁移检查"
|
||||||
|
elif [ "$BEFORE_REV" != "$AFTER_REV" ]; then
|
||||||
|
while IFS= read -r changed_file; do
|
||||||
|
if frontend_build_required "$changed_file"; then
|
||||||
|
FRONTEND_BUILD_NEEDED=1
|
||||||
|
FRONTEND_CHANGED_FILES="${FRONTEND_CHANGED_FILES}${changed_file}"$'\n'
|
||||||
|
fi
|
||||||
|
done < <(git diff --name-only "$BEFORE_REV" "$AFTER_REV")
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r change_status changed_file extra_path; do
|
||||||
|
if [ "$change_status" = "A" ]; then
|
||||||
|
case "$changed_file" in
|
||||||
|
database/migrations/*.php)
|
||||||
|
MIGRATION_NEEDED=1
|
||||||
|
NEW_MIGRATION_FILES="${NEW_MIGRATION_FILES}${changed_file}"$'\n'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
done < <(git diff --name-status "$BEFORE_REV" "$AFTER_REV")
|
||||||
|
fi
|
||||||
|
|
||||||
# 2. Composer Install (关键检查点)
|
# 2. Composer Install (关键检查点)
|
||||||
echo -e "${YELLOW}[2/8] 安装依赖 (Composer)...${NC}"
|
echo -e "${YELLOW}[2/8] 安装依赖 (Composer)...${NC}"
|
||||||
@@ -92,15 +143,32 @@ if [ $? -ne 0 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. 前端构建
|
# 3. 前端构建
|
||||||
echo -e "${YELLOW}[3/8] 前端构建 (npm run build)...${NC}"
|
if [ "$FRONTEND_BUILD_NEEDED" -eq 1 ]; then
|
||||||
npm run build
|
echo -e "${YELLOW}[3/8] 检测到前端构建输入变更,开始前端构建 (npm run build)...${NC}"
|
||||||
if [ $? -ne 0 ]; then echo -e "${RED}❌ npm run build 失败${NC}"; exit 1; fi
|
if [ -n "$FRONTEND_CHANGED_FILES" ]; then
|
||||||
echo -e "${GREEN}✅ 前端资源构建完成。${NC}"
|
echo -e "${BLUE}触发构建的文件:${NC}"
|
||||||
|
printf '%s\n' "$FRONTEND_CHANGED_FILES"
|
||||||
|
fi
|
||||||
|
npm run build
|
||||||
|
if [ $? -ne 0 ]; then echo -e "${RED}❌ npm run build 失败${NC}"; exit 1; fi
|
||||||
|
echo -e "${GREEN}✅ 前端资源构建完成。${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}[3/8] 未检测到前端构建输入变更,跳过 npm run build。${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
# 4. 数据库迁移
|
# 4. 数据库迁移
|
||||||
echo -e "${YELLOW}[4/8] 数据库迁移...${NC}"
|
if [ "$MIGRATION_NEEDED" -eq 1 ]; then
|
||||||
"$PHP_BIN" artisan migrate --force
|
echo -e "${YELLOW}[4/8] 检测到新增迁移文件,执行数据库迁移...${NC}"
|
||||||
|
if [ -n "$NEW_MIGRATION_FILES" ]; then
|
||||||
|
echo -e "${BLUE}新增迁移文件:${NC}"
|
||||||
|
printf '%s\n' "$NEW_MIGRATION_FILES"
|
||||||
|
fi
|
||||||
|
"$PHP_BIN" artisan migrate --force
|
||||||
|
if [ $? -ne 0 ]; then echo -e "${RED}❌ 数据库迁移失败${NC}"; exit 1; fi
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}[4/8] 未检测到新增迁移文件,跳过数据库迁移。${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
# 5. 优化
|
# 5. 优化
|
||||||
echo -e "${YELLOW}[5/8] 生产环境优化...${NC}"
|
echo -e "${YELLOW}[5/8] 生产环境优化...${NC}"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "chatroom-ride-development",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"description": "聊天室座驾开发插件,沉淀独立座驾模块、金币流水、特效注册、命名规则和测试清单。",
|
||||||
|
"interface": {
|
||||||
|
"displayName": "Chatroom Ride Development"
|
||||||
|
},
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"name": "chatroom-ride-development",
|
||||||
|
"path": "skills/chatroom-ride-development/SKILL.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
name: chatroom-ride-development
|
||||||
|
description: "开发 /Users/pllx/Web/Herd/chatroom 的聊天室座驾。适用于新增 ride_<key> 独立座驾、全屏 Canvas 特效、座驾音效、后台配置、前台座驾页面和进房欢迎语。"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Chatroom Ride Development
|
||||||
|
|
||||||
|
## 适用场景
|
||||||
|
|
||||||
|
- 新增或修改聊天室独立座驾。
|
||||||
|
- 新增 `resources/js/effects/<key>.js` 全屏座驾特效。
|
||||||
|
- 调整座驾购买、续期、替换、入场欢迎语或后台价格/天数配置。
|
||||||
|
- 排查座驾进房后特效不播放、欢迎语不显示、购买记录不正确的问题。
|
||||||
|
- 排查座驾购买没有金币流水、后台座驾列表为空、前台座驾弹窗为空的问题。
|
||||||
|
|
||||||
|
## 必须遵守
|
||||||
|
|
||||||
|
- 座驾是独立模块:必须使用 `rides` 和 `user_ride_purchases`,不要写入 `shop_items` 或 `user_purchases`。
|
||||||
|
- 商店模块只管理普通商店商品;不要给 `ShopItem` 增加 `TYPE_RIDE`、`isRide()`、`rideKey()` 或座驾欢迎语字段。
|
||||||
|
- 座驾 slug 固定为 `ride_<effect_key>`。
|
||||||
|
- `<effect_key>` 必须同时出现在:
|
||||||
|
- `rides.slug`
|
||||||
|
- `rides.effect_key`
|
||||||
|
- `Ride::rideKey()` 可解析结果
|
||||||
|
- `EffectBroadcast::TYPES`
|
||||||
|
- `resources/js/effects/effect-manager.js`
|
||||||
|
- `resources/js/effects/effect-sounds.js`
|
||||||
|
- `resources/js/effects/<effect_key>.js`
|
||||||
|
- 新增座驾默认通过迁移或 Seeder 写入 `rides`,字段至少包含名称、slug、`effect_key`、图标、价格、`duration_days`、排序和 `welcome_message`。
|
||||||
|
- `welcome_message` 支持 `{name}` 和 `{ride}`,输出前必须转义,不能直接信任后台输入。
|
||||||
|
- 当前版本只允许用户同时拥有一个 active 座驾;同款续购叠加有效期,不同款替换旧座驾并把旧记录置为 `cancelled`。
|
||||||
|
- 用户购买记录必须写入 `user_ride_purchases`,不要复用商店购买记录。
|
||||||
|
- 座驾购买扣金币必须走 `UserCurrencyService::deductGoldIfEnough()`,来源使用 `CurrencySource::RIDE_BUY`,并写入 `user_currency_logs`。
|
||||||
|
- 座驾购买接口必须携带房间 ID,并把 `room_id` 传给金币流水,方便后台按房间追溯。
|
||||||
|
- 测试中禁止使用 `Redis::flushall()` 清理聊天室状态;只允许清理 `room:*` 相关 key,避免把本机登录 session 一起删掉。
|
||||||
|
|
||||||
|
## 模块边界
|
||||||
|
|
||||||
|
- 前台接口:`GET /rides/items`、`POST /rides/buy`。
|
||||||
|
- 后台入口:`GET /admin/rides`,页面文件为 `resources/views/admin/rides/index.blade.php`。
|
||||||
|
- 前台弹窗脚本:`resources/js/chat-room/ride-controls.js`。
|
||||||
|
- 业务服务:`app/Services/RideService.php`。
|
||||||
|
- 数据模型:`app/Models/Ride.php`、`app/Models/UserRidePurchase.php`。
|
||||||
|
- 请求验证:`BuyRideRequest`、`StoreRideRequest`、`UpdateRideRequest`。
|
||||||
|
|
||||||
|
## 已验收的进房展示规则
|
||||||
|
|
||||||
|
- 用户有有效座驾时,座驾优先级最高:
|
||||||
|
- 只发送一条 `座驾播报` 文字消息。
|
||||||
|
- 不再发送普通 `进出播报`。
|
||||||
|
- 不再播放会员进场横幅或会员全屏特效。
|
||||||
|
- 座驾全屏特效播放范围是当前房间内所有在线用户:
|
||||||
|
- 进房用户本人通过 `initialRideEffect` 本地播放。
|
||||||
|
- 其他在线用户通过 `EffectBroadcast` 的 `room.{roomId}` PresenceChannel 播放。
|
||||||
|
- 不设置 `target_username`,表示当前房间全员可见。
|
||||||
|
- 座驾文字播报口径:
|
||||||
|
- 显示 `部门 <部门> · 职务 <图标 职务> · 会员 <图标 会员>`。
|
||||||
|
- 不在身份行前面显示 `用户 <用户名>`,因为后面的欢迎语模板已经包含 `{name}`。
|
||||||
|
- 示例:`🚀 部门 办公厅 · 职务 🎖️ 技术总监 · 会员 👑 至尊会员 · 【流星】乘【东风-5C战略导弹】点火升空...`
|
||||||
|
- 座驾动画 HUD 标题口径:
|
||||||
|
- 第一行用户身份信息显示 `用户 <用户名> · 部门 <部门> · 职务 <图标 职务> · 会员 <图标 会员>`。
|
||||||
|
- 第二行标题只显示 `乘坐【<座驾名称>】闪亮登场`,不要重复用户名。
|
||||||
|
- `effect_user_info` 供动画 HUD 第一行使用,`effect_title` 供动画 HUD 第二行使用,`identity_text` 只供文字播报使用。
|
||||||
|
- 座驾购买成功必须向当前房间广播文字通知:
|
||||||
|
- 通知使用 `系统传音`,`action = ride_purchase`。
|
||||||
|
- 文案包含 `【座驾】`,前端会按百家乐同款紧凑卡片样式渲染。
|
||||||
|
- 通知末尾必须带 `购买座驾` 按钮,点击调用 `openRideModal()`,方便其他人快速购买。
|
||||||
|
- 购买通知必须写入 `ChatStateService`、广播 `MessageSent`,并通过 `SaveMessageJob` 落库。
|
||||||
|
- 前端改动后如果浏览器仍显示旧动画标题,必须运行 `npm run build` 或确认 Vite dev server 已刷新,避免加载旧的 `public/build` 资源。
|
||||||
|
|
||||||
|
## 新增座驾步骤
|
||||||
|
|
||||||
|
1. 新增全屏特效文件:`resources/js/effects/<effect_key>.js`。
|
||||||
|
2. 在 `effect-manager.js` 注册模块加载和启动分支。
|
||||||
|
3. 在 `effect-sounds.js` 注册音效启动分支。
|
||||||
|
4. 在 `EffectBroadcast::TYPES` 加入 `<effect_key>`。
|
||||||
|
5. 在迁移或 Seeder 中新增 `rides` 记录,slug 使用 `ride_<effect_key>`,`effect_key` 使用 `<effect_key>`。
|
||||||
|
6. 确认后台 `座驾管理` 可以配置价格、使用天数、欢迎语、上下架状态和排序。
|
||||||
|
7. 确认 `RideService::buy()` 购买时写入 `user_ride_purchases` 和 `user_currency_logs`。
|
||||||
|
8. 更新座驾相关 PHPUnit 测试,至少覆盖列表、余额不足、购买成功、金币流水、续期、替换和进房触发。
|
||||||
|
|
||||||
|
## 金币流水要求
|
||||||
|
|
||||||
|
- 购买成功必须在 `user_currency_logs` 中写入:
|
||||||
|
- `currency = gold`
|
||||||
|
- `amount = -座驾价格`
|
||||||
|
- `source = ride_buy`
|
||||||
|
- `remark = 购买聊天室座驾:<座驾名称>`
|
||||||
|
- `room_id = 当前房间 ID`
|
||||||
|
- 余额不足时不能写 `user_ride_purchases`,也不能写 `user_currency_logs`。
|
||||||
|
- 不要用 `$user->decrement('jjb', ...)` 直接扣金币。
|
||||||
|
|
||||||
|
## 旧方案清理要求
|
||||||
|
|
||||||
|
- 不要恢复 `add_ride_fields_to_shop_items` 这类把座驾挂到商店表的迁移。
|
||||||
|
- 允许保留清理迁移,用于删除旧环境中 `shop_items.slug like ride_%`、`shop_items.type = ride` 或 `shop_items.welcome_message` 残留。
|
||||||
|
- 修改座驾模块后,检查 `rg 'TYPE_RIDE|ShopItem::rideKey|RideItemController|复用商店购买记录'`,确认没有旧方案引用。
|
||||||
|
|
||||||
|
## 验证清单
|
||||||
|
|
||||||
|
- `node --check resources/js/effects/<effect_key>.js`
|
||||||
|
- `node --check resources/js/effects/effect-manager.js`
|
||||||
|
- `node --check resources/js/effects/effect-sounds.js`
|
||||||
|
- `php artisan test --compact tests/Feature/RideControllerTest.php`
|
||||||
|
- 有进房逻辑变更时运行相关 `ChatControllerTest` 过滤用例。
|
||||||
|
- 涉及商店边界时运行 `php artisan test --compact tests/Feature/ShopControllerTest.php`。
|
||||||
|
- 修改 PHP 后运行 `vendor/bin/pint --dirty --format=agent`。
|
||||||
|
|
||||||
|
## 特别注意
|
||||||
|
|
||||||
|
- 如果从 stash 恢复昨天的座驾特效,必须确认 untracked 父提交中的新特效文件也已恢复,不能只恢复已跟踪文件。
|
||||||
|
- `99a` 这种以数字开头的 key 在 JS 对象字面量里必须加引号。
|
||||||
|
- 新座驾的展示名可以是中文,但 effect key 必须保持小写短横线/数字/字母风格,避免前后端匹配失败。
|
||||||
|
- 本项目 PHP 文件要求类和方法上方有中文 DocBlock;新增座驾相关 PHP 文件时必须补齐。
|
||||||
+234
-1
@@ -53,6 +53,13 @@
|
|||||||
--scrollbar-track: #EAF2FF;
|
--scrollbar-track: #EAF2FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Emoji 字体回退:仅对 Emoji 字符生效,不影响中文文本 */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'EmojiFallback';
|
||||||
|
src: local('Apple Color Emoji'), local('Segoe UI Emoji'), local('Segoe UI Symbol'), local('Noto Color Emoji');
|
||||||
|
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+1F600-1F64F, U+1F680-1F6FF, U+200D, U+20E3, U+231A-231B, U+23E9-23F3, U+23F8-23FA, U+25AA-25AB, U+25B6, U+25C0, U+25FB-25FE, U+FE00-FE0F, U+1F3FB-1F3FF, U+1F9B0-1F9B3, U+1F1E0-1F1FF;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -60,7 +67,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "Microsoft YaHei", "SimSun", "宋体", sans-serif;
|
font-family: "Microsoft YaHei", "SimSun", "宋体", "EmojiFallback", sans-serif;
|
||||||
font-size: 10pt;
|
font-size: 10pt;
|
||||||
background: var(--bg-main);
|
background: var(--bg-main);
|
||||||
color: var(--text-navy);
|
color: var(--text-navy);
|
||||||
@@ -170,6 +177,7 @@ a:hover {
|
|||||||
.room-title-bar {
|
.room-title-bar {
|
||||||
background: var(--bg-header);
|
background: var(--bg-header);
|
||||||
color: var(--text-white);
|
color: var(--text-white);
|
||||||
|
position: relative;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -178,6 +186,10 @@ a:hover {
|
|||||||
border-bottom: 1px solid var(--border-blue);
|
border-bottom: 1px solid var(--border-blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.room-title-main {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.room-title-bar .room-name {
|
.room-title-bar .room-name {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -188,6 +200,145 @@ a:hover {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-board {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 34px;
|
||||||
|
width: 132px;
|
||||||
|
color: #fff7ed;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.28;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-board-title {
|
||||||
|
color: #fde68a;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-avatars {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-avatar-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-avatar {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 999px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-avatar-rank-1 {
|
||||||
|
background: linear-gradient(135deg, #fde047, #f59e0b 48%, #fff7ad);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-avatar-rank-1::before {
|
||||||
|
content: "1";
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #fff7ed;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 14px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0 0 1px rgba(255, 247, 237, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-avatar-rank-2 {
|
||||||
|
background: linear-gradient(135deg, #f8fafc, #94a3b8 48%, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-avatar-rank-2::before {
|
||||||
|
content: "2";
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #64748b;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 14px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-avatar-rank-3 {
|
||||||
|
background: linear-gradient(135deg, #fed7aa, #c2410c 48%, #ffedd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-avatar-rank-3::before {
|
||||||
|
content: "3";
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #c2410c;
|
||||||
|
color: #fff7ed;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 14px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0 0 1px rgba(255, 247, 237, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-avatar-wrap::after {
|
||||||
|
content: attr(data-username);
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: -24px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: 110px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.88);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.12s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-avatar-wrap:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-empty {
|
||||||
|
color: #dbeafe;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
/* 公告/祝福语滚动条(复刻原版 marquee) */
|
/* 公告/祝福语滚动条(复刻原版 marquee) */
|
||||||
.room-announcement-bar {
|
.room-announcement-bar {
|
||||||
background: linear-gradient(to right, #a8d8a8, #c8f0c8, #a8d8a8);
|
background: linear-gradient(to right, #a8d8a8, #c8f0c8, #a8d8a8);
|
||||||
@@ -930,6 +1081,46 @@ a:hover {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 输入栏管理菜单在手机抽屉中会被原本的按钮相对定位带偏,手机端改为视口居中弹层。 */
|
||||||
|
#admin-menu {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 50% !important;
|
||||||
|
right: auto !important;
|
||||||
|
bottom: auto !important;
|
||||||
|
left: 50% !important;
|
||||||
|
width: min(92vw, 320px) !important;
|
||||||
|
max-width: calc(100vw - 24px) !important;
|
||||||
|
max-height: min(78vh, 560px) !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 10040 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 欢迎语模板较长,手机端同样需要脱离按钮定位,避免右侧被屏幕裁掉。 */
|
||||||
|
#welcome-menu {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 50% !important;
|
||||||
|
right: auto !important;
|
||||||
|
bottom: auto !important;
|
||||||
|
left: 50% !important;
|
||||||
|
width: min(92vw, 340px) !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: calc(100vw - 24px) !important;
|
||||||
|
max-height: min(72vh, 520px) !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: auto;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 10040 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#welcome-menu .welcome-menu-item {
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
line-height: 1.45;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 名单抽屉内用户列表 item 放大触摸区域 ── */
|
/* ── 名单抽屉内用户列表 item 放大触摸区域 ── */
|
||||||
#mob-online-users-list .user-item {
|
#mob-online-users-list .user-item {
|
||||||
padding: 7px 8px;
|
padding: 7px 8px;
|
||||||
@@ -956,6 +1147,48 @@ a:hover {
|
|||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-board {
|
||||||
|
top: 5px;
|
||||||
|
right: 6px;
|
||||||
|
width: 102px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-board-title {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-avatars {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-avatar-wrap {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-avatar {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-avatar-rank-1::before,
|
||||||
|
.daily-game-profit-avatar-rank-2::before,
|
||||||
|
.daily-game-profit-avatar-rank-3::before {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
font-size: 8px;
|
||||||
|
line-height: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-game-profit-avatar-wrap::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 手机端隐藏房间介绍 ── */
|
/* ── 手机端隐藏房间介绍 ── */
|
||||||
.room-desc {
|
.room-desc {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
|
|||||||
@@ -64,8 +64,10 @@ export { bindChatToast } from "./chat-room/toast.js";
|
|||||||
export { bindChatComposerControls, setChatComposerAction } from "./chat-room/composer.js";
|
export { bindChatComposerControls, setChatComposerAction } from "./chat-room/composer.js";
|
||||||
export {
|
export {
|
||||||
isExpiredChatImageMessage,
|
isExpiredChatImageMessage,
|
||||||
|
isAutoScrollEnabled,
|
||||||
localClearScreen,
|
localClearScreen,
|
||||||
scrollChatToBottom,
|
scrollChatToBottom,
|
||||||
|
setAutoScrollEnabled,
|
||||||
syncAutoScrollControls,
|
syncAutoScrollControls,
|
||||||
toggleAutoScroll,
|
toggleAutoScroll,
|
||||||
} from "./chat-room/message-utils.js";
|
} from "./chat-room/message-utils.js";
|
||||||
@@ -139,6 +141,10 @@ const _shop = createLazyModule(
|
|||||||
() => import("./chat-room/shop-controls.js"),
|
() => import("./chat-room/shop-controls.js"),
|
||||||
(mod) => mod.bindShopControls()
|
(mod) => mod.bindShopControls()
|
||||||
);
|
);
|
||||||
|
const _ride = createLazyModule(
|
||||||
|
() => import("./chat-room/ride-controls.js"),
|
||||||
|
(mod) => mod.bindRideControls()
|
||||||
|
);
|
||||||
const _compactShop = createLazyModule(
|
const _compactShop = createLazyModule(
|
||||||
() => import("./chat-room/compact-shop-panel.js"),
|
() => import("./chat-room/compact-shop-panel.js"),
|
||||||
(mod) => mod.bindCompactShopPanelControls()
|
(mod) => mod.bindCompactShopPanelControls()
|
||||||
@@ -234,8 +240,10 @@ import { bindChatToast } from "./chat-room/toast.js";
|
|||||||
import { bindChatComposerControls, setChatComposerAction } from "./chat-room/composer.js";
|
import { bindChatComposerControls, setChatComposerAction } from "./chat-room/composer.js";
|
||||||
import {
|
import {
|
||||||
isExpiredChatImageMessage,
|
isExpiredChatImageMessage,
|
||||||
|
isAutoScrollEnabled,
|
||||||
localClearScreen,
|
localClearScreen,
|
||||||
scrollChatToBottom,
|
scrollChatToBottom,
|
||||||
|
setAutoScrollEnabled,
|
||||||
syncAutoScrollControls,
|
syncAutoScrollControls,
|
||||||
toggleAutoScroll,
|
toggleAutoScroll,
|
||||||
} from "./chat-room/message-utils.js";
|
} from "./chat-room/message-utils.js";
|
||||||
@@ -315,8 +323,10 @@ if (typeof window !== "undefined") {
|
|||||||
bindChatComposerControls,
|
bindChatComposerControls,
|
||||||
setChatComposerAction,
|
setChatComposerAction,
|
||||||
isExpiredChatImageMessage,
|
isExpiredChatImageMessage,
|
||||||
|
isAutoScrollEnabled,
|
||||||
localClearScreen,
|
localClearScreen,
|
||||||
scrollChatToBottom,
|
scrollChatToBottom,
|
||||||
|
setAutoScrollEnabled,
|
||||||
syncAutoScrollControls,
|
syncAutoScrollControls,
|
||||||
toggleAutoScroll,
|
toggleAutoScroll,
|
||||||
bindInstantHoverTooltip,
|
bindInstantHoverTooltip,
|
||||||
@@ -392,6 +402,13 @@ if (typeof window !== "undefined") {
|
|||||||
renderShop: (...args) => _shop.wrap('renderShop')(...args),
|
renderShop: (...args) => _shop.wrap('renderShop')(...args),
|
||||||
showShopToast: (...args) => _shop.wrap('showShopToast')(...args),
|
showShopToast: (...args) => _shop.wrap('showShopToast')(...args),
|
||||||
submitRename: (...args) => _shop.wrap('submitRename')(...args),
|
submitRename: (...args) => _shop.wrap('submitRename')(...args),
|
||||||
|
bindRideControls: (...args) => _ride.wrap('bindRideControls')(...args),
|
||||||
|
buyRide: (...args) => _ride.wrap('buyRide')(...args),
|
||||||
|
closeRideModal: (...args) => _ride.wrap('closeRideModal')(...args),
|
||||||
|
fetchRideData: (...args) => _ride.wrap('fetchRideData')(...args),
|
||||||
|
loadRides: (...args) => _ride.wrap('loadRides')(...args),
|
||||||
|
openRideModal: (...args) => _ride.wrap('openRideModal')(...args),
|
||||||
|
renderRides: (...args) => _ride.wrap('renderRides')(...args),
|
||||||
bindCompactShopPanelControls: (...args) => _compactShop.wrap('bindCompactShopPanelControls')(...args),
|
bindCompactShopPanelControls: (...args) => _compactShop.wrap('bindCompactShopPanelControls')(...args),
|
||||||
buyCompactShopItem: (...args) => _compactShop.wrap('buyCompactShopItem')(...args),
|
buyCompactShopItem: (...args) => _compactShop.wrap('buyCompactShopItem')(...args),
|
||||||
closeCompactRenameModal: (...args) => _compactShop.wrap('closeCompactRenameModal')(...args),
|
closeCompactRenameModal: (...args) => _compactShop.wrap('closeCompactRenameModal')(...args),
|
||||||
@@ -581,8 +598,10 @@ if (typeof window !== "undefined") {
|
|||||||
// ── 静态核心模块 window 挂载 ──
|
// ── 静态核心模块 window 挂载 ──
|
||||||
window.escapeHtml = escapeHtml;
|
window.escapeHtml = escapeHtml;
|
||||||
window.isExpiredChatImageMessage = isExpiredChatImageMessage;
|
window.isExpiredChatImageMessage = isExpiredChatImageMessage;
|
||||||
|
window.isChatAutoScrollEnabled = isAutoScrollEnabled;
|
||||||
window.localClearScreen = localClearScreen;
|
window.localClearScreen = localClearScreen;
|
||||||
window.normalizeSafeChatUrl = normalizeSafeChatUrl;
|
window.normalizeSafeChatUrl = normalizeSafeChatUrl;
|
||||||
|
window.setChatAutoScrollEnabled = setAutoScrollEnabled;
|
||||||
window.setAction = setChatComposerAction;
|
window.setAction = setChatComposerAction;
|
||||||
window.syncAutoScrollControls = syncAutoScrollControls;
|
window.syncAutoScrollControls = syncAutoScrollControls;
|
||||||
|
|
||||||
@@ -628,6 +647,12 @@ if (typeof window !== "undefined") {
|
|||||||
window.renderShop = (...args) => _shop.wrap('renderShop')(...args);
|
window.renderShop = (...args) => _shop.wrap('renderShop')(...args);
|
||||||
window.showShopToast = (...args) => _shop.wrap('showShopToast')(...args);
|
window.showShopToast = (...args) => _shop.wrap('showShopToast')(...args);
|
||||||
window.submitRename = (...args) => _shop.wrap('submitRename')(...args);
|
window.submitRename = (...args) => _shop.wrap('submitRename')(...args);
|
||||||
|
window.buyRide = (...args) => _ride.wrap('buyRide')(...args);
|
||||||
|
window.closeRideModal = (...args) => _ride.wrap('closeRideModal')(...args);
|
||||||
|
window.fetchRideData = (...args) => _ride.wrap('fetchRideData')(...args);
|
||||||
|
window.loadRides = (...args) => _ride.wrap('loadRides')(...args);
|
||||||
|
window.openRideModal = (...args) => _ride.wrap('openRideModal')(...args);
|
||||||
|
window.renderRides = (...args) => _ride.wrap('renderRides')(...args);
|
||||||
window.closeAvatarPicker = (...args) => _profile.wrap('closeAvatarPicker')(...args);
|
window.closeAvatarPicker = (...args) => _profile.wrap('closeAvatarPicker')(...args);
|
||||||
window.closeSettingsModal = (...args) => _profile.wrap('closeSettingsModal')(...args);
|
window.closeSettingsModal = (...args) => _profile.wrap('closeSettingsModal')(...args);
|
||||||
window.copyWechatBindCode = (...args) => _profile.wrap('copyWechatBindCode')(...args);
|
window.copyWechatBindCode = (...args) => _profile.wrap('copyWechatBindCode')(...args);
|
||||||
|
|||||||
@@ -46,6 +46,19 @@ export function bindAdminMenuControls() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectPreviewButton = event.target.closest("[data-chat-admin-effect-preview]");
|
||||||
|
if (effectPreviewButton) {
|
||||||
|
event.preventDefault();
|
||||||
|
const effect = effectPreviewButton.getAttribute("data-chat-admin-effect-preview") || "";
|
||||||
|
const menu = document.getElementById("admin-menu");
|
||||||
|
if (menu) {
|
||||||
|
menu.style.display = "none";
|
||||||
|
}
|
||||||
|
// 预览按钮仅在当前浏览器播放,方便测试新特效时不打扰房间其他用户。
|
||||||
|
window.EffectManager?.play?.(effect);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.target.closest("[data-chat-admin-menu]")) {
|
if (event.target.closest("[data-chat-admin-menu]")) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// 聊天室任命/撤销公告监听,负责渲染大卡片和频道内系统提示。
|
// 聊天室任命/撤销公告监听,负责渲染大卡片和频道内系统提示。
|
||||||
|
|
||||||
import { escapeHtml } from "./html.js";
|
import { escapeHtml } from "./html.js";
|
||||||
|
import { isAutoScrollEnabled, scrollChatToBottom } from "./message-utils.js";
|
||||||
|
|
||||||
const APPOINTMENT_PHRASES = [
|
const APPOINTMENT_PHRASES = [
|
||||||
"望再接再厉,大展宏图,为大家服务!",
|
"望再接再厉,大展宏图,为大家服务!",
|
||||||
@@ -132,7 +133,7 @@ function appendAppointmentMessage(container, message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
container.appendChild(message);
|
container.appendChild(message);
|
||||||
container.scrollTop = container.scrollHeight;
|
scrollChatToBottom(container, isAutoScrollEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { escapeHtml, normalizeSafeChatUrl } from "./html.js";
|
import { escapeHtml, normalizeSafeChatUrl } from "./html.js";
|
||||||
import { normalizeDailyStatus } from "./preferences-status.js";
|
import { normalizeDailyStatus } from "./preferences-status.js";
|
||||||
import { enqueueChatMessage } from "./message-renderer.js";
|
import { enqueueChatMessage } from "./message-renderer.js";
|
||||||
|
import { isAutoScrollEnabled, scrollChatToBottom } from "./message-utils.js";
|
||||||
|
|
||||||
// ── 事件注册标记 ──
|
// ── 事件注册标记 ──
|
||||||
let chatEventsBound = false;
|
let chatEventsBound = false;
|
||||||
@@ -19,6 +20,16 @@ function getState() {
|
|||||||
return window.chatState;
|
return window.chatState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在开启自动滚屏时把指定聊天窗格滚动到底部。
|
||||||
|
*
|
||||||
|
* @param {HTMLElement|null|undefined} container 聊天消息容器
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function scrollWhenEnabled(container) {
|
||||||
|
scrollChatToBottom(container, isAutoScrollEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动 WebSocket 初始化(DOMContentLoaded 之后调用)。
|
* 启动 WebSocket 初始化(DOMContentLoaded 之后调用)。
|
||||||
*/
|
*/
|
||||||
@@ -59,6 +70,48 @@ function runWhenDomReady(callback) {
|
|||||||
callback();
|
callback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 Toast 通知是否需要对当前用户隐藏。
|
||||||
|
*
|
||||||
|
* @param {Record<string, any>} toastNotification 右下角通知载荷
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function shouldSkipToastForCurrentUser(toastNotification) {
|
||||||
|
if (!toastNotification?.skip_for_actor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(toastNotification.actor_username || "") === String(window.chatContext?.username || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前是否为手机浏览器视口。
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isMobileToastViewport() {
|
||||||
|
return window.matchMedia?.("(max-width: 640px)")?.matches === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机端只隐藏无本人关联的公屏全局 Toast。
|
||||||
|
*
|
||||||
|
* @param {Record<string, any>} message 聊天消息载荷
|
||||||
|
* @param {Record<string, any>} toastNotification 右下角通知载荷
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function shouldHideGlobalToastOnMobile(message, toastNotification) {
|
||||||
|
if (!isMobileToastViewport() || message?.to_user !== '大家') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUsername = String(window.chatContext?.username || "");
|
||||||
|
const actorUsername = String(toastNotification?.actor_username || "");
|
||||||
|
const targetUsername = String(toastNotification?.target_username || "");
|
||||||
|
|
||||||
|
return actorUsername !== currentUsername && targetUsername !== currentUsername;
|
||||||
|
}
|
||||||
|
|
||||||
// ── 禁言逻辑 ──
|
// ── 禁言逻辑 ──
|
||||||
function handleMutedEvent(e) {
|
function handleMutedEvent(e) {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
@@ -79,7 +132,7 @@ function handleMutedEvent(e) {
|
|||||||
: (state?.container);
|
: (state?.container);
|
||||||
if (targetContainer) {
|
if (targetContainer) {
|
||||||
targetContainer.appendChild(div);
|
targetContainer.appendChild(div);
|
||||||
targetContainer.scrollTop = targetContainer.scrollHeight;
|
scrollWhenEnabled(targetContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMe && d.mute_time > 0) {
|
if (isMe && d.mute_time > 0) {
|
||||||
@@ -99,7 +152,7 @@ function handleMutedEvent(e) {
|
|||||||
const say2 = document.getElementById("say2");
|
const say2 = document.getElementById("say2");
|
||||||
if (say2) {
|
if (say2) {
|
||||||
say2.appendChild(unmuteDiv);
|
say2.appendChild(unmuteDiv);
|
||||||
say2.scrollTop = say2.scrollHeight;
|
scrollWhenEnabled(say2);
|
||||||
}
|
}
|
||||||
}, d.mute_time * 60 * 1000);
|
}, d.mute_time * 60 * 1000);
|
||||||
}
|
}
|
||||||
@@ -148,7 +201,7 @@ function setupScreenClearedListener() {
|
|||||||
sysDiv.innerHTML = `<span style="color: #dc2626; font-weight: bold;">🧹 管理员 <b>${safeOperator}</b> 已执行全员清屏</span><span class="msg-time">(${timeStr})</span>`;
|
sysDiv.innerHTML = `<span style="color: #dc2626; font-weight: bold;">🧹 管理员 <b>${safeOperator}</b> 已执行全员清屏</span><span class="msg-time">(${timeStr})</span>`;
|
||||||
if (say1) {
|
if (say1) {
|
||||||
say1.appendChild(sysDiv);
|
say1.appendChild(sysDiv);
|
||||||
say1.scrollTop = say1.scrollHeight;
|
scrollWhenEnabled(say1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -205,7 +258,7 @@ function setupChangelogPublishedListener() {
|
|||||||
const say1 = document.getElementById("chat-messages-container");
|
const say1 = document.getElementById("chat-messages-container");
|
||||||
if (say1) {
|
if (say1) {
|
||||||
say1.appendChild(sysDiv);
|
say1.appendChild(sysDiv);
|
||||||
say1.scrollTop = say1.scrollHeight;
|
scrollWhenEnabled(say1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -255,7 +308,7 @@ function setupGomokuInviteListener() {
|
|||||||
const say1 = document.getElementById("chat-messages-container");
|
const say1 = document.getElementById("chat-messages-container");
|
||||||
if (say1) {
|
if (say1) {
|
||||||
say1.appendChild(div);
|
say1.appendChild(div);
|
||||||
say1.scrollTop = say1.scrollHeight;
|
scrollWhenEnabled(say1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isSelf) {
|
if (!isSelf) {
|
||||||
@@ -294,7 +347,7 @@ function setupGomokuInviteListener() {
|
|||||||
const say1 = document.getElementById("chat-messages-container");
|
const say1 = document.getElementById("chat-messages-container");
|
||||||
if (say1) {
|
if (say1) {
|
||||||
say1.appendChild(div);
|
say1.appendChild(div);
|
||||||
say1.scrollTop = say1.scrollHeight;
|
scrollWhenEnabled(say1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -406,12 +459,20 @@ export function bindChatEvents() {
|
|||||||
// 若消息携带 toast_notification 字段且当前用户是接收者或为公屏广播或为欢迎动作,弹右下角小卡片
|
// 若消息携带 toast_notification 字段且当前用户是接收者或为公屏广播或为欢迎动作,弹右下角小卡片
|
||||||
if (msg.toast_notification && (msg.to_user === window.chatContext?.username || msg.to_user === '大家' || msg.action === '欢迎')) {
|
if (msg.toast_notification && (msg.to_user === window.chatContext?.username || msg.to_user === '大家' || msg.action === '欢迎')) {
|
||||||
const t = msg.toast_notification;
|
const t = msg.toast_notification;
|
||||||
|
if (shouldSkipToastForCurrentUser(t)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldHideGlobalToastOnMobile(msg, t)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
window.chatToast?.show({
|
window.chatToast?.show({
|
||||||
title: t.title || "通知",
|
title: t.title || "通知",
|
||||||
message: t.message || "",
|
message: t.message || "",
|
||||||
icon: t.icon || "💬",
|
icon: t.icon || "💬",
|
||||||
color: t.color || "#336699",
|
color: t.color || "#336699",
|
||||||
duration: t.duration ?? 8000,
|
duration: t.duration ?? 3000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -482,10 +543,16 @@ export function bindChatEvents() {
|
|||||||
const target = e.detail?.target_username;
|
const target = e.detail?.target_username;
|
||||||
const operator = e.detail?.operator;
|
const operator = e.detail?.operator;
|
||||||
const myName = window.chatContext?.username;
|
const myName = window.chatContext?.username;
|
||||||
|
const effectOptions = {
|
||||||
|
effect_title: e.detail?.effect_title,
|
||||||
|
effect_user_info: e.detail?.effect_user_info,
|
||||||
|
ride_name: e.detail?.ride_name,
|
||||||
|
operator,
|
||||||
|
};
|
||||||
|
|
||||||
if (type && typeof EffectManager !== "undefined") {
|
if (type && typeof EffectManager !== "undefined") {
|
||||||
if (!target || target === myName || operator === myName) {
|
if (!target || target === myName || operator === myName) {
|
||||||
EffectManager.play(type);
|
EffectManager.play(type, effectOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const PRIVATE_MESSAGE_NODE_LIMIT = 300;
|
|||||||
export const CHAT_MESSAGE_FLUSH_BATCH_SIZE = 8;
|
export const CHAT_MESSAGE_FLUSH_BATCH_SIZE = 8;
|
||||||
export const ROOMS_ONLINE_STATUS_CACHE_TTL = 10000;
|
export const ROOMS_ONLINE_STATUS_CACHE_TTL = 10000;
|
||||||
export const HEARTBEAT_INTERVAL = 60 * 1000;
|
export const HEARTBEAT_INTERVAL = 60 * 1000;
|
||||||
export const SYSTEM_USERS = ["钓鱼播报", "星海小博士", "系统传音", "系统公告", "送花播报", "系统", "欢迎", "系统播报", "神秘箱子"];
|
export const SYSTEM_USERS = ["钓鱼播报", "星海小博士", "系统传音", "系统公告", "送花播报", "座驾播报", "系统", "欢迎", "系统播报", "神秘箱子"];
|
||||||
|
|
||||||
// 消息动作文字映射表:情绪型(着/地,放"对"之前)和动作型(了,替换"对X说")
|
// 消息动作文字映射表:情绪型(着/地,放"对"之前)和动作型(了,替换"对X说")
|
||||||
export const ACTION_TEXT_MAP = {
|
export const ACTION_TEXT_MAP = {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// 聊天输入区完整逻辑:发送消息、草稿管理、IME 防重、神秘箱子暗号拦截。
|
// 聊天输入区完整逻辑:发送消息、草稿管理、IME 防重、神秘箱子暗号拦截。
|
||||||
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
|
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
|
||||||
|
|
||||||
|
import { isAutoScrollEnabled, scrollChatToBottom } from "./message-utils.js";
|
||||||
|
|
||||||
let chatComposerEventsBound = false;
|
let chatComposerEventsBound = false;
|
||||||
|
|
||||||
function csrf() {
|
function csrf() {
|
||||||
@@ -165,7 +167,7 @@ async function sendMessage(e) {
|
|||||||
const say2 = document.getElementById("say2");
|
const say2 = document.getElementById("say2");
|
||||||
if (say2) {
|
if (say2) {
|
||||||
say2.appendChild(muteDiv);
|
say2.appendChild(muteDiv);
|
||||||
say2.scrollTop = say2.scrollHeight;
|
scrollChatToBottom(say2, isAutoScrollEnabled);
|
||||||
}
|
}
|
||||||
if (state) {
|
if (state) {
|
||||||
state.isSending = false;
|
state.isSending = false;
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function playEntryEffect(initialState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
window.EffectManager?.play?.(initialState.entryEffect);
|
window.EffectManager?.play?.(initialState.entryEffect, initialState.entryEffectOptions || {});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// 婚姻弹窗辅助入口,承接从 marriage-modals.blade.php 迁移出的全局函数。
|
// 婚姻弹窗辅助入口,承接从 marriage-modals.blade.php 迁移出的全局函数。
|
||||||
|
|
||||||
|
import { isAutoScrollEnabled, scrollChatToBottom } from "./message-utils.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 向聊天主窗口追加一条婚姻系统公告,允许传入受控 HTML 按钮。
|
* 向聊天主窗口追加一条婚姻系统公告,允许传入受控 HTML 按钮。
|
||||||
*
|
*
|
||||||
@@ -17,7 +19,7 @@ export function appendSystemMessage(html) {
|
|||||||
div.style.cssText = "background:linear-gradient(135deg,#fdf4ff,#fce7f3); border-left:3px solid #ec4899; border-radius:6px; padding:5px 12px; margin:3px 0; font-size:13px; line-height:1.6;";
|
div.style.cssText = "background:linear-gradient(135deg,#fdf4ff,#fce7f3); border-left:3px solid #ec4899; border-radius:6px; padding:5px 12px; margin:3px 0; font-size:13px; line-height:1.6;";
|
||||||
div.innerHTML = `<span style="color:#9d174d;">${html}</span>`;
|
div.innerHTML = `<span style="color:#9d174d;">${html}</span>`;
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
container.scrollTop = container.scrollHeight;
|
scrollChatToBottom(container, isAutoScrollEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -201,13 +201,25 @@ function resolveGameNotificationCardMeta(msg) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedContent.includes("【座驾】")) {
|
||||||
|
return {
|
||||||
|
label: "座驾",
|
||||||
|
icon: "🚀",
|
||||||
|
accent: "#0f766e",
|
||||||
|
background: "linear-gradient(135deg,#ecfeff,#f0fdfa)",
|
||||||
|
border: "rgba(15,118,110,.16)",
|
||||||
|
text: "#115e59",
|
||||||
|
chipBg: "#ccfbf1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
normalizedContent.includes("【赛马】")
|
normalizedContent.includes("【赛马】")
|
||||||
|| normalizedContent.startsWith("🐎 开赛:")
|
|| normalizedContent.startsWith("🐎 开赛:")
|
||||||
|| normalizedContent.startsWith("🏇 比赛开始:")
|
|| normalizedContent.startsWith("🏇 比赛开始:")
|
||||||
|| normalizedContent.startsWith("🏆 冠军:")
|
|| normalizedContent.startsWith("🏆 冠军:")
|
||||||
|| /^🐎\s*第\s*#?\d+\s*场开赛/u.test(normalizedContent)
|
|| /^🐎\s*第\s*#?\d+\s*场开赛/u.test(normalizedContent)
|
||||||
|| /^🏇\s*第\s*#?\d+\s*场比赛开始/u.test(normalizedContent)
|
|| /^🏇\s*(?:赛马)?第\s*#?\d+\s*场比赛开始/u.test(normalizedContent)
|
||||||
|| /^🏆\s*第\s*#?\d+\s*场结束/u.test(normalizedContent)
|
|| /^🏆\s*第\s*#?\d+\s*场结束/u.test(normalizedContent)
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
@@ -336,6 +348,14 @@ function extractSystemGameCardSummary(content, meta) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (meta.label === "座驾") {
|
||||||
|
return normalizedContent
|
||||||
|
.replace(/^[🚀]+\s*/u, "")
|
||||||
|
.replace(/^【座驾】/u, "")
|
||||||
|
.replace(/\s+/gu, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
if (meta.label === "赛马") {
|
if (meta.label === "赛马") {
|
||||||
return normalizedContent
|
return normalizedContent
|
||||||
.replace(/^[🐎🏇🏆]+\s*/u, "")
|
.replace(/^[🐎🏇🏆]+\s*/u, "")
|
||||||
|
|||||||
@@ -75,10 +75,46 @@ export function localClearScreen(roomId = window.chatContext?.roomId, maxMessage
|
|||||||
|
|
||||||
if (publicPane) {
|
if (publicPane) {
|
||||||
publicPane.appendChild(notice);
|
publicPane.appendChild(notice);
|
||||||
publicPane.scrollTop = publicPane.scrollHeight;
|
scrollChatToBottom(publicPane, isAutoScrollEnabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取当前是否允许聊天窗口自动滚屏。
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isAutoScrollEnabled() {
|
||||||
|
const state = window.chatState;
|
||||||
|
|
||||||
|
if (state && typeof state.autoScroll !== "undefined") {
|
||||||
|
return Boolean(state.autoScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkbox = document.getElementById("auto_scroll");
|
||||||
|
|
||||||
|
return checkbox ? Boolean(checkbox.checked) : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入自动滚屏状态,并同步页面复选框和状态文字。
|
||||||
|
*
|
||||||
|
* @param {boolean} enabled 是否开启自动滚屏
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function setAutoScrollEnabled(enabled) {
|
||||||
|
const nextEnabled = Boolean(enabled);
|
||||||
|
const state = window.chatState;
|
||||||
|
|
||||||
|
if (state) {
|
||||||
|
state.autoScroll = nextEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncAutoScrollControls(nextEnabled);
|
||||||
|
|
||||||
|
return nextEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步自动滚屏复选框与状态文字。
|
* 同步自动滚屏复选框与状态文字。
|
||||||
*
|
*
|
||||||
@@ -111,6 +147,8 @@ export function toggleAutoScroll(getCurrent, setCurrent) {
|
|||||||
|
|
||||||
if (typeof setCurrent === "function") {
|
if (typeof setCurrent === "function") {
|
||||||
setCurrent(nextEnabled);
|
setCurrent(nextEnabled);
|
||||||
|
} else {
|
||||||
|
setAutoScrollEnabled(nextEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
syncAutoScrollControls(nextEnabled);
|
syncAutoScrollControls(nextEnabled);
|
||||||
|
|||||||
@@ -545,14 +545,18 @@ export function resolveBlockedSystemSenderKey(msg) {
|
|||||||
return "百家乐";
|
return "百家乐";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 赛马通知已缩短为“开赛:...”“比赛开始:...”“冠军:...”等格式。
|
// 赛马通知可能来自旧版“跑马”发送者,也可能以系统传音发送紧凑开赛/结束文案。
|
||||||
if (
|
if (
|
||||||
|
fromUser === "跑马" ||
|
||||||
isSystemBroadcast && (
|
isSystemBroadcast && (
|
||||||
content.includes("赛马") ||
|
content.includes("赛马") ||
|
||||||
content.includes("跑马") ||
|
content.includes("跑马") ||
|
||||||
content.startsWith("🐎 开赛:") ||
|
content.startsWith("🐎 开赛:") ||
|
||||||
content.startsWith("🏇 比赛开始:") ||
|
content.startsWith("🏇 比赛开始:") ||
|
||||||
content.startsWith("🏆 冠军:")
|
content.startsWith("🏆 冠军:") ||
|
||||||
|
/^🐎\s*第\s*#?\d+\s*场开赛/u.test(content) ||
|
||||||
|
/^🏇\s*(?:赛马)?第\s*#?\d+\s*场比赛开始/u.test(content) ||
|
||||||
|
/^🏆\s*第\s*#?\d+\s*场结束/u.test(content)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return "跑马";
|
return "跑马";
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ export async function savePassword() {
|
|||||||
*/
|
*/
|
||||||
export async function saveSettings() {
|
export async function saveSettings() {
|
||||||
const profileData = {
|
const profileData = {
|
||||||
|
previous_name: element("set-previous-name").value || "",
|
||||||
sex: element("set-sex").value,
|
sex: element("set-sex").value,
|
||||||
email: element("set-email").value,
|
email: element("set-email").value,
|
||||||
email_code: element("set-email-code")?.value || "",
|
email_code: element("set-email-code")?.value || "",
|
||||||
|
|||||||
@@ -27,6 +27,24 @@ function byId(id) {
|
|||||||
return document.getElementById(id);
|
return document.getElementById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新红包弹窗状态提示,兼容状态提示区域被移除的简化弹窗。
|
||||||
|
*
|
||||||
|
* @param {string} message 状态文案
|
||||||
|
* @param {string} color 状态颜色
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function setRedPacketStatus(message, color) {
|
||||||
|
const status = byId("rp-status-msg");
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
status.textContent = message;
|
||||||
|
status.style.color = color;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重置礼包按钮状态。
|
* 重置礼包按钮状态。
|
||||||
*
|
*
|
||||||
@@ -317,9 +335,7 @@ function renderRedPacketModal(payload) {
|
|||||||
byId("rp-remaining").textContent = payload.remainingCount;
|
byId("rp-remaining").textContent = payload.remainingCount;
|
||||||
byId("rp-countdown").textContent = payload.expireSeconds;
|
byId("rp-countdown").textContent = payload.expireSeconds;
|
||||||
byId("rp-timer-bar").style.width = "100%";
|
byId("rp-timer-bar").style.width = "100%";
|
||||||
byId("rp-status-msg").textContent = "";
|
setRedPacketStatus("", "#16a34a");
|
||||||
byId("rp-claims-list").style.display = "none";
|
|
||||||
byId("rp-claims-items").textContent = "";
|
|
||||||
|
|
||||||
const emoji = modal.querySelector(".rp-emoji");
|
const emoji = modal.querySelector(".rp-emoji");
|
||||||
if (emoji) {
|
if (emoji) {
|
||||||
@@ -361,9 +377,7 @@ function startRedPacketTimer() {
|
|||||||
claimButton.disabled = true;
|
claimButton.disabled = true;
|
||||||
claimButton.textContent = "礼包已过期";
|
claimButton.textContent = "礼包已过期";
|
||||||
|
|
||||||
const status = byId("rp-status-msg");
|
setRedPacketStatus("红包已过期,即将关闭…", "#9ca3af");
|
||||||
status.style.color = "#9ca3af";
|
|
||||||
status.textContent = "红包已过期,即将关闭…";
|
|
||||||
|
|
||||||
setTimeout(() => closeRedPacketModal(), 3000);
|
setTimeout(() => closeRedPacketModal(), 3000);
|
||||||
}
|
}
|
||||||
@@ -479,9 +493,7 @@ export async function claimRedPacket() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
handleClaimResponse(response, data, button);
|
handleClaimResponse(response, data, button);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = byId("rp-status-msg");
|
setRedPacketStatus("网络异常,请重试", "#dc2626");
|
||||||
status.textContent = "网络异常,请重试";
|
|
||||||
status.style.color = "#dc2626";
|
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
button.textContent = "🧧 立即抢红包";
|
button.textContent = "🧧 立即抢红包";
|
||||||
}
|
}
|
||||||
@@ -496,14 +508,12 @@ export async function claimRedPacket() {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
function handleClaimResponse(response, data, button) {
|
function handleClaimResponse(response, data, button) {
|
||||||
const status = byId("rp-status-msg");
|
|
||||||
const typeLabel = redPacketType === "exp" ? "经验" : "金币";
|
const typeLabel = redPacketType === "exp" ? "经验" : "金币";
|
||||||
|
|
||||||
if (response.ok && data.status === "success") {
|
if (response.ok && data.status === "success") {
|
||||||
redPacketClaimed = true;
|
redPacketClaimed = true;
|
||||||
button.textContent = "🎉 已抢到!";
|
button.textContent = "🎉 已抢到!";
|
||||||
status.style.color = "#16a34a";
|
setRedPacketStatus(`恭喜!您抢到了 ${data.amount} ${typeLabel}!`, "#16a34a");
|
||||||
status.textContent = `恭喜!您抢到了 ${data.amount} ${typeLabel}!`;
|
|
||||||
|
|
||||||
const remaining = byId("rp-remaining");
|
const remaining = byId("rp-remaining");
|
||||||
if (remaining && typeof data.remaining_count === "number") {
|
if (remaining && typeof data.remaining_count === "number") {
|
||||||
@@ -515,15 +525,14 @@ function handleClaimResponse(response, data, button) {
|
|||||||
message: `恭喜您抢到了礼包 ${data.amount} ${typeLabel}!`,
|
message: `恭喜您抢到了礼包 ${data.amount} ${typeLabel}!`,
|
||||||
icon: "🧧",
|
icon: "🧧",
|
||||||
color: redPacketType === "exp" ? "#7c3aed" : "#dc2626",
|
color: redPacketType === "exp" ? "#7c3aed" : "#dc2626",
|
||||||
duration: 8000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => closeRedPacketModal(), 3000);
|
closeRedPacketModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
status.style.color = "#dc2626";
|
setRedPacketStatus(data.message || "抢包失败", "#dc2626");
|
||||||
status.textContent = data.message || "抢包失败";
|
|
||||||
updateClaimButtonAfterFailure(button, data.message || "");
|
updateClaimButtonAfterFailure(button, data.message || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,7 +565,7 @@ function updateClaimButtonAfterFailure(button, message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 收到领取广播后,同步弹窗内领取名单与剩余数。
|
* 收到领取广播后,同步弹窗内剩余份数与抢完状态。
|
||||||
*
|
*
|
||||||
* @param {string} username 领取者用户名
|
* @param {string} username 领取者用户名
|
||||||
* @param {number|string} amount 领取数量
|
* @param {number|string} amount 领取数量
|
||||||
@@ -570,25 +579,6 @@ export function updateRedPacketClaimsUI(username, amount, remaining, type = redP
|
|||||||
remainingElement.textContent = remaining;
|
remainingElement.textContent = remaining;
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = byId("rp-claims-list");
|
|
||||||
const items = byId("rp-claims-items");
|
|
||||||
if (!list || !items) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
list.style.display = "block";
|
|
||||||
|
|
||||||
const item = document.createElement("div");
|
|
||||||
const name = document.createElement("span");
|
|
||||||
const value = document.createElement("span");
|
|
||||||
const typeLabel = type === "exp" ? "经验" : "金币";
|
|
||||||
|
|
||||||
item.className = "rp-claim-item";
|
|
||||||
name.textContent = username;
|
|
||||||
value.textContent = `+${amount} ${typeLabel}`;
|
|
||||||
item.append(name, value);
|
|
||||||
items.prepend(item);
|
|
||||||
|
|
||||||
if (remaining <= 0) {
|
if (remaining <= 0) {
|
||||||
const button = byId("rp-claim-btn");
|
const button = byId("rp-claim-btn");
|
||||||
if (button && !redPacketClaimed) {
|
if (button && !redPacketClaimed) {
|
||||||
|
|||||||
@@ -17,22 +17,22 @@ const QUIZ_INLINE_META_FONT_SIZE = "0.78em";
|
|||||||
*/
|
*/
|
||||||
export function normalizeQuizRoundPayload(payload) {
|
export function normalizeQuizRoundPayload(payload) {
|
||||||
const source = payload && typeof payload === "object" ? payload : {};
|
const source = payload && typeof payload === "object" ? payload : {};
|
||||||
const quizType = String(source.quiz_type || source.idiom_type || "idiom");
|
const quizType = String(source.quiz_type || source.quizType || source.idiom_type || "idiom");
|
||||||
const quizTypeLabel = String(source.quiz_type_label || source.idiom_type_label || (quizType === "idiom" ? "成语题" : "谜题"));
|
const quizTypeLabel = String(source.quiz_type_label || source.quizTypeLabel || source.idiom_type_label || (quizType === "idiom" ? "成语题" : "谜题"));
|
||||||
const roundId = Number.parseInt(
|
const roundId = Number.parseInt(
|
||||||
String(source.quiz_round_id || source.idiom_game_round_id || source.idom_game_round_id || source.round_id || source.quiz_round_ended_id || source.idiom_game_round_ended_id || "0"),
|
String(source.quiz_round_id || source.quizRoundId || source.idiom_game_round_id || source.idom_game_round_id || source.round_id || source.roundId || source.quiz_round_ended_id || source.quizRoundEndedId || source.idiom_game_round_ended_id || "0"),
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
const endedRoundId = Number.parseInt(
|
const endedRoundId = Number.parseInt(
|
||||||
String(source.quiz_round_ended_id || source.idiom_game_round_ended_id || "0"),
|
String(source.quiz_round_ended_id || source.quizRoundEndedId || source.idiom_game_round_ended_id || "0"),
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
const rewardGold = Number.parseInt(
|
const rewardGold = Number.parseInt(
|
||||||
String(source.quiz_reward_gold ?? source.idiom_reward_gold ?? source.idiom_result_reward_gold ?? source.reward_gold ?? 0),
|
String(source.quiz_reward_gold ?? source.quizRewardGold ?? source.idiom_reward_gold ?? source.idiom_result_reward_gold ?? source.reward_gold ?? source.rewardGold ?? 0),
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
const rewardExp = Number.parseInt(
|
const rewardExp = Number.parseInt(
|
||||||
String(source.quiz_reward_exp ?? source.idiom_reward_exp ?? source.idiom_result_reward_exp ?? source.reward_exp ?? 0),
|
String(source.quiz_reward_exp ?? source.quizRewardExp ?? source.idiom_reward_exp ?? source.idiom_result_reward_exp ?? source.reward_exp ?? source.rewardExp ?? 0),
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
const hint = String(source.quiz_hint || source.hint || source.content || "");
|
const hint = String(source.quiz_hint || source.hint || source.content || "");
|
||||||
@@ -208,6 +208,10 @@ function syncQuizWinnerLabel(button, winnerUsername = "") {
|
|||||||
* 将指定回合的答题按钮标记为结束态,保留在历史消息中供用户回看。
|
* 将指定回合的答题按钮标记为结束态,保留在历史消息中供用户回看。
|
||||||
*/
|
*/
|
||||||
export function disableIdiomAnswerButtons(roundId = 0, endedText = "本回合已结束", winnerUsername = "") {
|
export function disableIdiomAnswerButtons(roundId = 0, endedText = "本回合已结束", winnerUsername = "") {
|
||||||
|
if (roundId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
queryQuizAnswerButtons(roundId).forEach((button) => {
|
queryQuizAnswerButtons(roundId).forEach((button) => {
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.dataset.quizEnded = "1";
|
button.dataset.quizEnded = "1";
|
||||||
@@ -412,14 +416,17 @@ function handleRiddleGameAnswered(e) {
|
|||||||
const roundId = quizMeta.endedRoundId || quizMeta.roundId;
|
const roundId = quizMeta.endedRoundId || quizMeta.roundId;
|
||||||
if (!answer) return;
|
if (!answer) return;
|
||||||
|
|
||||||
currentRoundId = 0;
|
|
||||||
currentQuizType = "idiom";
|
|
||||||
disableIdiomAnswerButtons(roundId, "本回合已结束", winnerUsername);
|
disableIdiomAnswerButtons(roundId, "本回合已结束", winnerUsername);
|
||||||
|
|
||||||
// 关闭当前用户的答题弹窗(如果开着的话)
|
// 只关闭当前结算回合自己的答题弹窗,避免另一道同时进行的题被误关。
|
||||||
const answerModal = document.getElementById("idiom-answer-modal");
|
const answerModal = document.getElementById("idiom-answer-modal");
|
||||||
if (answerModal && answerModal.style.display !== "none") {
|
const modalRoundId = Number.parseInt(String(answerModal?.dataset?.currentRoundId || "0"), 10);
|
||||||
|
if (answerModal && modalRoundId === roundId && answerModal.style.display !== "none") {
|
||||||
answerModal.style.display = "none";
|
answerModal.style.display = "none";
|
||||||
|
answerModal.dataset.currentRoundId = "0";
|
||||||
|
answerModal.dataset.currentQuizType = "";
|
||||||
|
currentRoundId = 0;
|
||||||
|
currentQuizType = "idiom";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 实时答题结果与刷新后的历史恢复统一走 appendMessage,避免两套分流逻辑跑偏。
|
// 实时答题结果与刷新后的历史恢复统一走 appendMessage,避免两套分流逻辑跑偏。
|
||||||
@@ -454,7 +461,7 @@ function handleRiddleGameAnswered(e) {
|
|||||||
message: `<b>${winnerUsername}</b> 答对了「${answer}」,获得 ${rewardGold}💰 + ${rewardExp}⭐!`,
|
message: `<b>${winnerUsername}</b> 答对了「${answer}」,获得 ${rewardGold}💰 + ${rewardExp}⭐!`,
|
||||||
icon: "🎉",
|
icon: "🎉",
|
||||||
color: "#16a34a",
|
color: "#16a34a",
|
||||||
duration: 6000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,6 +476,10 @@ function openIdiomAnswerModal(roundId, hint, rewardGold, rewardExp, typeLabel =
|
|||||||
const modal = document.getElementById("idiom-answer-modal");
|
const modal = document.getElementById("idiom-answer-modal");
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
|
|
||||||
|
modal.dataset.currentRoundId = String(roundId);
|
||||||
|
modal.dataset.currentRoomId = String(currentRoomId);
|
||||||
|
modal.dataset.currentQuizType = currentQuizType;
|
||||||
|
|
||||||
const hintEl = document.getElementById("idiom-answer-hint");
|
const hintEl = document.getElementById("idiom-answer-hint");
|
||||||
const rewardEl = document.getElementById("idiom-answer-reward");
|
const rewardEl = document.getElementById("idiom-answer-reward");
|
||||||
const typeEl = document.getElementById("idiom-answer-type");
|
const typeEl = document.getElementById("idiom-answer-type");
|
||||||
@@ -500,7 +511,12 @@ function openIdiomAnswerModal(roundId, hint, rewardGold, rewardExp, typeLabel =
|
|||||||
*/
|
*/
|
||||||
function closeIdiomAnswerModal() {
|
function closeIdiomAnswerModal() {
|
||||||
const modal = document.getElementById("idiom-answer-modal");
|
const modal = document.getElementById("idiom-answer-modal");
|
||||||
if (modal) modal.style.display = "none";
|
if (modal) {
|
||||||
|
modal.style.display = "none";
|
||||||
|
modal.dataset.currentRoundId = "0";
|
||||||
|
modal.dataset.currentRoomId = "0";
|
||||||
|
modal.dataset.currentQuizType = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -523,6 +539,11 @@ async function submitIdiomAnswer() {
|
|||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = "提交中...";
|
submitBtn.textContent = "提交中...";
|
||||||
|
|
||||||
|
const modal = document.getElementById("idiom-answer-modal");
|
||||||
|
const submittingRoundId = Number.parseInt(String(modal?.dataset?.currentRoundId || currentRoundId || "0"), 10);
|
||||||
|
const submittingRoomId = Number.parseInt(String(modal?.dataset?.currentRoomId || currentRoomId || "0"), 10);
|
||||||
|
const submittingQuizType = String(modal?.dataset?.currentQuizType || currentQuizType || "idiom");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/riddle-quiz/answer", {
|
const response = await fetch("/riddle-quiz/answer", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -532,34 +553,39 @@ async function submitIdiomAnswer() {
|
|||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
round_id: currentRoundId,
|
round_id: submittingRoundId,
|
||||||
answer: answer,
|
answer: answer,
|
||||||
room_id: currentRoomId,
|
room_id: submittingRoomId,
|
||||||
quiz_type: currentQuizType,
|
quiz_type: submittingQuizType,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.status === "success") {
|
if (data.status === "success") {
|
||||||
|
const answeredRoundId = Number.parseInt(String(data?.data?.quiz_round_id || data?.data?.round_id || submittingRoundId || "0"), 10);
|
||||||
feedbackEl.textContent = data.message || "🎉 回答正确!";
|
feedbackEl.textContent = data.message || "🎉 回答正确!";
|
||||||
feedbackEl.style.color = "#16a34a";
|
feedbackEl.style.color = "#16a34a";
|
||||||
input.disabled = true;
|
input.disabled = true;
|
||||||
disableIdiomAnswerButtons(
|
disableIdiomAnswerButtons(
|
||||||
currentRoundId,
|
answeredRoundId,
|
||||||
"本回合已结束",
|
"本回合已结束",
|
||||||
String(window.chatContext?.username || ""),
|
String(window.chatContext?.username || ""),
|
||||||
);
|
);
|
||||||
|
currentRoundId = currentRoundId === answeredRoundId ? 0 : currentRoundId;
|
||||||
|
|
||||||
// 延迟关闭弹窗
|
// 延迟关闭弹窗
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
closeIdiomAnswerModal();
|
const latestModalRoundId = Number.parseInt(String(modal?.dataset?.currentRoundId || "0"), 10);
|
||||||
|
if (latestModalRoundId === answeredRoundId) {
|
||||||
|
closeIdiomAnswerModal();
|
||||||
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} else {
|
} else {
|
||||||
feedbackEl.textContent = data.message || "答案不正确";
|
feedbackEl.textContent = data.message || "答案不正确";
|
||||||
feedbackEl.style.color = "#ef4444";
|
feedbackEl.style.color = "#ef4444";
|
||||||
if ((data.message || "").includes("已结束") || (data.message || "").includes("抢先答对") || (data.message || "").includes("超时")) {
|
if ((data.message || "").includes("已结束") || (data.message || "").includes("抢先答对") || (data.message || "").includes("超时")) {
|
||||||
disableIdiomAnswerButtons(currentRoundId, data.message || "本回合已结束");
|
disableIdiomAnswerButtons(submittingRoundId, data.message || "本回合已结束");
|
||||||
}
|
}
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = "提交答案";
|
submitBtn.textContent = "提交答案";
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
// 聊天室座驾弹窗模块,负责座驾列表、购买、当前座驾和购买记录展示。
|
||||||
|
|
||||||
|
import { escapeHtml } from "./html.js";
|
||||||
|
|
||||||
|
const DEFAULT_RIDE_ITEMS_URL = "/rides/items";
|
||||||
|
const DEFAULT_RIDE_BUY_URL = "/rides/buy";
|
||||||
|
|
||||||
|
let rideEventsBound = false;
|
||||||
|
let rideLoaded = false;
|
||||||
|
let rideState = {
|
||||||
|
items: [],
|
||||||
|
currentRide: null,
|
||||||
|
purchases: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取座驾弹窗接口地址配置。
|
||||||
|
*
|
||||||
|
* @returns {{items:string,buy:string}}
|
||||||
|
*/
|
||||||
|
function rideUrls() {
|
||||||
|
const modal = document.getElementById("ride-modal");
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: modal?.dataset.rideItemsUrl || DEFAULT_RIDE_ITEMS_URL,
|
||||||
|
buy: modal?.dataset.rideBuyUrl || DEFAULT_RIDE_BUY_URL,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取 CSRF Token。
|
||||||
|
*
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function csrf() {
|
||||||
|
return document.querySelector('meta[name="csrf-token"]')?.content || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取座驾接口请求头。
|
||||||
|
*
|
||||||
|
* @param {boolean} withJson 是否携带 JSON Content-Type
|
||||||
|
* @returns {Record<string, string>}
|
||||||
|
*/
|
||||||
|
function rideHeaders(withJson = false) {
|
||||||
|
const headers = {
|
||||||
|
"X-CSRF-TOKEN": csrf(),
|
||||||
|
"Accept": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (withJson) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开座驾弹窗并首次加载数据。
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function openRideModal() {
|
||||||
|
const modal = document.getElementById("ride-modal");
|
||||||
|
if (!modal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.style.display = "flex";
|
||||||
|
if (!rideLoaded) {
|
||||||
|
rideLoaded = true;
|
||||||
|
void loadRides();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭座驾弹窗。
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function closeRideModal() {
|
||||||
|
const modal = document.getElementById("ride-modal");
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取座驾页面数据。
|
||||||
|
*
|
||||||
|
* @returns {Promise<Record<string, any>>}
|
||||||
|
*/
|
||||||
|
export async function fetchRideData() {
|
||||||
|
const response = await fetch(rideUrls().items, {
|
||||||
|
headers: rideHeaders(),
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("座驾数据加载失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载并渲染座驾页面。
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function loadRides() {
|
||||||
|
const list = document.getElementById("ride-items-list");
|
||||||
|
if (list) {
|
||||||
|
list.innerHTML = '<div class="ride-empty">加载中...</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchRideData();
|
||||||
|
rideState = {
|
||||||
|
items: Array.isArray(data.items) ? data.items : [],
|
||||||
|
currentRide: data.current_ride || null,
|
||||||
|
purchases: Array.isArray(data.purchases) ? data.purchases : [],
|
||||||
|
};
|
||||||
|
renderRides(data);
|
||||||
|
} catch (error) {
|
||||||
|
if (list) {
|
||||||
|
list.innerHTML = '<div class="ride-empty ride-error">加载失败,请稍后重试</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染座驾弹窗全部内容。
|
||||||
|
*
|
||||||
|
* @param {Record<string, any>} data 接口返回数据
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function renderRides(data) {
|
||||||
|
const balance = document.getElementById("ride-jjb");
|
||||||
|
if (balance) {
|
||||||
|
balance.textContent = Number(data.user_jjb || data.jjb || 0).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCurrentRide(data.current_ride || null);
|
||||||
|
renderRideItems(Array.isArray(data.items) ? data.items : rideState.items);
|
||||||
|
renderRidePurchases(Array.isArray(data.purchases) ? data.purchases : rideState.purchases);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染当前激活座驾。
|
||||||
|
*
|
||||||
|
* @param {Record<string, any>|null} currentRide 当前座驾记录
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function renderCurrentRide(currentRide) {
|
||||||
|
const box = document.getElementById("ride-current");
|
||||||
|
if (!box) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = currentRide?.item;
|
||||||
|
if (!item) {
|
||||||
|
box.innerHTML = '<span class="ride-current-empty">当前未启用座驾</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
box.innerHTML = `
|
||||||
|
<span class="ride-current-icon">${escapeHtml(item.icon || "🚘")}</span>
|
||||||
|
<span><b>${escapeHtml(item.name)}</b> 生效中</span>
|
||||||
|
<span class="ride-current-expire">到期:${escapeHtml(currentRide.expires_at || "-")}</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染座驾商品卡片。
|
||||||
|
*
|
||||||
|
* @param {Array<Record<string, any>>} items 座驾商品列表
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function renderRideItems(items) {
|
||||||
|
const list = document.getElementById("ride-items-list");
|
||||||
|
if (!list) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
list.innerHTML = '<div class="ride-empty">暂无上架座驾</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeItemId = Number(rideState.currentRide?.item?.id || 0);
|
||||||
|
list.innerHTML = items.map((item) => {
|
||||||
|
const isActive = Number(item.id) === activeItemId;
|
||||||
|
const duration = Number(item.duration_days || 0);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="ride-card${isActive ? " active" : ""}">
|
||||||
|
<div class="ride-card-head">
|
||||||
|
<span class="ride-card-icon">${escapeHtml(item.icon || "🚘")}</span>
|
||||||
|
<span class="ride-card-title">${escapeHtml(item.name || "")}</span>
|
||||||
|
${isActive ? '<span class="ride-active-badge">当前</span>' : ""}
|
||||||
|
</div>
|
||||||
|
<div class="ride-card-desc">${escapeHtml(item.description || "")}</div>
|
||||||
|
<div class="ride-card-meta">
|
||||||
|
<span>💰 ${Number(item.price || 0).toLocaleString()} 金币</span>
|
||||||
|
<span>⏱ ${duration > 0 ? `${duration} 天` : "未配置"}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ride-buy-btn" data-ride-buy="${Number(item.id)}">
|
||||||
|
${isActive ? "续费座驾" : "购买座驾"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染座驾购买记录。
|
||||||
|
*
|
||||||
|
* @param {Array<Record<string, any>>} purchases 购买记录
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function renderRidePurchases(purchases) {
|
||||||
|
const list = document.getElementById("ride-purchase-list");
|
||||||
|
if (!list) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!purchases.length) {
|
||||||
|
list.innerHTML = '<div class="ride-empty">暂无座驾购买记录</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = purchases.map((purchase) => {
|
||||||
|
const item = purchase.item || {};
|
||||||
|
const statusMap = {
|
||||||
|
active: "使用中",
|
||||||
|
expired: "已过期",
|
||||||
|
cancelled: "已替换",
|
||||||
|
used: "已使用",
|
||||||
|
};
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="ride-record">
|
||||||
|
<span>${escapeHtml(item.icon || "🚘")} ${escapeHtml(item.name || "未知座驾")}</span>
|
||||||
|
<span>${escapeHtml(statusMap[purchase.status] || purchase.status || "-")}</span>
|
||||||
|
<span>${Number(purchase.price_paid || 0).toLocaleString()} 金币</span>
|
||||||
|
<span>${escapeHtml(purchase.expires_at || "-")}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 购买或续费座驾。
|
||||||
|
*
|
||||||
|
* @param {number|string} itemId 商品 ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function buyRide(itemId) {
|
||||||
|
const item = rideState.items.find((entry) => Number(entry.id) === Number(itemId));
|
||||||
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Number(item.duration_days || 0);
|
||||||
|
const ok = await window.chatDialog?.confirm?.(
|
||||||
|
`确认花费 ${Number(item.price || 0).toLocaleString()} 金币购买【${item.name}】吗?\n有效期:${duration} 天\n同款续购会自动叠加有效期。`,
|
||||||
|
"确认购买座驾",
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(rideUrls().buy, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: rideHeaders(true),
|
||||||
|
body: JSON.stringify({
|
||||||
|
item_id: Number(itemId),
|
||||||
|
room_id: window.chatContext?.roomId || 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || data.status !== "success") {
|
||||||
|
window.chatDialog?.alert?.(data.message || "购买失败", "座驾购买", "#cc4444");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rideState.currentRide = data.current_ride || null;
|
||||||
|
rideState.purchases = Array.isArray(data.purchases) ? data.purchases : [];
|
||||||
|
renderRides({
|
||||||
|
items: rideState.items,
|
||||||
|
current_ride: rideState.currentRide,
|
||||||
|
purchases: rideState.purchases,
|
||||||
|
jjb: data.jjb,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shopBalance = document.getElementById("shop-jjb");
|
||||||
|
if (shopBalance) {
|
||||||
|
shopBalance.textContent = Number(data.jjb || 0).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.chatDialog?.alert?.(data.message || "座驾购买成功", "座驾购买", "#16a34a");
|
||||||
|
} catch (error) {
|
||||||
|
window.chatDialog?.alert?.("网络异常,请稍后重试。", "座驾购买", "#cc4444");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定座驾弹窗事件。
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function bindRideControls() {
|
||||||
|
if (rideEventsBound || typeof document === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rideEventsBound = true;
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
if (!(event.target instanceof Element)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeButton = event.target.closest("[data-ride-modal-close]");
|
||||||
|
const modal = document.getElementById("ride-modal");
|
||||||
|
if (closeButton || (modal && event.target === modal)) {
|
||||||
|
event.preventDefault();
|
||||||
|
closeRideModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buyButton = event.target.closest("[data-ride-buy]");
|
||||||
|
if (buyButton) {
|
||||||
|
event.preventDefault();
|
||||||
|
void buyRide(buyButton.getAttribute("data-ride-buy") || "");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.openRideModal = openRideModal;
|
||||||
|
window.closeRideModal = closeRideModal;
|
||||||
|
window.loadRides = loadRides;
|
||||||
|
window.buyRide = buyRide;
|
||||||
|
}
|
||||||
@@ -7,10 +7,54 @@
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
function dismissToast(card) {
|
function dismissToast(card) {
|
||||||
|
if (card.dataset.toastClosing === "1") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
card.dataset.toastClosing = "1";
|
||||||
card.style.opacity = "0";
|
card.style.opacity = "0";
|
||||||
window.setTimeout(() => card.remove(), 400);
|
window.setTimeout(() => card.remove(), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭容器内所有 Toast 卡片。
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function dismissAllToasts(container) {
|
||||||
|
container.querySelectorAll(".chat-toast-card").forEach((card) => {
|
||||||
|
if (card instanceof HTMLElement) {
|
||||||
|
dismissToast(card);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定点击 Toast 外部区域时自动关闭。
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function bindOutsideDismiss(container) {
|
||||||
|
document.addEventListener("pointerdown", (event) => {
|
||||||
|
if (container.childElementCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof Element)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.closest(".chat-toast-card")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissAllToasts(container);
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建右下角 Toast 通知 API。
|
* 创建右下角 Toast 通知 API。
|
||||||
*
|
*
|
||||||
@@ -36,10 +80,11 @@ function createChatToast(container) {
|
|||||||
message,
|
message,
|
||||||
icon = "💬",
|
icon = "💬",
|
||||||
color = "#336699",
|
color = "#336699",
|
||||||
duration = 6000,
|
duration = 3000,
|
||||||
action = null,
|
action = null,
|
||||||
}) {
|
}) {
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
|
card.className = "chat-toast-card";
|
||||||
card.style.cssText = `
|
card.style.cssText = `
|
||||||
background:#fff; border-radius:10px; overflow:hidden;
|
background:#fff; border-radius:10px; overflow:hidden;
|
||||||
box-shadow:0 8px 32px rgba(0,0,0,.18);
|
box-shadow:0 8px 32px rgba(0,0,0,.18);
|
||||||
@@ -112,5 +157,6 @@ export function bindChatToast() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bindOutsideDismiss(container);
|
||||||
window.chatToast = createChatToast(container);
|
window.chatToast = createChatToast(container);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function runToolbarAction(action) {
|
|||||||
// 工具条只做入口分发,具体业务仍由原有全局函数负责。
|
// 工具条只做入口分发,具体业务仍由原有全局函数负责。
|
||||||
const actions = {
|
const actions = {
|
||||||
shop: () => window.openShopModal?.(),
|
shop: () => window.openShopModal?.(),
|
||||||
|
ride: () => window.openRideModal?.(),
|
||||||
vip: () => window.openVipModal?.(),
|
vip: () => window.openVipModal?.(),
|
||||||
"save-exp": () => window.saveExp?.(),
|
"save-exp": () => window.saveExp?.(),
|
||||||
game: () => window.openGameHall?.(),
|
game: () => window.openGameHall?.(),
|
||||||
|
|||||||
@@ -121,6 +121,17 @@ export function userCardComponent() {
|
|||||||
return operatorPositionRank >= targetPositionRank;
|
return operatorPositionRank >= targetPositionRank;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 格式化私信记录时间,只保留到秒。 */
|
||||||
|
formatWhisperTime(value) {
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedValue = String(value).replace("T", " ").replace(/\.\d+Z?$/, "").replace(/Z$/, "");
|
||||||
|
|
||||||
|
return normalizedValue.slice(0, 19);
|
||||||
|
},
|
||||||
|
|
||||||
/** 返回名片资产字段的中文名称。 */
|
/** 返回名片资产字段的中文名称。 */
|
||||||
assetValueLabel(asset) {
|
assetValueLabel(asset) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,585 @@
|
|||||||
|
/**
|
||||||
|
* 文件功能:聊天室 99A 主战坦克重装入场特效
|
||||||
|
*
|
||||||
|
* 使用全屏透明 Canvas 绘制中国 99A 主战坦克横穿屏幕、履带滚动、
|
||||||
|
* 长炮管炮击、楔形复合装甲、侧裙、尘土冲击波与重装入场 HUD。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Type99AEffect = (() => {
|
||||||
|
const DURATION = 8200;
|
||||||
|
const ARMOR = "#5f6f3a";
|
||||||
|
const DARK_ARMOR = "#1f2a1d";
|
||||||
|
const CAMO = "#7c6a36";
|
||||||
|
const DUST = "#fde68a";
|
||||||
|
const FIRE = "#f97316";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓出曲线,让坦克进场有重量感。
|
||||||
|
*
|
||||||
|
* @param {number} t 0 到 1 的进度
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function easeOutCubic(t) {
|
||||||
|
return 1 - Math.pow(1 - t, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓入缓出曲线,用于 HUD 和冲击波。
|
||||||
|
*
|
||||||
|
* @param {number} t 0 到 1 的进度
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function easeInOutSine(t) {
|
||||||
|
return -(Math.cos(Math.PI * t) - 1) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建地面尘土粒子。
|
||||||
|
*
|
||||||
|
* @param {number} w 画布宽度
|
||||||
|
* @param {number} h 画布高度
|
||||||
|
* @returns {Array<Record<string, number>>}
|
||||||
|
*/
|
||||||
|
function createDust(w, h) {
|
||||||
|
return Array.from({ length: 90 }, () => ({
|
||||||
|
x: Math.random() * w,
|
||||||
|
y: h * (0.66 + Math.random() * 0.18),
|
||||||
|
speed: 1.6 + Math.random() * 4.8,
|
||||||
|
size: 2 + Math.random() * 8,
|
||||||
|
alpha: 0.12 + Math.random() * 0.34,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制战场式地面背景。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} w 画布宽度
|
||||||
|
* @param {number} h 画布高度
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawBackdrop(ctx, w, h, progress) {
|
||||||
|
const fade = Math.min(1, progress / 0.16) * Math.min(1, (1 - progress) / 0.12);
|
||||||
|
const gradient = ctx.createRadialGradient(w * 0.5, h * 0.62, 0, w * 0.5, h * 0.62, Math.max(w, h) * 0.76);
|
||||||
|
gradient.addColorStop(0, `rgba(41,37,36,${0.42 * fade})`);
|
||||||
|
gradient.addColorStop(0.55, `rgba(63,98,18,${0.18 * fade})`);
|
||||||
|
gradient.addColorStop(1, "rgba(0,0,0,0)");
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const y = h * (0.7 + i * 0.028);
|
||||||
|
ctx.strokeStyle = `rgba(253,230,138,${0.1 * fade})`;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y + Math.sin(progress * 12 + i) * 4);
|
||||||
|
ctx.lineTo(w, y + Math.cos(progress * 9 + i) * 4);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制履带带起的尘土。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {Array<Record<string, number>>} dust 尘土粒子
|
||||||
|
* @param {number} w 画布宽度
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawDust(ctx, dust, w, progress) {
|
||||||
|
const fade = Math.min(1, progress / 0.18) * Math.min(1, (1 - progress) / 0.12);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
dust.forEach((particle, index) => {
|
||||||
|
const travel = (progress * (620 + particle.speed * 80) + index * 47) % (w + 420);
|
||||||
|
const x = w + 210 - travel;
|
||||||
|
ctx.globalAlpha = particle.alpha * fade;
|
||||||
|
ctx.fillStyle = DUST;
|
||||||
|
ctx.shadowColor = DUST;
|
||||||
|
ctx.shadowBlur = particle.size * 1.4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(x, particle.y, particle.size * 1.8, particle.size * 0.7, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制炮击冲击波。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} w 画布宽度
|
||||||
|
* @param {number} h 画布高度
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawShockwave(ctx, w, h, progress) {
|
||||||
|
const shot = Math.max(0, Math.min(1, (progress - 0.5) / 0.2));
|
||||||
|
if (shot <= 0 || shot >= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alpha = Math.sin(shot * Math.PI);
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.strokeStyle = `rgba(253,230,138,${0.38 * alpha})`;
|
||||||
|
ctx.lineWidth = 5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(w * 0.48, h * 0.68, w * (0.08 + shot * 0.38), h * (0.03 + shot * 0.08), 0, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制 99A 主炮炮口火焰。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawMuzzleFlash(ctx, progress) {
|
||||||
|
const flash = Math.max(0, 1 - Math.abs(progress - 0.5) / 0.045);
|
||||||
|
if (flash <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.shadowColor = FIRE;
|
||||||
|
ctx.shadowBlur = 28;
|
||||||
|
ctx.fillStyle = `rgba(249,115,22,${0.78 * flash})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(408, -118);
|
||||||
|
ctx.lineTo(528, -156);
|
||||||
|
ctx.lineTo(468, -112);
|
||||||
|
ctx.lineTo(532, -76);
|
||||||
|
ctx.lineTo(408, -100);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = `rgba(255,255,255,${0.74 * flash})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(414, -114);
|
||||||
|
ctx.lineTo(486, -132);
|
||||||
|
ctx.lineTo(452, -108);
|
||||||
|
ctx.lineTo(492, -92);
|
||||||
|
ctx.lineTo(414, -102);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制 99A 主战坦克主体。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} x 坦克中心 x
|
||||||
|
* @param {number} y 坦克中心 y
|
||||||
|
* @param {number} scale 缩放比例
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawTank(ctx, x, y, scale, progress) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.shadowColor = "rgba(0,0,0,0.72)";
|
||||||
|
ctx.shadowBlur = 16;
|
||||||
|
ctx.fillStyle = "rgba(0,0,0,0.45)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(0, 54, 250, 22, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
drawMuzzleFlash(ctx, progress);
|
||||||
|
|
||||||
|
// 99A 履带底盘:右侧前导轮更大,模拟参考图的右前方视角。
|
||||||
|
const track = ctx.createLinearGradient(-246, 24, 246, 94);
|
||||||
|
track.addColorStop(0, "#0a0a0a");
|
||||||
|
track.addColorStop(0.42, DARK_ARMOR);
|
||||||
|
track.addColorStop(1, "#030712");
|
||||||
|
ctx.fillStyle = track;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-250, 32);
|
||||||
|
ctx.lineTo(-206, 10);
|
||||||
|
ctx.lineTo(184, 12);
|
||||||
|
ctx.lineTo(252, 38);
|
||||||
|
ctx.lineTo(218, 94);
|
||||||
|
ctx.lineTo(-226, 96);
|
||||||
|
ctx.lineTo(-270, 70);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(15,23,42,0.74)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-236, 22);
|
||||||
|
ctx.lineTo(180, 23);
|
||||||
|
ctx.lineTo(236, 43);
|
||||||
|
ctx.lineTo(204, 62);
|
||||||
|
ctx.lineTo(-218, 58);
|
||||||
|
ctx.lineTo(-256, 42);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const wheelX = -180 + i * 58;
|
||||||
|
const wheelY = 62 + (i > 4 ? 2 : 0);
|
||||||
|
drawRoadWheel(ctx, wheelX, wheelY, progress + i * 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = "rgba(253,230,138,0.14)";
|
||||||
|
ctx.lineWidth = 4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-218, 82);
|
||||||
|
ctx.lineTo(212, 82);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 99A 车体:右侧为前装甲,首上装甲呈明显楔形下压。
|
||||||
|
const hull = ctx.createLinearGradient(-238, -50, 250, 36);
|
||||||
|
hull.addColorStop(0, "#42512b");
|
||||||
|
hull.addColorStop(0.38, ARMOR);
|
||||||
|
hull.addColorStop(0.66, CAMO);
|
||||||
|
hull.addColorStop(1, "#253018");
|
||||||
|
ctx.fillStyle = hull;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-238, 24);
|
||||||
|
ctx.lineTo(-212, -30);
|
||||||
|
ctx.lineTo(86, -52);
|
||||||
|
ctx.lineTo(226, -24);
|
||||||
|
ctx.lineTo(252, 14);
|
||||||
|
ctx.lineTo(202, 38);
|
||||||
|
ctx.lineTo(-216, 36);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(15,23,42,0.34)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(68, -44);
|
||||||
|
ctx.lineTo(226, -22);
|
||||||
|
ctx.lineTo(248, 9);
|
||||||
|
ctx.lineTo(138, 18);
|
||||||
|
ctx.lineTo(86, -5);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 侧裙装甲模块和数码迷彩块,增强 99A 识别度。
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const panelX = -206 + i * 50;
|
||||||
|
ctx.fillStyle = i % 2 === 0 ? "rgba(54,83,20,0.86)" : "rgba(120,113,55,0.82)";
|
||||||
|
ctx.fillRect(panelX, -2, 42, 24);
|
||||||
|
ctx.strokeStyle = "rgba(15,23,42,0.34)";
|
||||||
|
ctx.lineWidth = 1.4;
|
||||||
|
ctx.strokeRect(panelX, -2, 42, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
[
|
||||||
|
[-186, -24, 34, 18, "#7f8f57"],
|
||||||
|
[-118, -34, 42, 20, "#b9855a"],
|
||||||
|
[-32, -39, 48, 22, "#344329"],
|
||||||
|
[52, -46, 44, 20, "#8a9b61"],
|
||||||
|
[118, -30, 38, 19, "#a36f52"],
|
||||||
|
[182, -14, 46, 21, "#415329"],
|
||||||
|
[-220, 2, 30, 20, "#27351f"],
|
||||||
|
[-72, 4, 36, 18, "#718246"],
|
||||||
|
[22, 2, 42, 20, "#ac7654"],
|
||||||
|
].forEach(([px, py, pw, ph, color]) => {
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillRect(px, py, pw, ph);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 参考图里的大号车号、国旗和前车灯。
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.9)";
|
||||||
|
ctx.font = "900 31px serif";
|
||||||
|
ctx.fillText("807", -216, -12);
|
||||||
|
ctx.fillStyle = "rgba(220,38,38,0.95)";
|
||||||
|
ctx.fillRect(-128, -31, 34, 22);
|
||||||
|
ctx.fillStyle = "rgba(253,224,71,0.95)";
|
||||||
|
ctx.font = "900 12px serif";
|
||||||
|
ctx.fillText("★", -122, -16);
|
||||||
|
ctx.fillStyle = "rgba(254,242,242,0.88)";
|
||||||
|
roundRect(ctx, 178, -17, 18, 9, 5);
|
||||||
|
ctx.fill();
|
||||||
|
roundRect(ctx, 214, -8, 18, 9, 5);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 低矮楔形炮塔和 125mm 长炮管:炮管朝右并略微上扬。
|
||||||
|
const turret = ctx.createLinearGradient(-128, -108, 156, -28);
|
||||||
|
turret.addColorStop(0, "#66734a");
|
||||||
|
turret.addColorStop(0.46, "#87905d");
|
||||||
|
turret.addColorStop(1, "#24301d");
|
||||||
|
ctx.fillStyle = turret;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-132, -36);
|
||||||
|
ctx.lineTo(-82, -96);
|
||||||
|
ctx.lineTo(86, -108);
|
||||||
|
ctx.lineTo(158, -72);
|
||||||
|
ctx.lineTo(112, -32);
|
||||||
|
ctx.lineTo(-118, -20);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
[
|
||||||
|
[-98, -76, 42, 22, "#314222"],
|
||||||
|
[-28, -90, 50, 22, "#a36f52"],
|
||||||
|
[46, -92, 48, 20, "#73844d"],
|
||||||
|
[98, -66, 34, 20, "#2f3f24"],
|
||||||
|
].forEach(([px, py, pw, ph, color]) => {
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillRect(px, py, pw, ph);
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = "rgba(17,24,39,0.96)";
|
||||||
|
ctx.lineWidth = 18;
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(106, -70);
|
||||||
|
ctx.lineTo(410, -120);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.strokeStyle = "#64748b";
|
||||||
|
ctx.lineWidth = 6;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(114, -75);
|
||||||
|
ctx.lineTo(404, -123);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = "#1f2937";
|
||||||
|
roundRect(ctx, 392, -132, 30, 24, 8);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = "#40513a";
|
||||||
|
roundRect(ctx, 250, -107, 24, 20, 5);
|
||||||
|
ctx.fill();
|
||||||
|
roundRect(ctx, 322, -120, 24, 20, 5);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(17,24,39,0.95)";
|
||||||
|
roundRect(ctx, 86, -80, 62, 24, 8);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 炮塔前装甲、烟幕弹发射器和车长机枪。
|
||||||
|
ctx.fillStyle = "rgba(15,23,42,0.5)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(42, -48);
|
||||||
|
ctx.lineTo(96, -78);
|
||||||
|
ctx.lineTo(154, -66);
|
||||||
|
ctx.lineTo(112, -36);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
ctx.fillStyle = "rgba(15,23,42,0.9)";
|
||||||
|
roundRect(ctx, -124 + i * 13, -58 + (i % 2) * 10, 10, 18, 4);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = "rgba(15,23,42,0.9)";
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-24, -102);
|
||||||
|
ctx.lineTo(-18, -132);
|
||||||
|
ctx.lineTo(40, -138);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(15,23,42,0.84)";
|
||||||
|
roundRect(ctx, -32, -108, 58, 14, 7);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 装甲高光。
|
||||||
|
ctx.strokeStyle = "rgba(226,232,240,0.28)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-184, -22);
|
||||||
|
ctx.lineTo(92, -40);
|
||||||
|
ctx.moveTo(-142, 8);
|
||||||
|
ctx.lineTo(172, -2);
|
||||||
|
ctx.moveTo(92, -82);
|
||||||
|
ctx.lineTo(144, -66);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制坦克负重轮。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} x 中心 x
|
||||||
|
* @param {number} y 中心 y
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawRoadWheel(ctx, x, y, progress) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
ctx.rotate(progress * 30);
|
||||||
|
ctx.fillStyle = "#111827";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, 21, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = "#475569";
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.strokeStyle = "#94a3b8";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
ctx.rotate(Math.PI / 3);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, 0);
|
||||||
|
ctx.lineTo(16, 0);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.fillStyle = "#cbd5e1";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, 5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制 99A 重装入场 HUD 字幕。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} w 画布宽度
|
||||||
|
* @param {number} h 画布高度
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
* @param {string} title 入场标题
|
||||||
|
* @param {string} userInfo 用户身份信息
|
||||||
|
*/
|
||||||
|
function drawHud(ctx, w, h, progress, title, userInfo) {
|
||||||
|
const enter = Math.min(1, Math.max(0, (progress - 0.14) / 0.2));
|
||||||
|
const leave = Math.min(1, Math.max(0, (1 - progress) / 0.16));
|
||||||
|
const alpha = easeInOutSine(enter) * leave;
|
||||||
|
const y = h * 0.17 - (1 - enter) * 24;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.shadowColor = "rgba(253,230,138,0.95)";
|
||||||
|
ctx.shadowBlur = 22;
|
||||||
|
ctx.fillStyle = "rgba(28,25,23,0.66)";
|
||||||
|
ctx.strokeStyle = "rgba(253,230,138,0.72)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
roundRect(ctx, w * 0.5 - 340, y - 56, 680, 120, 18);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = "#fef3c7";
|
||||||
|
ctx.font = "700 16px serif";
|
||||||
|
ctx.fillText("ZTZ-99A ARMORED FORCE", w * 0.5, y - 24);
|
||||||
|
ctx.fillStyle = "#fde68a";
|
||||||
|
ctx.font = "700 18px serif";
|
||||||
|
ctx.fillText(userInfo, w * 0.5, y + 8, 620);
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.font = "900 34px serif";
|
||||||
|
ctx.fillText(title, w * 0.5, y + 45, 620);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制圆角矩形路径。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} x 左上角 x
|
||||||
|
* @param {number} y 左上角 y
|
||||||
|
* @param {number} w 宽度
|
||||||
|
* @param {number} h 高度
|
||||||
|
* @param {number} r 圆角半径
|
||||||
|
*/
|
||||||
|
function roundRect(ctx, x, y, w, h, r) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.lineTo(x + w - r, y);
|
||||||
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||||
|
ctx.lineTo(x + w, y + h - r);
|
||||||
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||||
|
ctx.lineTo(x + r, y + h);
|
||||||
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||||
|
ctx.lineTo(x, y + r);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动 99A 主战坦克重装入场特效。
|
||||||
|
*
|
||||||
|
* @param {HTMLCanvasElement} canvas 全屏特效画布
|
||||||
|
* @param {Function} onEnd 结束回调
|
||||||
|
* @param {object} options 特效附加参数
|
||||||
|
* @returns {{cancel: Function}}
|
||||||
|
*/
|
||||||
|
function start(canvas, onEnd, options = {}) {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
const dust = createDust(w, h);
|
||||||
|
const title = String(options.effect_title || "99A主战坦克 重装入场").trim() || "99A主战坦克 重装入场";
|
||||||
|
const userInfo = String(options.effect_user_info || "").trim();
|
||||||
|
const startTime = performance.now();
|
||||||
|
let animId = null;
|
||||||
|
let finished = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一结束动画,手动取消时只清理不回调。
|
||||||
|
*
|
||||||
|
* @param {boolean} canceled 是否为手动取消
|
||||||
|
*/
|
||||||
|
function finish(canceled) {
|
||||||
|
if (finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
finished = true;
|
||||||
|
if (animId) {
|
||||||
|
cancelAnimationFrame(animId);
|
||||||
|
}
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
if (!canceled) {
|
||||||
|
onEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逐帧绘制坦克入场动画。
|
||||||
|
*
|
||||||
|
* @param {number} now 当前高精度时间
|
||||||
|
*/
|
||||||
|
function animate(now) {
|
||||||
|
const elapsed = now - startTime;
|
||||||
|
const progress = Math.min(1, elapsed / DURATION);
|
||||||
|
const entry = easeOutCubic(Math.min(1, progress / 0.64));
|
||||||
|
const exit = easeInOutSine(Math.max(0, (progress - 0.76) / 0.24));
|
||||||
|
const tankX = -w * 0.24 + entry * w * 0.92 + exit * w * 0.62;
|
||||||
|
const tankY = h * 0.66 + Math.sin(progress * 24) * 2.5;
|
||||||
|
const scale = Math.min(1.12, Math.max(0.68, w / 1180));
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
drawBackdrop(ctx, w, h, progress);
|
||||||
|
drawDust(ctx, dust, w, progress);
|
||||||
|
drawShockwave(ctx, w, h, progress);
|
||||||
|
drawTank(ctx, tankX, tankY, scale, progress);
|
||||||
|
drawHud(ctx, w, h, progress, title, userInfo);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
animId = requestAnimationFrame(animate);
|
||||||
|
} else {
|
||||||
|
finish(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animId = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cancel() {
|
||||||
|
finish(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.Type99AEffect = Type99AEffect;
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
/**
|
||||||
|
* 文件功能:聊天室东风-5C洲际导弹发射预览特效
|
||||||
|
*
|
||||||
|
* 使用全屏透明 Canvas 绘制风格化洲际导弹升空、尾焰、烟尘冲击波、
|
||||||
|
* 雷达扫描网格和测试 HUD。该效果只用于聊天室视觉预览,不表达真实装备参数。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Df5cEffect = (() => {
|
||||||
|
const DURATION = 8200;
|
||||||
|
const FIRE = "#fb923c";
|
||||||
|
const HOT = "#fef3c7";
|
||||||
|
const RED = "#dc2626";
|
||||||
|
const BODY = "#e5e7eb";
|
||||||
|
const BODY_DARK = "#64748b";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓入缓出曲线,用于导弹升空和 HUD 动画。
|
||||||
|
*
|
||||||
|
* @param {number} t 0 到 1 的进度
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function easeInOutCubic(t) {
|
||||||
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓出曲线,用于烟尘扩散。
|
||||||
|
*
|
||||||
|
* @param {number} t 0 到 1 的进度
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function easeOutCubic(t) {
|
||||||
|
return 1 - Math.pow(1 - t, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建尾焰和烟尘粒子。
|
||||||
|
*
|
||||||
|
* @param {number} count 粒子数量
|
||||||
|
* @returns {Array<Record<string, number>>}
|
||||||
|
*/
|
||||||
|
function createParticles(count) {
|
||||||
|
return Array.from({ length: count }, () => ({
|
||||||
|
angle: Math.random() * Math.PI * 2,
|
||||||
|
spread: 0.3 + Math.random() * 1.3,
|
||||||
|
speed: 0.4 + Math.random() * 2.4,
|
||||||
|
size: 4 + Math.random() * 18,
|
||||||
|
alpha: 0.12 + Math.random() * 0.6,
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制夜空、雷达网格和扫描线。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} w 画布宽度
|
||||||
|
* @param {number} h 画布高度
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawBackdrop(ctx, w, h, progress) {
|
||||||
|
const fade = Math.min(1, progress / 0.14) * Math.min(1, (1 - progress) / 0.12);
|
||||||
|
const sky = ctx.createLinearGradient(0, 0, 0, h);
|
||||||
|
sky.addColorStop(0, `rgba(2,6,23,${0.86 * fade})`);
|
||||||
|
sky.addColorStop(0.58, `rgba(15,23,42,${0.62 * fade})`);
|
||||||
|
sky.addColorStop(1, `rgba(30,41,59,${0.26 * fade})`);
|
||||||
|
ctx.fillStyle = sky;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = fade;
|
||||||
|
ctx.strokeStyle = "rgba(56,189,248,0.16)";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let x = -w; x < w * 2; x += 72) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + progress * 120, 0);
|
||||||
|
ctx.lineTo(x - h * 0.55 + progress * 120, h);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = h * 0.2; y < h; y += 46) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y + Math.sin(progress * 18 + y) * 2);
|
||||||
|
ctx.lineTo(w, y + Math.cos(progress * 14 + y) * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = "rgba(248,113,113,0.34)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(w * 0.16, h * 0.76, w * (0.18 + progress * 0.22), -Math.PI * 0.95, -Math.PI * 0.12);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
const beamAngle = -Math.PI * 0.85 + progress * Math.PI * 1.15;
|
||||||
|
ctx.strokeStyle = "rgba(34,211,238,0.34)";
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(w * 0.16, h * 0.76);
|
||||||
|
ctx.lineTo(w * 0.16 + Math.cos(beamAngle) * w * 0.42, h * 0.76 + Math.sin(beamAngle) * w * 0.42);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制发射井底座、光柱和冲击波。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} w 画布宽度
|
||||||
|
* @param {number} h 画布高度
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawLaunchPad(ctx, w, h, progress) {
|
||||||
|
const ignition = Math.min(1, progress / 0.24);
|
||||||
|
const pulse = Math.sin(progress * Math.PI * 12) * 0.5 + 0.5;
|
||||||
|
const cx = w * 0.18;
|
||||||
|
const cy = h * 0.78;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
const beam = ctx.createRadialGradient(cx, cy, 0, cx, cy, h * 0.42);
|
||||||
|
beam.addColorStop(0, `rgba(251,146,60,${0.54 * ignition})`);
|
||||||
|
beam.addColorStop(0.3, `rgba(254,243,199,${0.18 * ignition})`);
|
||||||
|
beam.addColorStop(1, "rgba(0,0,0,0)");
|
||||||
|
ctx.fillStyle = beam;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
ctx.strokeStyle = `rgba(251,146,60,${(0.32 + pulse * 0.2) * ignition})`;
|
||||||
|
ctx.lineWidth = 5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, w * (0.06 + progress * 0.28), h * (0.025 + progress * 0.08), 0, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = "rgba(15,23,42,0.82)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy + 18, w * 0.12, h * 0.035, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = "rgba(148,163,184,0.7)";
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制尾焰和烟尘。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {Array<Record<string, number>>} particles 粒子数组
|
||||||
|
* @param {number} tailX 尾部 x
|
||||||
|
* @param {number} tailY 尾部 y
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawExhaust(ctx, particles, tailX, tailY, progress) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
particles.forEach((particle, index) => {
|
||||||
|
const t = (progress * 3.2 + index * 0.013) % 1;
|
||||||
|
const spread = easeOutCubic(t) * 118 * particle.spread;
|
||||||
|
const x = tailX - spread * 0.62 + Math.cos(particle.angle) * spread * 0.36;
|
||||||
|
const y = tailY + spread * 0.86 + Math.sin(particle.angle + particle.phase) * spread * 0.24;
|
||||||
|
const alpha = particle.alpha * (1 - t);
|
||||||
|
const radius = particle.size * (0.7 + t * 2.4);
|
||||||
|
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
ctx.fillStyle = t < 0.34 ? HOT : t < 0.62 ? FIRE : "rgba(148,163,184,0.9)";
|
||||||
|
ctx.shadowColor = t < 0.55 ? FIRE : "rgba(148,163,184,0.8)";
|
||||||
|
ctx.shadowBlur = radius * 1.2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(x, y, radius * 0.9, radius * 1.35, -0.38, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制东风-5C风格化导弹。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} x 导弹中心 x
|
||||||
|
* @param {number} y 导弹中心 y
|
||||||
|
* @param {number} scale 缩放比例
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawMissile(ctx, x, y, scale, progress) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
// 导弹沿左下到右上的轨迹飞行,箭体头部必须朝右上,尾焰才会落在后方。
|
||||||
|
ctx.rotate(0.62 + Math.sin(progress * 7) * 0.012);
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.shadowColor = "rgba(251,146,60,0.9)";
|
||||||
|
ctx.shadowBlur = 26;
|
||||||
|
ctx.fillStyle = "rgba(251,146,60,0.86)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-28, 170);
|
||||||
|
ctx.lineTo(0, 260);
|
||||||
|
ctx.lineTo(28, 170);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = "rgba(254,243,199,0.86)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-12, 176);
|
||||||
|
ctx.lineTo(0, 236);
|
||||||
|
ctx.lineTo(12, 176);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
const body = ctx.createLinearGradient(-44, -178, 44, 168);
|
||||||
|
body.addColorStop(0, "#f8fafc");
|
||||||
|
body.addColorStop(0.45, BODY);
|
||||||
|
body.addColorStop(1, BODY_DARK);
|
||||||
|
ctx.fillStyle = body;
|
||||||
|
roundRect(ctx, -42, -156, 84, 326, 40);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = RED;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-42, -126);
|
||||||
|
ctx.quadraticCurveTo(0, -214, 42, -126);
|
||||||
|
ctx.lineTo(42, -92);
|
||||||
|
ctx.lineTo(-42, -92);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = "#111827";
|
||||||
|
ctx.fillRect(-42, -74, 84, 10);
|
||||||
|
ctx.fillRect(-42, 74, 84, 10);
|
||||||
|
ctx.fillStyle = "rgba(239,68,68,0.92)";
|
||||||
|
ctx.fillRect(-42, -22, 84, 30);
|
||||||
|
|
||||||
|
ctx.fillStyle = "#111827";
|
||||||
|
ctx.font = "900 30px serif";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText("DF-5C", 0, 50);
|
||||||
|
ctx.fillStyle = "#fef3c7";
|
||||||
|
ctx.font = "900 20px serif";
|
||||||
|
ctx.fillText("★", 0, -1);
|
||||||
|
|
||||||
|
ctx.fillStyle = "#334155";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-42, 102);
|
||||||
|
ctx.lineTo(-104, 166);
|
||||||
|
ctx.lineTo(-42, 152);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(42, 102);
|
||||||
|
ctx.lineTo(104, 166);
|
||||||
|
ctx.lineTo(42, 152);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.42)";
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-20, -112);
|
||||||
|
ctx.lineTo(-20, 130);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制测试 HUD 文案。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} w 画布宽度
|
||||||
|
* @param {number} h 画布高度
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
* @param {string} title 入场标题
|
||||||
|
* @param {string} userInfo 用户身份信息
|
||||||
|
*/
|
||||||
|
function drawHud(ctx, w, h, progress, title, userInfo) {
|
||||||
|
const enter = Math.min(1, Math.max(0, (progress - 0.1) / 0.18));
|
||||||
|
const leave = Math.min(1, Math.max(0, (1 - progress) / 0.14));
|
||||||
|
const alpha = easeInOutCubic(enter) * leave;
|
||||||
|
const y = h * 0.16 - (1 - enter) * 20;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillStyle = "rgba(15,23,42,0.68)";
|
||||||
|
ctx.strokeStyle = "rgba(248,113,113,0.72)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
roundRect(ctx, w * 0.5 - 350, y - 56, 700, 120, 18);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.shadowColor = "rgba(248,113,113,0.95)";
|
||||||
|
ctx.shadowBlur = 22;
|
||||||
|
ctx.fillStyle = "#fee2e2";
|
||||||
|
ctx.font = "700 16px serif";
|
||||||
|
ctx.fillText("DF-5C STRATEGIC LAUNCH PREVIEW", w * 0.5, y - 24);
|
||||||
|
ctx.fillStyle = "#fecaca";
|
||||||
|
ctx.font = "700 18px serif";
|
||||||
|
ctx.fillText(userInfo, w * 0.5, y + 8, 640);
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.font = "900 34px serif";
|
||||||
|
ctx.fillText(title, w * 0.5, y + 45, 640);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制圆角矩形路径。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} x 左上角 x
|
||||||
|
* @param {number} y 左上角 y
|
||||||
|
* @param {number} w 宽度
|
||||||
|
* @param {number} h 高度
|
||||||
|
* @param {number} r 圆角半径
|
||||||
|
*/
|
||||||
|
function roundRect(ctx, x, y, w, h, r) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.lineTo(x + w - r, y);
|
||||||
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||||
|
ctx.lineTo(x + w, y + h - r);
|
||||||
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||||
|
ctx.lineTo(x + r, y + h);
|
||||||
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||||
|
ctx.lineTo(x, y + r);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动东风-5C洲际导弹发射预览特效。
|
||||||
|
*
|
||||||
|
* @param {HTMLCanvasElement} canvas 全屏特效画布
|
||||||
|
* @param {Function} onEnd 结束回调
|
||||||
|
* @param {object} options 特效附加参数
|
||||||
|
* @returns {{cancel: Function}}
|
||||||
|
*/
|
||||||
|
function start(canvas, onEnd, options = {}) {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
const particles = createParticles(120);
|
||||||
|
const title = String(options.effect_title || "东风-5C 洲际导弹 升空").trim() || "东风-5C 洲际导弹 升空";
|
||||||
|
const userInfo = String(options.effect_user_info || "").trim();
|
||||||
|
const startTime = performance.now();
|
||||||
|
let animId = null;
|
||||||
|
let finished = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一结束动画,手动取消时只清理不回调。
|
||||||
|
*
|
||||||
|
* @param {boolean} canceled 是否为手动取消
|
||||||
|
*/
|
||||||
|
function finish(canceled) {
|
||||||
|
if (finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
finished = true;
|
||||||
|
if (animId) {
|
||||||
|
cancelAnimationFrame(animId);
|
||||||
|
}
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
if (!canceled) {
|
||||||
|
onEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逐帧绘制发射动画。
|
||||||
|
*
|
||||||
|
* @param {number} now 当前高精度时间
|
||||||
|
*/
|
||||||
|
function animate(now) {
|
||||||
|
const progress = Math.min(1, (now - startTime) / DURATION);
|
||||||
|
const launch = easeInOutCubic(Math.min(1, progress / 0.78));
|
||||||
|
const launchX = w * (0.18 + launch * 0.66);
|
||||||
|
const launchY = h * (0.78 - launch * 0.95);
|
||||||
|
const scale = Math.min(1.08, Math.max(0.7, w / 1240));
|
||||||
|
const tailX = launchX - Math.sin(0.62) * 168 * scale;
|
||||||
|
const tailY = launchY + Math.cos(0.62) * 168 * scale;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
drawBackdrop(ctx, w, h, progress);
|
||||||
|
drawLaunchPad(ctx, w, h, progress);
|
||||||
|
drawExhaust(ctx, particles, tailX, tailY, progress);
|
||||||
|
drawMissile(ctx, launchX, launchY, scale, progress);
|
||||||
|
drawHud(ctx, w, h, progress, title, userInfo);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
animId = requestAnimationFrame(animate);
|
||||||
|
} else {
|
||||||
|
finish(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animId = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cancel() {
|
||||||
|
finish(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.Df5cEffect = Df5cEffect;
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* 统一管理全屏 Canvas 特效的入口、防重入和资源清理。
|
* 统一管理全屏 Canvas 特效的入口、防重入和资源清理。
|
||||||
* 播放期间用户点击屏幕任意位置可立即结束当前全屏特效。
|
* 播放期间用户点击屏幕任意位置可立即结束当前全屏特效。
|
||||||
* 使用方式:EffectManager.play('fireworks' | 'rain' | 'lightning' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies')
|
* 使用方式:EffectManager.play('fireworks' | 'rain' | 'lightning' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies' | 'j35' | '99a' | 'df5c' | 'fujian')
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const EffectManager = (() => {
|
const EffectManager = (() => {
|
||||||
@@ -22,6 +22,10 @@ const EffectManager = (() => {
|
|||||||
hearts: { key: "hearts", load: () => import("./hearts.js") },
|
hearts: { key: "hearts", load: () => import("./hearts.js") },
|
||||||
confetti: { key: "confetti", load: () => import("./confetti.js") },
|
confetti: { key: "confetti", load: () => import("./confetti.js") },
|
||||||
fireflies: { key: "fireflies", load: () => import("./fireflies.js") },
|
fireflies: { key: "fireflies", load: () => import("./fireflies.js") },
|
||||||
|
j35: { key: "j35", load: () => import("./j35.js") },
|
||||||
|
"99a": { key: "99a", load: () => import("./99a.js") },
|
||||||
|
df5c: { key: "df5c", load: () => import("./df5c.js") },
|
||||||
|
fujian: { key: "fujian", load: () => import("./fujian.js") },
|
||||||
};
|
};
|
||||||
// 特效模块 Promise 缓存,同类型重复触发时复用同一次加载
|
// 特效模块 Promise 缓存,同类型重复触发时复用同一次加载
|
||||||
const _effectModulePromises = new Map();
|
const _effectModulePromises = new Map();
|
||||||
@@ -217,9 +221,9 @@ const EffectManager = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (playNext) {
|
if (playNext) {
|
||||||
const nextType = _dequeueNextType();
|
const nextEffect = _dequeueNextType();
|
||||||
if (nextType) {
|
if (nextEffect) {
|
||||||
play(nextType);
|
play(nextEffect.type, nextEffect.options || {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,8 +232,9 @@ const EffectManager = (() => {
|
|||||||
* 将特效加入有限队列,同类型短时间重复触发时只保留一份。
|
* 将特效加入有限队列,同类型短时间重复触发时只保留一份。
|
||||||
*
|
*
|
||||||
* @param {string} type 待播放特效类型
|
* @param {string} type 待播放特效类型
|
||||||
|
* @param {object} options 特效附加参数
|
||||||
*/
|
*/
|
||||||
function _enqueue(type) {
|
function _enqueue(type, options = {}) {
|
||||||
const existingIndex = _queue.findIndex((item) => item.type === type);
|
const existingIndex = _queue.findIndex((item) => item.type === type);
|
||||||
if (existingIndex !== -1) {
|
if (existingIndex !== -1) {
|
||||||
_queue.splice(existingIndex, 1);
|
_queue.splice(existingIndex, 1);
|
||||||
@@ -237,6 +242,7 @@ const EffectManager = (() => {
|
|||||||
|
|
||||||
_queue.push({
|
_queue.push({
|
||||||
type,
|
type,
|
||||||
|
options,
|
||||||
queuedAt: Date.now(),
|
queuedAt: Date.now(),
|
||||||
keepUntilPlayed: type === "wedding-fireworks",
|
keepUntilPlayed: type === "wedding-fireworks",
|
||||||
});
|
});
|
||||||
@@ -248,7 +254,7 @@ const EffectManager = (() => {
|
|||||||
/**
|
/**
|
||||||
* 取出下一个仍然有效的排队特效。
|
* 取出下一个仍然有效的排队特效。
|
||||||
*
|
*
|
||||||
* @returns {string|null}
|
* @returns {{type: string, options: object}|null}
|
||||||
*/
|
*/
|
||||||
function _dequeueNextType() {
|
function _dequeueNextType() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -256,7 +262,7 @@ const EffectManager = (() => {
|
|||||||
while (_queue.length > 0) {
|
while (_queue.length > 0) {
|
||||||
const next = _queue.shift();
|
const next = _queue.shift();
|
||||||
if (next.keepUntilPlayed || now - next.queuedAt <= QUEUED_EFFECT_TTL) {
|
if (next.keepUntilPlayed || now - next.queuedAt <= QUEUED_EFFECT_TTL) {
|
||||||
return next.type;
|
return next;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,23 +332,25 @@ const EffectManager = (() => {
|
|||||||
* @param {HTMLCanvasElement} canvas 全屏特效画布
|
* @param {HTMLCanvasElement} canvas 全屏特效画布
|
||||||
* @param {Function} finishCurrent 当前特效结束回调
|
* @param {Function} finishCurrent 当前特效结束回调
|
||||||
* @param {string} startMethod 启动方法名称
|
* @param {string} startMethod 启动方法名称
|
||||||
|
* @param {object} options 特效附加参数
|
||||||
* @returns {boolean} 是否成功找到并启动特效
|
* @returns {boolean} 是否成功找到并启动特效
|
||||||
*/
|
*/
|
||||||
function _startEffect(effectObject, canvas, finishCurrent, startMethod = "start") {
|
function _startEffect(effectObject, canvas, finishCurrent, startMethod = "start", options = {}) {
|
||||||
if (!effectObject || typeof effectObject[startMethod] !== "function") {
|
if (!effectObject || typeof effectObject[startMethod] !== "function") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_bindEffectController(effectObject[startMethod](canvas, finishCurrent));
|
_bindEffectController(effectObject[startMethod](canvas, finishCurrent, options));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 播放指定特效
|
* 播放指定特效
|
||||||
*
|
*
|
||||||
* @param {string} type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies
|
* @param {string} type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies / j35 / 99a / df5c / fujian
|
||||||
|
* @param {object} options 特效附加参数
|
||||||
*/
|
*/
|
||||||
function play(type) {
|
function play(type, options = {}) {
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -354,19 +362,20 @@ const EffectManager = (() => {
|
|||||||
|
|
||||||
// 防重入:同时只允许一个特效
|
// 防重入:同时只允许一个特效
|
||||||
if (_current) {
|
if (_current) {
|
||||||
_enqueue(type);
|
_enqueue(type, options);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_play(type);
|
_play(type, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载模块后播放指定特效。
|
* 加载模块后播放指定特效。
|
||||||
*
|
*
|
||||||
* @param {string} type 特效类型
|
* @param {string} type 特效类型
|
||||||
|
* @param {object} options 特效附加参数
|
||||||
*/
|
*/
|
||||||
async function _play(type) {
|
async function _play(type, options = {}) {
|
||||||
_current = type;
|
_current = type;
|
||||||
const token = _playToken;
|
const token = _playToken;
|
||||||
|
|
||||||
@@ -434,6 +443,18 @@ const EffectManager = (() => {
|
|||||||
case "fireflies":
|
case "fireflies":
|
||||||
started = _startEffect(window.FirefliesEffect, canvas, finishCurrent);
|
started = _startEffect(window.FirefliesEffect, canvas, finishCurrent);
|
||||||
break;
|
break;
|
||||||
|
case "j35":
|
||||||
|
started = _startEffect(window.J35Effect, canvas, finishCurrent, "start", options);
|
||||||
|
break;
|
||||||
|
case "99a":
|
||||||
|
started = _startEffect(window.Type99AEffect, canvas, finishCurrent, "start", options);
|
||||||
|
break;
|
||||||
|
case "df5c":
|
||||||
|
started = _startEffect(window.Df5cEffect, canvas, finishCurrent, "start", options);
|
||||||
|
break;
|
||||||
|
case "fujian":
|
||||||
|
started = _startEffect(window.FujianEffect, canvas, finishCurrent, "start", options);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn(`[EffectManager] 未知特效类型:${type}`);
|
console.warn(`[EffectManager] 未知特效类型:${type}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@
|
|||||||
* hearts 爱心飘落(温暖双音)
|
* hearts 爱心飘落(温暖双音)
|
||||||
* confetti 彩带庆典(礼炮碎响 + 清亮点缀)
|
* confetti 彩带庆典(礼炮碎响 + 清亮点缀)
|
||||||
* fireflies 萤火虫(稀疏微光铃音)
|
* fireflies 萤火虫(稀疏微光铃音)
|
||||||
|
* j35 歼-35 战机(喷气低频 + 高速呼啸 + 音爆扫频)
|
||||||
|
* 99a 99A 主战坦克(履带低频 + 炮击冲击 + 金属震动)
|
||||||
|
* df5c 东风-5C(发射低频 + 尾焰轰鸣 + 高空呼啸)
|
||||||
|
* fujian 福建舰(海浪低频 + 舰载机掠过 + 甲板提示音)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const EffectSounds = (() => {
|
const EffectSounds = (() => {
|
||||||
@@ -762,6 +766,385 @@ const EffectSounds = (() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动歼-35 战机音效:喷气低频、空中掠过和音爆扫频。
|
||||||
|
*
|
||||||
|
* @returns {Function} 停止函数
|
||||||
|
*/
|
||||||
|
function _startJ35() {
|
||||||
|
const ctx = _getCtx();
|
||||||
|
const master = ctx.createGain();
|
||||||
|
master.gain.value = 0.58;
|
||||||
|
master.connect(ctx.destination);
|
||||||
|
|
||||||
|
const turbine = ctx.createOscillator();
|
||||||
|
const subRumble = ctx.createOscillator();
|
||||||
|
const turbineFilter = ctx.createBiquadFilter();
|
||||||
|
const turbineGain = ctx.createGain();
|
||||||
|
turbine.type = "sawtooth";
|
||||||
|
subRumble.type = "triangle";
|
||||||
|
turbine.frequency.setValueAtTime(72, ctx.currentTime);
|
||||||
|
turbine.frequency.exponentialRampToValueAtTime(210, ctx.currentTime + 1.8);
|
||||||
|
turbine.frequency.exponentialRampToValueAtTime(96, ctx.currentTime + 7.6);
|
||||||
|
subRumble.frequency.setValueAtTime(34, ctx.currentTime);
|
||||||
|
subRumble.frequency.exponentialRampToValueAtTime(68, ctx.currentTime + 1.8);
|
||||||
|
subRumble.frequency.exponentialRampToValueAtTime(38, ctx.currentTime + 7.6);
|
||||||
|
turbineFilter.type = "lowpass";
|
||||||
|
turbineFilter.frequency.setValueAtTime(420, ctx.currentTime);
|
||||||
|
turbineFilter.frequency.exponentialRampToValueAtTime(2600, ctx.currentTime + 1.6);
|
||||||
|
turbineFilter.frequency.exponentialRampToValueAtTime(520, ctx.currentTime + 7.8);
|
||||||
|
turbineGain.gain.setValueAtTime(0.001, ctx.currentTime);
|
||||||
|
turbineGain.gain.linearRampToValueAtTime(0.32, ctx.currentTime + 0.35);
|
||||||
|
turbineGain.gain.linearRampToValueAtTime(0.42, ctx.currentTime + 1.6);
|
||||||
|
turbineGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 8.1);
|
||||||
|
turbine.connect(turbineFilter);
|
||||||
|
subRumble.connect(turbineFilter);
|
||||||
|
turbineFilter.connect(turbineGain);
|
||||||
|
turbineGain.connect(master);
|
||||||
|
turbine.start(ctx.currentTime);
|
||||||
|
subRumble.start(ctx.currentTime);
|
||||||
|
turbine.stop(ctx.currentTime + 8.2);
|
||||||
|
subRumble.stop(ctx.currentTime + 8.2);
|
||||||
|
|
||||||
|
_scheduleNoiseSweep(ctx, master, {
|
||||||
|
delay: 0.12,
|
||||||
|
duration: 2.6,
|
||||||
|
startFreq: 180,
|
||||||
|
endFreq: 6200,
|
||||||
|
volume: 0.24,
|
||||||
|
q: 0.9,
|
||||||
|
});
|
||||||
|
_scheduleNoiseSweep(ctx, master, {
|
||||||
|
delay: 2.05,
|
||||||
|
duration: 0.72,
|
||||||
|
startFreq: 9000,
|
||||||
|
endFreq: 1200,
|
||||||
|
volume: 0.34,
|
||||||
|
q: 1.4,
|
||||||
|
});
|
||||||
|
_scheduleNoiseSweep(ctx, master, {
|
||||||
|
delay: 4.2,
|
||||||
|
duration: 1.05,
|
||||||
|
startFreq: 7200,
|
||||||
|
endFreq: 280,
|
||||||
|
volume: 0.2,
|
||||||
|
q: 1.8,
|
||||||
|
filterType: "highpass",
|
||||||
|
});
|
||||||
|
|
||||||
|
[0.42, 0.9, 1.32, 5.1].forEach((delay, index) => {
|
||||||
|
_scheduleTone(ctx, master, {
|
||||||
|
delay,
|
||||||
|
duration: 0.11,
|
||||||
|
freq: [1046.5, 1318.5, 1567.98, 2093][index % 4],
|
||||||
|
endFreq: [1567.98, 1975.53, 2349.32, 3135.96][index % 4],
|
||||||
|
volume: 0.045,
|
||||||
|
type: "square",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const endTimer = setTimeout(() => {
|
||||||
|
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8);
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
master.disconnect();
|
||||||
|
} catch (_) {}
|
||||||
|
}, 900);
|
||||||
|
}, 8800);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(endTimer);
|
||||||
|
try {
|
||||||
|
master.gain.setValueAtTime(0, ctx.currentTime);
|
||||||
|
turbine.stop();
|
||||||
|
subRumble.stop();
|
||||||
|
master.disconnect();
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动 99A 主战坦克音效:履带低频、炮击冲击与金属震动。
|
||||||
|
*
|
||||||
|
* @returns {Function} 停止函数
|
||||||
|
*/
|
||||||
|
function _startType99A() {
|
||||||
|
const ctx = _getCtx();
|
||||||
|
const master = ctx.createGain();
|
||||||
|
master.gain.value = 0.52;
|
||||||
|
master.connect(ctx.destination);
|
||||||
|
|
||||||
|
const engine = ctx.createOscillator();
|
||||||
|
const track = ctx.createOscillator();
|
||||||
|
const filter = ctx.createBiquadFilter();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
engine.type = "sawtooth";
|
||||||
|
track.type = "square";
|
||||||
|
engine.frequency.setValueAtTime(42, ctx.currentTime);
|
||||||
|
engine.frequency.exponentialRampToValueAtTime(68, ctx.currentTime + 1.1);
|
||||||
|
engine.frequency.exponentialRampToValueAtTime(46, ctx.currentTime + 7.5);
|
||||||
|
track.frequency.setValueAtTime(18, ctx.currentTime);
|
||||||
|
track.frequency.linearRampToValueAtTime(24, ctx.currentTime + 2.0);
|
||||||
|
track.frequency.linearRampToValueAtTime(18, ctx.currentTime + 7.5);
|
||||||
|
filter.type = "lowpass";
|
||||||
|
filter.frequency.setValueAtTime(220, ctx.currentTime);
|
||||||
|
filter.frequency.linearRampToValueAtTime(520, ctx.currentTime + 1.4);
|
||||||
|
filter.frequency.linearRampToValueAtTime(260, ctx.currentTime + 7.8);
|
||||||
|
gain.gain.setValueAtTime(0.001, ctx.currentTime);
|
||||||
|
gain.gain.linearRampToValueAtTime(0.32, ctx.currentTime + 0.35);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 8.1);
|
||||||
|
engine.connect(filter);
|
||||||
|
track.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(master);
|
||||||
|
engine.start(ctx.currentTime);
|
||||||
|
track.start(ctx.currentTime);
|
||||||
|
engine.stop(ctx.currentTime + 8.2);
|
||||||
|
track.stop(ctx.currentTime + 8.2);
|
||||||
|
|
||||||
|
_scheduleNoiseSweep(ctx, master, {
|
||||||
|
delay: 0.2,
|
||||||
|
duration: 2.8,
|
||||||
|
startFreq: 80,
|
||||||
|
endFreq: 420,
|
||||||
|
volume: 0.18,
|
||||||
|
q: 0.8,
|
||||||
|
filterType: "lowpass",
|
||||||
|
});
|
||||||
|
_scheduleNoiseSweep(ctx, master, {
|
||||||
|
delay: 4.02,
|
||||||
|
duration: 0.5,
|
||||||
|
startFreq: 180,
|
||||||
|
endFreq: 65,
|
||||||
|
volume: 0.42,
|
||||||
|
q: 0.7,
|
||||||
|
filterType: "lowpass",
|
||||||
|
});
|
||||||
|
_scheduleNoiseSweep(ctx, master, {
|
||||||
|
delay: 4.05,
|
||||||
|
duration: 0.18,
|
||||||
|
startFreq: 4800,
|
||||||
|
endFreq: 900,
|
||||||
|
volume: 0.16,
|
||||||
|
q: 1.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
[0.5, 1.1, 1.7, 2.25, 3.0, 3.55, 5.2, 5.85].forEach((delay) => {
|
||||||
|
_scheduleTone(ctx, master, {
|
||||||
|
delay,
|
||||||
|
duration: 0.08,
|
||||||
|
freq: 72,
|
||||||
|
endFreq: 44,
|
||||||
|
volume: 0.045,
|
||||||
|
type: "square",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const endTimer = setTimeout(() => {
|
||||||
|
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8);
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
master.disconnect();
|
||||||
|
} catch (_) {}
|
||||||
|
}, 900);
|
||||||
|
}, 8400);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(endTimer);
|
||||||
|
try {
|
||||||
|
master.gain.setValueAtTime(0, ctx.currentTime);
|
||||||
|
engine.stop();
|
||||||
|
track.stop();
|
||||||
|
master.disconnect();
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动东风-5C预览音效:发射低频、尾焰轰鸣与高空呼啸。
|
||||||
|
*
|
||||||
|
* @returns {Function} 停止函数
|
||||||
|
*/
|
||||||
|
function _startDf5c() {
|
||||||
|
const ctx = _getCtx();
|
||||||
|
const master = ctx.createGain();
|
||||||
|
master.gain.value = 0.58;
|
||||||
|
master.connect(ctx.destination);
|
||||||
|
|
||||||
|
const rumble = ctx.createOscillator();
|
||||||
|
const flame = ctx.createOscillator();
|
||||||
|
const filter = ctx.createBiquadFilter();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
rumble.type = "sawtooth";
|
||||||
|
flame.type = "triangle";
|
||||||
|
rumble.frequency.setValueAtTime(28, ctx.currentTime);
|
||||||
|
rumble.frequency.exponentialRampToValueAtTime(76, ctx.currentTime + 2.2);
|
||||||
|
rumble.frequency.exponentialRampToValueAtTime(38, ctx.currentTime + 7.4);
|
||||||
|
flame.frequency.setValueAtTime(96, ctx.currentTime);
|
||||||
|
flame.frequency.exponentialRampToValueAtTime(220, ctx.currentTime + 2.8);
|
||||||
|
flame.frequency.exponentialRampToValueAtTime(112, ctx.currentTime + 7.2);
|
||||||
|
filter.type = "lowpass";
|
||||||
|
filter.frequency.setValueAtTime(180, ctx.currentTime);
|
||||||
|
filter.frequency.exponentialRampToValueAtTime(1800, ctx.currentTime + 2.8);
|
||||||
|
filter.frequency.exponentialRampToValueAtTime(420, ctx.currentTime + 7.8);
|
||||||
|
gain.gain.setValueAtTime(0.001, ctx.currentTime);
|
||||||
|
gain.gain.linearRampToValueAtTime(0.4, ctx.currentTime + 0.9);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 8.0);
|
||||||
|
rumble.connect(filter);
|
||||||
|
flame.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(master);
|
||||||
|
rumble.start(ctx.currentTime);
|
||||||
|
flame.start(ctx.currentTime);
|
||||||
|
rumble.stop(ctx.currentTime + 8.1);
|
||||||
|
flame.stop(ctx.currentTime + 8.1);
|
||||||
|
|
||||||
|
_scheduleNoiseSweep(ctx, master, {
|
||||||
|
delay: 0.08,
|
||||||
|
duration: 3.4,
|
||||||
|
startFreq: 90,
|
||||||
|
endFreq: 2600,
|
||||||
|
volume: 0.28,
|
||||||
|
q: 0.8,
|
||||||
|
filterType: "lowpass",
|
||||||
|
});
|
||||||
|
_scheduleNoiseSweep(ctx, master, {
|
||||||
|
delay: 2.1,
|
||||||
|
duration: 2.6,
|
||||||
|
startFreq: 720,
|
||||||
|
endFreq: 8200,
|
||||||
|
volume: 0.22,
|
||||||
|
q: 1.3,
|
||||||
|
});
|
||||||
|
_scheduleNoiseSweep(ctx, master, {
|
||||||
|
delay: 4.8,
|
||||||
|
duration: 1.1,
|
||||||
|
startFreq: 9600,
|
||||||
|
endFreq: 1600,
|
||||||
|
volume: 0.18,
|
||||||
|
q: 1.8,
|
||||||
|
filterType: "highpass",
|
||||||
|
});
|
||||||
|
|
||||||
|
[0.2, 0.46, 0.74, 1.04, 1.34].forEach((delay) => {
|
||||||
|
_scheduleTone(ctx, master, {
|
||||||
|
delay,
|
||||||
|
duration: 0.12,
|
||||||
|
freq: 58,
|
||||||
|
endFreq: 32,
|
||||||
|
volume: 0.07,
|
||||||
|
type: "square",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const endTimer = setTimeout(() => {
|
||||||
|
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8);
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
master.disconnect();
|
||||||
|
} catch (_) {}
|
||||||
|
}, 900);
|
||||||
|
}, 8500);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(endTimer);
|
||||||
|
try {
|
||||||
|
master.gain.setValueAtTime(0, ctx.currentTime);
|
||||||
|
rumble.stop();
|
||||||
|
flame.stop();
|
||||||
|
master.disconnect();
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动福建舰预览音效:海浪低频、舰载机掠过与甲板提示音。
|
||||||
|
*
|
||||||
|
* @returns {Function} 停止函数
|
||||||
|
*/
|
||||||
|
function _startFujian() {
|
||||||
|
const ctx = _getCtx();
|
||||||
|
const master = ctx.createGain();
|
||||||
|
master.gain.value = 0.42;
|
||||||
|
master.connect(ctx.destination);
|
||||||
|
|
||||||
|
const engine = ctx.createOscillator();
|
||||||
|
const wake = ctx.createOscillator();
|
||||||
|
const filter = ctx.createBiquadFilter();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
engine.type = "sawtooth";
|
||||||
|
wake.type = "triangle";
|
||||||
|
engine.frequency.setValueAtTime(38, ctx.currentTime);
|
||||||
|
engine.frequency.exponentialRampToValueAtTime(52, ctx.currentTime + 2.4);
|
||||||
|
engine.frequency.exponentialRampToValueAtTime(34, ctx.currentTime + 8.2);
|
||||||
|
wake.frequency.setValueAtTime(74, ctx.currentTime);
|
||||||
|
wake.frequency.exponentialRampToValueAtTime(92, ctx.currentTime + 2.2);
|
||||||
|
wake.frequency.exponentialRampToValueAtTime(60, ctx.currentTime + 8.2);
|
||||||
|
filter.type = "lowpass";
|
||||||
|
filter.frequency.setValueAtTime(240, ctx.currentTime);
|
||||||
|
filter.frequency.exponentialRampToValueAtTime(720, ctx.currentTime + 2.0);
|
||||||
|
filter.frequency.exponentialRampToValueAtTime(260, ctx.currentTime + 8.4);
|
||||||
|
gain.gain.setValueAtTime(0.001, ctx.currentTime);
|
||||||
|
gain.gain.linearRampToValueAtTime(0.26, ctx.currentTime + 0.55);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 8.6);
|
||||||
|
engine.connect(filter);
|
||||||
|
wake.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(master);
|
||||||
|
engine.start(ctx.currentTime);
|
||||||
|
wake.start(ctx.currentTime);
|
||||||
|
engine.stop(ctx.currentTime + 8.7);
|
||||||
|
wake.stop(ctx.currentTime + 8.7);
|
||||||
|
|
||||||
|
_scheduleNoiseSweep(ctx, master, {
|
||||||
|
delay: 0.1,
|
||||||
|
duration: 7.8,
|
||||||
|
startFreq: 120,
|
||||||
|
endFreq: 520,
|
||||||
|
volume: 0.18,
|
||||||
|
q: 0.7,
|
||||||
|
filterType: "lowpass",
|
||||||
|
});
|
||||||
|
_scheduleNoiseSweep(ctx, master, {
|
||||||
|
delay: 3.15,
|
||||||
|
duration: 1.35,
|
||||||
|
startFreq: 420,
|
||||||
|
endFreq: 7600,
|
||||||
|
volume: 0.22,
|
||||||
|
q: 1.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
[0.8, 1.35, 2.0, 3.0, 4.7, 5.45].forEach((delay, index) => {
|
||||||
|
_scheduleTone(ctx, master, {
|
||||||
|
delay,
|
||||||
|
duration: 0.12,
|
||||||
|
freq: [880, 1174.66, 1567.98][index % 3],
|
||||||
|
endFreq: [987.77, 1318.51, 1760][index % 3],
|
||||||
|
volume: 0.045,
|
||||||
|
type: "sine",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const endTimer = setTimeout(() => {
|
||||||
|
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8);
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
master.disconnect();
|
||||||
|
} catch (_) {}
|
||||||
|
}, 900);
|
||||||
|
}, 9000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(endTimer);
|
||||||
|
try {
|
||||||
|
master.gain.setValueAtTime(0, ctx.currentTime);
|
||||||
|
engine.stop();
|
||||||
|
wake.stop();
|
||||||
|
master.disconnect();
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 公开 API ──────────────────────────────────────────────────
|
// ─── 公开 API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -770,7 +1153,7 @@ const EffectSounds = (() => {
|
|||||||
* 当 AudioContext 处于 suspended 状态时,先 resume() 再播放,
|
* 当 AudioContext 处于 suspended 状态时,先 resume() 再播放,
|
||||||
* 解决页面无用户手势时的自动静音问题(如管理员进房自动烟花)。
|
* 解决页面无用户手势时的自动静音问题(如管理员进房自动烟花)。
|
||||||
*
|
*
|
||||||
* @param {string} type 'lightning' | 'fireworks' | 'rain' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies'
|
* @param {string} type 'lightning' | 'fireworks' | 'rain' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies' | 'j35' | '99a' | 'df5c' | 'fujian'
|
||||||
*/
|
*/
|
||||||
function play(type) {
|
function play(type) {
|
||||||
// 用户开启禁音则跳过
|
// 用户开启禁音则跳过
|
||||||
@@ -813,6 +1196,18 @@ const EffectSounds = (() => {
|
|||||||
case "fireflies":
|
case "fireflies":
|
||||||
_stopFn = _startFireflies();
|
_stopFn = _startFireflies();
|
||||||
break;
|
break;
|
||||||
|
case "j35":
|
||||||
|
_stopFn = _startJ35();
|
||||||
|
break;
|
||||||
|
case "99a":
|
||||||
|
_stopFn = _startType99A();
|
||||||
|
break;
|
||||||
|
case "df5c":
|
||||||
|
_stopFn = _startDf5c();
|
||||||
|
break;
|
||||||
|
case "fujian":
|
||||||
|
_stopFn = _startFujian();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,529 @@
|
|||||||
|
/**
|
||||||
|
* 文件功能:聊天室福建舰航空母舰入场预览特效
|
||||||
|
*
|
||||||
|
* 使用全屏透明 Canvas 绘制福建舰航母破浪入场、甲板灯带、舰岛、编号 18、
|
||||||
|
* 弹射轨道、舰载机剪影起飞和海面尾流。该效果只用于本地视觉预览。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FujianEffect = (() => {
|
||||||
|
const DURATION = 8800;
|
||||||
|
const SEA = "#0f766e";
|
||||||
|
const DECK = "#334155";
|
||||||
|
const HULL = "#1f2937";
|
||||||
|
const LIGHT = "#67e8f9";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓入缓出曲线,让航母移动有重量感。
|
||||||
|
*
|
||||||
|
* @param {number} t 0 到 1 的进度
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function easeInOutCubic(t) {
|
||||||
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓出曲线,用于舰载机起飞和尾流扩散。
|
||||||
|
*
|
||||||
|
* @param {number} t 0 到 1 的进度
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function easeOutCubic(t) {
|
||||||
|
return 1 - Math.pow(1 - t, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建海浪粒子。
|
||||||
|
*
|
||||||
|
* @param {number} w 画布宽度
|
||||||
|
* @param {number} h 画布高度
|
||||||
|
* @returns {Array<Record<string, number>>}
|
||||||
|
*/
|
||||||
|
function createWaves(w, h) {
|
||||||
|
return Array.from({ length: 72 }, () => ({
|
||||||
|
x: Math.random() * w,
|
||||||
|
y: h * (0.62 + Math.random() * 0.28),
|
||||||
|
speed: 0.7 + Math.random() * 2.6,
|
||||||
|
width: 24 + Math.random() * 96,
|
||||||
|
alpha: 0.1 + Math.random() * 0.32,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制海面背景、雷达线和远处光带。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} w 画布宽度
|
||||||
|
* @param {number} h 画布高度
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawBackdrop(ctx, w, h, progress) {
|
||||||
|
const fade = Math.min(1, progress / 0.14) * Math.min(1, (1 - progress) / 0.12);
|
||||||
|
const sky = ctx.createLinearGradient(0, 0, 0, h);
|
||||||
|
sky.addColorStop(0, `rgba(15,23,42,${0.68 * fade})`);
|
||||||
|
sky.addColorStop(0.5, `rgba(30,64,175,${0.18 * fade})`);
|
||||||
|
sky.addColorStop(1, `rgba(15,118,110,${0.42 * fade})`);
|
||||||
|
ctx.fillStyle = sky;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = fade;
|
||||||
|
ctx.strokeStyle = "rgba(103,232,249,0.16)";
|
||||||
|
ctx.lineWidth = 1.2;
|
||||||
|
for (let y = h * 0.56; y < h; y += 38) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y + Math.sin(progress * 16 + y) * 6);
|
||||||
|
ctx.lineTo(w, y + Math.cos(progress * 12 + y) * 6);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.strokeStyle = "rgba(251,191,36,0.2)";
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(w * 0.12, h * 0.22);
|
||||||
|
ctx.lineTo(w * 0.88, h * 0.2 + Math.sin(progress * 10) * 5);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.strokeStyle = "rgba(34,211,238,0.18)";
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(w * 0.8, h * 0.32, w * (0.08 + i * 0.07 + progress * 0.02), 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制动态海浪和航母尾流。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {Array<Record<string, number>>} waves 海浪粒子
|
||||||
|
* @param {number} w 画布宽度
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
* @param {number} carrierX 航母中心 x
|
||||||
|
* @param {number} carrierY 航母中心 y
|
||||||
|
* @param {number} scale 缩放比例
|
||||||
|
*/
|
||||||
|
function drawWaves(ctx, waves, w, progress, carrierX, carrierY, scale) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
waves.forEach((wave, index) => {
|
||||||
|
const x = (wave.x - progress * 420 * wave.speed + index * 37) % (w + 220);
|
||||||
|
ctx.globalAlpha = wave.alpha;
|
||||||
|
ctx.strokeStyle = index % 3 === 0 ? "rgba(240,253,250,0.65)" : "rgba(125,211,252,0.44)";
|
||||||
|
ctx.lineWidth = index % 3 === 0 ? 3 : 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x - 110, wave.y);
|
||||||
|
ctx.lineTo(x - 110 + wave.width, wave.y + Math.sin(progress * 20 + index) * 5);
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.globalAlpha = 0.7;
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.72)";
|
||||||
|
ctx.lineWidth = 8 * scale;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(carrierX - 290 * scale, carrierY + 88 * scale);
|
||||||
|
ctx.bezierCurveTo(
|
||||||
|
carrierX - 460 * scale,
|
||||||
|
carrierY + 118 * scale,
|
||||||
|
carrierX - 580 * scale,
|
||||||
|
carrierY + 72 * scale,
|
||||||
|
carrierX - 720 * scale,
|
||||||
|
carrierY + 128 * scale,
|
||||||
|
);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.globalAlpha = 0.36;
|
||||||
|
ctx.lineWidth = 18 * scale;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制福建舰航母主体。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} x 航母中心 x
|
||||||
|
* @param {number} y 航母中心 y
|
||||||
|
* @param {number} scale 缩放比例
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawCarrier(ctx, x, y, scale, progress) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.shadowColor = "rgba(0,0,0,0.72)";
|
||||||
|
ctx.shadowBlur = 22;
|
||||||
|
ctx.fillStyle = "rgba(0,0,0,0.34)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(0, 112, 500, 32, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// 舰体侧面和上翘舰艏,整体拉长压低,更接近参考图的侧视航母比例。
|
||||||
|
const hull = ctx.createLinearGradient(-520, 22, 520, 126);
|
||||||
|
hull.addColorStop(0, "#0f172a");
|
||||||
|
hull.addColorStop(0.42, HULL);
|
||||||
|
hull.addColorStop(1, "#475569");
|
||||||
|
ctx.fillStyle = hull;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-540, 24);
|
||||||
|
ctx.lineTo(444, 22);
|
||||||
|
ctx.lineTo(522, 48);
|
||||||
|
ctx.lineTo(458, 112);
|
||||||
|
ctx.lineTo(-438, 122);
|
||||||
|
ctx.lineTo(-520, 68);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.strokeStyle = "rgba(148,163,184,0.34)";
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
for (let i = 0; i < 17; i++) {
|
||||||
|
const px = -420 + i * 54;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px, 42);
|
||||||
|
ctx.lineTo(px + 22, 42);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 大型平直飞行甲板:长矩形甲板与左侧上翘舰艏是主要识别点。
|
||||||
|
const deck = ctx.createLinearGradient(-520, -70, 512, 56);
|
||||||
|
deck.addColorStop(0, "#475569");
|
||||||
|
deck.addColorStop(0.48, DECK);
|
||||||
|
deck.addColorStop(1, "#64748b");
|
||||||
|
ctx.fillStyle = deck;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-520, -52);
|
||||||
|
ctx.lineTo(352, -62);
|
||||||
|
ctx.lineTo(512, -24);
|
||||||
|
ctx.lineTo(468, 42);
|
||||||
|
ctx.lineTo(-456, 58);
|
||||||
|
ctx.lineTo(-548, 6);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.strokeStyle = "rgba(226,232,240,0.46)";
|
||||||
|
ctx.lineWidth = 5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-492, -38);
|
||||||
|
ctx.lineTo(474, -28);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-500, 48);
|
||||||
|
ctx.lineTo(442, 30);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.strokeStyle = "rgba(226,232,240,0.5)";
|
||||||
|
ctx.lineWidth = 4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-440, 4);
|
||||||
|
ctx.lineTo(398, -18);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 三条弹射轨道和甲板灯带,突出福建舰电磁弹射视觉。
|
||||||
|
ctx.strokeStyle = "rgba(103,232,249,0.82)";
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
[-38, -15, 8].forEach((offset, index) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-348, offset);
|
||||||
|
ctx.lineTo(368, offset - 22 - index * 4);
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.strokeStyle = "rgba(251,191,36,0.72)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
for (let i = 0; i < 24; i++) {
|
||||||
|
const lx = -436 + i * 38;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(lx, 44 + Math.sin(progress * 20 + i) * 2);
|
||||||
|
ctx.lineTo(lx + 14, 43 + Math.sin(progress * 20 + i) * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 舰岛、雷达桅杆和红旗标识,位置靠中右,贴近参考图。
|
||||||
|
ctx.fillStyle = "#1e293b";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(106, -112);
|
||||||
|
ctx.lineTo(196, -124);
|
||||||
|
ctx.lineTo(226, -74);
|
||||||
|
ctx.lineTo(178, -36);
|
||||||
|
ctx.lineTo(92, -46);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = "#475569";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(126, -176);
|
||||||
|
ctx.lineTo(182, -166);
|
||||||
|
ctx.lineTo(172, -118);
|
||||||
|
ctx.lineTo(106, -126);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = "#cbd5e1";
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
ctx.fillRect(122 + i * 14, -154, 8, 8);
|
||||||
|
ctx.fillRect(118 + i * 14, -138, 8, 8);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = "rgba(226,232,240,0.5)";
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(154, -174);
|
||||||
|
ctx.lineTo(154, -220);
|
||||||
|
ctx.moveTo(134, -204);
|
||||||
|
ctx.lineTo(188, -216);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(239,68,68,0.95)";
|
||||||
|
ctx.fillRect(184, -104, 34, 22);
|
||||||
|
ctx.fillStyle = "#fde68a";
|
||||||
|
ctx.font = "900 13px serif";
|
||||||
|
ctx.fillText("★", 192, -88);
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.92)";
|
||||||
|
ctx.font = "900 46px serif";
|
||||||
|
ctx.fillText("18", -456, 24);
|
||||||
|
ctx.fillStyle = "rgba(203,213,225,0.92)";
|
||||||
|
ctx.font = "900 28px serif";
|
||||||
|
ctx.fillText("福建舰", -72, -34);
|
||||||
|
|
||||||
|
drawDeckAircraft(ctx, progress);
|
||||||
|
drawBowspray(ctx, progress);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制甲板飞机剪影和一架起飞中的舰载机。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawDeckAircraft(ctx, progress) {
|
||||||
|
[
|
||||||
|
[-356, -30, -0.03, 0.34],
|
||||||
|
[-296, -7, -0.02, 0.36],
|
||||||
|
[-244, 24, 0.02, 0.34],
|
||||||
|
[-188, -34, -0.04, 0.36],
|
||||||
|
[-132, -8, -0.02, 0.38],
|
||||||
|
[-76, 22, 0.02, 0.36],
|
||||||
|
[-22, -35, -0.05, 0.38],
|
||||||
|
[34, -12, -0.02, 0.36],
|
||||||
|
[92, 16, 0.02, 0.34],
|
||||||
|
[156, -26, -0.04, 0.34],
|
||||||
|
[214, 0, -0.02, 0.32],
|
||||||
|
[276, -30, -0.04, 0.3],
|
||||||
|
].forEach(([x, y, rotation, scale]) => {
|
||||||
|
drawJet(ctx, x, y, scale, rotation, "rgba(15,23,42,0.86)");
|
||||||
|
});
|
||||||
|
|
||||||
|
const takeoff = Math.max(0, Math.min(1, (progress - 0.34) / 0.42));
|
||||||
|
if (takeoff <= 0 || takeoff >= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.globalAlpha = Math.sin(takeoff * Math.PI);
|
||||||
|
ctx.strokeStyle = "rgba(103,232,249,0.7)";
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(248, -36);
|
||||||
|
ctx.lineTo(248 + takeoff * 270, -36 - takeoff * 132);
|
||||||
|
ctx.stroke();
|
||||||
|
drawJet(ctx, 248 + takeoff * 270, -36 - takeoff * 132, 0.56, -0.34, "rgba(226,232,240,0.95)");
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制简化舰载机剪影。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} x 中心 x
|
||||||
|
* @param {number} y 中心 y
|
||||||
|
* @param {number} scale 缩放比例
|
||||||
|
* @param {number} rotation 旋转角度
|
||||||
|
* @param {string} fill 填充颜色
|
||||||
|
*/
|
||||||
|
function drawJet(ctx, x, y, scale, rotation, fill) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
ctx.rotate(rotation);
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
ctx.fillStyle = fill;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(68, 0);
|
||||||
|
ctx.lineTo(-46, -18);
|
||||||
|
ctx.lineTo(-22, -3);
|
||||||
|
ctx.lineTo(-66, 0);
|
||||||
|
ctx.lineTo(-22, 3);
|
||||||
|
ctx.lineTo(-46, 18);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制舰艏破浪飞沫。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawBowspray(ctx, progress) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.strokeStyle = "rgba(240,253,250,0.72)";
|
||||||
|
ctx.lineWidth = 4;
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const spread = 20 + i * 16 + Math.sin(progress * 18 + i) * 8;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(470, 48 + i * 5);
|
||||||
|
ctx.quadraticCurveTo(520 + spread, 60 + i * 14, 570 + spread, 38 + i * 4);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制福建舰测试 HUD。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} w 画布宽度
|
||||||
|
* @param {number} h 画布高度
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
* @param {string} title 入场标题
|
||||||
|
* @param {string} userInfo 用户身份信息
|
||||||
|
*/
|
||||||
|
function drawHud(ctx, w, h, progress, title, userInfo) {
|
||||||
|
const enter = Math.min(1, Math.max(0, (progress - 0.12) / 0.2));
|
||||||
|
const leave = Math.min(1, Math.max(0, (1 - progress) / 0.14));
|
||||||
|
const alpha = easeInOutCubic(enter) * leave;
|
||||||
|
const y = h * 0.17 - (1 - enter) * 22;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillStyle = "rgba(15,23,42,0.68)";
|
||||||
|
ctx.strokeStyle = "rgba(103,232,249,0.72)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
roundRect(ctx, w * 0.5 - 340, y - 56, 680, 120, 18);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.shadowColor = "rgba(103,232,249,0.95)";
|
||||||
|
ctx.shadowBlur = 20;
|
||||||
|
ctx.fillStyle = "#cffafe";
|
||||||
|
ctx.font = "700 16px serif";
|
||||||
|
ctx.fillText("FUJIAN AIRCRAFT CARRIER PREVIEW", w * 0.5, y - 24);
|
||||||
|
ctx.fillStyle = "#a5f3fc";
|
||||||
|
ctx.font = "700 18px serif";
|
||||||
|
ctx.fillText(userInfo, w * 0.5, y + 8, 620);
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.font = "900 34px serif";
|
||||||
|
ctx.fillText(title, w * 0.5, y + 45, 620);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制圆角矩形路径。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} x 左上角 x
|
||||||
|
* @param {number} y 左上角 y
|
||||||
|
* @param {number} w 宽度
|
||||||
|
* @param {number} h 高度
|
||||||
|
* @param {number} r 圆角半径
|
||||||
|
*/
|
||||||
|
function roundRect(ctx, x, y, w, h, r) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.lineTo(x + w - r, y);
|
||||||
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||||
|
ctx.lineTo(x + w, y + h - r);
|
||||||
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||||
|
ctx.lineTo(x + r, y + h);
|
||||||
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||||
|
ctx.lineTo(x, y + r);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动福建舰航母入场预览特效。
|
||||||
|
*
|
||||||
|
* @param {HTMLCanvasElement} canvas 全屏特效画布
|
||||||
|
* @param {Function} onEnd 结束回调
|
||||||
|
* @param {object} options 特效附加参数
|
||||||
|
* @returns {{cancel: Function}}
|
||||||
|
*/
|
||||||
|
function start(canvas, onEnd, options = {}) {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
const waves = createWaves(w, h);
|
||||||
|
const title = String(options.effect_title || "福建舰 航母入场").trim() || "福建舰 航母入场";
|
||||||
|
const userInfo = String(options.effect_user_info || "").trim();
|
||||||
|
const startTime = performance.now();
|
||||||
|
let animId = null;
|
||||||
|
let finished = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一结束动画,手动取消时只清理不回调。
|
||||||
|
*
|
||||||
|
* @param {boolean} canceled 是否为手动取消
|
||||||
|
*/
|
||||||
|
function finish(canceled) {
|
||||||
|
if (finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
finished = true;
|
||||||
|
if (animId) {
|
||||||
|
cancelAnimationFrame(animId);
|
||||||
|
}
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
if (!canceled) {
|
||||||
|
onEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逐帧绘制福建舰入场动画。
|
||||||
|
*
|
||||||
|
* @param {number} now 当前高精度时间
|
||||||
|
*/
|
||||||
|
function animate(now) {
|
||||||
|
const progress = Math.min(1, (now - startTime) / DURATION);
|
||||||
|
const enter = easeInOutCubic(Math.min(1, progress / 0.72));
|
||||||
|
const exit = easeInOutCubic(Math.max(0, (progress - 0.78) / 0.22));
|
||||||
|
const scale = Math.min(1.02, Math.max(0.64, w / 1280));
|
||||||
|
const carrierX = -w * 0.24 + enter * w * 0.76 + exit * w * 0.54;
|
||||||
|
const carrierY = h * 0.66 + Math.sin(progress * 18) * 3;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
drawBackdrop(ctx, w, h, progress);
|
||||||
|
drawWaves(ctx, waves, w, progress, carrierX, carrierY, scale);
|
||||||
|
drawCarrier(ctx, carrierX, carrierY, scale, progress);
|
||||||
|
drawHud(ctx, w, h, progress, title, userInfo);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
animId = requestAnimationFrame(animate);
|
||||||
|
} else {
|
||||||
|
finish(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animId = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cancel() {
|
||||||
|
finish(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.FujianEffect = FujianEffect;
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
/**
|
||||||
|
* 文件功能:聊天室歼-35 战机入场特效
|
||||||
|
*
|
||||||
|
* 使用全屏透明 Canvas 绘制隐身战机高速掠过、喷口尾焰、音爆环、
|
||||||
|
* 流光航迹与战术 HUD 字幕,作为座驾/载具入场的战机版本原型。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const J35Effect = (() => {
|
||||||
|
const DURATION = 8200;
|
||||||
|
const STEEL = "#9ca3af";
|
||||||
|
const DARK_STEEL = "#1f2937";
|
||||||
|
const COCKPIT = "#0f172a";
|
||||||
|
const AFTERBURNER = "#38bdf8";
|
||||||
|
const WARNING_RED = "#ef4444";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓出曲线,用于战机高速进场后略微减速展示。
|
||||||
|
*
|
||||||
|
* @param {number} t 0 到 1 的进度
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function easeOutCubic(t) {
|
||||||
|
return 1 - Math.pow(1 - t, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓入缓出曲线,用于 HUD 与音爆环淡入淡出。
|
||||||
|
*
|
||||||
|
* @param {number} t 0 到 1 的进度
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function easeInOutSine(t) {
|
||||||
|
return -(Math.cos(Math.PI * t) - 1) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建高速流光粒子。
|
||||||
|
*
|
||||||
|
* @param {number} w 画布宽度
|
||||||
|
* @param {number} h 画布高度
|
||||||
|
* @returns {Array<Record<string, number|string>>}
|
||||||
|
*/
|
||||||
|
function createSpeedLines(w, h) {
|
||||||
|
return Array.from({ length: 110 }, () => ({
|
||||||
|
x: Math.random() * w,
|
||||||
|
y: h * (0.18 + Math.random() * 0.68),
|
||||||
|
speed: 2 + Math.random() * 5,
|
||||||
|
length: 90 + Math.random() * 190,
|
||||||
|
alpha: 0.18 + Math.random() * 0.42,
|
||||||
|
color: [AFTERBURNER, "#ffffff", "#fde68a", "#60a5fa"][Math.floor(Math.random() * 4)],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制夜航背景与扫描网格。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} w 画布宽度
|
||||||
|
* @param {number} h 画布高度
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawBackdrop(ctx, w, h, progress) {
|
||||||
|
const fade = Math.min(1, progress / 0.16) * Math.min(1, (1 - progress) / 0.12);
|
||||||
|
const gradient = ctx.createRadialGradient(w * 0.5, h * 0.48, 0, w * 0.5, h * 0.5, Math.max(w, h) * 0.82);
|
||||||
|
gradient.addColorStop(0, `rgba(15,23,42,${0.52 * fade})`);
|
||||||
|
gradient.addColorStop(0.55, `rgba(8,47,73,${0.26 * fade})`);
|
||||||
|
gradient.addColorStop(1, "rgba(0,0,0,0)");
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const y = h * (0.18 + i * 0.07);
|
||||||
|
ctx.strokeStyle = `rgba(56,189,248,${0.08 * fade})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y + Math.sin(progress * 18 + i) * 6);
|
||||||
|
ctx.lineTo(w, y + Math.cos(progress * 15 + i) * 6);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制高速运动线,方向与战机飞行一致。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {Array<Record<string, number|string>>} lines 流光线集合
|
||||||
|
* @param {number} w 画布宽度
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawSpeedLines(ctx, lines, w, progress) {
|
||||||
|
const fade = Math.min(1, progress / 0.12) * Math.min(1, (1 - progress) / 0.1);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
const travel = (progress * (900 + line.speed * 120) + index * 71) % (w + 520);
|
||||||
|
const x = w + 260 - travel;
|
||||||
|
ctx.globalAlpha = line.alpha * fade;
|
||||||
|
ctx.strokeStyle = line.color;
|
||||||
|
ctx.lineWidth = 1.4 + line.speed * 0.18;
|
||||||
|
ctx.shadowColor = line.color;
|
||||||
|
ctx.shadowBlur = 12;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, line.y);
|
||||||
|
ctx.lineTo(x + line.length, line.y + Math.sin(progress * 22 + index) * 4);
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制战机尾部双发喷口尾焰。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawAfterburners(ctx, progress) {
|
||||||
|
const pulse = 0.78 + Math.sin(progress * 70) * 0.16;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
[[168, -12], [168, 12]].forEach(([x, y]) => {
|
||||||
|
const flame = ctx.createLinearGradient(x, y, x + 170, y);
|
||||||
|
flame.addColorStop(0, `rgba(255,255,255,${0.9 * pulse})`);
|
||||||
|
flame.addColorStop(0.18, `rgba(56,189,248,${0.78 * pulse})`);
|
||||||
|
flame.addColorStop(0.58, `rgba(59,130,246,${0.3 * pulse})`);
|
||||||
|
flame.addColorStop(1, "rgba(59,130,246,0)");
|
||||||
|
ctx.fillStyle = flame;
|
||||||
|
ctx.shadowColor = AFTERBURNER;
|
||||||
|
ctx.shadowBlur = 26;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y - 9);
|
||||||
|
ctx.bezierCurveTo(x + 58, y - 18, x + 115, y - 18, x + 178, y);
|
||||||
|
ctx.bezierCurveTo(x + 115, y + 18, x + 58, y + 18, x, y + 9);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制歼-35 风格隐身战机主体。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} x 战机中心 x
|
||||||
|
* @param {number} y 战机中心 y
|
||||||
|
* @param {number} scale 缩放比例
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawJet(ctx, x, y, scale, progress) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
ctx.rotate(Math.sin(progress * 10) * 0.018);
|
||||||
|
|
||||||
|
drawAfterburners(ctx, progress);
|
||||||
|
|
||||||
|
const bodyGradient = ctx.createLinearGradient(-210, -62, 190, 62);
|
||||||
|
bodyGradient.addColorStop(0, "#d1d5db");
|
||||||
|
bodyGradient.addColorStop(0.34, "#6b7280");
|
||||||
|
bodyGradient.addColorStop(0.64, "#374151");
|
||||||
|
bodyGradient.addColorStop(1, "#111827");
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.shadowColor = "rgba(148,163,184,0.9)";
|
||||||
|
ctx.shadowBlur = 18;
|
||||||
|
|
||||||
|
// 主翼与机体采用多边形硬折线,体现隐身战机边缘。
|
||||||
|
ctx.fillStyle = bodyGradient;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-230, 0);
|
||||||
|
ctx.lineTo(-96, -44);
|
||||||
|
ctx.lineTo(12, -120);
|
||||||
|
ctx.lineTo(58, -42);
|
||||||
|
ctx.lineTo(170, -70);
|
||||||
|
ctx.lineTo(142, -18);
|
||||||
|
ctx.lineTo(202, 0);
|
||||||
|
ctx.lineTo(142, 18);
|
||||||
|
ctx.lineTo(170, 70);
|
||||||
|
ctx.lineTo(58, 42);
|
||||||
|
ctx.lineTo(12, 120);
|
||||||
|
ctx.lineTo(-96, 44);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
const spineGradient = ctx.createLinearGradient(-200, -16, 178, 16);
|
||||||
|
spineGradient.addColorStop(0, "#e5e7eb");
|
||||||
|
spineGradient.addColorStop(0.45, "#6b7280");
|
||||||
|
spineGradient.addColorStop(1, "#1f2937");
|
||||||
|
ctx.fillStyle = spineGradient;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-222, 0);
|
||||||
|
ctx.lineTo(-80, -24);
|
||||||
|
ctx.lineTo(92, -18);
|
||||||
|
ctx.lineTo(184, 0);
|
||||||
|
ctx.lineTo(92, 18);
|
||||||
|
ctx.lineTo(-80, 24);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 机头下方深色进气道。
|
||||||
|
ctx.fillStyle = "rgba(3,7,18,0.72)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-92, -33);
|
||||||
|
ctx.lineTo(-38, -74);
|
||||||
|
ctx.lineTo(-8, -55);
|
||||||
|
ctx.lineTo(-60, -24);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-92, 33);
|
||||||
|
ctx.lineTo(-38, 74);
|
||||||
|
ctx.lineTo(-8, 55);
|
||||||
|
ctx.lineTo(-60, 24);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 座舱盖。
|
||||||
|
const canopy = ctx.createLinearGradient(-148, -18, -58, 18);
|
||||||
|
canopy.addColorStop(0, "#020617");
|
||||||
|
canopy.addColorStop(0.55, "#1e3a8a");
|
||||||
|
canopy.addColorStop(1, "#111827");
|
||||||
|
ctx.fillStyle = canopy;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-160, 0);
|
||||||
|
ctx.bezierCurveTo(-130, -28, -78, -28, -48, 0);
|
||||||
|
ctx.bezierCurveTo(-78, 26, -130, 26, -160, 0);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 双垂尾。
|
||||||
|
ctx.fillStyle = DARK_STEEL;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(78, -44);
|
||||||
|
ctx.lineTo(154, -120);
|
||||||
|
ctx.lineTo(132, -38);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(78, 44);
|
||||||
|
ctx.lineTo(154, 120);
|
||||||
|
ctx.lineTo(132, 38);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 中国战机识别元素:低调红星,不做过大以免影响隐身外形。
|
||||||
|
drawRedStar(ctx, 18, -47, 15);
|
||||||
|
drawRedStar(ctx, 18, 47, 15);
|
||||||
|
|
||||||
|
// 面板高光线。
|
||||||
|
ctx.strokeStyle = "rgba(229,231,235,0.34)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-205, 0);
|
||||||
|
ctx.lineTo(168, 0);
|
||||||
|
ctx.moveTo(-70, -22);
|
||||||
|
ctx.lineTo(86, -16);
|
||||||
|
ctx.moveTo(-70, 22);
|
||||||
|
ctx.lineTo(86, 16);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制低调红星机徽。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} x 中心 x
|
||||||
|
* @param {number} y 中心 y
|
||||||
|
* @param {number} radius 半径
|
||||||
|
*/
|
||||||
|
function drawRedStar(ctx, x, y, radius) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
ctx.fillStyle = "rgba(239,68,68,0.9)";
|
||||||
|
ctx.strokeStyle = "rgba(254,202,202,0.68)";
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const angle = -Math.PI / 2 + (i * Math.PI) / 5;
|
||||||
|
const r = i % 2 === 0 ? radius : radius * 0.42;
|
||||||
|
const px = Math.cos(angle) * r;
|
||||||
|
const py = Math.sin(angle) * r;
|
||||||
|
if (i === 0) {
|
||||||
|
ctx.moveTo(px, py);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(px, py);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制高速掠过时的音爆环。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} w 画布宽度
|
||||||
|
* @param {number} h 画布高度
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
*/
|
||||||
|
function drawSonicRing(ctx, w, h, progress) {
|
||||||
|
const ringProgress = Math.max(0, Math.min(1, (progress - 0.38) / 0.22));
|
||||||
|
if (ringProgress <= 0 || ringProgress >= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alpha = Math.sin(ringProgress * Math.PI);
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "lighter";
|
||||||
|
ctx.strokeStyle = `rgba(224,242,254,${0.42 * alpha})`;
|
||||||
|
ctx.lineWidth = 5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(w * 0.5, h * 0.54, w * (0.08 + ringProgress * 0.32), h * (0.03 + ringProgress * 0.11), 0, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.strokeStyle = `rgba(56,189,248,${0.2 * alpha})`;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(w * 0.5, h * 0.54, w * (0.12 + ringProgress * 0.44), h * (0.05 + ringProgress * 0.16), 0, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制战术 HUD 字幕。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} w 画布宽度
|
||||||
|
* @param {number} h 画布高度
|
||||||
|
* @param {number} progress 播放进度
|
||||||
|
* @param {string} title 入场标题
|
||||||
|
* @param {string} userInfo 用户身份信息
|
||||||
|
*/
|
||||||
|
function drawHud(ctx, w, h, progress, title, userInfo) {
|
||||||
|
const enter = Math.min(1, Math.max(0, (progress - 0.13) / 0.18));
|
||||||
|
const leave = Math.min(1, Math.max(0, (1 - progress) / 0.16));
|
||||||
|
const alpha = easeInOutSine(enter) * leave;
|
||||||
|
const y = h * 0.18 - (1 - enter) * 26;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.shadowColor = "rgba(56,189,248,0.95)";
|
||||||
|
ctx.shadowBlur = 22;
|
||||||
|
ctx.fillStyle = "rgba(2,6,23,0.62)";
|
||||||
|
ctx.strokeStyle = "rgba(56,189,248,0.72)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
roundRect(ctx, w * 0.5 - 340, y - 56, 680, 120, 18);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = "#bae6fd";
|
||||||
|
ctx.font = "700 16px serif";
|
||||||
|
ctx.fillText("STEALTH FIGHTER ARRIVAL", w * 0.5, y - 24);
|
||||||
|
ctx.fillStyle = "#e0f2fe";
|
||||||
|
ctx.font = "700 18px serif";
|
||||||
|
ctx.fillText(userInfo, w * 0.5, y + 8, 620);
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.font = "900 34px serif";
|
||||||
|
ctx.fillText(title, w * 0.5, y + 45, 620);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制圆角矩形路径。
|
||||||
|
*
|
||||||
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
||||||
|
* @param {number} x 左上角 x
|
||||||
|
* @param {number} y 左上角 y
|
||||||
|
* @param {number} w 宽度
|
||||||
|
* @param {number} h 高度
|
||||||
|
* @param {number} r 圆角半径
|
||||||
|
*/
|
||||||
|
function roundRect(ctx, x, y, w, h, r) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.lineTo(x + w - r, y);
|
||||||
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||||
|
ctx.lineTo(x + w, y + h - r);
|
||||||
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||||
|
ctx.lineTo(x + r, y + h);
|
||||||
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||||
|
ctx.lineTo(x, y + r);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动歼-35 战机入场特效。
|
||||||
|
*
|
||||||
|
* @param {HTMLCanvasElement} canvas 全屏特效画布
|
||||||
|
* @param {Function} onEnd 结束回调
|
||||||
|
* @param {object} options 特效附加参数
|
||||||
|
* @returns {{cancel: Function}}
|
||||||
|
*/
|
||||||
|
function start(canvas, onEnd, options = {}) {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
const speedLines = createSpeedLines(w, h);
|
||||||
|
const title = String(options.effect_title || "中国歼-35 破空入场").trim() || "中国歼-35 破空入场";
|
||||||
|
const userInfo = String(options.effect_user_info || "").trim();
|
||||||
|
const startTime = performance.now();
|
||||||
|
let animId = null;
|
||||||
|
let finished = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一结束动画,手动取消时只清理不回调。
|
||||||
|
*
|
||||||
|
* @param {boolean} canceled 是否为手动取消
|
||||||
|
*/
|
||||||
|
function finish(canceled) {
|
||||||
|
if (finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
finished = true;
|
||||||
|
if (animId) {
|
||||||
|
cancelAnimationFrame(animId);
|
||||||
|
}
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
if (!canceled) {
|
||||||
|
onEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逐帧绘制战机入场动画。
|
||||||
|
*
|
||||||
|
* @param {number} now 当前高精度时间
|
||||||
|
*/
|
||||||
|
function animate(now) {
|
||||||
|
const elapsed = now - startTime;
|
||||||
|
const progress = Math.min(1, elapsed / DURATION);
|
||||||
|
const entry = easeOutCubic(Math.min(1, progress / 0.62));
|
||||||
|
const exit = easeInOutSine(Math.max(0, (progress - 0.76) / 0.24));
|
||||||
|
const jetX = w * 1.2 - entry * w * 0.82 - exit * w * 0.58;
|
||||||
|
const jetY = h * 0.58 + Math.sin(progress * 18) * 10;
|
||||||
|
const scale = Math.min(1.14, Math.max(0.68, w / 1180));
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
drawBackdrop(ctx, w, h, progress);
|
||||||
|
drawSpeedLines(ctx, speedLines, w, progress);
|
||||||
|
drawSonicRing(ctx, w, h, progress);
|
||||||
|
drawJet(ctx, jetX, jetY, scale, progress);
|
||||||
|
drawHud(ctx, w, h, progress, title, userInfo);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
animId = requestAnimationFrame(animate);
|
||||||
|
} else {
|
||||||
|
finish(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animId = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cancel() {
|
||||||
|
finish(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.J35Effect = J35Effect;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
{{--
|
||||||
|
文件功能:我的成就页面
|
||||||
|
按分类展示当前用户的固定成就解锁状态与进度。
|
||||||
|
--}}
|
||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', '我的成就 - 飘落流星')
|
||||||
|
|
||||||
|
@section('nav-icon', '🏅')
|
||||||
|
@section('nav-title', '我的成就')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<main class="p-4 sm:p-6 lg:p-8">
|
||||||
|
<div class="max-w-7xl mx-auto flex flex-col gap-6">
|
||||||
|
<section class="bg-white border border-gray-200 rounded-lg shadow-sm p-5">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-gray-900">{{ $user->username }} 的成就档案</h2>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">已解锁 {{ $unlocked_count }} / {{ $total_count }} 项</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-full sm:w-64 bg-gray-100 rounded-full h-3 overflow-hidden">
|
||||||
|
<div class="h-3 bg-amber-500"
|
||||||
|
style="width: {{ $total_count > 0 ? min(100, floor($unlocked_count / $total_count * 100)) : 0 }}%">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="bg-white border border-gray-200 rounded-lg shadow-sm p-1 flex flex-col sm:flex-row gap-1"
|
||||||
|
aria-label="成就筛选">
|
||||||
|
@foreach ($achievement_tabs as $tabKey => $tab)
|
||||||
|
<a href="{{ $tab['url'] }}"
|
||||||
|
class="flex-1 inline-flex items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-semibold {{ $active_tab === $tabKey ? 'bg-gray-900 text-white shadow-sm' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900' }}"
|
||||||
|
aria-current="{{ $active_tab === $tabKey ? 'page' : 'false' }}">
|
||||||
|
<span>{{ $tab['label'] }}</span>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full {{ $active_tab === $tabKey ? 'bg-white/15 text-white' : 'bg-gray-100 text-gray-500' }}">
|
||||||
|
{{ $tab['count'] }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@foreach ($categories as $categoryKey => $categoryLabel)
|
||||||
|
@php
|
||||||
|
$items = $achievements->where('category', $categoryKey)->values();
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($items->isEmpty())
|
||||||
|
@continue
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-bold text-gray-800">{{ $categoryLabel }}成就</h3>
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
{{ $items->where('unlocked', true)->count() }} / {{ $items->count() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
@foreach ($items as $achievement)
|
||||||
|
<article
|
||||||
|
class="bg-white border {{ $achievement['unlocked'] ? 'border-amber-200' : 'border-gray-200' }} rounded-lg p-4 shadow-sm flex gap-3">
|
||||||
|
<div
|
||||||
|
class="w-11 h-11 rounded-lg flex items-center justify-center text-2xl shrink-0 {{ $achievement['unlocked'] ? 'bg-amber-100' : 'bg-gray-100 grayscale' }}">
|
||||||
|
{{ $achievement['icon'] }}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h4 class="font-bold text-gray-900 truncate">{{ $achievement['name'] }}</h4>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded {{ $achievement['unlocked'] ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-500' }}">
|
||||||
|
{{ $achievement['unlocked'] ? '已解锁' : '进行中' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">{{ $achievement['description'] }}</p>
|
||||||
|
<div class="mt-3 flex items-center gap-3">
|
||||||
|
<div class="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div class="h-2 {{ $achievement['unlocked'] ? 'bg-emerald-500' : 'bg-indigo-500' }}"
|
||||||
|
style="width: {{ $achievement['progress_percent'] }}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500 whitespace-nowrap">
|
||||||
|
{{ number_format($achievement['progress_value']) }} /
|
||||||
|
{{ number_format($achievement['threshold']) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if ($achievement['achieved_at'])
|
||||||
|
<p class="text-xs text-amber-700 mt-2">
|
||||||
|
解锁于 {{ $achievement['achieved_at']->format('Y-m-d H:i') }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@if ($achievements->isEmpty())
|
||||||
|
<section class="bg-white border border-gray-200 rounded-lg shadow-sm p-8 text-center">
|
||||||
|
<h3 class="text-base font-bold text-gray-800">暂无对应成就</h3>
|
||||||
|
<p class="text-sm text-gray-500 mt-2">切换其他筛选查看成就列表。</p>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
{{--
|
||||||
|
文件功能:后台成就记录页面
|
||||||
|
提供固定成就目录、解锁统计与用户成就记录只读查询。
|
||||||
|
--}}
|
||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', '成就记录')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<p class="text-sm text-slate-500">固定成就</p>
|
||||||
|
<p class="mt-2 text-2xl font-bold text-slate-900">{{ number_format($summary['total_definitions']) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<p class="text-sm text-slate-500">解锁记录</p>
|
||||||
|
<p class="mt-2 text-2xl font-bold text-amber-600">{{ number_format($summary['unlocked_records']) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<p class="text-sm text-slate-500">解锁用户</p>
|
||||||
|
<p class="mt-2 text-2xl font-bold text-emerald-600">{{ number_format($summary['unlocked_users']) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
|
<section class="xl:col-span-2 rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div class="border-b border-slate-200 p-4">
|
||||||
|
<form method="GET" class="flex flex-col md:flex-row gap-3">
|
||||||
|
<input type="text" name="username" value="{{ request('username') }}" placeholder="用户名"
|
||||||
|
class="w-full md:w-56 rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none">
|
||||||
|
<select name="achievement_key"
|
||||||
|
class="w-full md:w-64 rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none">
|
||||||
|
<option value="">全部成就</option>
|
||||||
|
@foreach ($definitions as $key => $definition)
|
||||||
|
<option value="{{ $key }}" @selected(request('achievement_key') === $key)>
|
||||||
|
{{ $definition['icon'] }} {{ $definition['name'] }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<button type="submit"
|
||||||
|
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-bold text-white hover:bg-indigo-700">筛选</button>
|
||||||
|
<a href="{{ route('admin.achievements.index') }}"
|
||||||
|
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-bold text-slate-600 hover:bg-slate-50">重置</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||||
|
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3">用户</th>
|
||||||
|
<th class="px-4 py-3">成就</th>
|
||||||
|
<th class="px-4 py-3">进度</th>
|
||||||
|
<th class="px-4 py-3">解锁时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
@forelse ($records as $record)
|
||||||
|
@php
|
||||||
|
$definition = $definitions[$record->achievement_key] ?? null;
|
||||||
|
$threshold = (int) data_get($record->metadata, 'threshold', $definition['threshold'] ?? 0);
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 font-semibold text-slate-900">
|
||||||
|
{{ $record->user?->username ?? '未知用户' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="font-bold text-slate-800">
|
||||||
|
{{ $definition['icon'] ?? '🏅' }} {{ $definition['name'] ?? $record->achievement_key }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-500">{{ $definition['description'] ?? '' }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-slate-600">
|
||||||
|
{{ number_format($record->progress_value) }} / {{ number_format($threshold) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-slate-500">
|
||||||
|
{{ $record->achieved_at?->format('Y-m-d H:i') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-4 py-10 text-center text-slate-500">暂无解锁记录</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-slate-200 p-4">
|
||||||
|
{{ $records->links() }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 class="text-base font-bold text-slate-900">热门成就</h2>
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
@forelse ($topAchievements as $row)
|
||||||
|
@php $definition = $definitions[$row->achievement_key] ?? null; @endphp
|
||||||
|
<div class="flex items-center justify-between gap-3 rounded-md bg-slate-50 px-3 py-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate font-semibold text-slate-800">
|
||||||
|
{{ $definition['icon'] ?? '🏅' }} {{ $definition['name'] ?? $row->achievement_key }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-slate-500">{{ $definition['description'] ?? '' }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 rounded bg-amber-100 px-2 py-1 text-xs font-bold text-amber-700">
|
||||||
|
{{ number_format($row->unlocked_count) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="text-sm text-slate-500">暂无热门成就。</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -68,6 +68,10 @@
|
|||||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.currency-logs.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.currency-logs.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||||
{!! '💴 用户流水' !!}
|
{!! '💴 用户流水' !!}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ route('admin.achievements.index') }}"
|
||||||
|
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.achievements.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||||
|
🏅 成就记录
|
||||||
|
</a>
|
||||||
<a href="{{ route('admin.rooms.index') }}"
|
<a href="{{ route('admin.rooms.index') }}"
|
||||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.rooms.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.rooms.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||||
{!! '🏠 房间管理' !!}
|
{!! '🏠 房间管理' !!}
|
||||||
@@ -84,6 +88,10 @@
|
|||||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.shop.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.shop.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||||
{!! '🛒 商店管理' !!}
|
{!! '🛒 商店管理' !!}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ route('admin.rides.index') }}"
|
||||||
|
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.rides.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||||
|
🚘 座驾管理
|
||||||
|
</a>
|
||||||
<a href="{{ route('admin.marriages.index') }}"
|
<a href="{{ route('admin.marriages.index') }}"
|
||||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.marriages.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.marriages.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||||
{!! '💒 婚姻管理' !!}
|
{!! '💒 婚姻管理' !!}
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
{{--
|
||||||
|
文件功能:后台座驾独立管理页面
|
||||||
|
支持查看、新增、编辑、上下架切换、删除座驾,以及配置价格、使用天数和欢迎语。
|
||||||
|
--}}
|
||||||
|
|
||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', '🚘 座驾管理')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
@php require resource_path('views/admin/partials/list-theme.php'); @endphp
|
||||||
|
|
||||||
|
@php
|
||||||
|
$isSuperAdmin = Auth::id() === 1;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div x-data="{
|
||||||
|
showForm: false,
|
||||||
|
editing: null,
|
||||||
|
form: {
|
||||||
|
name: '',
|
||||||
|
slug: 'ride_',
|
||||||
|
effect_key: '',
|
||||||
|
icon: '🚘',
|
||||||
|
description: '',
|
||||||
|
price: 1000,
|
||||||
|
duration_days: 7,
|
||||||
|
welcome_message: '',
|
||||||
|
sort_order: 80,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
openCreate() {
|
||||||
|
this.editing = null;
|
||||||
|
this.form = {
|
||||||
|
name: '',
|
||||||
|
slug: 'ride_',
|
||||||
|
effect_key: '',
|
||||||
|
icon: '🚘',
|
||||||
|
description: '',
|
||||||
|
price: 1000,
|
||||||
|
duration_days: 7,
|
||||||
|
welcome_message: '',
|
||||||
|
sort_order: 80,
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
|
this.showForm = true;
|
||||||
|
this.$nextTick(() => this.$refs.nameInput?.focus());
|
||||||
|
},
|
||||||
|
|
||||||
|
openEdit(ride) {
|
||||||
|
this.editing = ride;
|
||||||
|
this.form = {
|
||||||
|
name: ride.name,
|
||||||
|
slug: ride.slug,
|
||||||
|
effect_key: ride.effect_key,
|
||||||
|
icon: ride.icon,
|
||||||
|
description: ride.description || '',
|
||||||
|
price: ride.price,
|
||||||
|
duration_days: ride.duration_days || 7,
|
||||||
|
welcome_message: ride.welcome_message || '',
|
||||||
|
sort_order: ride.sort_order,
|
||||||
|
is_active: ride.is_active,
|
||||||
|
};
|
||||||
|
this.showForm = true;
|
||||||
|
this.$nextTick(() => this.$refs.nameInput?.focus());
|
||||||
|
},
|
||||||
|
|
||||||
|
closeForm() {
|
||||||
|
this.showForm = false;
|
||||||
|
this.editing = null;
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<div class="{{ $adminListHeaderCardClass }} mb-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="{{ $adminListHeaderTitleClass }}">聊天室座驾列表</h2>
|
||||||
|
<p class="{{ $adminListHeaderSubtitleClass }}">单独管理座驾价格、使用天数、入场欢迎语和全屏特效 key。</p>
|
||||||
|
</div>
|
||||||
|
@if ($isSuperAdmin)
|
||||||
|
<button type="button" @click="openCreate()" class="{{ $adminListPrimaryButtonClass }}">
|
||||||
|
+ 新增座驾
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="{{ $adminListCardClass }}">
|
||||||
|
<table class="{{ $adminListTableClass }}">
|
||||||
|
<thead class="{{ $adminListTableHeadRowClass }}">
|
||||||
|
<tr>
|
||||||
|
<th class="{{ $adminListTableHeadCellClass }}">座驾</th>
|
||||||
|
<th class="{{ $adminListTableHeadCellClass }}">特效 Key</th>
|
||||||
|
<th class="{{ $adminListTableHeadCellClass }} text-right">价格</th>
|
||||||
|
<th class="{{ $adminListTableHeadCellClass }} text-center">使用天数</th>
|
||||||
|
<th class="{{ $adminListTableHeadCellClass }} text-center">排序</th>
|
||||||
|
<th class="{{ $adminListTableHeadCellClass }} text-center">状态</th>
|
||||||
|
<th class="{{ $adminListTableHeadCellClass }} text-center">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="{{ $adminListTableBodyClass }}">
|
||||||
|
@forelse ($rides as $ride)
|
||||||
|
<tr class="{{ $adminListTableRowClass }} {{ $ride->is_active ? '' : 'opacity-50' }}">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-2xl leading-none">{{ $ride->icon }}</span>
|
||||||
|
<div>
|
||||||
|
<p class="{{ $adminListPrimaryTextClass }}">{{ $ride->name }}</p>
|
||||||
|
<p class="{{ $adminListSecondaryTextClass }} font-mono">{{ $ride->slug }}</p>
|
||||||
|
@if ($ride->description)
|
||||||
|
<p class="mt-0.5 max-w-xs truncate text-xs text-gray-500" title="{{ $ride->description }}">
|
||||||
|
{{ $ride->description }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-gray-600">{{ $ride->effect_key }}</td>
|
||||||
|
<td class="px-4 py-3 text-right font-mono font-bold text-amber-600">
|
||||||
|
{{ number_format($ride->price) }} 金
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-center text-xs text-gray-500">{{ $ride->duration_days }} 天</td>
|
||||||
|
<td class="px-4 py-3 text-center font-mono text-xs text-gray-400">{{ $ride->sort_order }}</td>
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
<form method="POST" action="{{ route('admin.rides.toggle', $ride) }}" class="inline">
|
||||||
|
@csrf @method('PATCH')
|
||||||
|
<button type="submit"
|
||||||
|
class="rounded-full px-2.5 py-1 text-xs font-bold transition {{ $ride->is_active ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'bg-gray-100 text-gray-500 hover:bg-gray-200' }}">
|
||||||
|
{{ $ride->is_active ? '上架中' : '已下架' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="openEdit({{ json_encode([
|
||||||
|
'id' => $ride->id,
|
||||||
|
'name' => $ride->name,
|
||||||
|
'slug' => $ride->slug,
|
||||||
|
'effect_key' => $ride->effect_key,
|
||||||
|
'icon' => $ride->icon,
|
||||||
|
'description' => $ride->description,
|
||||||
|
'price' => $ride->price,
|
||||||
|
'duration_days' => $ride->duration_days,
|
||||||
|
'welcome_message' => $ride->welcome_message,
|
||||||
|
'sort_order' => $ride->sort_order,
|
||||||
|
'is_active' => (bool) $ride->is_active,
|
||||||
|
], JSON_UNESCAPED_UNICODE) }})"
|
||||||
|
class="{{ $adminListActionButtonClass }} text-indigo-600 hover:bg-indigo-50 hover:text-indigo-800">
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
@if ($isSuperAdmin)
|
||||||
|
<form method="POST" action="{{ route('admin.rides.destroy', $ride) }}"
|
||||||
|
data-admin-confirm="确定要删除「{{ $ride->name }}」吗?此操作不可撤销!">
|
||||||
|
@csrf @method('DELETE')
|
||||||
|
<button type="submit"
|
||||||
|
class="{{ $adminListActionButtonClass }} text-red-500 hover:bg-red-50 hover:text-red-700">
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="{{ $adminListEmptyClass }}">暂无座驾数据</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="showForm" x-cloak class="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
style="background: rgba(0,0,0,0.45);">
|
||||||
|
<div @click.stop class="max-h-[90vh] w-full max-w-xl overflow-y-auto rounded-2xl bg-white shadow-2xl"
|
||||||
|
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="translate-y-4 opacity-0"
|
||||||
|
x-transition:enter-end="translate-y-0 opacity-100">
|
||||||
|
<div class="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h3 class="text-lg font-bold text-gray-800" x-text="editing ? '编辑座驾:' + editing.name : '新增座驾'"></h3>
|
||||||
|
<button type="button" @click="closeForm()" class="text-2xl leading-none text-gray-400 hover:text-gray-600">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST"
|
||||||
|
:action="editing ? '{{ url('admin/rides') }}/' + editing.id : '{{ route('admin.rides.store') }}'"
|
||||||
|
class="space-y-4 px-6 py-5">
|
||||||
|
@csrf
|
||||||
|
<template x-if="editing"><input type="hidden" name="_method" value="PUT"></template>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="mb-1 block text-xs font-semibold text-gray-600">座驾名称 <span class="text-red-500">*</span></label>
|
||||||
|
<input x-ref="nameInput" type="text" name="name" x-model="form.name" required maxlength="100"
|
||||||
|
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold text-gray-600">Slug <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" name="slug" x-model="form.slug" required maxlength="100"
|
||||||
|
class="w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400">
|
||||||
|
<p class="mt-1 text-[11px] text-gray-500">格式:ride_j35、ride_df5c。</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold text-gray-600">特效 Key <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" name="effect_key" x-model="form.effect_key" required maxlength="50"
|
||||||
|
class="w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400">
|
||||||
|
<p class="mt-1 text-[11px] text-gray-500">对应 resources/js/effects/<key>.js。</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold text-gray-600">图标 <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" name="icon" x-model="form.icon" required maxlength="20"
|
||||||
|
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold text-gray-600">价格(金币)<span class="text-red-500">*</span></label>
|
||||||
|
<input type="number" name="price" x-model="form.price" required min="0"
|
||||||
|
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold text-gray-600">座驾描述</label>
|
||||||
|
<textarea name="description" x-model="form.description" rows="2" maxlength="500"
|
||||||
|
class="w-full resize-none rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold text-gray-600">使用天数 <span class="text-red-500">*</span></label>
|
||||||
|
<input type="number" name="duration_days" x-model="form.duration_days" required min="1"
|
||||||
|
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold text-gray-600">排序权重</label>
|
||||||
|
<input type="number" name="sort_order" x-model="form.sort_order" min="0"
|
||||||
|
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold text-gray-600">入场欢迎语句</label>
|
||||||
|
<textarea name="welcome_message" x-model="form.welcome_message" rows="2" maxlength="255"
|
||||||
|
placeholder="支持 {name} 用户名、{ride} 座驾名,例如:【{name}】驾驶【{ride}】震撼入场!"
|
||||||
|
class="w-full resize-none rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="hidden" name="is_active" :value="form.is_active ? 1 : 0">
|
||||||
|
<label class="relative inline-flex cursor-pointer items-center">
|
||||||
|
<input type="checkbox" x-model="form.is_active" class="peer sr-only">
|
||||||
|
<div class="h-5 w-10 rounded-full bg-gray-200 transition peer-checked:bg-indigo-500 peer-focus:ring-2 peer-focus:ring-indigo-300"></div>
|
||||||
|
<div class="absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow transition peer-checked:translate-x-5"></div>
|
||||||
|
</label>
|
||||||
|
<span class="text-sm text-gray-600" x-text="form.is_active ? '上架显示' : '下架隐藏'"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end gap-3 border-t pt-2">
|
||||||
|
<button type="button" @click="closeForm()"
|
||||||
|
class="rounded-lg border border-gray-300 px-5 py-2 text-sm text-gray-600 transition hover:bg-gray-50">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="rounded-lg bg-indigo-600 px-6 py-2 text-sm font-bold text-white shadow transition hover:bg-indigo-700">
|
||||||
|
<span x-text="editing ? '保存修改' : '创建座驾'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -126,7 +126,8 @@
|
|||||||
'localClearStorageKey' => "local_clear_msg_id_{$room->id}",
|
'localClearStorageKey' => "local_clear_msg_id_{$room->id}",
|
||||||
'welcomeMessage' => $initialWelcomeMessage ?? null,
|
'welcomeMessage' => $initialWelcomeMessage ?? null,
|
||||||
'welcomeMessages' => $initialWelcomeMessages ?? [],
|
'welcomeMessages' => $initialWelcomeMessages ?? [],
|
||||||
'entryEffect' => $newbieEffect ?: ($initialPresenceTheme['presence_effect'] ?? ($weekEffect ?? null)),
|
'entryEffect' => $newbieEffect ?: ($initialRideEffect ?? ($initialPresenceTheme['presence_effect'] ?? ($weekEffect ?? null))),
|
||||||
|
'entryEffectOptions' => $newbieEffect ? null : ($initialRideEffectOptions ?? null),
|
||||||
'presenceTheme' => $initialPresenceTheme ?? null,
|
'presenceTheme' => $initialPresenceTheme ?? null,
|
||||||
'pendingProposal' => $pendingProposal ?? null,
|
'pendingProposal' => $pendingProposal ?? null,
|
||||||
'pendingDivorce' => $pendingDivorce ?? null,
|
'pendingDivorce' => $pendingDivorce ?? null,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
window.showRedPacketModal(...) → 展示红包弹窗(收到 WebSocket 事件触发)
|
window.showRedPacketModal(...) → 展示红包弹窗(收到 WebSocket 事件触发)
|
||||||
window.closeRedPacketModal() → 关闭红包弹窗
|
window.closeRedPacketModal() → 关闭红包弹窗
|
||||||
window.claimRedPacket() → 用户抢红包
|
window.claimRedPacket() → 用户抢红包
|
||||||
window.updateRedPacketClaimsUI() → 更新领取名单(WebSocket 广播后调用)
|
window.updateRedPacketClaimsUI() → 更新剩余份数(WebSocket 广播后调用)
|
||||||
|
|
||||||
注:依赖 window.chatBanner(chat-banner.blade.php)、window.chatDialog、window.chatToast。
|
注:依赖 window.chatBanner(chat-banner.blade.php)、window.chatDialog、window.chatToast。
|
||||||
|
|
||||||
@@ -190,28 +190,6 @@
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 已领取名单 */
|
|
||||||
#rp-claims-list {
|
|
||||||
margin-top: 12px;
|
|
||||||
max-height: 100px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border-top: 1px dashed #fca5a5;
|
|
||||||
padding-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rp-claim-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #555;
|
|
||||||
padding: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rp-claim-item span:last-child {
|
|
||||||
color: #dc2626;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 关闭按钮 */
|
/* 关闭按钮 */
|
||||||
#rp-close-btn {
|
#rp-close-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -260,12 +238,6 @@
|
|||||||
<div id="rp-timer-bar" style="width:100%;"></div>
|
<div id="rp-timer-bar" style="width:100%;"></div>
|
||||||
</div>
|
</div>
|
||||||
<button id="rp-claim-btn" data-red-packet-claim>🧧 立即抢红包</button>
|
<button id="rp-claim-btn" data-red-packet-claim>🧧 立即抢红包</button>
|
||||||
<div id="rp-status-msg"></div>
|
|
||||||
{{-- 领取名单 --}}
|
|
||||||
<div id="rp-claims-list" style="display:none;">
|
|
||||||
<div style="font-size:11px; color:#92400e; margin-bottom:4px; font-weight:bold;">已领取名单:</div>
|
|
||||||
<div id="rp-claims-items"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,9 +7,28 @@
|
|||||||
|
|
||||||
{{-- 顶部标题栏(第一行:房间名称 + 介绍) --}}
|
{{-- 顶部标题栏(第一行:房间名称 + 介绍) --}}
|
||||||
<div class="room-title-bar">
|
<div class="room-title-bar">
|
||||||
<div class="room-name">●{{ $room->name }}●<span style="font-size:12px; font-weight:normal;">在线<span
|
<div class="room-title-main">
|
||||||
id="online-count">0</span>人</span></div>
|
<div class="room-name">●{{ $room->name }}●<span style="font-size:12px; font-weight:normal;">在线<span
|
||||||
<div class="room-desc" id="room-title-display">{{ $room->description ?? '欢迎光临本聊天室!畅所欲言,文明聊天。' }}
|
id="online-count">0</span>人</span></div>
|
||||||
|
<div class="room-desc" id="room-title-display">{{ $room->description ?? '欢迎光临本聊天室!畅所欲言,文明聊天。' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="daily-game-profit-board" aria-label="今日游戏三杰">
|
||||||
|
<div class="daily-game-profit-board-title">今日游戏三杰</div>
|
||||||
|
@if (($dailyGameProfitLeaders ?? collect())->isNotEmpty())
|
||||||
|
<div class="daily-game-profit-avatars">
|
||||||
|
@foreach ($dailyGameProfitLeaders as $leader)
|
||||||
|
<span class="daily-game-profit-avatar-wrap daily-game-profit-avatar-rank-{{ $leader->rank }}"
|
||||||
|
data-username="{{ $leader->username }}">
|
||||||
|
<img class="daily-game-profit-avatar" src="{{ $leader->headface_url }}" alt="{{ $leader->username }}"
|
||||||
|
onerror="this.src='/images/headface/1.gif'">
|
||||||
|
</span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="daily-game-profit-empty">今日财神位待开张</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -87,16 +87,16 @@
|
|||||||
<div id="welcome-menu" class="welcome-menu" data-chat-welcome-menu style="display:none;">
|
<div id="welcome-menu" class="welcome-menu" data-chat-welcome-menu style="display:none;">
|
||||||
@php
|
@php
|
||||||
$welcomeMessages = [
|
$welcomeMessages = [
|
||||||
'欢迎【{name}】来到我们的聊天室,请遵守规则,文明聊天!',
|
'热烈欢迎【{name}】驾到,卸下疲惫随心闲聊自在畅聊不负相逢',
|
||||||
'【{name}】,你好!欢迎来访,有什么问题随时告诉我们!',
|
'四海好友齐聚暖心聊天室,欢迎【{name}】来唠嗑谈心交友放松好心情',
|
||||||
'热烈欢迎【{name}】加入,愿您在这里度过愉快的时光!',
|
'欢迎【{name}】小可爱光临聊天室,随心畅聊无拘无束共度惬意时光',
|
||||||
'欢迎新朋友【{name}】!请先阅读公告,了解聊天室规则哦~',
|
'欢迎【{name}】前来报到 在这里随心聊天放松心情温暖同行',
|
||||||
'【{name}】来了!欢迎欢迎,希望你在这里玩得开心!',
|
'欢迎【{name}】新朋友光临本聊天室,愿你在这里卸下烦恼,随心闲聊自在舒心',
|
||||||
'亲爱的【{name}】,欢迎光临本聊天室,请保持文明礼貌!',
|
'欢迎大佬【{name}】空降聊天室!唠嗑摸鱼随便来,主打开心放松不拘谨',
|
||||||
'欢迎【{name}】入驻!有问题请联系管理员,我们随时为您服务!',
|
'恭喜【{name}】解锁暖心聊天室!欢迎加入,闲聊八卦吐槽日常都安排上',
|
||||||
'【{name}】,初来乍到,欢迎多多关照,我们是一家人!',
|
'小小聊天室盛满温柔,欢迎【{name}】驻足停留,畅谈日常治愈所有不开心',
|
||||||
'大家欢迎新成员【{name}】!请遵守群规,共建和谐聊天环境!',
|
'暖心聊天室恭候【{name}】到来,一起聊天互动解忧寻知己乐相伴',
|
||||||
'欢迎【{name}】莅临指导!希望你常来,让我们一起聊天!',
|
'岁月温柔相逢可贵,恭候【{name}】到来,一起闲话家常、治愈所有不开心',
|
||||||
];
|
];
|
||||||
@endphp
|
@endphp
|
||||||
@foreach ($welcomeMessages as $msg)
|
@foreach ($welcomeMessages as $msg)
|
||||||
@@ -188,7 +188,7 @@ $welcomeMessages = [
|
|||||||
<div style="font-size:10px;color:#4338ca;padding:0 2px 8px;">常用操作</div>
|
<div style="font-size:10px;color:#4338ca;padding:0 2px 8px;">常用操作</div>
|
||||||
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;">
|
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;">
|
||||||
<button type="button" data-chat-feature-local-clear
|
<button type="button" data-chat-feature-local-clear
|
||||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#475569;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🧹 清屏</button>
|
style="font-size:11px;padding:6px 8px;background:#fff;color:#475569;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🧹 本地清屏</button>
|
||||||
<button type="button" data-chat-daily-status-open
|
<button type="button" data-chat-daily-status-open
|
||||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#4f46e5;border:1px solid #a5b4fc;border-radius:6px;cursor:pointer;">
|
style="font-size:11px;padding:6px 8px;background:#fff;color:#4f46e5;border:1px solid #a5b4fc;border-radius:6px;cursor:pointer;">
|
||||||
<span id="daily-status-shortcut-icon">{{ $activeDailyStatus['icon'] ?? '🙂' }}</span>
|
<span id="daily-status-shortcut-icon">{{ $activeDailyStatus['icon'] ?? '🙂' }}</span>
|
||||||
@@ -201,6 +201,8 @@ $welcomeMessages = [
|
|||||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">✅ 签到</button>
|
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">✅ 签到</button>
|
||||||
<button type="button" data-chat-feature-shortcut="shop"
|
<button type="button" data-chat-feature-shortcut="shop"
|
||||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🛍 商店</button>
|
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🛍 商店</button>
|
||||||
|
<button type="button" data-chat-feature-shortcut="ride"
|
||||||
|
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🚘 座驾</button>
|
||||||
<button type="button" data-chat-feature-shortcut="vip"
|
<button type="button" data-chat-feature-shortcut="vip"
|
||||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">👑 会员</button>
|
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">👑 会员</button>
|
||||||
<button type="button" data-chat-feature-shortcut="game"
|
<button type="button" data-chat-feature-shortcut="game"
|
||||||
@@ -330,6 +332,14 @@ $welcomeMessages = [
|
|||||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#7c3aed;border:1px solid #ddd6fe;border-radius:6px;cursor:pointer;">🎊 彩带庆典</button>
|
style="font-size:11px;padding:6px 8px;background:#fff;color:#7c3aed;border:1px solid #ddd6fe;border-radius:6px;cursor:pointer;">🎊 彩带庆典</button>
|
||||||
<button type="button" data-chat-admin-effect="fireflies"
|
<button type="button" data-chat-admin-effect="fireflies"
|
||||||
style="font-size:11px;padding:6px 8px;background:#fff;color:#15803d;border:1px solid #bbf7d0;border-radius:6px;cursor:pointer;">✨ 萤火虫</button>
|
style="font-size:11px;padding:6px 8px;background:#fff;color:#15803d;border:1px solid #bbf7d0;border-radius:6px;cursor:pointer;">✨ 萤火虫</button>
|
||||||
|
<button type="button" data-chat-admin-effect-preview="j35"
|
||||||
|
style="grid-column:1 / -1;font-size:12px;padding:7px 10px;background:linear-gradient(135deg,#020617,#075985);color:#e0f2fe;border:1px solid #38bdf8;border-radius:8px;cursor:pointer;box-shadow:0 6px 16px rgba(14,165,233,.22);">✈️ 测试歼-35效果(仅自己可见)</button>
|
||||||
|
<button type="button" data-chat-admin-effect-preview="99a"
|
||||||
|
style="grid-column:1 / -1;font-size:12px;padding:7px 10px;background:linear-gradient(135deg,#1f2a1d,#4d7c0f);color:#fef3c7;border:1px solid #bef264;border-radius:8px;cursor:pointer;box-shadow:0 6px 16px rgba(77,124,15,.26);">🛡️ 测试99A坦克效果(仅自己可见)</button>
|
||||||
|
<button type="button" data-chat-admin-effect-preview="df5c"
|
||||||
|
style="grid-column:1 / -1;font-size:12px;padding:7px 10px;background:linear-gradient(135deg,#450a0a,#991b1b,#f97316);color:#fff7ed;border:1px solid #fdba74;border-radius:8px;cursor:pointer;box-shadow:0 6px 18px rgba(249,115,22,.3);">🚀 测试东风-5C效果(仅自己可见)</button>
|
||||||
|
<button type="button" data-chat-admin-effect-preview="fujian"
|
||||||
|
style="grid-column:1 / -1;font-size:12px;padding:7px 10px;background:linear-gradient(135deg,#0f172a,#0f766e,#22d3ee);color:#ecfeff;border:1px solid #67e8f9;border-radius:8px;cursor:pointer;box-shadow:0 6px 18px rgba(34,211,238,.28);">🚢 测试福建舰效果(仅自己可见)</button>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -128,6 +128,12 @@
|
|||||||
<div style="margin-bottom:16px; border:1px solid #e0e0e0; border-radius:6px; padding:12px;">
|
<div style="margin-bottom:16px; border:1px solid #e0e0e0; border-radius:6px; padding:12px;">
|
||||||
<div style="font-size:12px; font-weight:bold; color:#336699; margin-bottom:8px;">👤 个人资料</div>
|
<div style="font-size:12px; font-weight:bold; color:#336699; margin-bottom:8px;">👤 个人资料</div>
|
||||||
<div style="display:flex; flex-direction:column; gap:6px;">
|
<div style="display:flex; flex-direction:column; gap:6px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px;">
|
||||||
|
<label style="font-size:12px; width:50px; text-align:right;">曾用名:</label>
|
||||||
|
<input id="set-previous-name" type="text" value="{{ Auth::user()->previous_name ?? '' }}"
|
||||||
|
placeholder="设置曾用名(选填)"
|
||||||
|
style="flex:1; padding:5px; border:1px solid #ccc; border-radius:4px; font-size:12px;">
|
||||||
|
</div>
|
||||||
<div style="display:flex; align-items:center; gap:8px;">
|
<div style="display:flex; align-items:center; gap:8px;">
|
||||||
<label style="font-size:12px; width:50px; text-align:right;">性别:</label>
|
<label style="font-size:12px; width:50px; text-align:right;">性别:</label>
|
||||||
<select id="set-sex"
|
<select id="set-sex"
|
||||||
@@ -718,6 +724,62 @@
|
|||||||
|
|
||||||
{{-- 商店弹窗业务脚本已迁移到 resources/js/chat-room/shop-controls.js --}}
|
{{-- 商店弹窗业务脚本已迁移到 resources/js/chat-room/shop-controls.js --}}
|
||||||
|
|
||||||
|
{{-- ═══════════ 座驾弹窗 ═══════════ --}}
|
||||||
|
<style>
|
||||||
|
#ride-modal { display:none; position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9999; justify-content:center; align-items:center; font-family:'Microsoft YaHei',SimSun,sans-serif; }
|
||||||
|
#ride-modal-inner { width:820px; max-width:96vw; max-height:90vh; background:#f8fafc; border-radius:10px; overflow:hidden; display:flex; flex-direction:column; box-shadow:0 14px 44px rgba(15,23,42,.42); }
|
||||||
|
#ride-modal-header { background:linear-gradient(135deg,#0f172a,#0f766e); color:#fff; padding:12px 16px; display:flex; align-items:center; gap:12px; flex-shrink:0; }
|
||||||
|
#ride-modal-title { font-size:15px; font-weight:bold; }
|
||||||
|
#ride-modal-jjb { margin-left:auto; font-size:12px; background:rgba(255,255,255,.14); border:1px solid rgba(255,255,255,.22); border-radius:999px; padding:4px 10px; }
|
||||||
|
#ride-modal-close { cursor:pointer; font-size:20px; opacity:.82; }
|
||||||
|
#ride-modal-close:hover { opacity:1; }
|
||||||
|
#ride-current { margin:12px 14px 0; padding:9px 12px; background:#ecfdf5; border:1px solid #99f6e4; border-radius:8px; color:#115e59; display:flex; align-items:center; gap:8px; font-size:12px; min-height:38px; }
|
||||||
|
.ride-current-icon { font-size:18px; }
|
||||||
|
.ride-current-expire { margin-left:auto; color:#64748b; }
|
||||||
|
.ride-current-empty { color:#64748b; }
|
||||||
|
#ride-modal-body { padding:12px 14px 14px; overflow-y:auto; display:flex; flex-direction:column; gap:12px; }
|
||||||
|
.ride-section-title { font-size:12px; font-weight:bold; color:#0f172a; display:flex; align-items:center; gap:6px; margin-bottom:8px; }
|
||||||
|
#ride-items-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(188px,1fr)); gap:10px; }
|
||||||
|
.ride-card { background:#fff; border:1px solid #dbe4ef; border-radius:8px; padding:11px; display:flex; flex-direction:column; gap:8px; min-height:156px; box-shadow:0 3px 10px rgba(15,23,42,.04); }
|
||||||
|
.ride-card.active { border-color:#14b8a6; box-shadow:0 0 0 1px rgba(20,184,166,.18),0 6px 16px rgba(20,184,166,.08); }
|
||||||
|
.ride-card-head { display:flex; align-items:center; gap:7px; min-width:0; }
|
||||||
|
.ride-card-icon { font-size:22px; flex-shrink:0; }
|
||||||
|
.ride-card-title { font-size:13px; font-weight:bold; color:#102033; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.ride-active-badge { margin-left:auto; font-size:10px; color:#047857; background:#d1fae5; border-radius:999px; padding:2px 7px; flex-shrink:0; }
|
||||||
|
.ride-card-desc { color:#64748b; font-size:11px; line-height:1.45; min-height:32px; }
|
||||||
|
.ride-card-meta { display:flex; justify-content:space-between; color:#475569; font-size:11px; gap:8px; }
|
||||||
|
.ride-buy-btn { margin-top:auto; border:none; border-radius:6px; background:#0f766e; color:#fff; font-size:12px; font-weight:bold; padding:7px 8px; cursor:pointer; }
|
||||||
|
.ride-buy-btn:hover { background:#0d9488; }
|
||||||
|
#ride-purchase-list { background:#fff; border:1px solid #e2e8f0; border-radius:8px; overflow:hidden; }
|
||||||
|
.ride-record { display:grid; grid-template-columns:1.4fr .7fr .8fr 1.1fr; gap:8px; padding:8px 10px; border-bottom:1px solid #f1f5f9; color:#475569; font-size:11px; align-items:center; }
|
||||||
|
.ride-record:last-child { border-bottom:none; }
|
||||||
|
.ride-empty { grid-column:1/-1; text-align:center; padding:22px 0; color:#94a3b8; font-size:12px; }
|
||||||
|
.ride-error { color:#dc2626; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="ride-modal" data-ride-items-url="{{ route('rides.items') }}" data-ride-buy-url="{{ route('rides.buy') }}">
|
||||||
|
<div id="ride-modal-inner">
|
||||||
|
<div id="ride-modal-header">
|
||||||
|
<div id="ride-modal-title">🚘 聊天室座驾</div>
|
||||||
|
<div id="ride-modal-jjb">💰 <strong id="ride-jjb">--</strong> 金币</div>
|
||||||
|
<span id="ride-modal-close" data-ride-modal-close>✕</span>
|
||||||
|
</div>
|
||||||
|
<div id="ride-current"><span class="ride-current-empty">当前未启用座驾</span></div>
|
||||||
|
<div id="ride-modal-body">
|
||||||
|
<div>
|
||||||
|
<div class="ride-section-title">可购买座驾</div>
|
||||||
|
<div id="ride-items-list"><div class="ride-empty">加载中...</div></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="ride-section-title">购买记录</div>
|
||||||
|
<div id="ride-purchase-list"><div class="ride-empty">暂无座驾购买记录</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 座驾弹窗业务脚本已迁移到 resources/js/chat-room/ride-controls.js --}}
|
||||||
|
|
||||||
{{-- ═══════════ 会员中心弹窗 ═══════════ --}}
|
{{-- ═══════════ 会员中心弹窗 ═══════════ --}}
|
||||||
<style>
|
<style>
|
||||||
#vip-modal { display:none; position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:9999; justify-content:center; align-items:center; }
|
#vip-modal { display:none; position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:9999; justify-content:center; align-items:center; }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
- message: 卡片内容(支持 HTML)
|
- message: 卡片内容(支持 HTML)
|
||||||
- icon: 左侧 Emoji 图标(可选,默认 💬)
|
- icon: 左侧 Emoji 图标(可选,默认 💬)
|
||||||
- color: 强调色 HEX(可选,默认 #336699)
|
- color: 强调色 HEX(可选,默认 #336699)
|
||||||
- duration: 自动消失毫秒数(可选,默认 6000;0 = 不自动消失)
|
- duration: 自动消失毫秒数(可选,默认 3000;0 = 不自动消失)
|
||||||
- action: { label, onClick } 可选操作按钮
|
- action: { label, onClick } 可选操作按钮
|
||||||
|
|
||||||
使用示例:
|
使用示例:
|
||||||
@@ -37,4 +37,20 @@
|
|||||||
transform: translateX(0) scale(1);
|
transform: translateX(0) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
#chat-toast-container {
|
||||||
|
right: 12px !important;
|
||||||
|
bottom: calc(88px + env(safe-area-inset-bottom, 0px)) !important;
|
||||||
|
left: 12px !important;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-toast-container .chat-toast-card {
|
||||||
|
width: fit-content !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: min(320px, calc(100vw - 24px)) !important;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -45,6 +45,11 @@
|
|||||||
<div class="profile-info">
|
<div class="profile-info">
|
||||||
<h4>
|
<h4>
|
||||||
<span x-text="userInfo.username"></span>
|
<span x-text="userInfo.username"></span>
|
||||||
|
<template x-if="userInfo.previous_name">
|
||||||
|
<span style="font-size:11px; color:#999; font-weight:normal; margin-left:6px;">
|
||||||
|
(曾用名: <span x-text="userInfo.previous_name"></span>)
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
<span class="level-badge" x-text="'LV.' + userInfo.user_level"></span>
|
<span class="level-badge" x-text="'LV.' + userInfo.user_level"></span>
|
||||||
<span class="sex-badge"
|
<span class="sex-badge"
|
||||||
x-text="userInfo.sex === '男' ? '♂' : (userInfo.sex === '女' ? '♀' : '')"
|
x-text="userInfo.sex === '男' ? '♂' : (userInfo.sex === '女' ? '♀' : '')"
|
||||||
@@ -421,7 +426,8 @@
|
|||||||
$canBanUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_BAN] ?? false) === true);
|
$canBanUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_BAN] ?? false) === true);
|
||||||
$canBanIpUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_BANIP] ?? false) === true);
|
$canBanIpUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::USER_BANIP] ?? false) === true);
|
||||||
$canRewardUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::ROOM_REWARD] ?? false) === true);
|
$canRewardUser = Auth::id() === 1 || (($roomPermissionMap[\App\Support\PositionPermissionRegistry::ROOM_REWARD] ?? false) === true);
|
||||||
$hasUserModerationPermission = $canWarnUser || $canKickUser || $canMuteUser || $canBanUser || $canBanIpUser;
|
$canViewWhispers = $myLevel >= $superLevel;
|
||||||
|
$hasUserModerationPermission = $canWarnUser || $canKickUser || $canMuteUser || $canBanUser || $canBanIpUser || $canViewWhispers;
|
||||||
$hasPositionActions = Auth::user()->activePosition || $myLevel >= $superLevel;
|
$hasPositionActions = Auth::user()->activePosition || $myLevel >= $superLevel;
|
||||||
@endphp
|
@endphp
|
||||||
@if ($hasUserModerationPermission || $hasPositionActions)
|
@if ($hasUserModerationPermission || $hasPositionActions)
|
||||||
@@ -445,43 +451,49 @@
|
|||||||
<div style="font-size: 10px; color: #9ca3af; margin-bottom: 4px; padding-left: 2px;">
|
<div style="font-size: 10px; color: #9ca3af; margin-bottom: 4px; padding-left: 2px;">
|
||||||
管理员操作
|
管理员操作
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px;">
|
<div style="display: flex; gap: 4px; flex-wrap: nowrap; margin-bottom: 8px;">
|
||||||
@if ($canWarnUser)
|
@if ($canWarnUser)
|
||||||
<button
|
<button
|
||||||
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fef3c7; border: 1px solid #f59e0b; cursor: pointer;"
|
style="flex: 1 1 0; min-width: 0; padding: 5px 3px; border-radius: 4px; font-size: 11px; white-space: nowrap; background: #fef3c7; border: 1px solid #f59e0b; cursor: pointer;"
|
||||||
x-on:click="warnUser()">⚠️ 警告
|
x-on:click="warnUser()">警告
|
||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
@if ($canKickUser)
|
@if ($canKickUser)
|
||||||
<button
|
<button
|
||||||
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fee2e2; border: 1px solid #ef4444; cursor: pointer;"
|
style="flex: 1 1 0; min-width: 0; padding: 5px 3px; border-radius: 4px; font-size: 11px; white-space: nowrap; background: #fee2e2; border: 1px solid #ef4444; cursor: pointer;"
|
||||||
x-on:click="kickUser()">🚫 踢出
|
x-on:click="kickUser()">踢出
|
||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
@if ($canMuteUser)
|
@if ($canMuteUser)
|
||||||
<button
|
<button
|
||||||
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #e0e7ff; border: 1px solid #6366f1; cursor: pointer;"
|
style="flex: 1 1 0; min-width: 0; padding: 5px 3px; border-radius: 4px; font-size: 11px; white-space: nowrap; background: #e0e7ff; border: 1px solid #6366f1; cursor: pointer;"
|
||||||
x-on:click="isMuting = !isMuting">🔇 禁言
|
x-on:click="isMuting = !isMuting">禁言
|
||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
@if ($canBanUser)
|
@if ($canBanUser)
|
||||||
<button
|
<button
|
||||||
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fee2e2; border: 1px solid #b91c1c; cursor: pointer;"
|
style="flex: 1 1 0; min-width: 0; padding: 5px 3px; border-radius: 4px; font-size: 11px; white-space: nowrap; background: #fee2e2; border: 1px solid #b91c1c; cursor: pointer;"
|
||||||
x-on:click="banUser()">⛔ 封号
|
x-on:click="banUser()">封号
|
||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
@if ($canBanIpUser)
|
@if ($canBanIpUser)
|
||||||
<button
|
<button
|
||||||
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #ffedd5; border: 1px solid #c2410c; cursor: pointer;"
|
style="flex: 1 1 0; min-width: 0; padding: 5px 3px; border-radius: 4px; font-size: 11px; white-space: nowrap; background: #ffedd5; border: 1px solid #c2410c; cursor: pointer;"
|
||||||
x-on:click="banIpUser()">🌐 封IP
|
x-on:click="banIpUser()">封IP
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
@if ($canViewWhispers)
|
||||||
|
<button
|
||||||
|
style="flex: 1 1 0; min-width: 0; padding: 5px 3px; border-radius: 4px; font-size: 11px; white-space: nowrap; background: #ecfeff; border: 1px solid #0891b2; cursor: pointer;"
|
||||||
|
x-on:click="loadWhispers()">私信
|
||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- 职务奖励金币(凭空产生),仅有明确奖励权限且 max_reward != 0 的人可见 --}}
|
{{-- 职务奖励金币(凭空产生),仅有明确奖励权限且 max_reward != 0 的人可见 --}}
|
||||||
@if ($canRewardUser)
|
@if ($canRewardUser)
|
||||||
<button x-show="window.chatContext?.myMaxReward !== 0"
|
<button x-show="window.chatContext?.myMaxReward !== 0"
|
||||||
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fef3c7; border: 1px solid #f59e0b; cursor: pointer;"
|
style="flex: 1 1 0; min-width: 0; padding: 5px 3px; border-radius: 4px; font-size: 11px; white-space: nowrap; background: #fef3c7; border: 1px solid #f59e0b; cursor: pointer;"
|
||||||
x-on:click="openRewardModal(userInfo.username)">💰 奖励金币
|
x-on:click="openRewardModal(userInfo.username)">奖励
|
||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@@ -565,7 +577,7 @@
|
|||||||
<span style="color: #6366f1;" x-text="w.from_user"></span>
|
<span style="color: #6366f1;" x-text="w.from_user"></span>
|
||||||
→ <span style="color: #059669;" x-text="w.to_user"></span>:
|
→ <span style="color: #059669;" x-text="w.to_user"></span>:
|
||||||
<span x-text="w.content"></span>
|
<span x-text="w.content"></span>
|
||||||
<span style="color: #aaa; font-size: 10px;" x-text="'(' + w.sent_at + ')'"></span>
|
<span style="color: #aaa; font-size: 10px;" x-text="'(' + formatWhisperTime(w.sent_at) + ')'"></span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div x-show="whisperList.length === 0" style="font-size: 11px; color: #aaa;">暂无悄悄话记录</div>
|
<div x-show="whisperList.length === 0" style="font-size: 11px; color: #aaa;">暂无悄悄话记录</div>
|
||||||
|
|||||||
@@ -103,6 +103,10 @@
|
|||||||
class="text-green-400 hover:text-green-300 font-bold flex items-center transition hidden sm:flex">
|
class="text-green-400 hover:text-green-300 font-bold flex items-center transition hidden sm:flex">
|
||||||
今日榜
|
今日榜
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ route('achievements.index') }}"
|
||||||
|
class="text-amber-300 hover:text-amber-100 font-bold flex items-center transition hidden sm:flex {{ request()->routeIs('achievements.*') ? 'text-amber-100 underline underline-offset-4' : '' }}">
|
||||||
|
成就
|
||||||
|
</a>
|
||||||
<a href="{{ route('duty-hall.index') }}"
|
<a href="{{ route('duty-hall.index') }}"
|
||||||
class="text-purple-300 hover:text-purple-100 font-bold flex items-center transition hidden sm:flex {{ request()->routeIs('duty-hall.*') ? 'text-purple-100 underline underline-offset-4' : '' }}">
|
class="text-purple-300 hover:text-purple-100 font-bold flex items-center transition hidden sm:flex {{ request()->routeIs('duty-hall.*') ? 'text-purple-100 underline underline-offset-4' : '' }}">
|
||||||
勤务台
|
勤务台
|
||||||
@@ -184,6 +188,10 @@
|
|||||||
class="px-4 py-2.5 text-green-300 hover:bg-indigo-700 hover:text-green-200 font-medium border-l-4 {{ request()->routeIs('leaderboard.today') ? 'border-green-400 bg-indigo-700/50' : 'border-transparent' }}">
|
class="px-4 py-2.5 text-green-300 hover:bg-indigo-700 hover:text-green-200 font-medium border-l-4 {{ request()->routeIs('leaderboard.today') ? 'border-green-400 bg-indigo-700/50' : 'border-transparent' }}">
|
||||||
今日榜
|
今日榜
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ route('achievements.index') }}"
|
||||||
|
class="px-4 py-2.5 text-amber-200 hover:bg-indigo-700 hover:text-amber-100 font-medium border-l-4 {{ request()->routeIs('achievements.*') ? 'border-amber-400 bg-indigo-700/50' : 'border-transparent' }}">
|
||||||
|
成就
|
||||||
|
</a>
|
||||||
<a href="{{ route('duty-hall.index') }}"
|
<a href="{{ route('duty-hall.index') }}"
|
||||||
class="px-4 py-2.5 text-purple-200 hover:bg-indigo-700 hover:text-white font-medium border-l-4 {{ request()->routeIs('duty-hall.*') ? 'border-purple-400 bg-indigo-700/50' : 'border-transparent' }}">
|
class="px-4 py-2.5 text-purple-200 hover:bg-indigo-700 hover:text-white font-medium border-l-4 {{ request()->routeIs('duty-hall.*') ? 'border-purple-400 bg-indigo-700/50' : 'border-transparent' }}">
|
||||||
勤务台
|
勤务台
|
||||||
|
|||||||
+6
-2
@@ -12,10 +12,14 @@ Artisan::command('inspire', function () {
|
|||||||
Schedule::command('messages:purge')->dailyAt('03:00');
|
Schedule::command('messages:purge')->dailyAt('03:00');
|
||||||
|
|
||||||
// 每 5 分钟为所有在线用户自动存点(经验/金币/等级)
|
// 每 5 分钟为所有在线用户自动存点(经验/金币/等级)
|
||||||
Schedule::command('chatroom:auto-save-exp')->everyFiveMinutes();
|
Schedule::command('chatroom:auto-save-exp')->everyFiveMinutes()->withoutOverlapping()->runInBackground();
|
||||||
|
|
||||||
|
// 每 10 分钟扫描最近活跃用户的成就进度,夜间再做一次全量补算
|
||||||
|
Schedule::command('achievements:scan --notify')->everyTenMinutes()->withoutOverlapping();
|
||||||
|
Schedule::command('achievements:scan --all')->dailyAt('03:30')->withoutOverlapping();
|
||||||
|
|
||||||
// 每 1 分钟为 AI小班长 独立模拟一次挂机心跳,触发随机事件
|
// 每 1 分钟为 AI小班长 独立模拟一次挂机心跳,触发随机事件
|
||||||
Schedule::command('chatroom:ai-heartbeat')->everyMinute();
|
Schedule::command('chatroom:ai-heartbeat')->everyMinute()->withoutOverlapping()->runInBackground();
|
||||||
|
|
||||||
// 每 15 分钟:关闭掉线用户的开放职务日志(久无心跳 = 掉线,自动写入 logout_at)
|
// 每 15 分钟:关闭掉线用户的开放职务日志(久无心跳 = 掉线,自动写入 logout_at)
|
||||||
Schedule::command('duty:close-stale-logs')->everyFifteenMinutes();
|
Schedule::command('duty:close-stale-logs')->everyFifteenMinutes();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\AchievementController;
|
||||||
use App\Http\Controllers\Admin\AdminAuthController;
|
use App\Http\Controllers\Admin\AdminAuthController;
|
||||||
use App\Http\Controllers\AdminCommandController;
|
use App\Http\Controllers\AdminCommandController;
|
||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
@@ -78,6 +79,7 @@ Route::middleware(['chat.auth'])->group(function () {
|
|||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
Route::get('/leaderboard', [\App\Http\Controllers\LeaderboardController::class, 'index'])->name('leaderboard.index');
|
Route::get('/leaderboard', [\App\Http\Controllers\LeaderboardController::class, 'index'])->name('leaderboard.index');
|
||||||
Route::get('/leaderboard/today', [\App\Http\Controllers\LeaderboardController::class, 'todayIndex'])->name('leaderboard.today');
|
Route::get('/leaderboard/today', [\App\Http\Controllers\LeaderboardController::class, 'todayIndex'])->name('leaderboard.today');
|
||||||
|
Route::get('/achievements', [AchievementController::class, 'index'])->name('achievements.index');
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
// 邀请排行达人榜
|
// 邀请排行达人榜
|
||||||
@@ -372,6 +374,12 @@ Route::middleware(['chat.auth'])->group(function () {
|
|||||||
->name('shop.buy');
|
->name('shop.buy');
|
||||||
Route::post('/shop/rename', [\App\Http\Controllers\ShopController::class, 'rename'])->name('shop.rename');
|
Route::post('/shop/rename', [\App\Http\Controllers\ShopController::class, 'rename'])->name('shop.rename');
|
||||||
|
|
||||||
|
// ---- 座驾(独立页面,独立座驾购买记录)----
|
||||||
|
Route::get('/rides/items', [\App\Http\Controllers\RideController::class, 'items'])->name('rides.items');
|
||||||
|
Route::post('/rides/buy', [\App\Http\Controllers\RideController::class, 'buy'])
|
||||||
|
->middleware('throttle:chat-shop-buy')
|
||||||
|
->name('rides.buy');
|
||||||
|
|
||||||
// ---- 银行资金接口 ----
|
// ---- 银行资金接口 ----
|
||||||
Route::get('/bank', [\App\Http\Controllers\BankController::class, 'info'])->name('bank.info');
|
Route::get('/bank', [\App\Http\Controllers\BankController::class, 'info'])->name('bank.info');
|
||||||
Route::get('/bank/ranking', [\App\Http\Controllers\BankController::class, 'ranking'])->name('bank.ranking');
|
Route::get('/bank/ranking', [\App\Http\Controllers\BankController::class, 'ranking'])->name('bank.ranking');
|
||||||
@@ -506,6 +514,7 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
|
|||||||
|
|
||||||
// 全局用户金币/积分流水
|
// 全局用户金币/积分流水
|
||||||
Route::get('/currency-logs', [\App\Http\Controllers\Admin\CurrencyLogController::class, 'index'])->name('currency-logs.index');
|
Route::get('/currency-logs', [\App\Http\Controllers\Admin\CurrencyLogController::class, 'index'])->name('currency-logs.index');
|
||||||
|
Route::get('/achievements', [\App\Http\Controllers\Admin\AchievementController::class, 'index'])->name('achievements.index');
|
||||||
|
|
||||||
// 🛒 商店商品管理(查看/编辑所有 superlevel 可用,新增/删除仅 id=1)
|
// 🛒 商店商品管理(查看/编辑所有 superlevel 可用,新增/删除仅 id=1)
|
||||||
Route::get('/shop', [\App\Http\Controllers\Admin\ShopItemController::class, 'index'])->name('shop.index');
|
Route::get('/shop', [\App\Http\Controllers\Admin\ShopItemController::class, 'index'])->name('shop.index');
|
||||||
@@ -513,6 +522,11 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
|
|||||||
Route::put('/shop/{shopItem}', [\App\Http\Controllers\Admin\ShopItemController::class, 'update'])->name('shop.update');
|
Route::put('/shop/{shopItem}', [\App\Http\Controllers\Admin\ShopItemController::class, 'update'])->name('shop.update');
|
||||||
Route::patch('/shop/{shopItem}/toggle', [\App\Http\Controllers\Admin\ShopItemController::class, 'toggle'])->name('shop.toggle');
|
Route::patch('/shop/{shopItem}/toggle', [\App\Http\Controllers\Admin\ShopItemController::class, 'toggle'])->name('shop.toggle');
|
||||||
Route::delete('/shop/{shopItem}', [\App\Http\Controllers\Admin\ShopItemController::class, 'destroy'])->name('shop.destroy');
|
Route::delete('/shop/{shopItem}', [\App\Http\Controllers\Admin\ShopItemController::class, 'destroy'])->name('shop.destroy');
|
||||||
|
Route::get('/rides', [\App\Http\Controllers\Admin\RideController::class, 'index'])->name('rides.index');
|
||||||
|
Route::post('/rides', [\App\Http\Controllers\Admin\RideController::class, 'store'])->name('rides.store');
|
||||||
|
Route::put('/rides/{ride}', [\App\Http\Controllers\Admin\RideController::class, 'update'])->name('rides.update');
|
||||||
|
Route::patch('/rides/{ride}/toggle', [\App\Http\Controllers\Admin\RideController::class, 'toggle'])->name('rides.toggle');
|
||||||
|
Route::delete('/rides/{ride}', [\App\Http\Controllers\Admin\RideController::class, 'destroy'])->name('rides.destroy');
|
||||||
|
|
||||||
// 💒 婚姻管理(superlevel 及以上)
|
// 💒 婚姻管理(superlevel 及以上)
|
||||||
Route::prefix('marriages')->name('marriages.')->group(function () {
|
Route::prefix('marriages')->name('marriages.')->group(function () {
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:用户成就系统功能测试。
|
||||||
|
*
|
||||||
|
* 覆盖固定成就扫描、重复扫描幂等性与本人可见通知。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Enums\CurrencySource;
|
||||||
|
use App\Jobs\SaveMessageJob;
|
||||||
|
use App\Models\DailySignIn;
|
||||||
|
use App\Models\Message;
|
||||||
|
use App\Models\Room;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserAchievement;
|
||||||
|
use App\Models\UserCurrencyLog;
|
||||||
|
use App\Services\AchievementService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:验证成就扫描服务和 Artisan 命令的核心行为。
|
||||||
|
*/
|
||||||
|
class AchievementServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每个测试前清空 Redis 房间状态。
|
||||||
|
*/
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->flushChatRoomRedisState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试命令可以按现有日志解锁聊天、签到与游戏成就。
|
||||||
|
*/
|
||||||
|
public function test_scan_command_unlocks_achievements_from_existing_logs(): void
|
||||||
|
{
|
||||||
|
$room = Room::create(['room_name' => 'ach']);
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'username' => 'achiever',
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'jjb' => 700000,
|
||||||
|
'bank_jjb' => 300000,
|
||||||
|
]);
|
||||||
|
|
||||||
|
for ($i = 0; $i < 100; $i++) {
|
||||||
|
Message::query()->create([
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'from_user' => $user->username,
|
||||||
|
'to_user' => '大家',
|
||||||
|
'content' => '成就聊天',
|
||||||
|
'is_secret' => false,
|
||||||
|
'font_color' => '',
|
||||||
|
'action' => '',
|
||||||
|
'message_type' => 'text',
|
||||||
|
'retention_type' => Message::RETENTION_USER_CHAT,
|
||||||
|
'sent_at' => now()->subMinutes($i + 1),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
DailySignIn::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'sign_in_date' => today(),
|
||||||
|
'streak_days' => 7,
|
||||||
|
'gold_reward' => 10,
|
||||||
|
'exp_reward' => 20,
|
||||||
|
'charm_reward' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
UserCurrencyLog::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'username' => $user->username,
|
||||||
|
'currency' => 'gold',
|
||||||
|
'amount' => 1000,
|
||||||
|
'balance_after' => 1000,
|
||||||
|
'source' => CurrencySource::GAME_REWARD->value,
|
||||||
|
'remark' => '猜谜奖励',
|
||||||
|
'room_id' => $room->id,
|
||||||
|
]);
|
||||||
|
UserCurrencyLog::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'username' => $user->username,
|
||||||
|
'currency' => 'gold',
|
||||||
|
'amount' => -1000,
|
||||||
|
'balance_after' => 0,
|
||||||
|
'source' => CurrencySource::BACCARAT_BET->value,
|
||||||
|
'remark' => '百家乐下注',
|
||||||
|
'room_id' => $room->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('achievements:scan', ['--user' => $user->username])
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('user_achievements', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'achievement_key' => 'chat_first_message',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('user_achievements', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'achievement_key' => 'chat_100_messages',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('user_achievements', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'achievement_key' => 'signin_7_streak',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('user_achievements', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'achievement_key' => 'game_riddle_win',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('user_achievements', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'achievement_key' => 'game_win_1000',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('user_achievements', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'achievement_key' => 'game_loss_1000',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('user_achievements', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'achievement_key' => 'growth_assets_1000000',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('user_achievement_progress', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'achievement_key' => 'chat_100_messages',
|
||||||
|
'progress_value' => 100,
|
||||||
|
'threshold_value' => 100,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNotNull(UserAchievement::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('achievement_key', 'chat_100_messages')
|
||||||
|
->value('achieved_at'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试重复扫描不会重复创建同一个用户成就。
|
||||||
|
*/
|
||||||
|
public function test_scan_command_is_idempotent_for_same_achievement(): void
|
||||||
|
{
|
||||||
|
$room = Room::create(['room_name' => 'idem']);
|
||||||
|
$user = User::factory()->create(['username' => 'idem_user', 'room_id' => $room->id]);
|
||||||
|
Message::query()->create([
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'from_user' => $user->username,
|
||||||
|
'to_user' => '大家',
|
||||||
|
'content' => '第一条',
|
||||||
|
'is_secret' => false,
|
||||||
|
'font_color' => '',
|
||||||
|
'action' => '',
|
||||||
|
'message_type' => 'text',
|
||||||
|
'retention_type' => Message::RETENTION_USER_CHAT,
|
||||||
|
'sent_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('achievements:scan', ['--user' => $user->id])->assertSuccessful();
|
||||||
|
$this->artisan('achievements:scan', ['--user' => $user->id])->assertSuccessful();
|
||||||
|
|
||||||
|
$this->assertSame(1, UserAchievement::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('achievement_key', 'chat_first_message')
|
||||||
|
->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试成就解锁通知只发给本人并带悄悄话标记。
|
||||||
|
*/
|
||||||
|
public function test_unlock_notification_is_private_to_the_user(): void
|
||||||
|
{
|
||||||
|
Queue::fake([SaveMessageJob::class]);
|
||||||
|
|
||||||
|
$room = Room::create(['room_name' => 'notice']);
|
||||||
|
$user = User::factory()->create(['username' => 'notice_user', 'room_id' => $room->id]);
|
||||||
|
Message::query()->create([
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'from_user' => $user->username,
|
||||||
|
'to_user' => '大家',
|
||||||
|
'content' => '第一条',
|
||||||
|
'is_secret' => false,
|
||||||
|
'font_color' => '',
|
||||||
|
'action' => '',
|
||||||
|
'message_type' => 'text',
|
||||||
|
'retention_type' => Message::RETENTION_USER_CHAT,
|
||||||
|
'sent_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(AchievementService::class)->scanUser($user, notify: true);
|
||||||
|
|
||||||
|
$messages = collect(Redis::lrange("room:{$room->id}:messages", 0, -1))
|
||||||
|
->map(fn (string $item): array => json_decode($item, true));
|
||||||
|
$notice = $messages->first(fn (array $item): bool => ($item['action'] ?? '') === 'achievement_unlocked');
|
||||||
|
|
||||||
|
$this->assertNotNull($notice);
|
||||||
|
$this->assertSame($user->username, $notice['to_user'] ?? null);
|
||||||
|
$this->assertTrue((bool) ($notice['is_secret'] ?? false));
|
||||||
|
$this->assertSame(Message::RETENTION_SYSTEM_NOTICE, $notice['retention_type'] ?? null);
|
||||||
|
Queue::assertPushed(SaveMessageJob::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试打开我的成就页面时会静默补算已达标成就。
|
||||||
|
*/
|
||||||
|
public function test_achievement_page_silently_unlocks_reached_achievements(): void
|
||||||
|
{
|
||||||
|
$room = Room::create(['room_name' => 'page']);
|
||||||
|
$user = User::factory()->create(['username' => 'page_user', 'room_id' => $room->id]);
|
||||||
|
|
||||||
|
Message::query()->create([
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'from_user' => $user->username,
|
||||||
|
'to_user' => '大家',
|
||||||
|
'content' => '页面触发补算',
|
||||||
|
'is_secret' => false,
|
||||||
|
'font_color' => '',
|
||||||
|
'action' => '',
|
||||||
|
'message_type' => 'text',
|
||||||
|
'retention_type' => Message::RETENTION_USER_CHAT,
|
||||||
|
'sent_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('user_achievements', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'achievement_key' => 'chat_first_message',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('achievements.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('已解锁 1 /', false);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('user_achievements', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'achievement_key' => 'chat_first_message',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNotNull(UserAchievement::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('achievement_key', 'chat_first_message')
|
||||||
|
->value('achieved_at'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试我的成就页面可以按已完成和未达成筛选。
|
||||||
|
*/
|
||||||
|
public function test_achievement_page_can_filter_by_unlocked_and_locked_tabs(): void
|
||||||
|
{
|
||||||
|
$room = Room::create(['room_name' => 'tabs']);
|
||||||
|
$user = User::factory()->create(['username' => 'tab_user', 'room_id' => $room->id]);
|
||||||
|
|
||||||
|
Message::query()->create([
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'from_user' => $user->username,
|
||||||
|
'to_user' => '大家',
|
||||||
|
'content' => '筛选测试',
|
||||||
|
'is_secret' => false,
|
||||||
|
'font_color' => '',
|
||||||
|
'action' => '',
|
||||||
|
'message_type' => 'text',
|
||||||
|
'retention_type' => Message::RETENTION_USER_CHAT,
|
||||||
|
'sent_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('achievements.index', ['status' => 'unlocked']))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('已完成')
|
||||||
|
->assertSee('初来乍到')
|
||||||
|
->assertDontSee('百句达人');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('achievements.index', ['status' => 'locked']))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('未达成')
|
||||||
|
->assertSee('百句达人')
|
||||||
|
->assertDontSee('初来乍到');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:AI小班长心跳命令测试
|
||||||
|
*
|
||||||
|
* 覆盖定时心跳的快速退出、基础奖励、签到幂等和钓鱼任务派发,
|
||||||
|
* 确保优化调度耗时后不改变原有业务行为。
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Events\MessageSent;
|
||||||
|
use App\Jobs\AiFishingJob;
|
||||||
|
use App\Models\DailySignIn;
|
||||||
|
use App\Models\GameConfig;
|
||||||
|
use App\Models\Sysparam;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\SignInService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类功能:验证 AI小班长定时心跳命令的性能优化不破坏核心效果。
|
||||||
|
*/
|
||||||
|
class AiHeartbeatCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每个测试前清空配置缓存,避免系统参数读取互相串扰。
|
||||||
|
*/
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 开关关闭时,命令应直接成功退出且不修改 AI 资产。
|
||||||
|
*/
|
||||||
|
public function test_ai_heartbeat_exits_when_chatbot_disabled(): void
|
||||||
|
{
|
||||||
|
$aiUser = User::factory()->create([
|
||||||
|
'username' => 'AI小班长',
|
||||||
|
'exp_num' => 10,
|
||||||
|
'jjb' => 20,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Sysparam::updateOrCreate(['alias' => 'chatbot_enabled'], ['body' => '0']);
|
||||||
|
|
||||||
|
$this->artisan('chatroom:ai-heartbeat')->assertSuccessful();
|
||||||
|
|
||||||
|
$aiUser->refresh();
|
||||||
|
$this->assertSame(10, (int) $aiUser->exp_num);
|
||||||
|
$this->assertSame(20, (int) $aiUser->jjb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 开启时,基础心跳仍会发放配置中的经验和金币。
|
||||||
|
*/
|
||||||
|
public function test_ai_heartbeat_grants_base_exp_and_gold(): void
|
||||||
|
{
|
||||||
|
Event::fake([MessageSent::class]);
|
||||||
|
|
||||||
|
$aiUser = User::factory()->create([
|
||||||
|
'username' => 'AI小班长',
|
||||||
|
'user_level' => 1,
|
||||||
|
'exp_num' => 10,
|
||||||
|
'jjb' => 20,
|
||||||
|
]);
|
||||||
|
$this->markAlreadySigned($aiUser);
|
||||||
|
$this->enableHeartbeat([
|
||||||
|
'exp_per_heartbeat' => '5',
|
||||||
|
'jjb_per_heartbeat' => '7',
|
||||||
|
'auto_event_chance' => '0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('chatroom:ai-heartbeat')->assertSuccessful();
|
||||||
|
|
||||||
|
$aiUser->refresh();
|
||||||
|
$this->assertSame(15, (int) $aiUser->exp_num);
|
||||||
|
$this->assertSame(27, (int) $aiUser->jjb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 今日已签到时,命令不应再进入签到事务,避免每分钟重复做重活。
|
||||||
|
*/
|
||||||
|
public function test_ai_heartbeat_skips_sign_in_claim_when_already_signed_today(): void
|
||||||
|
{
|
||||||
|
$aiUser = User::factory()->create([
|
||||||
|
'username' => 'AI小班长',
|
||||||
|
'exp_num' => 0,
|
||||||
|
'jjb' => 0,
|
||||||
|
]);
|
||||||
|
$this->markAlreadySigned($aiUser);
|
||||||
|
$this->enableHeartbeat([
|
||||||
|
'exp_per_heartbeat' => '0',
|
||||||
|
'jjb_per_heartbeat' => '0',
|
||||||
|
'auto_event_chance' => '0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mock(SignInService::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('claim')->never();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->artisan('chatroom:ai-heartbeat')->assertSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 钓鱼开启且命中概率时,命令只派发延迟收竿任务,不在当前心跳里等待结果。
|
||||||
|
*/
|
||||||
|
public function test_ai_heartbeat_dispatches_delayed_fishing_job(): void
|
||||||
|
{
|
||||||
|
Bus::fake();
|
||||||
|
Event::fake([MessageSent::class]);
|
||||||
|
|
||||||
|
$aiUser = User::factory()->create([
|
||||||
|
'username' => 'AI小班长',
|
||||||
|
'exp_num' => 0,
|
||||||
|
'jjb' => 100,
|
||||||
|
]);
|
||||||
|
$this->markAlreadySigned($aiUser);
|
||||||
|
$this->enableHeartbeat([
|
||||||
|
'exp_per_heartbeat' => '0',
|
||||||
|
'jjb_per_heartbeat' => '0',
|
||||||
|
'auto_event_chance' => '0',
|
||||||
|
'chatbot_fishing_enabled' => '1',
|
||||||
|
'chatbot_fishing_chance' => '100',
|
||||||
|
]);
|
||||||
|
GameConfig::query()->create([
|
||||||
|
'game_key' => 'fishing',
|
||||||
|
'name' => '钓鱼',
|
||||||
|
'icon' => '🎣',
|
||||||
|
'enabled' => true,
|
||||||
|
'params' => [
|
||||||
|
'fishing_cost' => 5,
|
||||||
|
'fishing_wait_min' => 8,
|
||||||
|
'fishing_wait_max' => 8,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('chatroom:ai-heartbeat')->assertSuccessful();
|
||||||
|
|
||||||
|
Bus::assertDispatched(AiFishingJob::class);
|
||||||
|
$aiUser->refresh();
|
||||||
|
$this->assertSame(95, (int) $aiUser->jjb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量写入本轮心跳需要的系统参数。
|
||||||
|
*
|
||||||
|
* @param array<string, string> $overrides 覆盖默认值的配置
|
||||||
|
*/
|
||||||
|
private function enableHeartbeat(array $overrides = []): void
|
||||||
|
{
|
||||||
|
$defaults = [
|
||||||
|
'chatbot_enabled' => '1',
|
||||||
|
'exp_per_heartbeat' => '0',
|
||||||
|
'jjb_per_heartbeat' => '0',
|
||||||
|
'superlevel' => '100',
|
||||||
|
'auto_event_chance' => '0',
|
||||||
|
'chatbot_fishing_enabled' => '0',
|
||||||
|
'chatbot_fishing_chance' => '0',
|
||||||
|
'fishing_cost' => '5',
|
||||||
|
'fishing_wait_min' => '8',
|
||||||
|
'fishing_wait_max' => '15',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (array_merge($defaults, $overrides) as $alias => $body) {
|
||||||
|
Sysparam::query()->updateOrCreate(['alias' => $alias], ['body' => $body]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建今日已签到记录,让测试聚焦心跳命令本身。
|
||||||
|
*/
|
||||||
|
private function markAlreadySigned(User $user): void
|
||||||
|
{
|
||||||
|
DailySignIn::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'room_id' => 1,
|
||||||
|
'sign_in_date' => today()->toDateString(),
|
||||||
|
'streak_days' => 1,
|
||||||
|
'gold_reward' => 0,
|
||||||
|
'exp_reward' => 0,
|
||||||
|
'charm_reward' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@ class BaccaratControllerTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 方法功能:初始化百家乐默认配置。
|
||||||
|
*/
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
@@ -46,7 +49,10 @@ class BaccaratControllerTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_can_get_current_round()
|
/**
|
||||||
|
* 方法功能:验证可以获取当前百家乐局次。
|
||||||
|
*/
|
||||||
|
public function test_can_get_current_round(): void
|
||||||
{
|
{
|
||||||
/** @var \App\Models\User $user */
|
/** @var \App\Models\User $user */
|
||||||
$user = User::factory()->create(['jjb' => 3456]);
|
$user = User::factory()->create(['jjb' => 3456]);
|
||||||
@@ -73,7 +79,10 @@ class BaccaratControllerTest extends TestCase
|
|||||||
$this->assertSame(3456, $response->json('jjb'));
|
$this->assertSame(3456, $response->json('jjb'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_can_bet()
|
/**
|
||||||
|
* 方法功能:验证可以下注并给房间在线用户广播右下角通知。
|
||||||
|
*/
|
||||||
|
public function test_can_bet(): void
|
||||||
{
|
{
|
||||||
Event::fake();
|
Event::fake();
|
||||||
|
|
||||||
@@ -112,9 +121,24 @@ class BaccaratControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
Event::assertDispatched(\App\Events\BaccaratPoolUpdated::class);
|
Event::assertDispatched(\App\Events\BaccaratPoolUpdated::class);
|
||||||
|
|
||||||
|
$messages = Redis::lrange('room:1:messages', 0, -1);
|
||||||
|
$publicMessage = collect($messages)
|
||||||
|
->map(fn (string $item) => json_decode($item, true))
|
||||||
|
->first(fn (array $item) => ($item['to_user'] ?? null) === '大家'
|
||||||
|
&& ($item['toast_notification']['title'] ?? null) === '🎲 有人下注百家乐'
|
||||||
|
&& str_contains((string) ($item['toast_notification']['message'] ?? ''), $user->username));
|
||||||
|
|
||||||
|
$this->assertNotNull($publicMessage);
|
||||||
|
$this->assertFalse((bool) ($publicMessage['is_secret'] ?? true));
|
||||||
|
$this->assertStringContainsString($user->username, (string) ($publicMessage['toast_notification']['message'] ?? ''));
|
||||||
|
$this->assertSame('🎲', $publicMessage['toast_notification']['icon'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_cannot_bet_out_of_range()
|
/**
|
||||||
|
* 方法功能:验证超出配置范围的下注会被拦截。
|
||||||
|
*/
|
||||||
|
public function test_cannot_bet_out_of_range(): void
|
||||||
{
|
{
|
||||||
/** @var \App\Models\User $user */
|
/** @var \App\Models\User $user */
|
||||||
$user = User::factory()->create(['jjb' => 200]);
|
$user = User::factory()->create(['jjb' => 200]);
|
||||||
@@ -143,7 +167,10 @@ class BaccaratControllerTest extends TestCase
|
|||||||
$response->assertJson(['ok' => false]);
|
$response->assertJson(['ok' => false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_cannot_bet_twice_in_same_round()
|
/**
|
||||||
|
* 方法功能:验证同一用户同一局不能重复下注。
|
||||||
|
*/
|
||||||
|
public function test_cannot_bet_twice_in_same_round(): void
|
||||||
{
|
{
|
||||||
/** @var \App\Models\User $user */
|
/** @var \App\Models\User $user */
|
||||||
$user = User::factory()->create(['jjb' => 200]);
|
$user = User::factory()->create(['jjb' => 200]);
|
||||||
@@ -180,7 +207,10 @@ class BaccaratControllerTest extends TestCase
|
|||||||
$response->assertJson(['ok' => false]);
|
$response->assertJson(['ok' => false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_can_get_history()
|
/**
|
||||||
|
* 方法功能:验证可以获取最近已结算百家乐历史。
|
||||||
|
*/
|
||||||
|
public function test_can_get_history(): void
|
||||||
{
|
{
|
||||||
/** @var \App\Models\User $user */
|
/** @var \App\Models\User $user */
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|||||||
@@ -8,14 +8,18 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Enums\CurrencySource;
|
||||||
use App\Events\MessageSent;
|
use App\Events\MessageSent;
|
||||||
use App\Models\Department;
|
use App\Models\Department;
|
||||||
use App\Models\Gift;
|
use App\Models\Gift;
|
||||||
use App\Models\Position;
|
use App\Models\Position;
|
||||||
|
use App\Models\Ride;
|
||||||
use App\Models\Room;
|
use App\Models\Room;
|
||||||
use App\Models\Sysparam;
|
use App\Models\Sysparam;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\UserCurrencyLog;
|
||||||
use App\Models\UserPosition;
|
use App\Models\UserPosition;
|
||||||
|
use App\Models\UserRidePurchase;
|
||||||
use App\Models\VipLevel;
|
use App\Models\VipLevel;
|
||||||
use App\Support\PositionPermissionRegistry;
|
use App\Support\PositionPermissionRegistry;
|
||||||
use Illuminate\Broadcasting\PresenceChannel;
|
use Illuminate\Broadcasting\PresenceChannel;
|
||||||
@@ -23,6 +27,7 @@ use Illuminate\Broadcasting\PrivateChannel;
|
|||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Session\TokenMismatchException;
|
||||||
use Illuminate\Support\Facades\Broadcast;
|
use Illuminate\Support\Facades\Broadcast;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Redis;
|
use Illuminate\Support\Facades\Redis;
|
||||||
@@ -44,7 +49,7 @@ class ChatControllerTest extends TestCase
|
|||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
Redis::flushall();
|
$this->flushChatRoomRedisState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,6 +69,57 @@ class ChatControllerTest extends TestCase
|
|||||||
$this->assertEquals(1, Redis::hexists("room:{$room->id}:users", $user->username));
|
$this->assertEquals(1, Redis::hexists("room:{$room->id}:users", $user->username));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试聊天室顶部会展示百家乐与赛马今日净盈利前三。
|
||||||
|
*/
|
||||||
|
public function test_room_header_displays_daily_game_profit_top_three(): void
|
||||||
|
{
|
||||||
|
Cache::flush();
|
||||||
|
Sysparam::updateOrCreate(['alias' => 'superlevel'], ['body' => '100']);
|
||||||
|
|
||||||
|
$room = Room::create(['room_name' => 'profitroom']);
|
||||||
|
$viewer = User::factory()->create(['username' => '围观群众', 'user_level' => 1]);
|
||||||
|
$first = User::factory()->create(['username' => '第一赢家', 'user_level' => 1]);
|
||||||
|
$second = User::factory()->create(['username' => '第二赢家', 'user_level' => 1]);
|
||||||
|
$third = User::factory()->create(['username' => '第三赢家', 'user_level' => 1]);
|
||||||
|
$yesterdayWinner = User::factory()->create(['username' => '昨日财神', 'user_level' => 1]);
|
||||||
|
$breakEvenUser = User::factory()->create(['username' => '白忙选手', 'user_level' => 1]);
|
||||||
|
$admin = User::factory()->create(['username' => '超级管理员', 'user_level' => 100]);
|
||||||
|
$bot = User::factory()->create(['username' => 'AI小班长', 'user_level' => 1]);
|
||||||
|
|
||||||
|
$this->recordGameGoldLog($first, CurrencySource::BACCARAT_WIN, 10000);
|
||||||
|
$this->recordGameGoldLog($first, CurrencySource::BACCARAT_BET, -2500);
|
||||||
|
$this->recordGameGoldLog($second, CurrencySource::HORSE_WIN, 6000);
|
||||||
|
$this->recordGameGoldLog($second, CurrencySource::HORSE_BET, -1000);
|
||||||
|
$this->recordGameGoldLog($third, CurrencySource::BACCARAT_WIN, 3000);
|
||||||
|
$this->recordGameGoldLog($yesterdayWinner, CurrencySource::HORSE_WIN, 50000, now()->subDay());
|
||||||
|
$this->recordGameGoldLog($breakEvenUser, CurrencySource::BACCARAT_WIN, 1000);
|
||||||
|
$this->recordGameGoldLog($breakEvenUser, CurrencySource::BACCARAT_BET, -1000);
|
||||||
|
$this->recordGameGoldLog($admin, CurrencySource::BACCARAT_WIN, 999999);
|
||||||
|
$this->recordGameGoldLog($bot, CurrencySource::HORSE_WIN, 888888);
|
||||||
|
|
||||||
|
$response = $this->actingAs($viewer)->get(route('chat.room', $room->id));
|
||||||
|
|
||||||
|
$leaders = $response->viewData('dailyGameProfitLeaders');
|
||||||
|
|
||||||
|
$this->assertSame(['超级管理员', '第一赢家', '第二赢家'], $leaders->pluck('username')->all());
|
||||||
|
$this->assertSame([999999, 7500, 5000], $leaders->pluck('net_profit')->all());
|
||||||
|
$this->assertStringStartsWith('/images/headface/', $leaders->first()->headface_url);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertSee('今日游戏三杰');
|
||||||
|
$response->assertSee('class="daily-game-profit-avatar"', false);
|
||||||
|
$response->assertSee('data-username="超级管理员"', false);
|
||||||
|
$response->assertSee('data-username="第一赢家"', false);
|
||||||
|
$response->assertSee('data-username="第二赢家"', false);
|
||||||
|
$response->assertDontSee('金库爆破王');
|
||||||
|
$response->assertDontSee('马桌双修财神');
|
||||||
|
$response->assertDontSee('金币收割机');
|
||||||
|
$response->assertDontSee('+999,999');
|
||||||
|
$response->assertDontSee('+7,500');
|
||||||
|
$response->assertDontSee('+5,000');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 测试关闭房间会拒绝普通用户直接进入。
|
* 测试关闭房间会拒绝普通用户直接进入。
|
||||||
*/
|
*/
|
||||||
@@ -777,10 +833,36 @@ class ChatControllerTest extends TestCase
|
|||||||
|
|
||||||
\Illuminate\Testing\TestResponse::fromBaseResponse($response)->assertStatus(419)->assertJson([
|
\Illuminate\Testing\TestResponse::fromBaseResponse($response)->assertStatus(419)->assertJson([
|
||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
'message' => '页面已过期,请刷新后重试。',
|
'code' => 'SESSION_EXPIRED',
|
||||||
|
'message' => '登录状态已失效,请刷新页面后重新登录。',
|
||||||
|
'reload' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试掉线后的普通 JSON 接口遇到 CSRF 失效时,不会泄露框架异常结构。
|
||||||
|
*/
|
||||||
|
public function test_json_token_mismatch_exception_renders_session_expired_response(): void
|
||||||
|
{
|
||||||
|
$request = Request::create('/user/profile', 'POST', server: [
|
||||||
|
'HTTP_ACCEPT' => 'application/json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->app->make(\Illuminate\Contracts\Debug\ExceptionHandler::class)
|
||||||
|
->render($request, new TokenMismatchException('CSRF token mismatch.'));
|
||||||
|
|
||||||
|
\Illuminate\Testing\TestResponse::fromBaseResponse($response)
|
||||||
|
->assertStatus(419)
|
||||||
|
->assertJson([
|
||||||
|
'status' => 'error',
|
||||||
|
'code' => 'SESSION_EXPIRED',
|
||||||
|
'message' => '登录状态已失效,请刷新页面后重新登录。',
|
||||||
|
'reload' => true,
|
||||||
|
])
|
||||||
|
->assertJsonMissingPath('exception')
|
||||||
|
->assertJsonMissingPath('trace');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 测试房间公告更新广播中的动态内容会被转义。
|
* 测试房间公告更新广播中的动态内容会被转义。
|
||||||
*/
|
*/
|
||||||
@@ -852,6 +934,84 @@ class ChatControllerTest extends TestCase
|
|||||||
Storage::disk('public')->assertMissing('chat-images/2026-04-08/sample_thumb.png');
|
Storage::disk('public')->assertMissing('chat-images/2026-04-08/sample_thumb.png');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试消息清理命令会永久保留普通用户聊天,只删除过期游戏/临时通知。
|
||||||
|
*/
|
||||||
|
public function test_purge_command_keeps_user_chat_but_deletes_expired_notices(): void
|
||||||
|
{
|
||||||
|
$userMessage = \App\Models\Message::create([
|
||||||
|
'room_id' => 1,
|
||||||
|
'from_user' => 'normal_user',
|
||||||
|
'to_user' => '大家',
|
||||||
|
'content' => '这条用户聊天需要永久保留',
|
||||||
|
'is_secret' => false,
|
||||||
|
'font_color' => '#000000',
|
||||||
|
'action' => '',
|
||||||
|
'message_type' => 'text',
|
||||||
|
'retention_type' => \App\Models\Message::RETENTION_USER_CHAT,
|
||||||
|
'sent_at' => now()->subDays(60),
|
||||||
|
]);
|
||||||
|
$gameNotice = \App\Models\Message::create([
|
||||||
|
'room_id' => 1,
|
||||||
|
'from_user' => '系统传音',
|
||||||
|
'to_user' => '大家',
|
||||||
|
'content' => '过期游戏通知',
|
||||||
|
'is_secret' => false,
|
||||||
|
'font_color' => '#000000',
|
||||||
|
'action' => '',
|
||||||
|
'message_type' => 'text',
|
||||||
|
'retention_type' => \App\Models\Message::RETENTION_GAME_NOTICE,
|
||||||
|
'sent_at' => now()->subDays(60),
|
||||||
|
]);
|
||||||
|
$temporaryNotice = \App\Models\Message::create([
|
||||||
|
'room_id' => 1,
|
||||||
|
'from_user' => '进出播报',
|
||||||
|
'to_user' => '大家',
|
||||||
|
'content' => '过期进出播报',
|
||||||
|
'is_secret' => false,
|
||||||
|
'font_color' => '#000000',
|
||||||
|
'action' => 'system_welcome',
|
||||||
|
'message_type' => 'text',
|
||||||
|
'retention_type' => \App\Models\Message::RETENTION_EPHEMERAL_NOTICE,
|
||||||
|
'sent_at' => now()->subDays(60),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('messages:purge', [
|
||||||
|
'--days' => 30,
|
||||||
|
'--image-days' => 3,
|
||||||
|
])->assertExitCode(0);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('messages', ['id' => $userMessage->id]);
|
||||||
|
$this->assertDatabaseMissing('messages', ['id' => $gameNotice->id]);
|
||||||
|
$this->assertDatabaseMissing('messages', ['id' => $temporaryNotice->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试迁移前默认归为 user_chat 的旧游戏通知也会被清理。
|
||||||
|
*/
|
||||||
|
public function test_purge_command_deletes_legacy_game_notice_patterns(): void
|
||||||
|
{
|
||||||
|
$legacyNotice = \App\Models\Message::create([
|
||||||
|
'room_id' => 1,
|
||||||
|
'from_user' => '钓鱼播报',
|
||||||
|
'to_user' => '大家',
|
||||||
|
'content' => '旧钓鱼通知',
|
||||||
|
'is_secret' => false,
|
||||||
|
'font_color' => '#000000',
|
||||||
|
'action' => 'fishing_result',
|
||||||
|
'message_type' => 'text',
|
||||||
|
'retention_type' => \App\Models\Message::RETENTION_USER_CHAT,
|
||||||
|
'sent_at' => now()->subDays(60),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('messages:purge', [
|
||||||
|
'--days' => 30,
|
||||||
|
'--image-days' => 3,
|
||||||
|
])->assertExitCode(0);
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('messages', ['id' => $legacyNotice->id]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 测试心跳接口可以正常返回成功响应。
|
* 测试心跳接口可以正常返回成功响应。
|
||||||
*/
|
*/
|
||||||
@@ -1095,6 +1255,169 @@ class ChatControllerTest extends TestCase
|
|||||||
$this->assertStringContainsString($user->username, $presenceMessage['presence_text']);
|
$this->assertStringContainsString($user->username, $presenceMessage['presence_text']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试有效座驾用户首次进房时会生成座驾播报并返回座驾入场特效。
|
||||||
|
*/
|
||||||
|
public function test_active_ride_user_join_message_uses_ride_presence_payload(): void
|
||||||
|
{
|
||||||
|
$room = Room::create(['room_name' => 'rideroom']);
|
||||||
|
$user = User::factory()->create(['has_received_new_gift' => true]);
|
||||||
|
$ride = Ride::query()->updateOrCreate(['slug' => 'ride_j35'], [
|
||||||
|
'name' => '歼-35测试座驾',
|
||||||
|
'effect_key' => 'j35',
|
||||||
|
'description' => '测试座驾',
|
||||||
|
'icon' => '🛩️',
|
||||||
|
'price' => 18888,
|
||||||
|
'duration_days' => 7,
|
||||||
|
'sort_order' => 80,
|
||||||
|
'is_active' => true,
|
||||||
|
'welcome_message' => '【{name}】驾驶【{ride}】震撼入场',
|
||||||
|
]);
|
||||||
|
UserRidePurchase::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'ride_id' => $ride->id,
|
||||||
|
'status' => 'active',
|
||||||
|
'price_paid' => 18888,
|
||||||
|
'expires_at' => now()->addDays(3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$history = collect($response->viewData('historyMessages'));
|
||||||
|
$rideMessage = $history->first(fn (array $message): bool => ($message['welcome_kind'] ?? '') === 'ride_presence');
|
||||||
|
$entryBroadcast = $history->first(fn (array $message): bool => ($message['welcome_kind'] ?? '') === 'entry_broadcast');
|
||||||
|
|
||||||
|
$this->assertNotNull($rideMessage);
|
||||||
|
$this->assertNull($entryBroadcast);
|
||||||
|
$this->assertSame('座驾播报', $rideMessage['from_user']);
|
||||||
|
$this->assertSame('j35', $rideMessage['ride_key']);
|
||||||
|
$this->assertSame('乘坐【歼-35测试座驾】闪亮登场', $rideMessage['effect_title']);
|
||||||
|
$this->assertSame("用户 {$user->username} · 部门 无部门 · 职务 无职务 · 会员 普通会员", $rideMessage['effect_user_info']);
|
||||||
|
$this->assertStringContainsString($user->username, $rideMessage['content']);
|
||||||
|
$this->assertStringContainsString('部门 无部门 · 职务 无职务 · 会员 普通会员', $rideMessage['content']);
|
||||||
|
$this->assertStringNotContainsString("用户 {$user->username} · 部门 无部门", $rideMessage['content']);
|
||||||
|
$this->assertSame('j35', $response->viewData('initialRideEffect'));
|
||||||
|
$this->assertSame([
|
||||||
|
'effect_title' => '乘坐【歼-35测试座驾】闪亮登场',
|
||||||
|
'effect_user_info' => "用户 {$user->username} · 部门 无部门 · 职务 无职务 · 会员 普通会员",
|
||||||
|
'ride_name' => '歼-35测试座驾',
|
||||||
|
'operator' => $user->username,
|
||||||
|
], $response->viewData('initialRideEffectOptions'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试会员用户有座驾时优先播放座驾,并在座驾播报中展示完整身份摘要。
|
||||||
|
*/
|
||||||
|
public function test_ride_presence_takes_priority_over_vip_presence_and_includes_identity_summary(): void
|
||||||
|
{
|
||||||
|
$room = Room::create(['room_name' => 'rvip']);
|
||||||
|
$vipLevel = VipLevel::factory()->create([
|
||||||
|
'name' => '至尊会员',
|
||||||
|
'icon' => '👑',
|
||||||
|
'join_effect' => 'lightning',
|
||||||
|
'join_banner_style' => 'storm',
|
||||||
|
'allow_custom_messages' => true,
|
||||||
|
]);
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'vip_level_id' => $vipLevel->id,
|
||||||
|
'hy_time' => now()->addDays(30),
|
||||||
|
'custom_join_message' => '{username} 带着风暴王座闪耀降临',
|
||||||
|
'has_received_new_gift' => true,
|
||||||
|
]);
|
||||||
|
$department = Department::create([
|
||||||
|
'name' => '战备部',
|
||||||
|
'rank' => 90,
|
||||||
|
'color' => '#0f766e',
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
$position = Position::create([
|
||||||
|
'department_id' => $department->id,
|
||||||
|
'name' => '试飞官',
|
||||||
|
'icon' => '🛡️',
|
||||||
|
'rank' => 90,
|
||||||
|
'level' => 90,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
UserPosition::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'position_id' => $position->id,
|
||||||
|
'appointed_at' => now(),
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
$ride = Ride::query()->updateOrCreate(['slug' => 'ride_99a'], [
|
||||||
|
'name' => '99A测试座驾',
|
||||||
|
'effect_key' => '99a',
|
||||||
|
'description' => '测试座驾',
|
||||||
|
'icon' => '🛡️',
|
||||||
|
'price' => 28888,
|
||||||
|
'duration_days' => 7,
|
||||||
|
'sort_order' => 90,
|
||||||
|
'is_active' => true,
|
||||||
|
'welcome_message' => '【{name}】乘坐【{ride}】闪亮登场',
|
||||||
|
]);
|
||||||
|
UserRidePurchase::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'ride_id' => $ride->id,
|
||||||
|
'status' => 'active',
|
||||||
|
'price_paid' => 28888,
|
||||||
|
'expires_at' => now()->addDays(3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$history = collect($response->viewData('historyMessages'));
|
||||||
|
$rideMessage = $history->first(fn (array $message): bool => ($message['welcome_kind'] ?? '') === 'ride_presence');
|
||||||
|
$vipPresenceMessage = $history->first(fn (array $message): bool => ($message['action'] ?? '') === 'vip_presence');
|
||||||
|
|
||||||
|
$this->assertNotNull($rideMessage);
|
||||||
|
$this->assertNull($vipPresenceMessage);
|
||||||
|
$this->assertStringContainsString('部门 战备部 · 职务 🛡️ 试飞官 · 会员 👑 至尊会员', $rideMessage['content']);
|
||||||
|
$this->assertStringNotContainsString("用户 {$user->username} · 部门 战备部", $rideMessage['content']);
|
||||||
|
$this->assertSame("用户 {$user->username} · 部门 战备部 · 职务 🛡️ 试飞官 · 会员 👑 至尊会员", $rideMessage['effect_user_info']);
|
||||||
|
$this->assertStringContainsString($user->username, $rideMessage['content']);
|
||||||
|
$this->assertSame('99a', $response->viewData('initialRideEffect'));
|
||||||
|
$this->assertSame("用户 {$user->username} · 部门 战备部 · 职务 🛡️ 试飞官 · 会员 👑 至尊会员", $response->viewData('initialRideEffectOptions')['effect_user_info']);
|
||||||
|
$this->assertNull($response->viewData('initialWelcomeMessage'));
|
||||||
|
$this->assertNull($response->viewData('initialPresenceTheme'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试过期座驾用户进房时不会触发座驾播报。
|
||||||
|
*/
|
||||||
|
public function test_expired_ride_does_not_trigger_ride_presence_payload(): void
|
||||||
|
{
|
||||||
|
$room = Room::create(['room_name' => '过期房']);
|
||||||
|
$user = User::factory()->create(['has_received_new_gift' => true]);
|
||||||
|
$ride = Ride::query()->updateOrCreate(['slug' => 'ride_df5c'], [
|
||||||
|
'name' => '过期座驾',
|
||||||
|
'effect_key' => 'df5c',
|
||||||
|
'description' => '测试座驾',
|
||||||
|
'icon' => '🚀',
|
||||||
|
'price' => 18888,
|
||||||
|
'duration_days' => 7,
|
||||||
|
'sort_order' => 80,
|
||||||
|
'is_active' => true,
|
||||||
|
'welcome_message' => '【{name}】驾驶【{ride}】震撼入场',
|
||||||
|
]);
|
||||||
|
$purchase = UserRidePurchase::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'ride_id' => $ride->id,
|
||||||
|
'status' => 'active',
|
||||||
|
'price_paid' => 18888,
|
||||||
|
'expires_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$history = collect($response->viewData('historyMessages'));
|
||||||
|
$this->assertNull($history->first(fn (array $message): bool => ($message['welcome_kind'] ?? '') === 'ride_presence'));
|
||||||
|
$this->assertNull($response->viewData('initialRideEffect'));
|
||||||
|
$this->assertSame('expired', $purchase->fresh()->status);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 测试新人首次进房时首屏历史包含礼包公告、AI 欢迎和普通进场播报。
|
* 测试新人首次进房时首屏历史包含礼包公告、AI 欢迎和普通进场播报。
|
||||||
*/
|
*/
|
||||||
@@ -1229,6 +1552,23 @@ class ChatControllerTest extends TestCase
|
|||||||
$response->assertStatus(403);
|
$response->assertStatus(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录一条游戏金币流水,用于构造每日游戏净盈利榜测试数据。
|
||||||
|
*/
|
||||||
|
private function recordGameGoldLog(User $user, CurrencySource $source, int $amount, \DateTimeInterface|string|null $createdAt = null): void
|
||||||
|
{
|
||||||
|
UserCurrencyLog::forceCreate([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'username' => $user->username,
|
||||||
|
'currency' => 'gold',
|
||||||
|
'amount' => $amount,
|
||||||
|
'balance_after' => max(0, 100000 + $amount),
|
||||||
|
'source' => $source->value,
|
||||||
|
'remark' => '每日游戏净盈利榜测试',
|
||||||
|
'created_at' => $createdAt ?? now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建带指定聊天室权限的在职职务用户。
|
* 创建带指定聊天室权限的在职职务用户。
|
||||||
*
|
*
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user