diff --git a/app/Http/Controllers/AdminCommandController.php b/app/Http/Controllers/AdminCommandController.php
index 2f5d2cc..972c55a 100644
--- a/app/Http/Controllers/AdminCommandController.php
+++ b/app/Http/Controllers/AdminCommandController.php
@@ -83,6 +83,17 @@ class AdminCommandController extends Controller
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
+ // 给被警告用户补一条私聊提示,并复用右下角 toast 通知。
+ $this->pushTargetToastMessage(
+ roomId: (int) $roomId,
+ targetUsername: $targetUsername,
+ content: "⚠️ 管理员 {$admin->username} 警告了你:{$reason}",
+ title: '⚠️ 收到警告',
+ toastMessage: "{$admin->username} 警告了你:{$reason}",
+ color: '#f59e0b',
+ icon: '⚠️',
+ );
+
return response()->json(['status' => 'success', 'message' => "已警告 {$targetUsername}"]);
}
@@ -112,6 +123,17 @@ class AdminCommandController extends Controller
return response()->json(['status' => 'error', 'message' => '权限不足'], 403);
}
+ // 在强制踢出前补发目标私聊提示,尽量让对方先看到 toast。
+ $this->pushTargetToastMessage(
+ roomId: (int) $roomId,
+ targetUsername: $targetUsername,
+ content: "🚫 管理员 {$admin->username} 已将你踢出聊天室。原因:{$reason}",
+ title: '🚫 已被踢出',
+ toastMessage: "{$admin->username} 已将你踢出聊天室。
原因:{$reason}",
+ color: '#ef4444',
+ icon: '🚫',
+ );
+
// 从 Redis 在线列表移除
$this->chatState->userLeave($roomId, $targetUsername);
@@ -183,6 +205,17 @@ class AdminCommandController extends Controller
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
+ // 给被禁言用户补一条私聊提示,并复用右下角 toast 通知。
+ $this->pushTargetToastMessage(
+ roomId: (int) $roomId,
+ targetUsername: $targetUsername,
+ content: "🔇 管理员 {$admin->username} 已将你禁言 {$duration} 分钟。",
+ title: '🔇 已被禁言',
+ toastMessage: "{$admin->username} 已将你禁言 {$duration} 分钟。",
+ color: '#6366f1',
+ icon: '🔇',
+ );
+
// 广播禁言事件(前端禁用输入框)
broadcast(new \App\Events\UserMuted(
roomId: $roomId,
@@ -228,6 +261,17 @@ class AdminCommandController extends Controller
$target->user_level = -1;
$target->save();
+ // 先给被冻结用户补发私聊提示,再将其移出各房间并强制下线。
+ $this->pushTargetToastMessage(
+ roomId: (int) $roomId,
+ targetUsername: $targetUsername,
+ content: "🧊 管理员 {$admin->username} 已冻结你的账号。原因:{$reason}",
+ title: '🧊 账号已冻结',
+ toastMessage: "{$admin->username} 已冻结你的账号。
原因:{$reason}",
+ color: '#3b82f6',
+ icon: '🧊',
+ );
+
// 从所有房间移除
$rooms = $this->chatState->getUserRooms($targetUsername);
foreach ($rooms as $rid) {
@@ -709,4 +753,41 @@ class AdminCommandController extends Controller
return true;
}
+
+ /**
+ * 向目标用户补发一条系统私聊消息,并附带右下角 toast 配置。
+ */
+ private function pushTargetToastMessage(
+ int $roomId,
+ string $targetUsername,
+ string $content,
+ string $title,
+ string $toastMessage,
+ string $color,
+ string $icon,
+ ): void {
+ $msg = [
+ 'id' => $this->chatState->nextMessageId($roomId),
+ 'room_id' => $roomId,
+ 'from_user' => '系统',
+ 'to_user' => $targetUsername,
+ 'content' => $content,
+ 'is_secret' => true,
+ 'font_color' => $color,
+ 'action' => '',
+ 'sent_at' => now()->toDateTimeString(),
+ // 复用现有聊天 toast 机制,在右下角弹出操作结果提示。
+ 'toast_notification' => [
+ 'title' => $title,
+ 'message' => $toastMessage,
+ 'icon' => $icon,
+ 'color' => $color,
+ 'duration' => 10000,
+ ],
+ ];
+
+ $this->chatState->pushMessage($roomId, $msg);
+ broadcast(new MessageSent($roomId, $msg));
+ SaveMessageJob::dispatch($msg);
+ }
}
diff --git a/app/Http/Controllers/ChatAppointmentController.php b/app/Http/Controllers/ChatAppointmentController.php
index 5ef2481..7339b8a 100644
--- a/app/Http/Controllers/ChatAppointmentController.php
+++ b/app/Http/Controllers/ChatAppointmentController.php
@@ -13,6 +13,8 @@
namespace App\Http\Controllers;
use App\Events\AppointmentAnnounced;
+use App\Events\MessageSent;
+use App\Jobs\SaveMessageJob;
use App\Models\Position;
use App\Models\User;
use App\Services\AppointmentService;
@@ -92,6 +94,17 @@ class ChatAppointmentController extends Controller
departmentName: $position->department?->name ?? '',
operatorName: $operator->username,
));
+
+ // 给被任命用户补一条私聊提示,并复用右下角 toast 通知。
+ $this->pushTargetToastMessage(
+ roomId: (int) $roomId,
+ targetUsername: $target->username,
+ content: "✨ {$operator->username} 已任命你为 {$position->icon} {$position->name}。",
+ title: '✨ 职务任命通知',
+ toastMessage: "{$operator->username} 已任命你为 {$position->icon} {$position->name}。",
+ color: '#a855f7',
+ icon: '✨',
+ );
}
}
@@ -136,6 +149,17 @@ class ChatAppointmentController extends Controller
operatorName: $operator->username,
type: 'revoke',
));
+
+ // 给被撤职用户补一条私聊提示,并复用右下角 toast 通知。
+ $this->pushTargetToastMessage(
+ roomId: (int) $roomId,
+ targetUsername: $target->username,
+ content: "📋 {$operator->username} 已撤销你的 {$posIcon} {$posName} 职务。",
+ title: '📋 职务变动通知',
+ toastMessage: "{$operator->username} 已撤销你的 {$posIcon} {$posName} 职务。",
+ color: '#6b7280',
+ icon: '📋',
+ );
}
}
@@ -144,4 +168,41 @@ class ChatAppointmentController extends Controller
'message' => $result['message'],
], $result['ok'] ? 200 : 422);
}
+
+ /**
+ * 向目标用户补发一条系统私聊消息,并附带右下角 toast 配置。
+ */
+ private function pushTargetToastMessage(
+ int $roomId,
+ string $targetUsername,
+ string $content,
+ string $title,
+ string $toastMessage,
+ string $color,
+ string $icon,
+ ): void {
+ $msg = [
+ 'id' => $this->chatState->nextMessageId($roomId),
+ 'room_id' => $roomId,
+ 'from_user' => '系统',
+ 'to_user' => $targetUsername,
+ 'content' => $content,
+ 'is_secret' => true,
+ 'font_color' => $color,
+ 'action' => '',
+ 'sent_at' => now()->toDateTimeString(),
+ // 复用现有聊天 toast 机制,在右下角弹出职务变动提示。
+ 'toast_notification' => [
+ 'title' => $title,
+ 'message' => $toastMessage,
+ 'icon' => $icon,
+ 'color' => $color,
+ 'duration' => 10000,
+ ],
+ ];
+
+ $this->chatState->pushMessage($roomId, $msg);
+ broadcast(new MessageSent($roomId, $msg));
+ SaveMessageJob::dispatch($msg);
+ }
}
diff --git a/tests/Feature/ChatAppointmentControllerTest.php b/tests/Feature/ChatAppointmentControllerTest.php
new file mode 100644
index 0000000..2a4213e
--- /dev/null
+++ b/tests/Feature/ChatAppointmentControllerTest.php
@@ -0,0 +1,155 @@
+createAppointmentActors();
+
+ $response = $this->actingAs($admin)->postJson(route('chat.appoint.appoint'), [
+ 'username' => $target->username,
+ 'position_id' => $position->id,
+ 'remark' => '聊天室快速任命',
+ 'room_id' => $room->id,
+ ]);
+
+ $response->assertOk()->assertJson([
+ 'status' => 'success',
+ 'message' => "已成功将【{$target->username}】任命为【{$position->name}】。",
+ ]);
+
+ $privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已任命你为');
+
+ $this->assertNotNull($privateMessage);
+ $this->assertSame('✨ 职务任命通知', $privateMessage['toast_notification']['title'] ?? null);
+ $this->assertSame('✨', $privateMessage['toast_notification']['icon'] ?? null);
+ $this->assertSame('#a855f7', $privateMessage['toast_notification']['color'] ?? null);
+
+ Queue::assertPushed(SaveMessageJob::class, 1);
+ }
+
+ /**
+ * 测试撤销职务后会给目标用户写入带 toast 的私聊提示。
+ */
+ public function test_revoke_pushes_private_toast_notification_to_target(): void
+ {
+ Queue::fake();
+
+ [$admin, $target, $room, $position] = $this->createAppointmentActors();
+
+ UserPosition::create([
+ 'user_id' => $target->id,
+ 'position_id' => $position->id,
+ 'appointed_by_user_id' => $admin->id,
+ 'appointed_at' => now()->subDay(),
+ 'remark' => '已有职务',
+ 'is_active' => true,
+ ]);
+ $target->update(['user_level' => $position->level]);
+
+ $response = $this->actingAs($admin)->postJson(route('chat.appoint.revoke'), [
+ 'username' => $target->username,
+ 'remark' => '聊天室快速撤销',
+ 'room_id' => $room->id,
+ ]);
+
+ $response->assertOk()->assertJson([
+ 'status' => 'success',
+ ]);
+
+ $privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已撤销你的');
+
+ $this->assertNotNull($privateMessage);
+ $this->assertSame('📋 职务变动通知', $privateMessage['toast_notification']['title'] ?? null);
+ $this->assertSame('📋', $privateMessage['toast_notification']['icon'] ?? null);
+ $this->assertSame('#6b7280', $privateMessage['toast_notification']['color'] ?? null);
+
+ Queue::assertPushed(SaveMessageJob::class, 1);
+ }
+
+ /**
+ * 创建任命测试共用的操作者、目标用户、房间与职务。
+ *
+ * @return array{0: User, 1: User, 2: Room, 3: Position}
+ */
+ private function createAppointmentActors(): array
+ {
+ $admin = User::factory()->create([
+ 'id' => 1,
+ 'user_level' => 100,
+ ]);
+ $target = User::factory()->create([
+ 'user_level' => 1,
+ ]);
+ $room = Room::create([
+ 'room_name' => '职务操作房',
+ ]);
+ $department = Department::create([
+ 'name' => '办公厅',
+ 'rank' => 90,
+ ]);
+ $position = Position::create([
+ 'department_id' => $department->id,
+ 'name' => '巡查员',
+ 'icon' => '✨',
+ 'rank' => 50,
+ 'level' => 20,
+ ]);
+
+ return [$admin, $target, $room, $position];
+ }
+
+ /**
+ * 从房间消息缓存中定位目标用户收到的系统私聊提示。
+ *
+ * @return array|null
+ */
+ private function findPrivateSystemMessage(int $roomId, string $targetUsername, string $needle): ?array
+ {
+ $messages = Redis::lrange("room:{$roomId}:messages", 0, -1);
+
+ return collect($messages)
+ ->map(fn (string $item) => json_decode($item, true))
+ ->first(fn (array $item) => ($item['from_user'] ?? null) === '系统'
+ && ($item['to_user'] ?? null) === $targetUsername
+ && str_contains((string) ($item['content'] ?? ''), $needle));
+ }
+}
diff --git a/tests/Feature/Feature/AdminCommandControllerTest.php b/tests/Feature/Feature/AdminCommandControllerTest.php
index 048c879..7be495a 100644
--- a/tests/Feature/Feature/AdminCommandControllerTest.php
+++ b/tests/Feature/Feature/AdminCommandControllerTest.php
@@ -23,6 +23,15 @@ class AdminCommandControllerTest extends TestCase
{
use RefreshDatabase;
+ /**
+ * 每个测试前清空 Redis,避免跨用例的聊天室消息污染断言。
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+ Redis::flushall();
+ }
+
/**
* 测试站长可以触发全部新增全屏特效。
*/
@@ -98,4 +107,158 @@ class AdminCommandControllerTest extends TestCase
// 奖励公告和接收者私信都应进入异步落库队列。
Queue::assertPushed(SaveMessageJob::class, 2);
}
+
+ /**
+ * 测试警告操作会给目标用户写入带 toast 的私聊提示。
+ */
+ public function test_warn_message_contains_toast_notification_for_receiver(): void
+ {
+ Queue::fake();
+ [$admin, $target, $room] = $this->createAdminCommandActors();
+
+ $response = $this->actingAs($admin)->postJson(route('command.warn'), [
+ 'username' => $target->username,
+ 'room_id' => $room->id,
+ 'reason' => '请注意发言尺度',
+ ]);
+
+ $response->assertOk()->assertJson([
+ 'status' => 'success',
+ 'message' => "已警告 {$target->username}",
+ ]);
+
+ $privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '管理员 '.$admin->username.' 警告了你');
+
+ $this->assertNotNull($privateMessage);
+ $this->assertSame('⚠️ 收到警告', $privateMessage['toast_notification']['title'] ?? null);
+ $this->assertSame('⚠️', $privateMessage['toast_notification']['icon'] ?? null);
+ $this->assertSame('#f59e0b', $privateMessage['toast_notification']['color'] ?? null);
+ Queue::assertPushed(SaveMessageJob::class, 2);
+ }
+
+ /**
+ * 测试禁言操作会给目标用户写入带 toast 的私聊提示。
+ */
+ public function test_mute_message_contains_toast_notification_for_receiver(): void
+ {
+ Queue::fake();
+ [$admin, $target, $room] = $this->createAdminCommandActors();
+
+ $response = $this->actingAs($admin)->postJson(route('command.mute'), [
+ 'username' => $target->username,
+ 'room_id' => $room->id,
+ 'duration' => 15,
+ ]);
+
+ $response->assertOk()->assertJson([
+ 'status' => 'success',
+ 'message' => "已禁言 {$target->username} 15 分钟",
+ ]);
+
+ $this->assertTrue((bool) Redis::exists("mute:{$room->id}:{$target->username}"));
+
+ $privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已将你禁言 15 分钟');
+
+ $this->assertNotNull($privateMessage);
+ $this->assertSame('🔇 已被禁言', $privateMessage['toast_notification']['title'] ?? null);
+ $this->assertSame('🔇', $privateMessage['toast_notification']['icon'] ?? null);
+ $this->assertSame('#6366f1', $privateMessage['toast_notification']['color'] ?? null);
+ Queue::assertPushed(SaveMessageJob::class, 2);
+ }
+
+ /**
+ * 测试踢出操作会给目标用户写入带 toast 的私聊提示。
+ */
+ public function test_kick_message_contains_toast_notification_for_receiver(): void
+ {
+ Queue::fake();
+ [$admin, $target, $room] = $this->createAdminCommandActors();
+ Redis::hset("room:{$room->id}:users", $target->username, json_encode(['user_id' => $target->id], JSON_UNESCAPED_UNICODE));
+
+ $response = $this->actingAs($admin)->postJson(route('command.kick'), [
+ 'username' => $target->username,
+ 'room_id' => $room->id,
+ 'reason' => '刷屏',
+ ]);
+
+ $response->assertOk()->assertJson([
+ 'status' => 'success',
+ 'message' => "已踢出 {$target->username}",
+ ]);
+
+ $privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已将你踢出聊天室');
+
+ $this->assertNotNull($privateMessage);
+ $this->assertSame('🚫 已被踢出', $privateMessage['toast_notification']['title'] ?? null);
+ $this->assertSame('🚫', $privateMessage['toast_notification']['icon'] ?? null);
+ $this->assertSame('#ef4444', $privateMessage['toast_notification']['color'] ?? null);
+ Queue::assertPushed(SaveMessageJob::class, 2);
+ }
+
+ /**
+ * 测试冻结操作会给目标用户写入带 toast 的私聊提示。
+ */
+ public function test_freeze_message_contains_toast_notification_for_receiver(): void
+ {
+ Queue::fake();
+ [$admin, $target, $room] = $this->createAdminCommandActors();
+
+ $response = $this->actingAs($admin)->postJson(route('command.freeze'), [
+ 'username' => $target->username,
+ 'room_id' => $room->id,
+ 'reason' => '严重违规',
+ ]);
+
+ $response->assertOk()->assertJson([
+ 'status' => 'success',
+ 'message' => "已冻结 {$target->username} 的账号",
+ ]);
+
+ $this->assertSame(-1, (int) $target->fresh()->user_level);
+
+ $privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已冻结你的账号');
+
+ $this->assertNotNull($privateMessage);
+ $this->assertSame('🧊 账号已冻结', $privateMessage['toast_notification']['title'] ?? null);
+ $this->assertSame('🧊', $privateMessage['toast_notification']['icon'] ?? null);
+ $this->assertSame('#3b82f6', $privateMessage['toast_notification']['color'] ?? null);
+ Queue::assertPushed(SaveMessageJob::class, 2);
+ }
+
+ /**
+ * 创建管理员命令测试共用的操作者、目标用户和房间。
+ *
+ * @return array{0: User, 1: User, 2: Room}
+ */
+ private function createAdminCommandActors(): array
+ {
+ $admin = User::factory()->create([
+ 'id' => 1,
+ 'user_level' => 100,
+ ]);
+ $target = User::factory()->create([
+ 'user_level' => 1,
+ ]);
+ $room = Room::create([
+ 'room_name' => '管理操作房',
+ ]);
+
+ return [$admin, $target, $room];
+ }
+
+ /**
+ * 从房间消息缓存中定位目标用户收到的系统私聊提示。
+ *
+ * @return array|null
+ */
+ private function findPrivateSystemMessage(int $roomId, string $targetUsername, string $needle): ?array
+ {
+ $messages = Redis::lrange("room:{$roomId}:messages", 0, -1);
+
+ return collect($messages)
+ ->map(fn (string $item) => json_decode($item, true))
+ ->first(fn (array $item) => ($item['from_user'] ?? null) === '系统'
+ && ($item['to_user'] ?? null) === $targetUsername
+ && str_contains((string) ($item['content'] ?? ''), $needle));
+ }
}