新增聊天室状态与功能快捷菜单

This commit is contained in:
2026-04-24 21:17:44 +08:00
parent d7ec42a025
commit 0f0bfef2a8
18 changed files with 1361 additions and 124 deletions
+57
View File
@@ -0,0 +1,57 @@
<?php
/**
* 文件功能:聊天室用户状态变更广播事件
* 负责在用户设置或清除当日状态后,实时同步当前房间在线名单展示。
*/
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 UserStatusUpdated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造聊天室用户状态变更广播事件。
*
* @param int $roomId 房间 ID
* @param string $username 状态变更用户昵称
* @param array<string, mixed> $user 最新在线名单载荷
*/
public function __construct(
public readonly int $roomId,
public readonly string $username,
public readonly array $user,
) {}
/**
* 获取广播频道。
*
* @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 [
'username' => $this->username,
'user' => $this->user,
];
}
}
@@ -20,6 +20,7 @@ use App\Http\Controllers\Controller;
use App\Models\AiProviderConfig;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -33,6 +34,7 @@ class AiProviderController extends Controller
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $chatUserPresenceService,
) {}
/**
@@ -283,19 +285,8 @@ class AiProviderController extends Controller
]);
}
$userData = [
'user_id' => $user->id,
'username' => $user->username,
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => false,
'position_icon' => '',
'position_name' => '',
];
// 机器人在线载荷也统一走聊天室展示服务,避免名单字段口径逐步漂移。
$userData = $this->chatUserPresenceService->build($user);
// 广播机器人进出事件(供前端名单增删)
broadcast(new \App\Events\ChatBotToggled($userData, $isEnabled));
+11 -47
View File
@@ -25,11 +25,13 @@ use App\Models\Sysparam;
use App\Models\User;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService;
use App\Services\MessageFilterService;
use App\Services\PositionPermissionService;
use App\Services\RoomBroadcastService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use App\Support\ChatDailyStatusCatalog;
use App\Support\PositionPermissionRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -54,6 +56,7 @@ class ChatController extends Controller
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $chatUserPresenceService,
private readonly MessageFilterService $filter,
private readonly VipService $vipService,
private readonly \App\Services\ShopService $shopService,
@@ -105,21 +108,7 @@ class ChatController extends Controller
}
// 2. 将当前用户加入到 Redis 房间在线列表(包含 VIP 和管理员信息)
$superLevel = (int) Sysparam::getValue('superlevel', '100');
// 获取当前在职职务信息(用于内容显示)
$activePosition = $user->activePosition;
$userData = [
'user_id' => $user->id,
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
'position_icon' => $activePosition?->position?->icon ?? '',
'position_name' => $activePosition?->position?->name ?? '',
];
$userData = $this->chatUserPresenceService->build($user);
$this->chatState->userJoin($id, $user->username, $userData);
// 记录重新加入房间的精确时间戳(微秒),用于防抖判断(刷新的时候避免闪退闪进播报)
\Illuminate\Support\Facades\Redis::set("room:{$id}:join_time:{$user->username}", microtime(true));
@@ -296,6 +285,8 @@ class ChatController extends Controller
'pendingDivorce' => $pendingDivorceData,
'roomPermissionMap' => $roomPermissionMap,
'hasRoomManagementPermission' => in_array(true, $roomPermissionMap, true),
'dailyStatusCatalog' => ChatDailyStatusCatalog::groupedOptions(),
'activeDailyStatus' => $this->chatUserPresenceService->currentDailyStatus($user),
]);
}
@@ -526,18 +517,7 @@ class ChatController extends Controller
// 3. 将新的等级反馈给当前用户的在线名单上
// 确保刚刚升级后别人查看到的也是最准确等级
$activePosition = $user->activePosition;
$this->chatState->userJoin($id, $user->username, [
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
'position_icon' => $activePosition?->position?->icon ?? '',
'position_name' => $activePosition?->position?->name ?? '',
]);
$this->chatState->userJoin($id, $user->username, $this->chatUserPresenceService->build($user));
// 4. 如果突破境界,向全房系统喊话广播!
if ($leveledUp) {
@@ -806,18 +786,10 @@ class ChatController extends Controller
// 将新头像同步到 Redis 在线用户列表中(所有房间)
// 通过更新 Redis 的用户信息,使得其他用户和自己刷新后都能看到新头像
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$rooms = $this->chatState->getUserRooms($user->username);
foreach ($rooms as $roomId) {
$this->chatState->userJoin((int) $roomId, $user->username, [
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $headface,
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
]);
// 头像更新后,统一通过在线载荷服务刷新所有扩展字段,避免状态或职务字段丢失。
$this->chatState->userJoin((int) $roomId, $user->username, $this->chatUserPresenceService->build($user));
}
return response()->json([
@@ -872,18 +844,10 @@ class ChatController extends Controller
}
// 同步 Redis 状态
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$rooms = $this->chatState->getUserRooms($user->username);
foreach ($rooms as $roomId) {
$this->chatState->userJoin((int) $roomId, $user->username, [
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface, // Use accessor
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
]);
// 自定义头像上传成功后,同步覆盖在线名单中的全部展示字段。
$this->chatState->userJoin((int) $roomId, $user->username, $this->chatUserPresenceService->build($user));
}
return response()->json([
+64
View File
@@ -19,20 +19,35 @@ namespace App\Http\Controllers;
use App\Events\UserKicked;
use App\Events\UserMuted;
use App\Events\UserStatusUpdated;
use App\Http\Requests\ChangePasswordRequest;
use App\Http\Requests\UpdateChatPreferencesRequest;
use App\Http\Requests\UpdateDailyStatusRequest;
use App\Http\Requests\UpdateProfileRequest;
use App\Models\Room;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\ChatUserPresenceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redis;
/**
* 类功能:处理用户资料、聊天室偏好、当日状态与基础管理动作。
*/
class UserController extends Controller
{
/**
* 构造用户控制器依赖。
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly ChatUserPresenceService $chatUserPresenceService,
) {}
/**
* 查看其他用户资料片 (对应 USERinfo.ASP)
*/
@@ -230,6 +245,55 @@ class UserController extends Controller
]);
}
/**
* 保存聊天室当日状态,并同步当前在线名单显示。
*/
public function updateDailyStatus(UpdateDailyStatusRequest $request): JsonResponse
{
$user = Auth::user();
$data = $request->validated();
$roomId = (int) $data['room_id'];
// 仅允许当前确实在线的用户从聊天室内修改状态,避免离线脏请求写入。
if (! $this->chatState->isUserInRoom($roomId, $user->username)) {
return response()->json([
'status' => 'error',
'message' => '请先进入聊天室后再设置状态。',
], 422);
}
if ($data['action'] === 'clear') {
$user->update([
'daily_status_key' => null,
'daily_status_expires_at' => null,
]);
} else {
// 状态有效期固定维持到当天结束,次日自动失效。
$user->update([
'daily_status_key' => $data['status_key'],
'daily_status_expires_at' => now()->endOfDay(),
]);
}
$user->refresh();
$presencePayload = $this->chatUserPresenceService->build($user);
$roomIds = $this->chatState->getUserRooms($user->username);
foreach ($roomIds as $activeRoomId) {
// 所有当前在线房间都刷新 Redis 载荷,确保头像、会员与状态显示口径一致。
$this->chatState->userJoin((int) $activeRoomId, $user->username, $presencePayload);
broadcast(new UserStatusUpdated((int) $activeRoomId, $user->username, $presencePayload));
}
return response()->json([
'status' => 'success',
'message' => $data['action'] === 'clear' ? '状态已清除。' : '状态已更新。',
'data' => [
'status' => $this->chatUserPresenceService->currentDailyStatus($user),
],
]);
}
/**
* 修改密码 (对应 chpasswd.asp)
*/
@@ -0,0 +1,60 @@
<?php
/**
* 文件功能:聊天室用户状态设置验证器
* 负责校验用户在聊天室内提交的当日状态设置与清除请求。
*/
namespace App\Http\Requests;
use App\Support\ChatDailyStatusCatalog;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateDailyStatusRequest extends FormRequest
{
/**
* 允许已登录用户保存自己的当日状态。
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* 获取聊天室当日状态设置的验证规则。
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'room_id' => ['required', 'integer', 'exists:rooms,id'],
'action' => ['required', 'string', Rule::in(['set', 'clear'])],
'status_key' => [
Rule::requiredIf(fn (): bool => $this->input('action') === 'set'),
'nullable',
'string',
Rule::in(ChatDailyStatusCatalog::keys()),
],
];
}
/**
* 获取聊天室当日状态设置的中文错误提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'room_id.required' => '缺少当前房间信息。',
'room_id.integer' => '房间编号格式无效。',
'room_id.exists' => '当前房间不存在。',
'action.required' => '缺少状态操作类型。',
'action.in' => '不支持的状态操作类型。',
'status_key.required' => '请选择要设置的状态。',
'status_key.in' => '请选择系统支持的状态。',
];
}
}
+3
View File
@@ -54,6 +54,8 @@ class User extends Authenticatable
'custom_join_effect',
'custom_leave_effect',
'chat_preferences',
'daily_status_key',
'daily_status_expires_at',
'user_level',
'inviter_id',
'room_id',
@@ -107,6 +109,7 @@ class User extends Authenticatable
'q3_time' => 'datetime',
'has_received_new_gift' => 'boolean',
'chat_preferences' => 'array',
'daily_status_expires_at' => 'datetime',
];
}
+69
View File
@@ -0,0 +1,69 @@
<?php
/**
* 文件功能:聊天室在线用户展示数据服务
* 负责统一拼装聊天室在线名单、Presence 频道与 Redis 在线状态使用的用户载荷。
*/
namespace App\Services;
use App\Models\Sysparam;
use App\Models\User;
use App\Support\ChatDailyStatusCatalog;
class ChatUserPresenceService
{
/**
* 构建聊天室在线用户载荷。
*
* @return array<string, mixed>
*/
public function build(User $user): array
{
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$activePosition = $user->activePosition;
$position = $activePosition?->position;
$payload = [
'id' => $user->id,
'user_id' => $user->id,
'username' => $user->username,
'level' => $user->user_level,
'user_level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
'headface_url' => $user->headfaceUrl,
'headfaceUrl' => $user->headfaceUrl,
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
'position_icon' => $position?->icon ?? '',
'position_name' => $position?->name ?? '',
'department_name' => $position?->department?->name ?? '',
];
$activeStatus = $this->currentDailyStatus($user);
if ($activeStatus !== null) {
$payload['daily_status_key'] = $activeStatus['key'];
$payload['daily_status_label'] = $activeStatus['label'];
$payload['daily_status_icon'] = $activeStatus['icon'];
$payload['daily_status_group'] = $activeStatus['group'];
$payload['daily_status_expires_at'] = $activeStatus['expires_at'];
}
return $payload;
}
/**
* 读取用户当前仍然有效的当日状态。
*
* @return array{key: string, label: string, icon: string, group: string, expires_at: string}|null
*/
public function currentDailyStatus(User $user): ?array
{
return ChatDailyStatusCatalog::resolveActiveStatus(
$user->daily_status_key,
$user->daily_status_expires_at,
);
}
}
+180
View File
@@ -0,0 +1,180 @@
<?php
/**
* 文件功能:聊天室当日状态目录
* 负责集中维护聊天室状态分组、文案、图标与激活状态解析逻辑。
*/
namespace App\Support;
use Carbon\Carbon;
use Carbon\CarbonInterface;
class ChatDailyStatusCatalog
{
/**
* 固定状态目录。
*
* @return array<int, array{group: string, items: array<int, array{key: string, label: string, icon: string}>}>
*/
public static function groupedOptions(): array
{
return [
[
'group' => '心情想法',
'items' => [
['key' => 'feeling_great', 'label' => '美滋滋', 'icon' => '🙂'],
['key' => 'heartbroken', 'label' => '裂开', 'icon' => '💔'],
['key' => 'lucky_fish', 'label' => '求锦鲤', 'icon' => '🐟'],
['key' => 'waiting_sun', 'label' => '等天晴', 'icon' => '☀️'],
['key' => 'tired', 'label' => '疲惫', 'icon' => '😮‍💨'],
['key' => 'dazed', 'label' => '发呆', 'icon' => '💭'],
['key' => 'charging', 'label' => '冲', 'icon' => '🚀'],
['key' => 'emo', 'label' => 'emo', 'icon' => '🥀'],
['key' => 'overthinking', 'label' => '胡思乱想', 'icon' => '☁️'],
['key' => 'energetic', 'label' => '元气满满', 'icon' => '✨'],
['key' => 'bot', 'label' => 'bot', 'icon' => '🤖'],
],
],
[
'group' => '工作学习',
'items' => [
['key' => 'working_hard', 'label' => '搬砖', 'icon' => '🧱'],
['key' => 'studying', 'label' => '沉迷学习', 'icon' => '📚'],
['key' => 'busy', 'label' => '忙', 'icon' => '🙆'],
['key' => 'slacking', 'label' => '摸鱼', 'icon' => '🐟'],
['key' => 'business_trip', 'label' => '出差', 'icon' => '✈️'],
['key' => 'running_home', 'label' => '飞奔回家', 'icon' => '🏃'],
['key' => 'do_not_disturb', 'label' => '勿扰模式', 'icon' => '🌙'],
],
],
[
'group' => '活动',
'items' => [
['key' => 'wandering', 'label' => '浪', 'icon' => '🌊'],
['key' => 'clocking_in', 'label' => '打卡', 'icon' => '✌️'],
['key' => 'exercising', 'label' => '运动', 'icon' => '🏃‍♂️'],
['key' => 'coffee', 'label' => '喝咖啡', 'icon' => '☕'],
['key' => 'milk_tea', 'label' => '喝奶茶', 'icon' => '🧋'],
['key' => 'eating', 'label' => '干饭', 'icon' => '🍚'],
['key' => 'parenting', 'label' => '带娃', 'icon' => '🧒'],
['key' => 'save_world', 'label' => '拯救世界', 'icon' => '🦸'],
['key' => 'selfie', 'label' => '自拍', 'icon' => '🤳'],
],
],
[
'group' => '休息',
'items' => [
['key' => 'retreat', 'label' => '闭关', 'icon' => '🧘'],
['key' => 'staying_home', 'label' => '宅', 'icon' => '🛋️'],
['key' => 'sleeping', 'label' => '睡觉', 'icon' => '💤'],
['key' => 'cat_time', 'label' => '吸猫', 'icon' => '🐈'],
['key' => 'walk_dog', 'label' => '遛狗', 'icon' => '🐕'],
['key' => 'gaming', 'label' => '玩游戏', 'icon' => '🎮'],
['key' => 'listening_music', 'label' => '听歌', 'icon' => '🎧'],
],
],
];
}
/**
* 返回全部合法状态键。
*
* @return array<int, string>
*/
public static function keys(): array
{
return array_values(array_map(
static fn (array $item): string => $item['key'],
self::flatOptions()
));
}
/**
* 根据状态键查找对应配置。
*
* @return array{key: string, label: string, icon: string, group: string}|null
*/
public static function find(?string $key): ?array
{
if ($key === null || $key === '') {
return null;
}
foreach (self::flatOptions() as $item) {
if ($item['key'] === $key) {
return $item;
}
}
return null;
}
/**
* 解析当前仍处于有效期内的状态数据。
*
* @param CarbonInterface|string|null $expiresAt 到期时间
* @return array{key: string, label: string, icon: string, group: string, expires_at: string}|null
*/
public static function resolveActiveStatus(?string $key, CarbonInterface|string|null $expiresAt): ?array
{
$option = self::find($key);
if ($option === null) {
return null;
}
$expiresAtCarbon = self::normalizeExpiresAt($expiresAt);
if ($expiresAtCarbon === null || $expiresAtCarbon->isPast()) {
return null;
}
return [
'key' => $option['key'],
'label' => $option['label'],
'icon' => $option['icon'],
'group' => $option['group'],
'expires_at' => $expiresAtCarbon->toIso8601String(),
];
}
/**
* 将分组目录整理为扁平列表,便于校验与查找。
*
* @return array<int, array{key: string, label: string, icon: string, group: string}>
*/
private static function flatOptions(): array
{
$flat = [];
foreach (self::groupedOptions() as $group) {
foreach ($group['items'] as $item) {
$flat[] = [
'key' => $item['key'],
'label' => $item['label'],
'icon' => $item['icon'],
'group' => $group['group'],
];
}
}
return $flat;
}
/**
* 规整状态到期时间为 Carbon 对象。
*
* @param CarbonInterface|string|null $expiresAt 原始到期时间
*/
private static function normalizeExpiresAt(CarbonInterface|string|null $expiresAt): ?CarbonInterface
{
if ($expiresAt instanceof CarbonInterface) {
return $expiresAt;
}
if ($expiresAt === null || $expiresAt === '') {
return null;
}
return Carbon::parse($expiresAt);
}
}
@@ -0,0 +1,43 @@
<?php
/**
* 文件功能:为 users 表新增聊天室当日状态字段
* 支持记录用户当天选择的状态键与到期时间。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* users 表补充聊天室当日状态字段。
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('daily_status_key', 50)
->nullable()
->after('chat_preferences')
->comment('聊天室当日状态键');
$table->dateTime('daily_status_expires_at')
->nullable()
->after('daily_status_key')
->comment('聊天室当日状态到期时间');
});
}
/**
* 回滚 users 表中的聊天室当日状态字段。
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'daily_status_key',
'daily_status_expires_at',
]);
});
}
};
+7
View File
@@ -243,6 +243,13 @@ export function initChat(roomId) {
new CustomEvent("chat:screen-cleared", { detail: e }),
);
})
// 监听用户状态变更,实时刷新右侧在线名单。
.listen("UserStatusUpdated", (e) => {
console.log("用户状态更新:", e);
window.dispatchEvent(
new CustomEvent("chat:user-status-updated", { detail: e }),
);
})
// 监听站长触发的全员刷新
.listen("BrowserRefreshRequested", (e) => {
console.log("全员刷新:", e);
+5 -14
View File
@@ -57,20 +57,8 @@
if ($chatbotEnabledState) {
$botUser = \App\Models\User::where('username', 'AI小班长')->first();
if ($botUser) {
$botUserData = [
'user_id' => $botUser->id,
'username' => $botUser->username,
'level' => $botUser->user_level,
'sex' => $botUser->sex,
'headface' => $botUser->headface,
'headfaceUrl' => $botUser->headfaceUrl,
'vip_icon' => $botUser->vipIcon(),
'vip_name' => $botUser->vipName(),
'vip_color' => $botUser->isVip() ? ($botUser->vipLevel?->color ?? '') : '',
'is_admin' => false,
'position_icon' => '',
'position_name' => '',
];
$botUserData = app(\App\Services\ChatUserPresenceService::class)->build($botUser);
$botUserData['headfaceUrl'] = $botUser->headfaceUrl;
}
}
@endphp
@@ -106,9 +94,12 @@
rewardQuotaUrl: "{{ route('command.reward_quota') }}",
refreshAllUrl: "{{ route('command.refresh_all') }}",
chatPreferencesUrl: "{{ route('user.update_chat_preferences') }}",
dailyStatusUpdateUrl: "{{ route('user.update_daily_status') }}",
userJjb: {{ (int) $user->jjb }}, // 当前用户金币(求婚前金额预检查用)
myGold: {{ (int) $user->jjb }}, // 赠金币面板显示余额用(赠送成功后前端更新)
chatPreferences: @json($user->chat_preferences ?? []),
currentDailyStatus: @json($activeDailyStatus),
dailyStatusCatalog: @json($dailyStatusCatalog),
// ─── 婚姻系统 ──────────────────────────────
minWeddingCost: {{ (int) \App\Models\WeddingTier::where('is_active', true)->orderBy('amount')->value('amount') ?? 0 }},
@@ -15,6 +15,7 @@
$canSendRedPacket = $roomPermissionMap[\App\Support\PositionPermissionRegistry::ROOM_RED_PACKET] ?? false;
$canManageLossCover = $roomPermissionMap[\App\Support\PositionPermissionRegistry::ROOM_BACCARAT_LOSS_COVER] ?? false;
$canTriggerFullscreenEffect = $roomPermissionMap[\App\Support\PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT] ?? false;
$currentDailyStatusKey = $activeDailyStatus['key'] ?? '';
@endphp
<div class="input-bar">
@@ -165,6 +166,94 @@ $welcomeMessages = [
</div>
</div>
<div style="position:relative;display:inline-block;" id="feature-btn-wrap">
<button type="button" onclick="toggleFeatureMenu(event)"
style="font-size:11px;padding:1px 6px;background:linear-gradient(135deg,#4f46e5,#6366f1);color:#fff;border:none;border-radius:2px;cursor:pointer;font-weight:bold;">
⚙️ 功能
</button>
<div id="feature-menu"
onclick="event.stopPropagation()"
style="display:none;position:absolute;bottom:calc(100% + 6px);left:0;z-index:10020;min-width:236px;padding:10px;background:#eef2ff;border:1px solid #c7d2fe;border-radius:10px;box-shadow:0 10px 24px rgba(15,23,42,.18);">
<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;">
<button type="button" onclick="handleFeatureLocalClear()"
style="font-size:11px;padding:6px 8px;background:#fff;color:#475569;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🧹 清屏</button>
<button type="button" onclick="openDailyStatusEditor()"
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-label">{{ $activeDailyStatus['label'] ?? '状态' }}</span>
</button>
</div>
<div style="font-size:10px;color:#4338ca;padding:10px 2px 8px;">快捷入口</div>
<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;">
<button type="button" onclick="runFeatureShortcut('shop')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🛍 商店</button>
<button type="button" onclick="runFeatureShortcut('vip')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">👑 会员</button>
<button type="button" onclick="runFeatureShortcut('game')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🎮 娱乐</button>
<button type="button" onclick="runFeatureShortcut('avatar')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🖼 头像</button>
<button type="button" onclick="runFeatureShortcut('bank')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">🏦 银行</button>
<button type="button" onclick="runFeatureShortcut('marriage')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">💍 婚姻</button>
<button type="button" onclick="runFeatureShortcut('friend')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">👥 好友</button>
<button type="button" onclick="runFeatureShortcut('settings')"
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">⚙️ 设置</button>
</div>
</div>
</div>
<div id="daily-status-editor-overlay"
onclick="closeDailyStatusEditor()"
style="display:none;position:fixed;inset:0;z-index:10030;background:rgba(15,23,42,.45);backdrop-filter:blur(2px);">
<div id="daily-status-editor"
onclick="event.stopPropagation()"
style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:min(92vw,620px);max-height:min(78vh,680px);overflow-y:auto;padding:14px;background:linear-gradient(180deg,#eef2ff 0%,#f8fafc 100%);border:1px solid #c7d2fe;border-radius:14px;box-shadow:0 18px 40px rgba(15,23,42,.28);">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:4px 4px 12px;">
<div>
<div style="font-size:22px;font-weight:bold;color:#312e81;line-height:1.2;">设个状态</div>
<div style="font-size:12px;color:#6366f1;margin-top:4px;">朋友当天内可见,次日会自动失效</div>
</div>
<button type="button" onclick="closeDailyStatusEditor()"
style="width:28px;height:28px;border:none;border-radius:999px;background:#e0e7ff;color:#4338ca;font-size:18px;cursor:pointer;line-height:1;">×</button>
</div>
<div
style="display:flex;flex-wrap:wrap;gap:8px;padding:0 4px 12px;margin-bottom:12px;border-bottom:1px solid #dbeafe;">
<button type="button" onclick="clearDailyStatus()"
style="font-size:12px;padding:7px 12px;background:#fff7ed;color:#c2410c;border:1px solid #fdba74;border-radius:999px;cursor:pointer;">
♻️ 清除状态
</button>
</div>
@foreach ($dailyStatusCatalog as $group)
<div style="padding:0 4px 14px;">
<div style="font-size:15px;font-weight:bold;color:#475569;margin-bottom:10px;">
{{ $group['group'] }}
</div>
<div style="display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px;">
@foreach ($group['items'] as $item)
<button type="button"
class="daily-status-item"
data-status-key="{{ $item['key'] }}"
data-status-label="{{ $item['label'] }}"
data-status-icon="{{ $item['icon'] }}"
data-status-active="{{ $currentDailyStatusKey === $item['key'] ? '1' : '0' }}"
onclick="updateDailyStatus('{{ $item['key'] }}')"
style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;min-height:82px;padding:10px 6px;background:#ffffffcc;border:1px solid #e5e7eb;border-radius:12px;cursor:pointer;transition:all .15s ease;color:#334155;">
<span style="font-size:24px;line-height:1;">{{ $item['icon'] }}</span>
<span style="font-size:12px;line-height:1.25;text-align:center;">{{ $item['label'] }}</span>
</button>
@endforeach
</div>
</div>
@endforeach
</div>
</div>
@if (! empty($hasRoomManagementPermission))
<div style="position:relative;display:inline-block;" id="admin-btn-wrap">
<button type="button" onclick="toggleAdminMenu(event)"
@@ -235,10 +324,6 @@ $welcomeMessages = [
</div>
</div>
@endif
<button type="button" onclick="localClearScreen()"
style="font-size: 11px; padding: 1px 6px; background: #64748b; color: #fff; border: none; border-radius: 2px; cursor: pointer;">🔄
清屏</button>
</div>
{{-- 第二行:输入框 + 发送 --}}
@@ -24,7 +24,7 @@
<div class="tool-btn" onclick="openMarriageStatusModal()" title="婚姻状态">婚姻</div>
<div class="tool-btn" onclick="openFriendPanel()" title="好友列表">好友</div>
<div class="tool-btn" onclick="openAvatarPicker()" title="修改头像">头像</div>
<div class="tool-btn" onclick="document.getElementById('settings-modal').style.display='flex'" title="个人设置">设置
<div class="tool-btn" onclick="openSettingsModal()" title="个人设置">设置
</div>
<div class="tool-btn" onclick="window.open('{{ route('feedback.index') }}', '_blank')" title="反馈">反馈</div>
<div class="tool-btn" onclick="window.open('{{ route('guestbook.index') }}', '_blank')" title="留言板/私信">留言</div>
@@ -89,9 +89,10 @@
{{-- ═══════════ 个人设置弹窗 ═══════════ --}}
<div id="settings-modal"
onclick="closeSettingsModal()"
style="display:none; position:fixed; top:0; left:0; right:0; bottom:0;
background:rgba(0,0,0,0.5); z-index:10000; justify-content:center; align-items:center;">
<div
<div onclick="event.stopPropagation()"
style="background:#fff; border-radius:8px; width:380px; max-height:90vh;
box-shadow:0 8px 32px rgba(0,0,0,0.3); display:flex; flex-direction:column;">
{{-- --}}
@@ -99,7 +100,7 @@
style="background:linear-gradient(135deg,#336699,#5a8fc0); color:#fff;
padding:12px 16px; border-radius:8px 8px 0 0; display:flex; justify-content:space-between; align-items:center; flex-shrink:0;">
<span style="font-size:14px; font-weight:bold;">⚙️ 个人设置</span>
<span onclick="document.getElementById('settings-modal').style.display='none'"
<span onclick="closeSettingsModal()"
style="cursor:pointer; font-size:18px; opacity:0.8;">&times;</span>
</div>
@@ -261,6 +262,20 @@
document.getElementById('avatar-picker-modal').style.display = 'none';
}
/**
* 打开个人设置弹窗。
*/
function openSettingsModal() {
document.getElementById('settings-modal').style.display = 'flex';
}
/**
* 关闭个人设置弹窗。
*/
function closeSettingsModal() {
document.getElementById('settings-modal').style.display = 'none';
}
/**
* 加载头像列表(懒加载,首次打开时请求)
*/
+580 -24
View File
@@ -72,6 +72,7 @@
})();
let onlineUsers = {};
let autoScroll = true;
let userBadgeRotationTick = 0;
let _maxMsgId = 0; // 记录当前收到的最大消息 ID
const initialChatPreferences = normalizeChatPreferences(window.chatContext?.chatPreferences || {});
let blockedSystemSenders = new Set(initialChatPreferences.blocked_system_senders);
@@ -94,6 +95,394 @@
};
}
/**
* 解析并标准化状态到期时间。
*
* @param {string|null|undefined} expiresAt 原始到期时间
* @returns {Date|null}
*/
function parseDailyStatusExpiry(expiresAt) {
if (!expiresAt) {
return null;
}
const parsed = new Date(expiresAt);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
/**
* 将状态对象规整为前端统一结构,并过滤掉已过期状态。
*
* @param {Record<string, any>|null|undefined} raw 原始状态对象
* @returns {Object|null}
*/
function normalizeDailyStatus(raw) {
if (!raw || typeof raw !== 'object') {
return null;
}
const key = String(raw.key ?? raw.daily_status_key ?? '');
const label = String(raw.label ?? raw.daily_status_label ?? '');
const icon = String(raw.icon ?? raw.daily_status_icon ?? '');
const group = String(raw.group ?? raw.daily_status_group ?? '');
const expiresAt = raw.expires_at ?? raw.daily_status_expires_at ?? null;
const parsedExpiry = parseDailyStatusExpiry(expiresAt);
if (!key || !label || !icon || !parsedExpiry) {
return null;
}
if (parsedExpiry.getTime() <= Date.now()) {
return null;
}
return {
key,
label,
icon,
group,
expires_at: parsedExpiry.toISOString(),
};
}
/**
* 获取当前登录用户仍然有效的状态。
*
* @returns {Object|null}
*/
function getCurrentUserDailyStatus() {
return normalizeDailyStatus(window.chatContext?.currentDailyStatus);
}
/**
* 清除用户在线载荷中的状态字段,避免合并时残留旧状态。
*
* @param {Record<string, any>} payload 用户在线载荷
*/
function removeDailyStatusFields(payload) {
if (!payload || typeof payload !== 'object') {
return;
}
delete payload.daily_status_key;
delete payload.daily_status_label;
delete payload.daily_status_icon;
delete payload.daily_status_group;
delete payload.daily_status_expires_at;
}
/**
* 将状态写回指定用户的在线载荷。
*
* @param {string} username 用户名
* @param {Object|null} status 标准化后的状态对象
*/
function setOnlineUserDailyStatus(username, status) {
if (!username || !onlineUsers[username]) {
return;
}
removeDailyStatusFields(onlineUsers[username]);
if (!status) {
return;
}
onlineUsers[username].daily_status_key = status.key;
onlineUsers[username].daily_status_label = status.label;
onlineUsers[username].daily_status_icon = status.icon;
onlineUsers[username].daily_status_group = status.group;
onlineUsers[username].daily_status_expires_at = status.expires_at;
}
/**
* 用服务端最新的在线载荷刷新指定用户,并先清空旧状态字段。
*
* @param {string} username 用户名
* @param {Record<string, any>} payload 最新在线载荷
*/
function hydrateOnlineUserPayload(username, payload) {
const nextPayload = {
...(onlineUsers[username] || {}),
};
removeDailyStatusFields(nextPayload);
onlineUsers[username] = {
...nextPayload,
...payload,
};
}
/**
* 同步状态按钮文字与图标。
*/
function syncDailyStatusTrigger() {
const shortcutIcon = document.getElementById('daily-status-shortcut-icon');
const shortcutLabel = document.getElementById('daily-status-shortcut-label');
const activeStatus = getCurrentUserDailyStatus();
if (shortcutIcon) {
shortcutIcon.textContent = activeStatus?.icon || '🙂';
}
if (shortcutLabel) {
shortcutLabel.textContent = activeStatus?.label || '状态';
}
}
/**
* 同步状态面板中当前选中项的高亮样式。
*/
function syncDailyStatusMenuSelection() {
const activeKey = getCurrentUserDailyStatus()?.key || '';
document.querySelectorAll('#daily-status-editor-overlay .daily-status-item').forEach((button) => {
const selected = button.dataset.statusKey === activeKey;
button.style.borderColor = selected ? '#6366f1' : '#e5e7eb';
button.style.background = selected ? 'linear-gradient(180deg,#eef2ff 0%,#e0e7ff 100%)' : '#ffffffcc';
button.style.color = selected ? '#312e81' : '#334155';
button.style.boxShadow = selected ? '0 8px 18px rgba(99,102,241,.18)' : 'none';
button.style.transform = selected ? 'translateY(-1px)' : 'translateY(0)';
});
}
/**
* 同步聊天室状态相关 UI。
*/
function syncDailyStatusUi() {
const activeStatus = getCurrentUserDailyStatus();
if (window.chatContext) {
window.chatContext.currentDailyStatus = activeStatus;
}
syncDailyStatusTrigger();
syncDailyStatusMenuSelection();
}
/**
* 关闭功能菜单。
*/
function closeFeatureMenu() {
const menu = document.getElementById('feature-menu');
if (menu) {
menu.style.display = 'none';
}
}
/**
* 切换功能菜单显示状态。
*
* @param {Event} event 点击事件
*/
function toggleFeatureMenu(event) {
event.stopPropagation();
const menu = document.getElementById('feature-menu');
const welcomeMenu = document.getElementById('welcome-menu');
const adminMenu = document.getElementById('admin-menu');
const blockMenu = document.getElementById('block-menu');
const editorOverlay = document.getElementById('daily-status-editor-overlay');
if (!menu) {
return;
}
if (welcomeMenu) {
welcomeMenu.style.display = 'none';
}
if (adminMenu) {
adminMenu.style.display = 'none';
}
if (blockMenu) {
blockMenu.style.display = 'none';
}
if (editorOverlay) {
editorOverlay.style.display = 'none';
}
syncDailyStatusUi();
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
}
/**
* 打开状态编辑窗口。
*/
function openDailyStatusEditor() {
const overlay = document.getElementById('daily-status-editor-overlay');
closeFeatureMenu();
syncDailyStatusUi();
if (overlay) {
overlay.style.display = 'block';
}
}
/**
* 关闭状态编辑窗口。
*/
function closeDailyStatusEditor() {
const overlay = document.getElementById('daily-status-editor-overlay');
if (overlay) {
overlay.style.display = 'none';
}
}
/**
* 执行功能菜单中的快捷入口,并在打开目标面板前先关闭功能菜单。
*
* @param {string} action 快捷入口动作名
*/
function runFeatureShortcut(action) {
closeFeatureMenu();
if (action === 'shop' && typeof window.openShopModal === 'function') {
window.openShopModal();
return;
}
if (action === 'vip' && typeof window.openVipModal === 'function') {
window.openVipModal();
return;
}
if (action === 'game' && typeof window.openGameHall === 'function') {
window.openGameHall();
return;
}
if (action === 'avatar' && typeof window.openAvatarPicker === 'function') {
window.openAvatarPicker();
return;
}
if (action === 'bank' && typeof window.openBankModal === 'function') {
window.openBankModal();
return;
}
if (action === 'marriage' && typeof window.openMarriageStatusModal === 'function') {
window.openMarriageStatusModal();
return;
}
if (action === 'friend' && typeof window.openFriendPanel === 'function') {
window.openFriendPanel();
return;
}
if (action === 'settings' && typeof window.openSettingsModal === 'function') {
window.openSettingsModal();
}
}
/**
* 提交状态设置/清除请求。
*
* @param {Object} payload 请求载荷
* @returns {Promise<Object>}
*/
async function submitDailyStatusPayload(payload) {
const response = await fetch(window.chatContext.dailyStatusUpdateUrl, {
method: 'PUT',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(payload),
});
const result = await response.json();
if (!response.ok || result?.status !== 'success') {
throw new Error(result?.message || '状态保存失败');
}
return result;
}
/**
* 将服务端返回的状态结果应用到当前用户本地名单。
*
* @param {Object|null} status 标准化后的状态对象
*/
function applyCurrentUserDailyStatus(status) {
if (window.chatContext) {
window.chatContext.currentDailyStatus = status;
}
setOnlineUserDailyStatus(window.chatContext.username, status);
syncDailyStatusUi();
renderUserList();
}
/**
* 设置新的当日状态。
*
* @param {string} statusKey 状态键
*/
async function updateDailyStatus(statusKey) {
if (!window.chatContext?.dailyStatusUpdateUrl) {
return;
}
try {
const result = await submitDailyStatusPayload({
room_id: window.chatContext.roomId,
action: 'set',
status_key: statusKey,
});
const status = normalizeDailyStatus(result?.data?.status);
applyCurrentUserDailyStatus(status);
closeDailyStatusEditor();
window.chatToast?.show({
title: '状态已更新',
message: status ? `${status.icon} ${status.label}` : '已更新',
icon: status?.icon || '🙂',
color: '#4f46e5',
duration: 2600,
});
} catch (error) {
window.chatDialog?.alert(error.message || '状态设置失败', '操作失败', '#cc4444');
}
}
/**
* 清除当前当日状态。
*/
async function clearDailyStatus() {
if (!window.chatContext?.dailyStatusUpdateUrl) {
return;
}
try {
await submitDailyStatusPayload({
room_id: window.chatContext.roomId,
action: 'clear',
});
applyCurrentUserDailyStatus(null);
closeDailyStatusEditor();
window.chatToast?.show({
title: '状态已清除',
message: '名字后方将恢复默认徽标展示。',
icon: '♻️',
color: '#c2410c',
duration: 2400,
});
} catch (error) {
window.chatDialog?.alert(error.message || '状态清除失败', '操作失败', '#cc4444');
}
}
/**
* localStorage 读取已屏蔽的系统播报发送者列表。
*
@@ -308,6 +697,8 @@
const menu = document.getElementById('block-menu');
const welcomeMenu = document.getElementById('welcome-menu');
const adminMenu = document.getElementById('admin-menu');
const featureMenu = document.getElementById('feature-menu');
const dailyStatusEditor = document.getElementById('daily-status-editor-overlay');
if (!menu) {
return;
@@ -321,6 +712,14 @@
adminMenu.style.display = 'none';
}
if (featureMenu) {
featureMenu.style.display = 'none';
}
if (dailyStatusEditor) {
dailyStatusEditor.style.display = 'none';
}
syncBlockedSystemSenderCheckboxes();
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
}
@@ -615,6 +1014,8 @@
const menu = document.getElementById('welcome-menu');
const adminMenu = document.getElementById('admin-menu');
const blockMenu = document.getElementById('block-menu');
const featureMenu = document.getElementById('feature-menu');
const dailyStatusEditor = document.getElementById('daily-status-editor-overlay');
if (!menu) {
return;
}
@@ -624,6 +1025,12 @@
if (blockMenu) {
blockMenu.style.display = 'none';
}
if (featureMenu) {
featureMenu.style.display = 'none';
}
if (dailyStatusEditor) {
dailyStatusEditor.style.display = 'none';
}
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
}
@@ -635,6 +1042,8 @@
const menu = document.getElementById('admin-menu');
const welcomeMenu = document.getElementById('welcome-menu');
const blockMenu = document.getElementById('block-menu');
const featureMenu = document.getElementById('feature-menu');
const dailyStatusEditor = document.getElementById('daily-status-editor-overlay');
if (!menu) {
return;
}
@@ -644,6 +1053,12 @@
if (blockMenu) {
blockMenu.style.display = 'none';
}
if (featureMenu) {
featureMenu.style.display = 'none';
}
if (dailyStatusEditor) {
dailyStatusEditor.style.display = 'none';
}
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
}
@@ -803,6 +1218,16 @@
if (blockMenu) {
blockMenu.style.display = 'none';
}
const featureMenu = document.getElementById('feature-menu');
if (featureMenu) {
featureMenu.style.display = 'none';
}
const dailyStatusEditor = document.getElementById('daily-status-editor-overlay');
if (dailyStatusEditor) {
dailyStatusEditor.style.display = 'none';
}
});
// ── 动作选择 ──────────────────────────────────────
@@ -986,29 +1411,14 @@
const headImgSrc = headface.startsWith('storage/') ? '/' + headface : '/images/headface/' +
headface;
// 徽章优先级:职务图标 > 管理员 > VIP
let badges = '';
if (user.position_icon) {
const posTitle = (user.position_name || '在职') + ' · ' + username;
const safePosTitle = escapeHtml(String(posTitle));
const safePositionIcon = escapeHtml(String(user.position_icon || '🎖️'));
badges +=
`<span class="user-badge-icon" style="font-size:13px; margin-left:2px;" data-instant-tooltip="${safePosTitle}">${safePositionIcon}</span>`;
} else if (user.is_admin) {
badges += `<span class="user-badge-icon" style="font-size:12px; margin-left:2px;" data-instant-tooltip="最高统帅">🎖️</span>`;
} else if (user.vip_icon) {
const vipColor = user.vip_color || '#f59e0b';
const safeVipTitle = escapeHtml(String(user.vip_name || 'VIP'));
const safeVipIcon = escapeHtml(String(user.vip_icon || '👑'));
badges +=
`<span class="user-badge-icon" style="font-size:12px; margin-left:2px; color:${vipColor};" data-instant-tooltip="${safeVipTitle}">${safeVipIcon}</span>`;
}
const badges = buildUserBadgeHtml(user, username);
// 女生名字使用玫粉色
const nameColor = (user.sex == 2) ? 'color:#e91e8c;' : '';
item.innerHTML = `
<img class="user-head" src="${headImgSrc}" onerror="this.src='/images/headface/1.gif'">
<span class="user-name" style="${nameColor}">${username}</span>${badges}
<span class="user-name" style="${nameColor}">${username}</span>
<span class="user-badge-slot">${badges}</span>
`;
// 单击/双击互斥:单击延迟 250ms 执行,双击取消单击定时器后直接执行双击逻辑
@@ -1048,6 +1458,8 @@
}, { passive: false });
targetContainer.appendChild(item);
});
refreshRenderedUserBadges(targetContainer);
}
function renderUserList() {
@@ -1089,6 +1501,115 @@
window.dispatchEvent(new Event('chatroom:users-updated'));
}
/**
* 获取用户当前仍然有效的当日状态。
*
* @param {Record<string, any>} user 用户在线载荷
* @returns {Object|null}
*/
function resolveUserDailyStatus(user) {
return normalizeDailyStatus(user);
}
/**
* 构建原有徽标(职务 / 管理员 / VIP)。
*
* @param {Record<string, any>} user 用户在线载荷
* @param {string} username 用户名
* @returns {string}
*/
function buildUserPrimaryBadgeHtml(user, username) {
if (user.position_icon) {
const posTitle = (user.position_name || '在职') + ' · ' + username;
const safePosTitle = escapeHtml(String(posTitle));
const safePositionIcon = escapeHtml(String(user.position_icon || '🎖️'));
return `<span class="user-badge-icon" style="font-size:13px; margin-left:2px;" data-instant-tooltip="${safePosTitle}">${safePositionIcon}</span>`;
}
if (user.is_admin) {
return `<span class="user-badge-icon" style="font-size:12px; margin-left:2px;" data-instant-tooltip="最高统帅">🎖️</span>`;
}
if (user.vip_icon) {
const vipColor = user.vip_color || '#f59e0b';
const safeVipTitle = escapeHtml(String(user.vip_name || 'VIP'));
const safeVipIcon = escapeHtml(String(user.vip_icon || '👑'));
return `<span class="user-badge-icon" style="font-size:12px; margin-left:2px; color:${vipColor};" data-instant-tooltip="${safeVipTitle}">${safeVipIcon}</span>`;
}
return '';
}
/**
* 构建状态徽标。
*
* @param {Record<string, any>} user 用户在线载荷
* @returns {string}
*/
function buildUserStatusBadgeHtml(user) {
const status = resolveUserDailyStatus(user);
if (!status) {
return '';
}
const safeIcon = escapeHtml(status.icon);
const safeLabel = escapeHtml(status.label);
const safeTooltip = escapeHtml(`${status.group} · ${status.label}`);
return `
<span style="display:inline-flex;align-items:center;gap:3px;margin-left:4px;padding:0 6px;height:18px;border-radius:999px;background:#eef2ff;border:1px solid #c7d2fe;color:#4338ca;font-size:11px;line-height:18px;vertical-align:middle;"
data-instant-tooltip="${safeTooltip}">
<span style="font-size:11px;line-height:1;">${safeIcon}</span>
<span style="line-height:1;">${safeLabel}</span>
</span>
`;
}
/**
* 3 秒节奏在原有徽标与状态徽标之间切换。
*
* @param {Record<string, any>} user 用户在线载荷
* @param {string} username 用户名
* @returns {string}
*/
function buildUserBadgeHtml(user, username) {
const statusBadge = buildUserStatusBadgeHtml(user);
const primaryBadge = buildUserPrimaryBadgeHtml(user, username);
if (statusBadge && primaryBadge) {
return userBadgeRotationTick % 2 === 0 ? statusBadge : primaryBadge;
}
return statusBadge || primaryBadge;
}
/**
* 仅刷新当前已渲染用户行的徽标槽位,避免重建头像节点造成闪烁。
*
* @param {HTMLElement|Document} scope 需要刷新的 DOM 范围
*/
function refreshRenderedUserBadges(scope = document) {
scope.querySelectorAll('.user-item[data-username]').forEach((item) => {
const username = item.dataset.username;
const badgeSlot = item.querySelector('.user-badge-slot');
if (!username || !badgeSlot) {
return;
}
badgeSlot.innerHTML = buildUserBadgeHtml(onlineUsers[username] || {}, username);
});
}
// 名单中“状态 / 原徽标”双轨展示时,每 3 秒只刷新徽标槽位,不重建头像行。
window.setInterval(() => {
userBadgeRotationTick = (userBadgeRotationTick + 1) % 2;
refreshRenderedUserBadges();
syncDailyStatusUi();
}, 3000);
/**
* 搜索/过滤用户列表
*/
@@ -1467,17 +1988,17 @@
const users = e.detail;
onlineUsers = {};
users.forEach(u => {
onlineUsers[u.username] = u;
hydrateOnlineUserPayload(u.username, u);
});
// 初始加载时,如果全局且开启,注入 AI
if (window.chatContext.chatBotEnabled && window.chatContext.botUser) {
onlineUsers['AI小班长'] = window.chatContext.botUser;
hydrateOnlineUserPayload('AI小班长', window.chatContext.botUser);
}
setOnlineUserDailyStatus(window.chatContext.username, getCurrentUserDailyStatus());
syncDailyStatusUi();
renderUserList();
});
// 监听机器人动态开关
@@ -1486,7 +2007,7 @@
window.chatContext.chatBotEnabled = detail.isOnline;
if (detail.isOnline && detail.user && detail.user.username) {
onlineUsers[detail.user.username] = detail.user;
hydrateOnlineUserPayload(detail.user.username, detail.user);
window.chatContext.botUser = detail.user;
} else {
delete onlineUsers['AI小班长'];
@@ -1495,9 +2016,27 @@
renderUserList();
});
window.addEventListener('chat:user-status-updated', (e) => {
const username = e.detail?.username;
const payload = e.detail?.user;
if (!username || !payload) {
return;
}
hydrateOnlineUserPayload(username, payload);
if (username === window.chatContext.username) {
window.chatContext.currentDailyStatus = normalizeDailyStatus(payload);
syncDailyStatusUi();
}
renderUserList();
});
window.addEventListener('chat:joining', (e) => {
const user = e.detail;
onlineUsers[user.username] = user;
hydrateOnlineUserPayload(user.username, user);
renderUserList();
});
@@ -1904,10 +2443,19 @@
}
window.toggleAdminMenu = toggleAdminMenu;
window.toggleBlockMenu = toggleBlockMenu;
window.toggleFeatureMenu = toggleFeatureMenu;
window.closeFeatureMenu = closeFeatureMenu;
window.openDailyStatusEditor = openDailyStatusEditor;
window.closeDailyStatusEditor = closeDailyStatusEditor;
window.runFeatureShortcut = runFeatureShortcut;
window.runAdminAction = runAdminAction;
window.selectEffect = selectEffect;
window.triggerEffect = triggerEffect;
window.toggleBlockedSystemSender = toggleBlockedSystemSender;
window.updateDailyStatus = updateDailyStatus;
window.clearDailyStatus = clearDailyStatus;
window.handleFeatureLocalClear = handleFeatureLocalClear;
syncDailyStatusUi();
// ── 字号设置(持久化到 localStorage)─────────────────
/**
@@ -2480,6 +3028,14 @@
}
}
/**
* 在状态面板中触发本地清屏,并顺手关闭面板。
*/
function handleFeatureLocalClear() {
closeFeatureMenu();
localClearScreen();
}
// ── 滚屏开关 ─────────────────────────────────────
function toggleAutoScroll() {
autoScroll = !autoScroll;
+1 -19
View File
@@ -19,25 +19,7 @@ Broadcast::channel('room.{roomId}', function ($user, $roomId) {
return false;
}
$superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
// 预加载当前在职职务,供右侧在线名单直接显示职务图标与名称。
$activePosition = $user->activePosition()->with('position.department')->first();
$position = $activePosition?->position;
return [
'id' => $user->id,
'username' => $user->username,
'user_level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface, // 通过 accessor 读取 usersf,默认 1.gif
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
'position_icon' => $position?->icon ?? '',
'position_name' => $position?->name ?? '',
'department_name' => $position?->department?->name ?? '',
];
return app(\App\Services\ChatUserPresenceService::class)->build($user);
});
// 用户私有频道鉴权(好友通知:FriendAdded / FriendRemoved / BannerNotification
+1
View File
@@ -99,6 +99,7 @@ Route::middleware(['chat.auth'])->group(function () {
Route::get('/user/{username}', [UserController::class, 'show'])->name('user.show');
Route::put('/user/profile', [UserController::class, 'updateProfile'])->name('user.update_profile');
Route::put('/user/chat-preferences', [UserController::class, 'updateChatPreferences'])->name('user.update_chat_preferences');
Route::put('/user/daily-status', [UserController::class, 'updateDailyStatus'])->name('user.update_daily_status');
Route::post('/user/generate-wechat-code', [UserController::class, 'generateWechatCode'])->name('user.generate_wechat_code');
Route::post('/user/unbind-wechat', [UserController::class, 'unbindWechat'])->name('user.unbind_wechat');
Route::post('/user/send-email-code', [\App\Http\Controllers\Api\VerificationController::class, 'sendEmailCode'])->name('user.send_email_code');
+58
View File
@@ -149,6 +149,38 @@ class ChatControllerTest extends TestCase
$this->assertSame($position?->department?->name, $authorizedPayload['department_name'] ?? null);
}
/**
* 测试聊天室 Presence 频道会返回仍在有效期内的当日状态载荷。
*/
public function test_room_presence_channel_returns_active_daily_status_payload(): void
{
$room = Room::create([
'room_name' => 'dsbadge',
'door_open' => true,
]);
$user = User::factory()->create([
'daily_status_key' => 'working_hard',
'daily_status_expires_at' => now()->addHours(3),
]);
$channelCallback = Broadcast::driver()->getChannels()->get('room.{roomId}');
$this->assertIsCallable($channelCallback);
$this->actingAs($user)->get(route('chat.room', $room->id));
$authorizedPayload = $channelCallback($user, (string) $room->id);
$this->assertIsArray($authorizedPayload);
$this->assertSame('working_hard', $authorizedPayload['daily_status_key'] ?? null);
$this->assertSame('搬砖', $authorizedPayload['daily_status_label'] ?? null);
$this->assertSame('🧱', $authorizedPayload['daily_status_icon'] ?? null);
$this->assertSame('工作学习', $authorizedPayload['daily_status_group'] ?? null);
$this->assertSame(
$user->fresh()->daily_status_expires_at?->toIso8601String(),
$authorizedPayload['daily_status_expires_at'] ?? null
);
}
/**
* 测试主干默认聊天室页面不会渲染虚拟形象挂载点和配置。
*/
@@ -186,6 +218,32 @@ class ChatControllerTest extends TestCase
$response->assertSee('toggleBlockedSystemSender');
}
/**
* 测试聊天室输入区会渲染状态入口,并移除旧的直接清屏按钮绑定。
*/
public function test_room_view_renders_daily_status_button_and_hides_direct_local_clear_button(): void
{
$room = Room::create(['room_name' => 'statusmenu']);
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
$response->assertOk();
$response->assertSee('toggleFeatureMenu(event)', false);
$response->assertSee('功能');
$response->assertSee('设个状态', false);
$response->assertSee('runFeatureShortcut(\'shop\')', false);
$response->assertSee('runFeatureShortcut(\'vip\')', false);
$response->assertSee('runFeatureShortcut(\'game\')', false);
$response->assertSee('runFeatureShortcut(\'avatar\')', false);
$response->assertSee('runFeatureShortcut(\'bank\')', false);
$response->assertSee('runFeatureShortcut(\'marriage\')', false);
$response->assertSee('runFeatureShortcut(\'friend\')', false);
$response->assertSee('runFeatureShortcut(\'settings\')', false);
$response->assertSee('本地清屏', false);
$response->assertDontSee('onclick="localClearScreen()"', false);
}
/**
* 测试无聊天室权限的职务用户看不到顶部管理按钮。
*/
+111
View File
@@ -10,6 +10,8 @@ namespace Tests\Feature;
use App\Models\Room;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatUserPresenceService;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
@@ -31,6 +33,9 @@ class UserControllerTest extends TestCase
{
parent::setUp();
// 清空 Redis 在线状态,避免当日状态与房间在线缓存互相污染。
Redis::flushall();
Sysparam::updateOrCreate(['alias' => 'superlevel'], ['body' => '100']);
Sysparam::updateOrCreate(['alias' => 'level_kick'], ['body' => '15']);
Sysparam::updateOrCreate(['alias' => 'level_mute'], ['body' => '15']);
@@ -163,6 +168,95 @@ class UserControllerTest extends TestCase
], $user->chat_preferences);
}
/**
* 测试合法聊天室当日状态可以保存,并在当天结束后自动失效。
*/
public function test_can_update_daily_status_until_end_of_day(): void
{
Carbon::setTestNow('2026-04-24 10:36:00');
try {
$user = User::factory()->create([
'username' => 'status-user',
]);
$room = $this->enterRoomForUser($user);
$response = $this->actingAs($user)->putJson(route('user.update_daily_status'), [
'room_id' => $room->id,
'action' => 'set',
'status_key' => 'working_hard',
]);
$expectedExpiry = now()->endOfDay();
$response->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('data.status.key', 'working_hard')
->assertJsonPath('data.status.label', '搬砖')
->assertJsonPath('data.status.group', '工作学习');
$user->refresh();
$this->assertSame('working_hard', $user->daily_status_key);
$this->assertSame(
$expectedExpiry->toDateTimeString(),
$user->daily_status_expires_at?->toDateTimeString()
);
// 时间推进到次日,验证在线名单服务不再返回已过期状态。
Carbon::setTestNow($expectedExpiry->copy()->addSecond());
$this->assertNull(app(ChatUserPresenceService::class)->currentDailyStatus($user->fresh()));
} finally {
Carbon::setTestNow();
}
}
/**
* 测试非法聊天室当日状态键会返回 422 校验错误。
*/
public function test_invalid_daily_status_key_returns_validation_error(): void
{
$user = User::factory()->create();
$room = Room::create([
'room_name' => 'dsinvalid',
'room_owner' => 'someone',
]);
$response = $this->actingAs($user)->putJson(route('user.update_daily_status'), [
'room_id' => $room->id,
'action' => 'set',
'status_key' => 'not-a-real-status',
]);
$response->assertStatus(422)
->assertJsonValidationErrors('status_key');
}
/**
* 测试清除聊天室当日状态后会把状态字段置空。
*/
public function test_clear_daily_status_resets_status_fields(): void
{
$user = User::factory()->create([
'daily_status_key' => 'working_hard',
'daily_status_expires_at' => now()->endOfDay(),
]);
$room = $this->enterRoomForUser($user);
$response = $this->actingAs($user)->putJson(route('user.update_daily_status'), [
'room_id' => $room->id,
'action' => 'clear',
]);
$response->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('message', '状态已清除。')
->assertJsonPath('data.status', null);
$user->refresh();
$this->assertNull($user->daily_status_key);
$this->assertNull($user->daily_status_expires_at);
}
public function test_can_change_password()
{
$user = User::factory()->create([
@@ -304,4 +398,21 @@ class UserControllerTest extends TestCase
$target->refresh();
$this->assertEquals(-1, $target->user_level);
}
/**
* 让指定用户先进入聊天室,满足“仅在线用户可设置状态”的前置条件。
*/
private function enterRoomForUser(User $user): Room
{
$room = Room::create([
'room_name' => 'dsr'.$user->id,
'room_owner' => 'someone',
]);
$this->actingAs($user)
->get(route('chat.room', $room->id))
->assertOk();
return $room;
}
}