From fed51dda187aedfe2b2d51968533e6ddcdf0b846 Mon Sep 17 00:00:00 2001 From: lkddi Date: Tue, 21 Apr 2026 17:14:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=81=8A=E5=A4=A9=E5=AE=A4?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E5=90=8C=E6=AD=A5=E4=B8=8E=E5=85=A8=E5=91=98?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Events/BrowserRefreshRequested.php | 62 +++++++++ app/Events/UserBrowserRefreshRequested.php | 58 ++++++++ .../Controllers/AdminCommandController.php | 35 +++++ .../Controllers/ChatAppointmentController.php | 15 +++ resources/js/chat.js | 13 ++ resources/views/chat/frame.blade.php | 4 +- .../chat/partials/layout/input-bar.blade.php | 6 +- .../views/chat/partials/scripts.blade.php | 127 +++++++++++++++++- .../chat/partials/user-actions.blade.php | 7 +- routes/web.php | 1 + .../Feature/ChatAppointmentControllerTest.php | 16 +++ tests/Feature/ChatControllerTest.php | 35 +++++ .../Feature/AdminCommandControllerTest.php | 55 ++++++++ 13 files changed, 425 insertions(+), 9 deletions(-) create mode 100644 app/Events/BrowserRefreshRequested.php create mode 100644 app/Events/UserBrowserRefreshRequested.php diff --git a/app/Events/BrowserRefreshRequested.php b/app/Events/BrowserRefreshRequested.php new file mode 100644 index 0000000..24f6195 --- /dev/null +++ b/app/Events/BrowserRefreshRequested.php @@ -0,0 +1,62 @@ + + */ + public function broadcastOn(): array + { + return [ + new PresenceChannel('room.'.$this->roomId), + ]; + } + + /** + * 广播数据:前端用于展示提示并执行刷新。 + * + * @return array + */ + public function broadcastWith(): array + { + return [ + 'operator' => $this->operator, + 'reason' => $this->reason, + ]; + } +} diff --git a/app/Events/UserBrowserRefreshRequested.php b/app/Events/UserBrowserRefreshRequested.php new file mode 100644 index 0000000..33942d3 --- /dev/null +++ b/app/Events/UserBrowserRefreshRequested.php @@ -0,0 +1,58 @@ +targetUserId); + } + + /** + * 广播数据:供前端展示提示并执行刷新。 + * + * @return array + */ + public function broadcastWith(): array + { + return [ + 'operator' => $this->operator, + 'reason' => $this->reason, + ]; + } +} diff --git a/app/Http/Controllers/AdminCommandController.php b/app/Http/Controllers/AdminCommandController.php index 1989262..d159159 100644 --- a/app/Http/Controllers/AdminCommandController.php +++ b/app/Http/Controllers/AdminCommandController.php @@ -16,6 +16,7 @@ namespace App\Http\Controllers; use App\Enums\CurrencySource; +use App\Events\BrowserRefreshRequested; use App\Events\EffectBroadcast; use App\Events\MessageSent; use App\Jobs\SaveMessageJob; @@ -449,6 +450,40 @@ class AdminCommandController extends Controller return response()->json(['status' => 'success', 'message' => '已执行全员清屏']); } + /** + * 站长触发当前房间全员刷新页面。 + * + * 仅允许 id=1 的站长使用,向当前聊天室在线用户广播刷新事件, + * 适用于功能更新后强制让前端重新拉取最新页面状态。 + */ + public function refreshAll(Request $request): JsonResponse + { + $request->validate([ + 'room_id' => 'required|integer', + 'reason' => 'nullable|string|max:100', + ]); + + $admin = Auth::user(); + if ((int) $admin->id !== 1) { + return response()->json(['status' => 'error', 'message' => '仅站长可执行全员刷新'], 403); + } + + $roomId = (int) $request->input('room_id'); + $reason = trim((string) $request->input('reason', '')); + + // 立即广播页面刷新指令,确保在线用户尽快拿到最新前端状态。 + broadcast(new BrowserRefreshRequested( + roomId: $roomId, + operator: $admin->username, + reason: $reason, + )); + + return response()->json([ + 'status' => 'success', + 'message' => '已通知当前房间所有在线用户刷新页面', + ]); + } + /** * 管理员触发全屏特效。 * diff --git a/app/Http/Controllers/ChatAppointmentController.php b/app/Http/Controllers/ChatAppointmentController.php index 7339b8a..e6fdd8a 100644 --- a/app/Http/Controllers/ChatAppointmentController.php +++ b/app/Http/Controllers/ChatAppointmentController.php @@ -14,6 +14,7 @@ namespace App\Http\Controllers; use App\Events\AppointmentAnnounced; use App\Events\MessageSent; +use App\Events\UserBrowserRefreshRequested; use App\Jobs\SaveMessageJob; use App\Models\Position; use App\Models\User; @@ -106,6 +107,13 @@ class ChatAppointmentController extends Controller icon: '✨', ); } + + // 任命成功后,通知目标用户刷新页面,及时同步输入框上方的管理按钮与权限状态。 + broadcast(new UserBrowserRefreshRequested( + targetUserId: (int) $target->id, + operator: $operator->username, + reason: '你的职务已发生变更,页面权限正在同步更新。', + )); } return response()->json([ @@ -161,6 +169,13 @@ class ChatAppointmentController extends Controller icon: '📋', ); } + + // 撤职成功后,同步通知目标用户刷新页面,移除已失效的管理入口和权限按钮。 + broadcast(new UserBrowserRefreshRequested( + targetUserId: (int) $target->id, + operator: $operator->username, + reason: '你的职务已被撤销,页面权限正在同步更新。', + )); } return response()->json([ diff --git a/resources/js/chat.js b/resources/js/chat.js index df12e3c..78c2649 100644 --- a/resources/js/chat.js +++ b/resources/js/chat.js @@ -73,6 +73,13 @@ export function initChat(roomId) { new CustomEvent("chat:screen-cleared", { detail: e }), ); }) + // 监听站长触发的全员刷新 + .listen("BrowserRefreshRequested", (e) => { + console.log("全员刷新:", e); + window.dispatchEvent( + new CustomEvent("chat:browser-refresh-requested", { detail: e }), + ); + }) // 监听管理员触发的全屏特效(烟花/下雨/雷电) .listen("EffectBroadcast", (e) => { console.log("特效播放:", e); @@ -158,6 +165,12 @@ export function initChat(roomId) { window.dispatchEvent( new CustomEvent("chat:message", { detail: e.message }), ); + }) + .listen("UserBrowserRefreshRequested", (e) => { + console.log("收到定向刷新通知:", e); + window.dispatchEvent( + new CustomEvent("chat:user-browser-refresh-requested", { detail: e }), + ); }); } } diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index 77dd08c..f8b7af2 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -74,7 +74,8 @@ chatBotEnabled: {{ $chatbotEnabledState ? 'true' : 'false' }}, botUser: @json($botUserData), hasPosition: {{ Auth::user()->activePosition || Auth::user()->user_level >= $superLevel ? 'true' : 'false' }}, - hasRoomManagementPermission: {{ ! empty($hasRoomManagementPermission) ? 'true' : 'false' }}, + hasRoomManagementPermission: {{ (! empty($hasRoomManagementPermission) || Auth::id() === 1) ? 'true' : 'false' }}, + isSiteOwner: {{ Auth::id() === 1 ? 'true' : 'false' }}, positionPermissions: @json($positionPermissions), positionPermissionMap: @json($roomPermissionMap ?? []), @php @@ -98,6 +99,7 @@ revokeUrl: "{{ route('chat.appoint.revoke') }}", rewardUrl: "{{ route('command.reward') }}", rewardQuotaUrl: "{{ route('command.reward_quota') }}", + refreshAllUrl: "{{ route('command.refresh_all') }}", chatPreferencesUrl: "{{ route('user.update_chat_preferences') }}", userJjb: {{ (int) $user->jjb }}, // 当前用户金币(求婚前金额预检查用) myGold: {{ (int) $user->jjb }}, // 赠金币面板显示余额用(赠送成功后前端更新) diff --git a/resources/views/chat/partials/layout/input-bar.blade.php b/resources/views/chat/partials/layout/input-bar.blade.php index 5831b52..5b023c3 100644 --- a/resources/views/chat/partials/layout/input-bar.blade.php +++ b/resources/views/chat/partials/layout/input-bar.blade.php @@ -181,7 +181,7 @@ $welcomeMessages = [ @endif - @if ($canPublicBroadcast || $canClearScreen || $canSendRedPacket || $canManageLossCover) + @if ($canPublicBroadcast || $canClearScreen || $canSendRedPacket || $canManageLossCover || Auth::id() === 1)
聊天室管理
@if ($canPublicBroadcast) @@ -200,6 +200,10 @@ $welcomeMessages = [ @endif + @if (Auth::id() === 1) + + @endif
@endif diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index 02fc217..fda2fd6 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -672,6 +672,9 @@ case 'loss-cover': openAdminBaccaratLossCoverModal(); break; + case 'refresh-all': + refreshAllBrowsers(); + break; default: break; } @@ -691,6 +694,57 @@ triggerEffect(type); } + /** + * 站长通知当前房间所有在线用户刷新页面。 + */ + async function refreshAllBrowsers() { + if (!window.chatContext?.isSiteOwner || !window.chatContext?.refreshAllUrl) { + window.chatDialog?.alert('仅站长可执行全员刷新。', '无权限', '#cc4444'); + return; + } + + const confirmed = await window.chatDialog?.confirm( + '确定通知当前房间所有在线用户刷新页面吗?\n适用于功能更新后强制同步最新按钮与权限状态。', + '♻️ 刷新全员', + '#0f766e' + ); + + if (!confirmed) { + return; + } + + try { + const response = await fetch(window.chatContext.refreshAllUrl, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ + room_id: window.chatContext.roomId, + reason: '功能更新,站长要求刷新页面', + }), + }); + const data = await response.json(); + + if (data.status === 'success') { + window.chatToast?.show({ + title: '已发送刷新通知', + message: data.message, + icon: '♻️', + color: '#0f766e', + duration: 3500, + }); + return; + } + + window.chatDialog?.alert(data.message || '发送刷新通知失败', '操作失败', '#cc4444'); + } catch (error) { + window.chatDialog?.alert('网络异常,请稍后再试', '错误', '#cc4444'); + } + } + /** * 将选中的欢迎语模板填入输入框,{name} 替换为当前选中的聊天对象, * 并在前面加上「部门 职务 姓名:」前缀,然后自动发送 @@ -1436,6 +1490,48 @@ document.getElementById('room-title-display').innerText = e.detail.title; }); + /** + * 收到站长的全员刷新通知后,先弹出提示,再延迟刷新页面。 + */ + window.addEventListener('chat:browser-refresh-requested', (e) => { + const detail = e.detail || {}; + const operatorName = escapeHtml(String(detail.operator || '站长')); + const reasonText = escapeHtml(String(detail.reason || '页面功能已更新,请重新载入。')); + + window.chatToast?.show({ + title: '页面即将刷新', + message: `${operatorName} 通知全员刷新页面。
${reasonText}`, + icon: '♻️', + color: '#0f766e', + duration: 2200, + }); + + window.setTimeout(() => { + window.location.reload(); + }, 900); + }); + + /** + * 任命/撤职后,目标用户收到定向刷新通知,自动同步页面上的权限按钮。 + */ + window.addEventListener('chat:user-browser-refresh-requested', (e) => { + const detail = e.detail || {}; + const operatorName = escapeHtml(String(detail.operator || '管理员')); + const reasonText = escapeHtml(String(detail.reason || '你的权限状态已发生变化,页面即将刷新。')); + + window.chatToast?.show({ + title: '权限同步中', + message: `${operatorName} 已更新你的职务状态。
${reasonText}`, + icon: '🔄', + color: '#7c3aed', + duration: 2600, + }); + + window.setTimeout(() => { + window.location.reload(); + }, 1000); + }); + // ── 管理员全员清屏事件(等待 Echo 就绪后监听) ─────── function setupScreenClearedListener() { if (!window.Echo || !window.chatContext) { @@ -1484,6 +1580,32 @@ // DOMContentLoaded 后启动,确保 Vite 编译的 JS 已加载 document.addEventListener('DOMContentLoaded', setupScreenClearedListener); + /** + * 注册房间级“刷新全员”监听。 + * + * 放在 Blade 脚本内,避免前端资源未重新构建时收不到刷新广播。 + */ + function setupRoomBrowserRefreshListener() { + if (!window.Echo || !window.chatContext) { + setTimeout(setupRoomBrowserRefreshListener, 500); + return; + } + + window.Echo.join(`room.${window.chatContext.roomId}`) + .listen('BrowserRefreshRequested', (e) => { + console.log('收到全员刷新事件:', e); + window.dispatchEvent( + new CustomEvent('chat:browser-refresh-requested', { + detail: e + }) + ); + }); + + console.log('BrowserRefreshRequested 监听器已注册'); + } + + document.addEventListener('DOMContentLoaded', setupRoomBrowserRefreshListener); + // ── 开发日志发布通知(仅 Room 1 大厅可见)──────────── /** * 监听 ChangelogPublished 事件,在大厅聊天区展示系统通知 @@ -1642,11 +1764,12 @@ window.addEventListener('chat:effect', (e) => { const type = e.detail?.type; const target = e.detail?.target_username; // null = 全员,otherwise 指定昵称 + const operator = e.detail?.operator; // 定向赠送时,购买者自己也应能看到特效 const myName = window.chatContext?.username; - // null 表示全员,或者 target 匹配自己才播放 + // null 表示全员;若有指定接收者,则购买者本人和指定用户都播放 if (type && typeof EffectManager !== 'undefined') { - if (!target || target === myName) { + if (!target || target === myName || operator === myName) { EffectManager.play(type); } } diff --git a/resources/views/chat/partials/user-actions.blade.php b/resources/views/chat/partials/user-actions.blade.php index b763e7a..52fea56 100644 --- a/resources/views/chat/partials/user-actions.blade.php +++ b/resources/views/chat/partials/user-actions.blade.php @@ -349,13 +349,10 @@ }); const data = await res.json(); const revOk = data.status === 'success'; - this.$alert( - data.message, - revOk ? '撤销成功' : '操作失败', - revOk ? '#6b7280' : '#cc4444' - ); if (revOk) { this.showUserModal = false; + } else { + this.$alert(data.message, '操作失败', '#cc4444'); } } catch (e) { this.$alert('网络异常', '错误', '#cc4444'); diff --git a/routes/web.php b/routes/web.php index 75b634a..3774925 100644 --- a/routes/web.php +++ b/routes/web.php @@ -327,6 +327,7 @@ Route::middleware(['chat.auth'])->group(function () { Route::get('/command/whispers/{username}', [AdminCommandController::class, 'viewWhispers'])->name('command.whispers'); Route::post('/command/announce', [AdminCommandController::class, 'announce'])->name('command.announce'); Route::post('/command/clear-screen', [AdminCommandController::class, 'clearScreen'])->name('command.clear_screen'); + Route::post('/command/refresh-all', [AdminCommandController::class, 'refreshAll'])->name('command.refresh_all'); Route::post('/command/effect', [AdminCommandController::class, 'effect'])->name('command.effect'); Route::post('/command/baccarat-loss-cover', [\App\Http\Controllers\Admin\BaccaratLossCoverEventController::class, 'store'])->name('command.baccarat_loss_cover.store'); Route::post('/command/baccarat-loss-cover/{event}/close', [\App\Http\Controllers\Admin\BaccaratLossCoverEventController::class, 'close'])->name('command.baccarat_loss_cover.close'); diff --git a/tests/Feature/ChatAppointmentControllerTest.php b/tests/Feature/ChatAppointmentControllerTest.php index 2a4213e..c14d803 100644 --- a/tests/Feature/ChatAppointmentControllerTest.php +++ b/tests/Feature/ChatAppointmentControllerTest.php @@ -8,6 +8,7 @@ namespace Tests\Feature; +use App\Events\UserBrowserRefreshRequested; use App\Jobs\SaveMessageJob; use App\Models\Department; use App\Models\Position; @@ -15,6 +16,7 @@ use App\Models\Room; use App\Models\User; use App\Models\UserPosition; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Redis; use Tests\TestCase; @@ -40,6 +42,7 @@ class ChatAppointmentControllerTest extends TestCase */ public function test_appoint_pushes_private_toast_notification_to_target(): void { + Event::fake([UserBrowserRefreshRequested::class]); Queue::fake(); [$admin, $target, $room, $position] = $this->createAppointmentActors(); @@ -63,6 +66,12 @@ class ChatAppointmentControllerTest extends TestCase $this->assertSame('✨', $privateMessage['toast_notification']['icon'] ?? null); $this->assertSame('#a855f7', $privateMessage['toast_notification']['color'] ?? null); + Event::assertDispatched(UserBrowserRefreshRequested::class, function (UserBrowserRefreshRequested $event) use ($target, $admin) { + return $event->targetUserId === $target->id + && $event->operator === $admin->username + && $event->reason === '你的职务已发生变更,页面权限正在同步更新。'; + }); + Queue::assertPushed(SaveMessageJob::class, 1); } @@ -71,6 +80,7 @@ class ChatAppointmentControllerTest extends TestCase */ public function test_revoke_pushes_private_toast_notification_to_target(): void { + Event::fake([UserBrowserRefreshRequested::class]); Queue::fake(); [$admin, $target, $room, $position] = $this->createAppointmentActors(); @@ -102,6 +112,12 @@ class ChatAppointmentControllerTest extends TestCase $this->assertSame('📋', $privateMessage['toast_notification']['icon'] ?? null); $this->assertSame('#6b7280', $privateMessage['toast_notification']['color'] ?? null); + Event::assertDispatched(UserBrowserRefreshRequested::class, function (UserBrowserRefreshRequested $event) use ($target, $admin) { + return $event->targetUserId === $target->id + && $event->operator === $admin->username + && $event->reason === '你的职务已被撤销,页面权限正在同步更新。'; + }); + Queue::assertPushed(SaveMessageJob::class, 1); } diff --git a/tests/Feature/ChatControllerTest.php b/tests/Feature/ChatControllerTest.php index 03bff80..2f497a6 100644 --- a/tests/Feature/ChatControllerTest.php +++ b/tests/Feature/ChatControllerTest.php @@ -215,6 +215,41 @@ class ChatControllerTest extends TestCase $response->assertDontSee("runAdminAction('announce-message')", false); } + /** + * 测试站长即使没有在职职务,也能看到管理菜单中的刷新全员按钮。 + */ + public function test_room_view_shows_refresh_all_button_for_site_owner(): void + { + $room = Room::create(['room_name' => 'owner-rf']); + $user = User::factory()->create([ + 'id' => 1, + 'user_level' => 100, + ]); + + $response = $this->actingAs($user)->get(route('chat.room', $room->id)); + + $response->assertOk(); + $response->assertSee('🛠 管理', false); + $response->assertSee("runAdminAction('refresh-all')", false); + $response->assertSee('♻️ 刷新全员', false); + } + + /** + * 测试普通职务用户不会看到刷新全员按钮。 + */ + public function test_room_view_hides_refresh_all_button_for_non_site_owner(): void + { + $room = Room::create(['room_name' => 'normal-rf']); + $user = $this->createUserWithPositionPermissions([ + PositionPermissionRegistry::ROOM_ANNOUNCEMENT, + ]); + + $response = $this->actingAs($user)->get(route('chat.room', $room->id)); + + $response->assertOk(); + $response->assertDontSee("runAdminAction('refresh-all')", false); + } + /** * 测试用户可以发送普通文本消息。 */ diff --git a/tests/Feature/Feature/AdminCommandControllerTest.php b/tests/Feature/Feature/AdminCommandControllerTest.php index 86b206f..4d391ea 100644 --- a/tests/Feature/Feature/AdminCommandControllerTest.php +++ b/tests/Feature/Feature/AdminCommandControllerTest.php @@ -7,6 +7,7 @@ namespace Tests\Feature\Feature; +use App\Events\BrowserRefreshRequested; use App\Jobs\SaveMessageJob; use App\Models\Department; use App\Models\Position; @@ -15,6 +16,7 @@ use App\Models\User; use App\Models\UserPosition; use App\Support\PositionPermissionRegistry; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Redis; use Tests\TestCase; @@ -173,6 +175,59 @@ class AdminCommandControllerTest extends TestCase $this->assertSame([], Redis::lrange("room:{$room->id}:messages", 0, -1)); } + /** + * 测试站长可以触发当前房间全员刷新事件。 + */ + public function test_site_owner_can_request_refresh_for_all_users_in_room(): void + { + Event::fake([BrowserRefreshRequested::class]); + + $admin = User::factory()->create([ + 'id' => 1, + 'user_level' => 100, + ]); + $room = Room::create([ + 'room_name' => '刷新房', + ]); + + $response = $this->actingAs($admin)->postJson(route('command.refresh_all'), [ + 'room_id' => $room->id, + 'reason' => '功能更新,要求刷新', + ]); + + $response->assertOk()->assertJson([ + 'status' => 'success', + ]); + + Event::assertDispatched(BrowserRefreshRequested::class, function (BrowserRefreshRequested $event) use ($room, $admin) { + return $event->roomId === $room->id + && $event->operator === $admin->username + && $event->reason === '功能更新,要求刷新'; + }); + } + + /** + * 测试非站长用户不能触发全员刷新。 + */ + public function test_non_site_owner_cannot_request_refresh_for_all_users(): void + { + Event::fake([BrowserRefreshRequested::class]); + + $admin = $this->createPositionedManager([ + PositionPermissionRegistry::ROOM_CLEAR_SCREEN, + ]); + $room = Room::create([ + 'room_name' => '无权刷新房', + ]); + + $response = $this->actingAs($admin)->postJson(route('command.refresh_all'), [ + 'room_id' => $room->id, + ]); + + $response->assertStatus(403); + Event::assertNotDispatched(BrowserRefreshRequested::class); + } + /** * 测试管理操作中的奖励金币会给接收方写入带右下角提示的私聊消息。 */