新增聊天室刷新同步与全员刷新功能

This commit is contained in:
2026-04-21 17:14:12 +08:00
parent c209221bad
commit fed51dda18
13 changed files with 425 additions and 9 deletions
+62
View File
@@ -0,0 +1,62 @@
<?php
/**
* 文件功能:聊天室浏览器刷新请求广播事件
*
* 仅供站长触发“刷新全员”命令时使用,
* 向当前房间所有在线用户广播前端刷新指令。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向房间内全部在线用户广播页面刷新指令。
*/
class BrowserRefreshRequested implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造函数:记录房间与操作者信息。
*/
public function __construct(
public readonly int $roomId,
public readonly string $operator,
public readonly string $reason = '',
) {}
/**
* 广播频道:当前聊天室 PresenceChannel。
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PresenceChannel('room.'.$this->roomId),
];
}
/**
* 广播数据:前端用于展示提示并执行刷新。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'operator' => $this->operator,
'reason' => $this->reason,
];
}
}
@@ -0,0 +1,58 @@
<?php
/**
* 文件功能:用户定向页面刷新广播事件
*
* 在任命或撤销职务成功后,向目标用户私有频道推送刷新指令,
* 确保对方页面上的权限按钮与职务状态及时同步。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定用户广播页面刷新请求。
*/
class UserBrowserRefreshRequested implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造函数:记录目标用户与刷新说明。
*/
public function __construct(
public readonly int $targetUserId,
public readonly string $operator,
public readonly string $reason = '',
) {}
/**
* 广播频道:目标用户私有频道。
*/
public function broadcastOn(): PrivateChannel
{
return new PrivateChannel('user.'.$this->targetUserId);
}
/**
* 广播数据:供前端展示提示并执行刷新。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'operator' => $this->operator,
'reason' => $this->reason,
];
}
}
@@ -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' => '已通知当前房间所有在线用户刷新页面',
]);
}
/**
* 管理员触发全屏特效。
*
@@ -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([
+13
View File
@@ -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 }),
);
});
}
}
+3 -1
View File
@@ -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 }}, // 赠金币面板显示余额用(赠送成功后前端更新)
@@ -181,7 +181,7 @@ $welcomeMessages = [
</div>
@endif
@if ($canPublicBroadcast || $canClearScreen || $canSendRedPacket || $canManageLossCover)
@if ($canPublicBroadcast || $canClearScreen || $canSendRedPacket || $canManageLossCover || Auth::id() === 1)
<div style="font-size:10px;color:#9a3412;padding:10px 2px 6px;">聊天室管理</div>
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;">
@if ($canPublicBroadcast)
@@ -200,6 +200,10 @@ $welcomeMessages = [
<button type="button" onclick="runAdminAction('loss-cover')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#15803d;border:1px solid #86efac;border-radius:6px;cursor:pointer;">🎁 买单活动</button>
@endif
@if (Auth::id() === 1)
<button type="button" onclick="runAdminAction('refresh-all')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#0f766e;border:1px solid #99f6e4;border-radius:6px;cursor:pointer;">♻️ 刷新全员</button>
@endif
</div>
@endif
+125 -2
View File
@@ -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: `<b>${operatorName}</b> 通知全员刷新页面。<br><span style="color:#475569;">${reasonText}</span>`,
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: `<b>${operatorName}</b> 已更新你的职务状态。<br><span style="color:#475569;">${reasonText}</span>`,
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);
}
}
@@ -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');
+1
View File
@@ -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');
@@ -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);
}
+35
View File
@@ -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);
}
/**
* 测试用户可以发送普通文本消息。
*/
@@ -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);
}
/**
* 测试管理操作中的奖励金币会给接收方写入带右下角提示的私聊消息。
*/