feat: 实现挂机修仙、排行榜、大厅重构与全站留言板系统

- (Phase 8) 后台各维度管理与配置
- (Phase 9) 全自动静默挂机修仙升级
- (Phase 9) 四大维度风云排行榜页面
- (Phase 10) 全站留言板与悄悄话私信功能
- 运行 Pint 代码格式化
This commit is contained in:
2026-02-26 13:35:38 +08:00
parent 7d6423902d
commit 50fc804402
85 changed files with 5776 additions and 30 deletions

View File

@@ -0,0 +1,59 @@
<?php
/**
* 文件功能:用户发送新消息广播事件
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageSent implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*
* @param int $roomId 房间ID
* @param array $message 发送的消息数据
*/
public function __construct(
public readonly int $roomId,
public readonly array $message,
) {}
/**
* Get the channels the event should broadcast on.
*
* 聊天消息广播至包含在线状态管理的 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 [
'message' => $this->message,
];
}
}

View File

@@ -0,0 +1,58 @@
<?php
/**
* 文件功能:房间标题变更广播事件
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class RoomTitleUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*
* @param int $roomId 房间ID
* @param string $title 新房间标题
*/
public function __construct(
public readonly int $roomId,
public readonly string $title,
) {}
/**
* Get the channels the event should broadcast on.
*
* @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 [
'title' => $this->title,
'message' => "系统提示:房间标题已变更为「{$this->title}",
];
}
}

60
app/Events/UserJoined.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
/**
* 文件功能:用户进入房间广播事件
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserJoined implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*
* @param int $roomId 房间ID
* @param string $username 进入的用户昵称
* @param array $userInfo 用户的属性(如性别,等级等)
*/
public function __construct(
public readonly int $roomId,
public readonly string $username,
public readonly array $userInfo,
) {}
/**
* Get the channels the event should broadcast on.
*
* @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,
'info' => $this->userInfo,
];
}
}

58
app/Events/UserKicked.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
/**
* 文件功能:用户被踢出房间广播事件
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserKicked implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*
* @param int $roomId 房间ID
* @param string $username 被踢出的用户昵称
*/
public function __construct(
public readonly int $roomId,
public readonly string $username,
) {}
/**
* Get the channels the event should broadcast on.
*
* @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,
'message' => "用户 [{$this->username}] 已被踢出聊天室。",
];
}
}

57
app/Events/UserLeft.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
/**
* 文件功能:用户离开房间广播事件
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserLeft implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*
* @param int $roomId 房间ID
* @param string $username 离开的用户昵称
*/
public function __construct(
public readonly int $roomId,
public readonly string $username,
) {}
/**
* Get the channels the event should broadcast on.
*
* @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,
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserLevelUp
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct()
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('channel-name'),
];
}
}

65
app/Events/UserMuted.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
/**
* 文件功能:用户被封口/解封广播事件
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserMuted implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*
* @param int $roomId 房间ID
* @param string $username 被封口的用户昵称
* @param int $muteTime 封口时长(如 10 分钟),如果为 0 则是解封
*/
public function __construct(
public readonly int $roomId,
public readonly string $username,
public readonly int $muteTime,
) {}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PresenceChannel('room.'.$this->roomId),
];
}
/**
* 获取广播时的数据
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
$statusMessage = $this->muteTime > 0
? "用户 [{$this->username}] 已被系统封口 {$this->muteTime} 次发言时间。"
: "用户 [{$this->username}] 已被解除封口。";
return [
'username' => $this->username,
'mute_time' => $this->muteTime,
'message' => $statusMessage,
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
/**
* 文件功能:后台首页控制台
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Room;
use App\Models\User;
use Illuminate\View\View;
class DashboardController extends Controller
{
/**
* 显示后台首页与全局统计
*/
public function index(): View
{
$stats = [
'total_users' => User::count(),
'total_rooms' => Room::count(),
// 更多统计指标以后再发掘
];
return view('admin.dashboard', compact('stats'));
}
}

View File

@@ -0,0 +1,76 @@
<?php
/**
* 文件功能:后台 SQL 探针
* (替代原版 SQL.ASP严格限制为只读模式)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class SqlController extends Controller
{
/**
* 显示 SQL 执行沙盒界面
*/
public function index(): View
{
return view('admin.sql.index', ['results' => null, 'query' => '', 'columns' => []]);
}
/**
* 极度受限地执行 SQL (仅限 SELECT)
*/
public function execute(Request $request): View
{
$request->validate([
'query' => 'required|string|min:6',
]);
$sql = trim($request->input('query'));
// 安全拦截:绝不允许含有 update/delete/insert/truncate/drop 等破坏性指令
// 我们只允许查询,所以要求必须以 SELECT 起手,或者 EXPLAIN/SHOW
if (! preg_match('/^(SELECT|EXPLAIN|SHOW|DESCRIBE)\s/i', $sql)) {
return view('admin.sql.index', [
'results' => null,
'columns' => [],
'query' => $sql,
'error' => '安全保护触发:本探针只允许执行 SELECT / SHOW 等只读查询!',
]);
}
try {
$results = DB::select($sql);
// 提取表头
$columns = [];
if (! empty($results)) {
$firstRow = (array) $results[0];
$columns = array_keys($firstRow);
}
return view('admin.sql.index', [
'results' => $results,
'columns' => $columns,
'query' => $sql,
'error' => null,
]);
} catch (\Exception $e) {
return view('admin.sql.index', [
'results' => null,
'columns' => [],
'query' => $sql,
'error' => 'SQL 执行发生异常: '.$e->getMessage(),
]);
}
}
}

View File

@@ -0,0 +1,60 @@
<?php
/**
* 文件功能:系统参数配置控制器
* (替代原版 VIEWSYS.ASP / SetSYS.ASP)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SysParam;
use App\Services\ChatStateService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SystemController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState
) {}
/**
* 显示全局参数配置表单
*/
public function edit(): View
{
// 读取数据库中最新的参数
$params = SysParam::all()->pluck('body', 'alias')->toArray();
// 为后台界面准备的文案对照 (可动态化或硬编码)
$descriptions = SysParam::all()->pluck('guidetxt', 'alias')->toArray();
return view('admin.system.edit', compact('params', 'descriptions'));
}
/**
* 更新全局参数,并刷新全站 Cache 缓存
*/
public function update(Request $request): RedirectResponse
{
$data = $request->except(['_token', '_method']);
foreach ($data as $alias => $body) {
SysParam::updateOrCreate(
['alias' => $alias],
['body' => $body]
);
// 写入 Cache 保证极速读取
$this->chatState->setSysParam($alias, $body);
}
return redirect()->route('admin.system.edit')->with('success', '系统参数已成功更新并生效!');
}
}

View File

@@ -0,0 +1,114 @@
<?php
/**
* 文件功能:后台用户大盘管理控制器
* (替代原版 gl/ 下的各种管理面)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;
class UserManagerController extends Controller
{
/**
* 显示拥护列表及搜索
*/
public function index(Request $request): View
{
$query = User::query();
if ($request->filled('username')) {
$query->where('username', 'like', '%'.$request->input('username').'%');
}
// 分页获取用户
$users = $query->orderBy('id', 'desc')->paginate(20);
return view('admin.users.index', compact('users'));
}
/**
* 修改用户资料、等级或密码 (AJAX 或表单)
*/
public function update(Request $request, int $id): JsonResponse|RedirectResponse
{
$targetUser = User::findOrFail($id);
$currentUser = Auth::user();
// 越权防护:不能修改 等级大于或等于自己 的目标(除非修改自己)
if ($targetUser->id !== $currentUser->id && $targetUser->user_level >= $currentUser->user_level) {
return response()->json(['status' => 'error', 'message' => '权限不足:您无法修改同级或高级管理人员资料。'], 403);
}
$validated = $request->validate([
'sex' => 'sometimes|in:男,女,保密',
'user_level' => 'sometimes|integer|min:0',
'headface' => 'sometimes|string|max:50',
'sign' => 'sometimes|string|max:255',
'password' => 'nullable|string|min:6',
]);
// 如果传了且没超权,直接赋予
if (isset($validated['user_level'])) {
// 不能把自己或别人提权到超过自己的等级
if ($validated['user_level'] > $currentUser->user_level && $currentUser->id !== $targetUser->id) {
return response()->json(['status' => 'error', 'message' => '您不能将别人提升至超过您的等级!'], 403);
}
$targetUser->user_level = $validated['user_level'];
}
if (isset($validated['sex'])) {
$targetUser->sex = $validated['sex'];
}
if (isset($validated['headface'])) {
$targetUser->headface = $validated['headface'];
}
if (isset($validated['sign'])) {
$targetUser->sign = $validated['sign'];
}
if (! empty($validated['password'])) {
$targetUser->password = Hash::make($validated['password']);
}
$targetUser->save();
if ($request->wantsJson()) {
return response()->json(['status' => 'success', 'message' => '用户资料已强行更新完毕!']);
}
return back()->with('success', '用户资料已更新!');
}
/**
* 物理删除杀封用户
*/
public function destroy(Request $request, int $id): RedirectResponse
{
$targetUser = User::findOrFail($id);
$currentUser = Auth::user();
// 越权防护
if ($targetUser->id !== $currentUser->id && $targetUser->user_level >= $currentUser->user_level) {
abort(403, '权限不足:无法删除同级或高级账号!');
}
$targetUser->delete();
// 可选:触发解散名下房间等
return back()->with('success', '目标已被物理删除。');
}
}

View File

@@ -0,0 +1,121 @@
<?php
/**
* 文件功能:认证控制器 (处理登录即注册等逻辑)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Http\Requests\LoginRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
class AuthController extends Controller
{
/**
* 处理用户登录/注册尝试。
* 逻辑:
* 1. 如果用户已存在验证密码。为了兼容老数据库先验证Bcrypt再退化验证MD5。如果MD5正确则升级为Bcrypt。
* 2. 如果用户不存在,直接注册新用户并登录。
*/
public function login(LoginRequest $request): JsonResponse
{
$credentials = $request->validated();
$username = $credentials['username'];
$password = $credentials['password'];
$ip = $request->ip();
$user = User::where('username', $username)->first();
if ($user) {
// 用户存在,验证密码
if (Hash::check($password, $user->password)) {
// Bcrypt 验证通过
$this->performLogin($user, $ip);
return response()->json(['status' => 'success', 'message' => '登录成功']);
}
// 退化为 MD5 验证(兼容原 ASP 系统的老密码)
if (md5($password) === $user->password) {
// MD5 验证通过,升级密码为 Bcrypt
$user->password = Hash::make($password);
$user->save();
$this->performLogin($user, $ip);
return response()->json(['status' => 'success', 'message' => '登录成功,且安全策略已自动升级']);
}
// 密码错误
return response()->json([
'status' => 'error',
'message' => '密码错误,请重试。',
], 422);
}
// --- 核心:第一次登录即为注册 ---
$newUser = User::create([
'username' => $username,
'password' => Hash::make($password),
'first_ip' => $ip,
'last_ip' => $ip,
'user_level' => 1, // 默认普通用户等级
'sex' => '保密', // 默认性别
// 如果原表里还有其他必填字段,在这里初始化默认值
]);
$this->performLogin($newUser, $ip);
return response()->json(['status' => 'success', 'message' => '注册并登录成功!']);
}
/**
* 执行实际的登录操作并记录时间、IP 等。
*/
private function performLogin(User $user, string $ip): void
{
Auth::login($user);
// 更新最后登录IP和时间
$user->update([
'last_ip' => $ip,
'log_time' => now(),
'in_time' => now(),
]);
// 可选:将用户登录状态也同步写入原有的 IpLog 模型,以便数据归档查询
\App\Models\IpLog::create([
'ip' => $ip,
'sdate' => now(),
'uuname' => $user->username,
]);
}
/**
* 退出登录
*/
public function logout(Request $request): JsonResponse
{
if (Auth::check()) {
$user = Auth::user();
// 记录退出时间
$user->update(['out_time' => now()]);
}
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->json(['status' => 'success', 'message' => '已成功退出。']);
}
}

View File

@@ -0,0 +1,200 @@
<?php
/**
* 文件功能:聊天室核心控制器
* 接管原版 INIT.ASP, NEWSAY.ASP, LEAVE.ASP 的所有职责
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Events\MessageSent;
use App\Events\UserJoined;
use App\Events\UserLeft;
use App\Http\Requests\SendMessageRequest;
use App\Jobs\SaveMessageJob;
use App\Models\Room;
use App\Services\ChatStateService;
use App\Services\MessageFilterService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class ChatController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly MessageFilterService $filter,
) {}
/**
* 进入房间初始化 (等同于原版 INIT.ASP)
*
* @param int $id 房间ID
* @return View|JsonResponse
*/
public function init(int $id)
{
$room = Room::findOrFail($id);
$user = Auth::user();
// 1. 将当前用户加入到 Redis 房间在线列表
$this->chatState->userJoin($id, $user->username, [
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
]);
// 2. 广播 UserJoined 事件,通知房间内的其他人
broadcast(new UserJoined($id, $user->username, [
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
]))->toOthers();
// 3. 获取历史消息用于初次渲染
// TODO: 可在前端通过请求另外的接口拉取历史记录,或者直接在这里 attach
// 渲染主聊天框架视图
return view('chat.frame', [
'room' => $room,
'user' => $user,
]);
}
/**
* 发送消息 (等同于原版 NEWSAY.ASP)
*
* @param int $id 房间ID
*/
public function send(SendMessageRequest $request, int $id): JsonResponse
{
$data = $request->validated();
$user = Auth::user();
// 1. 过滤净化消息体
$pureContent = $this->filter->filter($data['content'] ?? '');
if (empty($pureContent)) {
return response()->json(['status' => 'error', 'message' => '消息内容不能为空或不合法。'], 422);
}
// 2. 封装消息对象
$messageData = [
'id' => $this->chatState->nextMessageId($id), // 分布式安全自增序号
'room_id' => $id,
'from_user' => $user->username,
'to_user' => $data['to_user'] ?? '大家',
'content' => $pureContent,
'is_secret' => $data['is_secret'] ?? false,
'font_color' => $data['font_color'] ?? '',
'action' => $data['action'] ?? '',
'sent_at' => now()->toDateTimeString(),
];
// 3. 压入 Redis 缓存列表 (防炸内存,只保留最近 N 条)
$this->chatState->pushMessage($id, $messageData);
// 4. 立刻向 WebSocket 发射广播,前端达到 0 延迟渲染
broadcast(new MessageSent($id, $messageData));
// 5. 丢进异步列队,慢慢持久化到 MySQL保护数据库连接池
SaveMessageJob::dispatch($messageData);
return response()->json(['status' => 'success']);
}
/**
* 自动挂机存点心跳与经验升级 (新增)
* 替代原版定时 iframe 刷新的 save.asp。
*
* @param int $id 房间ID
*/
public function heartbeat(Request $request, int $id): JsonResponse
{
$user = Auth::user();
if (! $user) {
return response()->json(['status' => 'error'], 401);
}
// 1. 每次心跳 +1 点经验
$user->exp_num += 1;
// 2. 检查等级计算:设定简单粗暴的平滑算式:需要经验=等级*等级*10
// 例如0级->0点1级->10点2级->40点3级->90点10级->1000点
$currentLevel = $user->user_level;
$requiredExpForNextLevel = ($currentLevel) * ($currentLevel) * 10;
$leveledUp = false;
if ($user->exp_num >= $requiredExpForNextLevel) {
$user->user_level += 1;
$leveledUp = true;
}
$user->save(); // 存点入库
// 3. 将新的等级反馈给当前用户的在线名单上
// 确保刚刚升级后别人查看到的也是最准确等级
$this->chatState->userJoin($id, $user->username, [
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
]);
// 4. 如果突破境界,向全房系统喊话广播!
if ($leveledUp) {
// 生成炫酷广播消息发向该频道
$sysMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🌟 天道酬勤!恭喜侠客【{$user->username}】挂机苦修,境界突破至 LV.{$user->user_level}",
'is_secret' => false,
'font_color' => '#d97706', // 琥珀橙色
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $sysMsg);
broadcast(new MessageSent($id, $sysMsg));
// 落库
SaveMessageJob::dispatch($sysMsg);
}
return response()->json([
'status' => 'success',
'data' => [
'exp_num' => $user->exp_num,
'user_level' => $user->user_level,
'leveled_up' => $leveledUp,
],
]);
}
/**
* 离开房间 (等同于原版 LEAVE.ASP)
*
* @param int $id 房间ID
*/
public function leave(Request $request, int $id): JsonResponse
{
$user = Auth::user();
if (! $user) {
return response()->json(['status' => 'error'], 401);
}
// 1. 从 Redis 删除该用户
$this->chatState->userLeave($id, $user->username);
// 2. 广播通知他人
broadcast(new UserLeft($id, $user->username))->toOthers();
return response()->json(['status' => 'success']);
}
}

View File

@@ -0,0 +1,125 @@
<?php
/**
* 文件功能:全站留言板与站内悄悄信控制器
* (替代原版 Guestbook 系列功能)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Http\Requests\StoreGuestbookRequest;
use App\Models\Guestbook;
use App\Models\User;
use App\Services\MessageFilterService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class GuestbookController extends Controller
{
public function __construct(
private readonly MessageFilterService $filter
) {}
/**
* 留言簿主面板 (支持分类 Tab: public/inbox/outbox)
*/
public function index(Request $request): View
{
$tab = $request->input('tab', 'public');
$user = Auth::user();
$query = Guestbook::query()->orderByDesc('id');
// 根据 Tab 拆分查询逻辑
if ($tab === 'inbox') {
// 收件箱:发给自己的,无论公私
$query->where('towho', $user->username);
} elseif ($tab === 'outbox') {
// 发件箱:自己发出去的,无论公私
$query->where('who', $user->username);
} else {
// 默认公共墙:
// 条件 = (公开留言) 或者 (悄悄话但发件人是自己) 或者 (悄悄话但收件人是自己)
$query->where(function ($q) use ($user) {
$q->where('secret', 0)
->orWhere('who', $user->username)
->orWhere('towho', $user->username);
});
}
$messages = $query->paginate(15)->appends(['tab' => $tab]);
// 获取收件人默认值 (比如点击他人名片的"写私信"转跳过来)
$defaultTo = $request->input('to', '');
return view('guestbook.index', compact('messages', 'tab', 'defaultTo'));
}
/**
* 创建一条新留言或私信
*/
public function store(StoreGuestbookRequest $request): RedirectResponse
{
$data = $request->validated();
$user = Auth::user();
// 强力消毒文本
$pureBody = $this->filter->filter($data['text_body']);
if (empty($pureBody)) {
return back()->withInput()->with('error', '留言内容不合法或全为敏感词被过滤!');
}
// 处理目标人,如果没填或者填写了"大家",则默认是 null (公共留言)
$towho = trim($data['towho'] ?? '');
if ($towho === '大家' || empty($towho)) {
$towho = null;
}
// 如果明确指定了人,检查一下这人存不存在 (原版可不查,但查一下体验更好)
if ($towho && ! User::where('username', $towho)->exists()) {
return back()->withInput()->with('error', "目标收件人 [{$towho}] 不存在于系统中。");
}
Guestbook::create([
'who' => $user->username,
'towho' => $towho,
'secret' => isset($data['secret']) ? 1 : 0,
'text_title' => mb_substr(trim($data['text_title'] ?? ''), 0, 50),
'text_body' => $pureBody,
'ip' => $request->ip(),
'post_time' => now(), // 原数据库可能用 post_time 代替了 created_at这里两个都写保证兼容
'created_at' => now(),
'updated_at' => now(),
]);
return back()->with('success', '飞鸽传书已成功发送!');
}
/**
* 删除留言
*/
public function destroy(int $id): RedirectResponse
{
$msg = Guestbook::findOrFail($id);
$user = Auth::user();
// 权限校验只能删除自己发的、发给自己的或者自己是15级以上超管
$canDelete = $user->username === $msg->who
|| $user->username === $msg->towho
|| $user->user_level >= 15;
if (! $canDelete) {
abort(403, '越权操作:您无权擦除此留言记录!');
}
$msg->delete();
return back()->with('success', '该行留言已被抹除。');
}
}

View File

@@ -0,0 +1,71 @@
<?php
/**
* 文件功能:全局风云排行榜控制器
* 各种维度等级、经验、交友币、魅力的前20名抓取与缓存展示。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
class LeaderboardController extends Controller
{
/**
* 渲染排行榜主视角
*/
public function index(): View
{
// 缓存 15 分钟,防止每秒几百个人看排行榜把数据库扫死
// 选用 remember 则在过期时自动执行闭包查询并重置缓存
$ttl = 60 * 15;
// 1. 境界榜 (以 user_level 为尊)
$topLevels = Cache::remember('leaderboard:top_levels', $ttl, function () {
return User::select('id', 'username', 'headface', 'user_level', 'sex', 'sign')
->where('user_level', '>', 0)
->orderByDesc('user_level')
->orderBy('id')
->limit(20)
->get();
});
// 2. 修为榜 (以 exp_num 为尊)
$topExp = Cache::remember('leaderboard:top_exp', $ttl, function () {
return User::select('id', 'username', 'headface', 'exp_num', 'sex', 'user_level', 'sign')
->where('exp_num', '>', 0)
->orderByDesc('exp_num')
->orderBy('id')
->limit(20)
->get();
});
// 3. 财富榜 (以 jjb-交友币 为尊)
$topWealth = Cache::remember('leaderboard:top_wealth', $ttl, function () {
return User::select('id', 'username', 'headface', 'jjb', 'sex', 'user_level', 'sign')
->where('jjb', '>', 0)
->orderByDesc('jjb')
->orderBy('id')
->limit(20)
->get();
});
// 4. 魅力榜 (以 meili 为尊)
$topCharm = Cache::remember('leaderboard:top_charm', $ttl, function () {
return User::select('id', 'username', 'headface', 'meili', 'sex', 'user_level', 'sign')
->where('meili', '>', 0)
->orderByDesc('meili')
->orderBy('id')
->limit(20)
->get();
});
return view('leaderboard.index', compact('topLevels', 'topExp', 'topWealth', 'topCharm'));
}
}

View File

@@ -0,0 +1,129 @@
<?php
/**
* 文件功能:房间管理器
* 接管原版 ROOMLIST.ASP, NEWROOM.ASP, ROOMSET.ASP, CUTROOM.ASP, OVERROOM.ASP 的职责
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Events\RoomTitleUpdated;
use App\Http\Requests\StoreRoomRequest;
use App\Http\Requests\UpdateRoomRequest;
use App\Models\Room;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class RoomController extends Controller
{
/**
* 显示房间大厅列表 (对应 ROOMLIST.ASP)
*/
public function index(): View
{
$rooms = Room::with('masterUser')
->orderByDesc('is_system') // 系统房间排在最前面
->orderByDesc('id')
->get();
return view('rooms.index', compact('rooms'));
}
/**
* 创建房间 (对应 NEWROOM.ASP)
*/
public function store(StoreRoomRequest $request): RedirectResponse
{
$data = $request->validated();
$room = Room::create([
'name' => $data['name'],
'description' => $data['description'] ?? '',
'master' => Auth::user()->username,
'is_system' => false, // 用户自建均为非系统房
]);
return redirect()->route('rooms.index')->with('success', "聊天室 [{$room->name}] 创建成功!");
}
/**
* 更新房间属性 (对应 ROOMSET.ASP)
*/
public function update(UpdateRoomRequest $request, int $id): RedirectResponse
{
$room = Room::findOrFail($id);
// 鉴权:必须是该房间的主人或者是高管 (系统级 > 15)
$user = Auth::user();
if ($room->master !== $user->username && $user->user_level < 15) {
abort(403, '只有房主或超级管理员才能修改此房间设置。');
}
$data = $request->validated();
$room->update([
'name' => $data['name'],
'description' => $data['description'] ?? '',
]);
// 广播房间信息更新 (所有人立即可以在聊天框顶部看到)
broadcast(new RoomTitleUpdated($room->id, $room->name.($room->description ? ' - '.$room->description : '')));
return back()->with('success', '房间设置更新成功!');
}
/**
* 解散/删除房间 (对应 CUTROOM.ASP)
*/
public function destroy(int $id): RedirectResponse
{
$room = Room::findOrFail($id);
// 鉴权:系统自带房不能被任何人删除
if ($room->is_system) {
abort(403, '系统固定聊天室无法被删除。');
}
$user = Auth::user();
if ($room->master !== $user->username && $user->user_level < 15) {
abort(403, '只有房主或超级管理员才能解散该房间。');
}
$room->delete();
// 备注:如果该房间内还有人,他们应该会被前端由于 channel 无法维系而被请出,或者可以在这里再发送一个专门的踢出事件。
return redirect()->route('rooms.index')->with('success', "聊天室 [{$room->name}] 已被彻底解散。");
}
/**
* 转让房主 (对应 OVERROOM.ASP)
*/
public function transfer(int $id): RedirectResponse
{
$room = Room::findOrFail($id);
$user = Auth::user();
if ($room->master !== $user->username && $user->user_level < 15) {
abort(403, '只有当前房主可以进行转让。');
}
$targetUsername = request('target_username');
if (empty($targetUsername)) {
return back()->with('error', '请输入目标用户的昵称。');
}
$targetUser = User::where('username', $targetUsername)->first();
if (! $targetUser) {
return back()->with('error', '该用户不存在,无法转让。');
}
$room->update(['master' => $targetUser->username]);
return back()->with('success', "房间已成功转让给 [{$targetUser->username}]。");
}
}

View File

@@ -0,0 +1,149 @@
<?php
/**
* 文件功能:用户中心与管理控制器
* 接管原版 USERinfo.ASP, USERSET.ASP, chpasswd.asp, KILLUSER.ASP
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Events\UserKicked;
use App\Events\UserMuted;
use App\Http\Requests\ChangePasswordRequest;
use App\Http\Requests\UpdateProfileRequest;
use App\Models\Room;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
class UserController extends Controller
{
/**
* 查看其他用户资料片 (对应 USERinfo.ASP)
*/
public function show(string $username): JsonResponse
{
$user = User::where('username', $username)->firstOrFail();
// 隐藏关键信息,只返回公开资料
return response()->json([
'username' => $user->username,
'sex' => $user->sex,
'headface' => $user->headface,
'user_level' => $user->user_level,
'sign' => $user->sign ?? '这个人很懒,什么都没留下。',
'created_at' => $user->created_at->format('Y-m-d'),
]);
}
/**
* 修改个人资料 (对应 USERSET.ASP)
*/
public function updateProfile(UpdateProfileRequest $request): JsonResponse
{
$user = Auth::user();
$user->update($request->validated());
return response()->json(['status' => 'success', 'message' => '资料更新成功。']);
}
/**
* 修改密码 (对应 chpasswd.asp)
*/
public function changePassword(ChangePasswordRequest $request): JsonResponse
{
$user = Auth::user();
$oldPasswordInput = $request->input('old_password');
// 双模式密码校验逻辑(沿用 AuthController 中策略)
$isOldPasswordCorrect = false;
// 优先验证 Bcrypt
if (Hash::check($oldPasswordInput, $user->password)) {
$isOldPasswordCorrect = true;
}
// 降级验证旧版 MD5
elseif (md5($oldPasswordInput) === $user->password) {
$isOldPasswordCorrect = true;
}
if (! $isOldPasswordCorrect) {
return response()->json(['status' => 'error', 'message' => '当前密码输入不正确。'], 422);
}
// 验证通过,覆盖为新的 Bcrypt 加密串
$user->password = Hash::make($request->input('new_password'));
$user->save();
return response()->json(['status' => 'success', 'message' => '密码已成功修改。下次请使用新密码登录。']);
}
/**
* 管理员/房主操作:踢出房间 (对应 KILLUSER.ASP)
*/
public function kick(Request $request, string $username): JsonResponse
{
$operator = Auth::user();
$roomId = $request->input('room_id');
if (! $roomId) {
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
}
$room = Room::findOrFail($roomId);
// 鉴权:操作者要是房间房主或者系统超管
if ($room->master !== $operator->username && $operator->user_level < 15) {
return response()->json(['status' => 'error', 'message' => '权限不足,无法执行踢出操作。'], 403);
}
$targetUser = User::where('username', $username)->first();
if (! $targetUser) {
return response()->json(['status' => 'error', 'message' => '目标用户不存在。'], 404);
}
// 防误伤高管
if ($targetUser->user_level >= 15 && $operator->user_level < 15) {
return response()->json(['status' => 'error', 'message' => '权限不足,无法踢出同级或高级管理人员。'], 403);
}
// 核心动作:向频道内所有人发送包含“某某踢出某某”的事件
broadcast(new UserKicked($roomId, $targetUser->username, "管理员 [{$operator->username}] 将 [{$targetUser->username}] 踢出了聊天室。"));
return response()->json(['status' => 'success', 'message' => "已成功将 {$targetUser->username} 踢出房间。"]);
}
/**
* 管理员/具有道具者操作:禁言 (对应新加的限制功能)
*/
public function mute(Request $request, string $username): JsonResponse
{
$operator = Auth::user();
$roomId = $request->input('room_id');
$duration = $request->input('duration', 5); // 默认封停分钟数
if (! $roomId) {
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
}
$room = Room::findOrFail($roomId);
// 此处只做简单鉴权演示,和踢人一致
if ($room->master !== $operator->username && $operator->user_level < 15) {
return response()->json(['status' => 'error', 'message' => '权限不足,无法执行禁言操作。'], 403);
}
// 后续可以在 Redis 中写入一个 `mute:{$username}` 并附带 `TTL` 以在后台拦截
// 立刻向房间发送 Muted 事件
broadcast(new UserMuted($roomId, $username, $duration));
return response()->json(['status' => 'success', 'message' => "已对 {$username} 实施封口 {$duration} 分钟。"]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
/**
* 文件功能:聊天室登录验证中间件
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class ChatAuthenticate
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (! Auth::check()) {
if ($request->expectsJson()) {
return response()->json(['message' => '未登录', 'status' => 'error'], 401);
}
return redirect()->route('home')->withErrors([
'auth' => '请先输入昵称进入聊天室',
]);
}
return $next($request);
}
}

View File

@@ -0,0 +1,45 @@
<?php
/**
* 文件功能:用户等级权限验证中间件
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class LevelRequired
{
/**
* Handle an incoming request.
* 校验当前登录用户的等级是否大于或等于要求等级。
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param int $level 要求达到的最低等级 (例如 15 为室主)
*/
public function handle(Request $request, Closure $next, int $level): Response
{
if (! Auth::check()) {
return redirect()->route('home');
}
$user = Auth::user();
if ($user->user_level < $level) {
if ($request->expectsJson()) {
return response()->json(['message' => '权限不足', 'status' => 'error'], 403);
}
abort(403, '权限不足,无法执行此操作。');
}
return $next($request);
}
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* 文件功能:用户修改密码请求验证器
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ChangePasswordRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'old_password' => ['required', 'string'],
'new_password' => ['required', 'string', 'min:6', 'confirmed'],
];
}
public function messages(): array
{
return [
'old_password.required' => '请输入当前密码。',
'new_password.required' => '新密码不能为空。',
'new_password.min' => '新密码长度至少需要 6 位。',
'new_password.confirmed' => '两次输入的新密码不一致。',
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
/**
* 文件功能:登录/注册请求验证器
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'username' => [
'required',
'string',
'min:2',
'max:12',
// 允许中英文数字及常见符号但严格过滤可能引起XSS/SQL注入的危险字符< > ' "
'regex:/^[^<>\'"]+$/u',
],
'password' => ['required', 'string', 'min:1'],
'captcha' => ['required', 'captcha'],
];
}
/**
* 获取已定义验证规则的错误消息。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'username.required' => '必须填写用户名。',
'username.min' => '用户名长度不得少于 2 个字符。',
'username.max' => '用户名长度不得超过 12 个字符。',
'username.regex' => '用户名包含非法字符(不允许使用尖括号或引号)。',
'password.required' => '必须填写密码。',
'password.min' => '密码长度不得少于 1 个字符。',
'captcha.required' => '必须填写验证码。',
'captcha.captcha' => '验证码不正确。',
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
/**
* 文件功能:聊天室发言请求验证器
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class SendMessageRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // 权限验证已交由中间件处理
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'content' => ['required', 'string', 'max:500'], // 防止超长文本炸服
'to_user' => ['nullable', 'string', 'max:50'],
'is_secret' => ['nullable', 'boolean'],
'font_color' => ['nullable', 'string', 'max:10'], // html color hex
'action' => ['nullable', 'string', 'max:50'], // 动作(例如:微笑着说)
];
}
public function messages(): array
{
return [
'content.required' => '不能发送空消息。',
'content.max' => '发言内容不能超过 500 个字符。',
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreGuestbookRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return \Illuminate\Support\Facades\Auth::check(); // 必须登录
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'towho' => 'nullable|string|max:50',
'secret' => 'sometimes|boolean',
'text_title' => 'nullable|string|max:250',
'text_body' => 'required|string|max:2000',
];
}
public function messages(): array
{
return [
'text_body.required' => '留言内容不能为空!',
'text_body.max' => '留言内容不能超过 2000 个字符。',
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
/**
* 文件功能:新建聊天室请求验证器
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
class StoreRoomRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
// 只有登录用户,且 user_level 达到特定阈值(例如 >= 10才可以自己建房
// 具体阈值可以根据运营需求调整,此处暂设 10 为门槛。
return Auth::check() && Auth::user()->user_level >= 10;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:50', 'unique:rooms,name'],
'description' => ['nullable', 'string', 'max:255'],
];
}
public function messages(): array
{
return [
'name.required' => '必须填写房间名称。',
'name.unique' => '该房间名称已被占用。',
'name.max' => '房间名称最多 50 个字符。',
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
/**
* 文件功能:用户修改个人资料请求验证器
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateProfileRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'sex' => ['required', 'string', 'in:男,女,保密'],
'headface' => ['required', 'string', 'max:50'], // 比如存放 01.gif - 50.gif
'sign' => ['nullable', 'string', 'max:255'],
];
}
public function messages(): array
{
return [
'sex.in' => '性别选项无效。',
'headface.required' => '必须选择一个头像。',
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
/**
* 文件功能:修改聊天室设置请求验证器
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateRoomRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
// 权限判断(是否为房主)会直接在 Controller/Policy 中进行
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:50', 'unique:rooms,name,'.$this->route('id')],
'description' => ['nullable', 'string', 'max:255'],
];
}
public function messages(): array
{
return [
'name.required' => '房间名称不能为空。',
'name.unique' => '该房间名称已存在。',
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
/**
* 文件功能:异步持久化聊天记录队列
* 承接高频聊天缓存,防堵塞 MySQL 进程。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Jobs;
use App\Models\Message;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Carbon;
class SaveMessageJob implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
*
* @param array $messageData 包装好的消息数组
*/
public function __construct(
public readonly array $messageData
) {}
/**
* Execute the job.
* 将缓存在 Redis 刚广播出去的消息,真实映射写入到 `messages` 数据表。
*/
public function handle(): void
{
Message::create([
'room_id' => $this->messageData['room_id'],
'from_user' => $this->messageData['from_user'],
'to_user' => $this->messageData['to_user'] ?? '大家',
'content' => $this->messageData['content'],
'is_secret' => $this->messageData['is_secret'] ?? false,
'font_color' => $this->messageData['font_color'] ?? '',
'action' => $this->messageData['action'] ?? '',
// 恢复 Carbon 时间对象
'sent_at' => Carbon::parse($this->messageData['sent_at']),
]);
}
}

31
app/Models/Action.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
/**
* 文件功能:动作表情模型
*
* 对应原 ASP 文件action
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Action extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'act_name',
'alias',
'toall',
'toself',
'toother',
];
}

60
app/Models/AdminLog.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
/**
* 文件功能:管理员操作统计模型
*
* 对应原 ASP 文件admincz
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AdminLog extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'username',
'uu_level',
'in_time',
'out_time',
'caozuo',
'fs_sl',
'fs_name',
'tr_sl',
'tr_name',
'jg_sl',
'jg_name',
'dj_sl',
'dj_name',
'yjdj_sl',
'yjdj_name',
'fip_sl',
'fip_name',
'fjj_sl',
'fjy_sl',
'flh_sl',
'jl_time',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'in_time' => 'datetime',
'jl_time' => 'datetime',
];
}
}

41
app/Models/AuditLog.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
/**
* 文件功能:操作日志模型
*
* 对应原 ASP 文件record
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AuditLog extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'occ_time',
'occ_env',
'stype',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'occ_time' => 'datetime',
];
}
}

44
app/Models/FriendCall.php Normal file
View File

@@ -0,0 +1,44 @@
<?php
/**
* 文件功能:好友呼叫模型
*
* 对应原 ASP 文件calls
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class FriendCall extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'who',
'towho',
'callmess',
'calltime',
'read',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'calltime' => 'datetime',
'read' => 'boolean',
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
/**
* 文件功能:好友申请模型
*
* 对应原 ASP 文件friendrq
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class FriendRequest extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'who',
'towho',
'sub_time',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'sub_time' => 'datetime',
];
}
}

49
app/Models/Guestbook.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
/**
* 文件功能:留言板模型
*
* 对应原 ASP 文件guestbook
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Guestbook extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'who',
'towho',
'secret',
'ip',
'email',
'web',
'addr',
'post_time',
'text_title',
'text_body',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'post_time' => 'datetime',
'secret' => 'boolean',
];
}
}

41
app/Models/IpLock.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
/**
* 文件功能IP 封禁模型
*
* 对应原 ASP 文件ip_lock
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class IpLock extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'ip',
'end_time',
'act_level',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'end_time' => 'datetime',
];
}
}

41
app/Models/IpLog.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
/**
* 文件功能IP 登录日志模型
*
* 对应原 ASP 文件ip
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class IpLog extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'ip',
'sdate',
'uuname',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'sdate' => 'datetime',
];
}
}

44
app/Models/Marriage.php Normal file
View File

@@ -0,0 +1,44 @@
<?php
/**
* 文件功能:婚姻关系模型
*
* 对应原 ASP 文件hy / lh
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Marriage extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'hyname',
'hyname1',
'hytime',
'hygb',
'hyjb',
'i',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'hytime' => 'datetime',
];
}
}

47
app/Models/Message.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
/**
* 文件功能:聊天消息模型
*
* 对应原 ASP 文件:内存 Application("_says") 变量 (用数据库做持久化归档)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Message extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'room_id',
'from_user',
'to_user',
'content',
'is_secret',
'font_color',
'action',
'sent_at',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'sent_at' => 'datetime',
'is_secret' => 'boolean',
];
}
}

57
app/Models/Room.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
/**
* 文件功能:聊天房间模型
*
* 对应原 ASP 文件room
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Room extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'room_name',
'room_auto',
'room_owner',
'room_des',
'room_top',
'room_title',
'room_keep',
'room_time',
'room_tt',
'room_html',
'room_exp',
'build_time',
'permit_level',
'door_open',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'room_time' => 'datetime',
'build_time' => 'datetime',
'room_keep' => 'boolean',
'room_tt' => 'boolean',
'room_html' => 'boolean',
'door_open' => 'boolean',
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
/**
* 文件功能:房间描述模板模型
*
* 对应原 ASP 文件room_des
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class RoomDescription extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'alias',
'describ',
];
}

41
app/Models/ScrollAd.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
/**
* 文件功能:滚动广告/公告模型
*
* 对应原 ASP 文件scrollad
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ScrollAd extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'ad_title',
'ad_link',
'ad_new_flag',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'ad_new_flag' => 'boolean',
];
}
}

29
app/Models/SysParam.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
/**
* 文件功能:系统参数模型
*
* 对应原 ASP 文件sysparam
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SysParam extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'alias',
'guidetxt',
'body',
];
}

View File

@@ -1,36 +1,52 @@
<?php
/**
* 文件功能:主用户模型
*
* 对应原 ASP 文件user
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
* @var array<int, string>
*/
protected $fillable = [
'name',
'email',
'username',
'password',
'email',
'sex',
'user_level',
'room_id',
'first_ip',
'last_ip',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
* @var array<int, string>
*/
protected $hidden = [
'password',
'remember_token',
'temppass',
'ppass',
'userpassword',
];
/**
@@ -43,6 +59,14 @@ class User extends Authenticatable
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'log_time' => 'datetime',
'in_time' => 'datetime',
'out_time' => 'datetime',
'hy_time' => 'datetime',
'xr_time' => 'datetime',
'yx_time' => 'datetime',
'sj' => 'datetime',
'q3_time' => 'datetime',
];
}
}

43
app/Models/UserItem.php Normal file
View File

@@ -0,0 +1,43 @@
<?php
/**
* 文件功能:用户道具模型
*
* 对应原 ASP 文件gg
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UserItem extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'times',
'gg',
'dayy',
'lx',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'times' => 'datetime',
];
}
}

View File

@@ -28,9 +28,7 @@ class HorizonServiceProvider extends HorizonApplicationServiceProvider
protected function gate(): void
{
Gate::define('viewHorizon', function ($user = null) {
return in_array(optional($user)->email, [
//
]);
return $user && $user->user_level >= 15;
});
}
}

View File

@@ -0,0 +1,165 @@
<?php
/**
* 文件功能:聊天全局状态中心 (替代旧版 ASP中的 Application 全局对象)
* 依赖 Redis 内存存取实现在线人员列表、发言记录的高并发读写。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Services;
use App\Models\SysParam;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
class ChatStateService
{
/**
* 将用户加入到指定房间的在线列表中。
*
* @param int $roomId 房间ID
* @param string $username 用户名
* @param array $info 用户详细信息 (头像、等级等)
*/
public function userJoin(int $roomId, string $username, array $info): void
{
$key = "room:{$roomId}:users";
Redis::hset($key, $username, json_encode($info, JSON_UNESCAPED_UNICODE));
}
/**
* 将用户从指定房间的在线列表中移除。
*
* @param int $roomId 房间ID
* @param string $username 用户名
*/
public function userLeave(int $roomId, string $username): void
{
$key = "room:{$roomId}:users";
Redis::hdel($key, $username);
}
/**
* 获取指定房间的所有在线用户列表。
*
* @param int $roomId 房间ID
*/
public function getRoomUsers(int $roomId): array
{
$key = "room:{$roomId}:users";
$users = Redis::hgetall($key);
$result = [];
foreach ($users as $username => $jsonInfo) {
$result[$username] = json_decode($jsonInfo, true);
}
return $result;
}
/**
* 将一条新发言推入 Redis 列表,并限制最大保留数量,防止内存泄漏。
*
* @param int $roomId 房间ID
* @param array $message 发言数据包
* @param int $maxKeep 最大保留条数
*/
public function pushMessage(int $roomId, array $message, int $maxKeep = 100): void
{
$key = "room:{$roomId}:messages";
Redis::rpush($key, json_encode($message, JSON_UNESCAPED_UNICODE));
// 仅保留最新的 $maxKeep 条,旧的自动截断弹弃 (-$maxKeep 到 -1 的区间保留)
Redis::ltrim($key, -$maxKeep, -1);
}
/**
* 获取指定房间的新发言记录。
* 在高频长轮询或前端断线重连拉取时使用。
*
* @param int $roomId 房间ID
* @param int $lastId 客户端收到的最后一条发言的ID
*/
public function getNewMessages(int $roomId, int $lastId): array
{
$key = "room:{$roomId}:messages";
$messages = Redis::lrange($key, 0, -1); // 获取当前缓存的全部
$newMessages = [];
foreach ($messages as $msgJson) {
$msg = json_decode($msgJson, true);
if (isset($msg['id']) && $msg['id'] > $lastId) {
$newMessages[] = $msg;
}
}
return $newMessages;
}
/**
* 分布式发号器:为房间内的新消息生成绝对递增的 ID。
* 解决了 MySQL 自增在极致并发下依赖读锁的问题。
*
* @param int $roomId 房间ID
*/
public function nextMessageId(int $roomId): int
{
$key = "room:{$roomId}:message_seq";
return Redis::incr($key);
}
/**
* 读取系统参数并设置合理的缓存。
* 替代每次都去 MySQL query 的性能损耗。
*
* @param string $alias 别名
*/
public function getSysParam(string $alias): ?string
{
return Cache::remember("sys_param:{$alias}", 60, function () use ($alias) {
return SysParam::where('alias', $alias)->value('body');
});
}
/**
* 更新系统参数并刷新 Cache 缓存。
*
* @param string $alias 别名
* @param mixed $value 参数值
*/
public function setSysParam(string $alias, mixed $value): void
{
Cache::put("sys_param:{$alias}", $value);
}
/**
* 提供一个分布式的 Redis 互斥锁包围。
* 防止并发抢占资源(如同时创建重名房间,同时修改某一敏感属性)。
*
* @param string $key 锁名称
* @param callable $callback 获得锁后执行的闭包
* @param int $timeout 锁超时时间(秒)
* @return mixed
*/
public function withLock(string $key, callable $callback, int $timeout = 5)
{
$lockKey = "lock:{$key}";
// 尝试获取锁set nx ex
$isLocked = Redis::set($lockKey, 1, 'EX', $timeout, 'NX');
if (! $isLocked) {
// 获取锁失败,业务可自行决定抛异常或重试
throw new \Exception("The lock {$key} is currently held by another process.");
}
try {
return $callback();
} finally {
Redis::del($lockKey);
}
}
}

View File

@@ -0,0 +1,52 @@
<?php
/**
* 文件功能:聊天内容过滤器 (敏感词与 HTML 净化)
* 替代旧版 ASP中的 TrStr() / SHTM() 等各种过滤函数。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Services;
class MessageFilterService
{
/**
* 简单的关键词黑名单,未来可放到数据库或 Redis 动态加载
*/
private array $badWords = [
'外挂', '刷单', '脚本', // 示例黑名单
];
/**
* 执行过滤净化,保障入库和显示安全。
*
* @param string $content 原始用户发送内容
* @return string 净化后的内容
*/
public function filter(string $content): string
{
if (empty($content)) {
return '';
}
// 1. HTML 标签全量脱除,阻绝任意 XSS/HTML 注入
$content = strip_tags($content);
// 2. 敏感词替换
foreach ($this->badWords as $word) {
if (mb_strpos($content, $word) !== false) {
// 将脏字替换为相同长度的 星号 或 提示
$replacement = str_repeat('*', mb_strlen($word));
$content = str_replace($word, $replacement, $content);
}
}
// 3. 将连续的空格去重,只保留一个真正的空格
$content = preg_replace('/\s+/', ' ', $content);
return trim($content);
}
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* 文件功能:用户等级与权限辅助获取服务
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
class UserLevelService
{
/**
* 安全地获取用户的当前权限等级。
* 结合 Redis 缓存,防止每次判断权限都产生额外的 MySQL 查询。
*
* @param string $username 目标查询的用户名
* @return int 用户等级,如果没有查到默认为 1
*/
public function getUserLevel(string $username): int
{
if (empty($username)) {
return 1;
}
return (int) Cache::remember("user_level:{$username}", 60, function () use ($username) {
$level = User::where('username', $username)->value('user_level');
return $level !== null ? $level : 1;
});
}
/**
* 当用户等级被管理员修改时,调用此方法清空其对应缓存。
*
* @param string $username 用户名
*/
public function forgetUserLevel(string $username): void
{
Cache::forget("user_level:{$username}");
}
}

View File

@@ -11,8 +11,14 @@ return Application::configure(basePath: dirname(__DIR__))
channels: __DIR__.'/../routes/channels.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'chat.auth' => \App\Http\Middleware\ChatAuthenticate::class,
'chat.level' => \App\Http\Middleware\LevelRequired::class,
]);
// 这一步是为了防止用户访问需要登录的页面时,默认被跳到原版 Laravel 未定义的 login 路由报错
$middleware->redirectGuestsTo('/');
})
->withExceptions(function (Exceptions $exceptions): void {
//

View File

@@ -14,6 +14,7 @@
"laravel/horizon": "^5.45",
"laravel/reverb": "^1.8",
"laravel/tinker": "^2.10.1",
"mews/captcha": "^3.4",
"predis/predis": "^3.4"
},
"require-dev": {

219
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "6aad8905e01f3b6f841e516e084a6f15",
"content-hash": "b289aa628565e10fed1f7dc23db468e9",
"packages": [
{
"name": "brick/math",
@@ -1229,6 +1229,150 @@
],
"time": "2025-08-22T14:27:06+00:00"
},
{
"name": "intervention/gif",
"version": "4.2.4",
"source": {
"type": "git",
"url": "https://github.com/Intervention/gif.git",
"reference": "c3598a16ebe7690cd55640c44144a9df383ea73c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Intervention/gif/zipball/c3598a16ebe7690cd55640c44144a9df383ea73c",
"reference": "c3598a16ebe7690cd55640c44144a9df383ea73c",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^10.0 || ^11.0 || ^12.0",
"slevomat/coding-standard": "~8.0",
"squizlabs/php_codesniffer": "^3.8"
},
"type": "library",
"autoload": {
"psr-4": {
"Intervention\\Gif\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Oliver Vogel",
"email": "oliver@intervention.io",
"homepage": "https://intervention.io/"
}
],
"description": "Native PHP GIF Encoder/Decoder",
"homepage": "https://github.com/intervention/gif",
"keywords": [
"animation",
"gd",
"gif",
"image"
],
"support": {
"issues": "https://github.com/Intervention/gif/issues",
"source": "https://github.com/Intervention/gif/tree/4.2.4"
},
"funding": [
{
"url": "https://paypal.me/interventionio",
"type": "custom"
},
{
"url": "https://github.com/Intervention",
"type": "github"
},
{
"url": "https://ko-fi.com/interventionphp",
"type": "ko_fi"
}
],
"time": "2026-01-04T09:27:23+00:00"
},
{
"name": "intervention/image",
"version": "3.11.7",
"source": {
"type": "git",
"url": "https://github.com/Intervention/image.git",
"reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Intervention/image/zipball/2159bcccff18f09d2a392679b81a82c5a003f9bb",
"reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"intervention/gif": "^4.2",
"php": "^8.1"
},
"require-dev": {
"mockery/mockery": "^1.6",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^10.0 || ^11.0 || ^12.0",
"slevomat/coding-standard": "~8.0",
"squizlabs/php_codesniffer": "^3.8"
},
"suggest": {
"ext-exif": "Recommended to be able to read EXIF data properly."
},
"type": "library",
"autoload": {
"psr-4": {
"Intervention\\Image\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Oliver Vogel",
"email": "oliver@intervention.io",
"homepage": "https://intervention.io"
}
],
"description": "PHP Image Processing",
"homepage": "https://image.intervention.io",
"keywords": [
"gd",
"image",
"imagick",
"resize",
"thumbnail",
"watermark"
],
"support": {
"issues": "https://github.com/Intervention/image/issues",
"source": "https://github.com/Intervention/image/tree/3.11.7"
},
"funding": [
{
"url": "https://paypal.me/interventionio",
"type": "custom"
},
{
"url": "https://github.com/Intervention",
"type": "github"
},
{
"url": "https://ko-fi.com/interventionphp",
"type": "ko_fi"
}
],
"time": "2026-02-19T13:11:17+00:00"
},
{
"name": "laravel/framework",
"version": "v12.53.0",
@@ -2414,6 +2558,79 @@
],
"time": "2026-01-15T06:54:53+00:00"
},
{
"name": "mews/captcha",
"version": "3.4.7",
"source": {
"type": "git",
"url": "https://github.com/mewebstudio/captcha.git",
"reference": "2622c4f90dd621f19fe57e03e45f6f099509e839"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mewebstudio/captcha/zipball/2622c4f90dd621f19fe57e03e45f6f099509e839",
"reference": "2622c4f90dd621f19fe57e03e45f6f099509e839",
"shasum": ""
},
"require": {
"ext-gd": "*",
"illuminate/config": "~5|^6|^7|^8|^9|^10|^11|^12",
"illuminate/filesystem": "~5|^6|^7|^8|^9|^10|^11|^12",
"illuminate/hashing": "~5|^6|^7|^8|^9|^10|^11|^12",
"illuminate/session": "~5|^6|^7|^8|^9|^10|^11|^12",
"illuminate/support": "~5|^6|^7|^8|^9|^10|^11|^12",
"intervention/image": "^3.7",
"php": "^7.2|^8.1|^8.2|^8.3"
},
"require-dev": {
"mockery/mockery": "^1.0",
"phpunit/phpunit": "^8.5|^9.5.10|^10.5|^11"
},
"type": "package",
"extra": {
"laravel": {
"aliases": {
"Captcha": "Mews\\Captcha\\Facades\\Captcha"
},
"providers": [
"Mews\\Captcha\\CaptchaServiceProvider"
]
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Mews\\Captcha\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Muharrem ERİN",
"email": "me@mewebstudio.com",
"homepage": "https://github.com/mewebstudio",
"role": "Developer"
}
],
"description": "Laravel 5/6/7/8/9/10/11/12 Captcha Package",
"homepage": "https://github.com/mewebstudio/captcha",
"keywords": [
"captcha",
"laravel12 Captcha",
"laravel12 Security",
"laravel5 Security"
],
"support": {
"issues": "https://github.com/mewebstudio/captcha/issues",
"source": "https://github.com/mewebstudio/captcha/tree/3.4.7"
},
"time": "2025-10-11T14:42:33+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",

View File

@@ -13,10 +13,67 @@ return new class extends Migration
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->string('username', 50)->unique()->comment('用户名');
$table->string('password')->comment('密码 (MD5 or Bcrypt)');
$table->string('email', 250)->nullable()->comment('邮箱');
$table->tinyInteger('sex')->default(0)->comment('性别 (0保密 1男 2女)');
$table->tinyInteger('user_level')->default(1)->comment('用户等级');
$table->dateTime('log_time')->nullable()->comment('登录时间');
$table->integer('visit_num')->default(0)->comment('访问次数');
$table->dateTime('in_time')->nullable()->comment('进房时间');
$table->tinyInteger('out_info')->default(0)->comment('退出信息');
$table->dateTime('out_time')->nullable()->comment('退出时间');
$table->integer('exp_num')->default(0)->index()->comment('经验值');
$table->tinyInteger('f_size')->nullable()->comment('字体大小');
$table->tinyInteger('l_height')->nullable()->comment('行高');
$table->tinyInteger('n_color')->nullable()->comment('名称颜色');
$table->integer('s_color')->nullable()->comment('发言颜色');
$table->string('remand', 250)->nullable()->comment('密码提示问题');
$table->string('answer', 250)->nullable()->comment('密码提示答案');
$table->string('bgcolor', 50)->nullable()->comment('背景颜色');
$table->string('temppass', 20)->nullable()->comment('临时密码');
$table->string('oicq', 30)->nullable()->comment('QQ号');
$table->tinyInteger('saved')->nullable()->comment('是否保存');
$table->string('first_ip', 50)->nullable()->comment('首次IP');
$table->string('last_ip', 50)->nullable()->comment('最后IP');
$table->string('aihaos', 250)->nullable()->comment('爱好');
$table->string('friends', 250)->nullable()->comment('好友列表');
$table->integer('headface')->nullable()->comment('头像');
$table->integer('room_id')->default(0)->index()->comment('所在房间');
$table->tinyInteger('auto_update')->nullable()->comment('自动刷新');
$table->string('ppass', 50)->nullable()->comment('二级密码');
$table->integer('jjb')->default(0)->comment('交友币/金币');
$table->string('love', 50)->nullable()->comment('伴侣');
$table->string('gzdw', 50)->nullable()->comment('工作单位');
$table->integer('photo')->nullable()->comment('照片 (对应原表中文列名照片)');
$table->integer('hj')->default(0)->comment('呼叫状态');
$table->string('djname', 50)->nullable()->comment('等级名称');
$table->string('usersf', 50)->nullable()->comment('用户身份');
$table->integer('yh')->nullable()->comment('隐身状态');
$table->text('userpassword')->nullable()->comment('备用密码');
$table->string('huiyuan', 50)->nullable()->comment('会员组别');
$table->dateTime('hy_time')->nullable()->comment('会员到期时间');
$table->string('tuijian', 50)->nullable()->comment('推荐人');
$table->string('tuijian_ip', 50)->nullable()->comment('推荐IP');
$table->string('xiaohai', 50)->nullable()->comment('小孩');
$table->string('qingren', 50)->nullable()->comment('情人');
$table->string('zhufang', 50)->nullable()->comment('住房');
$table->string('zuoqiimg', 50)->nullable()->comment('坐骑图片');
$table->string('zuoqi', 50)->nullable()->comment('坐骑名称');
$table->integer('guanli')->nullable()->comment('管理员标记');
$table->integer('meili')->nullable()->comment('魅力值');
$table->integer('teshu')->nullable()->comment('特殊权限');
$table->dateTime('xr_time')->nullable()->comment('仙人时间');
$table->dateTime('yx_time')->nullable()->comment('英雄时间');
$table->integer('q1')->nullable();
$table->integer('q2')->nullable();
$table->integer('q3')->nullable();
$table->string('hua', 255)->nullable()->comment('鲜花');
$table->dateTime('sj')->nullable()->comment('注册/更新时间');
$table->string('pig', 255)->nullable()->comment('宠物');
$table->text('qianming')->nullable()->comment('个性签名');
$table->dateTime('q3_time')->nullable();
$table->rememberToken();
$table->timestamps();
});

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('rooms', function (Blueprint $table) {
$table->id();
$table->string('room_name', 10)->unique()->comment('房间标识/名称');
$table->string('room_auto', 10)->nullable()->comment('房间别名/自动属性');
$table->string('room_owner', 10)->nullable()->comment('房主');
$table->string('room_des', 250)->nullable()->comment('房间描述');
$table->string('room_top', 100)->nullable()->comment('置顶信息');
$table->string('room_title', 250)->nullable()->comment('房间标题');
$table->tinyInteger('room_keep')->default(0)->comment('是否保留');
$table->dateTime('room_time')->nullable()->comment('建立/最后时间');
$table->tinyInteger('room_tt')->default(0)->comment('相关开关');
$table->tinyInteger('room_html')->default(0)->comment('是否允许HTML');
$table->integer('room_exp')->default(0)->comment('所需经验');
$table->dateTime('build_time')->nullable()->comment('建立时间');
$table->tinyInteger('permit_level')->default(1)->comment('允许进入的最低等级');
$table->tinyInteger('door_open')->default(1)->comment('大门是否开启 (1开 0关)');
$table->integer('ooooo')->nullable()->comment('未知/扩展属性o5');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('rooms');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->dateTime('occ_time')->nullable()->index()->comment('发生时间');
$table->text('occ_env')->nullable()->comment('发生环境/详情');
$table->tinyInteger('stype')->default(0)->comment('类别标识');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('guestbooks', function (Blueprint $table) {
$table->id();
$table->string('who', 50)->index()->comment('留言人');
$table->string('towho', 50)->nullable()->index()->comment('给谁留言');
$table->tinyInteger('secret')->default(0)->comment('是否悄悄话 (1是 0否)');
$table->string('ip', 50)->nullable()->comment('留言者IP');
$table->string('email', 250)->nullable()->comment('留言者邮箱');
$table->string('web', 250)->nullable()->comment('留言者网站');
$table->string('addr', 250)->nullable()->comment('留言者地址');
$table->dateTime('post_time')->nullable()->index()->comment('留言时间');
$table->string('text_title', 250)->nullable()->comment('留言标题');
$table->text('text_body')->nullable()->comment('留言内容');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('guestbooks');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('ip_locks', function (Blueprint $table) {
$table->id();
$table->string('ip', 20)->unique()->comment('封锁的IP');
$table->dateTime('end_time')->nullable()->comment('封禁结束时间');
$table->tinyInteger('act_level')->unsigned()->default(1)->comment('封禁级别');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ip_locks');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('messages', function (Blueprint $table) {
$table->id();
$table->integer('room_id')->index()->comment('房间ID');
$table->string('from_user', 50)->index()->comment('发送者');
$table->string('to_user', 50)->nullable()->index()->comment('接收者');
$table->text('content')->comment('消息内容');
$table->tinyInteger('is_secret')->default(0)->comment('是否私聊');
$table->string('font_color', 50)->nullable()->comment('字体颜色');
$table->string('action', 50)->nullable()->comment('动作');
$table->dateTime('sent_at')->useCurrent()->index()->comment('发送时间');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('messages');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('sys_params', function (Blueprint $table) {
$table->id();
$table->string('alias', 50)->unique()->comment('参数别名');
$table->text('guidetxt')->nullable()->comment('参数说明');
$table->text('body')->nullable()->comment('参数值');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sys_params');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('actions', function (Blueprint $table) {
$table->id();
$table->string('act_name', 50)->unique()->comment('动作名称');
$table->string('alias', 50)->nullable()->comment('动作别名');
$table->string('toall', 200)->nullable()->comment('对所有人表现');
$table->string('toself', 200)->nullable()->comment('对自己的表现');
$table->string('toother', 200)->nullable()->comment('对其他人的表现');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('actions');
}
};

View File

@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('admin_logs', function (Blueprint $table) {
$table->id();
$table->string('username', 50)->index()->comment('管理员账号');
$table->integer('uu_level')->nullable()->comment('管理员等级');
$table->dateTime('in_time')->nullable()->comment('进入时间');
$table->integer('out_time')->nullable()->comment('离开时间');
$table->text('caozuo')->nullable()->comment('操作详情');
$table->integer('fs_sl')->default(0)->comment('封杀数量');
$table->text('fs_name')->nullable()->comment('封杀名单');
$table->integer('tr_sl')->default(0)->comment('踢人数量');
$table->text('tr_name')->nullable()->comment('踢人名单');
$table->integer('jg_sl')->default(0)->comment('警告数量');
$table->text('jg_name')->nullable()->comment('警告名单');
$table->integer('dj_sl')->default(0)->comment('冻结数量');
$table->text('dj_name')->nullable()->comment('冻结名单');
$table->integer('yjdj_sl')->default(0)->comment('永久冻结数量');
$table->text('yjdj_name')->nullable()->comment('永久冻结名单');
$table->integer('fip_sl')->default(0)->comment('封IP数量');
$table->text('fip_name')->nullable()->comment('封IP名单');
$table->integer('fjj_sl')->default(0)->comment('发奖金数量');
$table->integer('fjy_sl')->default(0)->comment('发经验数量');
$table->integer('flh_sl')->default(0)->comment('发老虎数量');
$table->dateTime('jl_time')->nullable()->index()->comment('记录时间');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('admin_logs');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('friend_calls', function (Blueprint $table) {
$table->id();
$table->string('who', 50)->index()->comment('呼叫人');
$table->string('towho', 50)->index()->comment('被呼叫人');
$table->text('callmess')->nullable()->comment('呼叫信息');
$table->dateTime('calltime')->nullable()->index()->comment('呼叫时间');
$table->tinyInteger('read')->default(0)->comment('是否已读 (1是 0否)');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('friend_calls');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('friend_requests', function (Blueprint $table) {
$table->id();
$table->string('who', 50)->index()->comment('申请人');
$table->string('towho', 50)->index()->comment('被申请人');
$table->dateTime('sub_time')->nullable()->index()->comment('申请时间');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('friend_requests');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('ip_logs', function (Blueprint $table) {
$table->id();
$table->string('ip', 50)->index()->comment('登录IP');
$table->dateTime('sdate')->nullable()->index()->comment('登录时间');
$table->string('uuname', 50)->index()->comment('登录用户名');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ip_logs');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('marriages', function (Blueprint $table) {
$table->id();
$table->string('hyname', 50)->index()->comment('夫');
$table->string('hyname1', 50)->index()->comment('妻');
$table->dateTime('hytime')->nullable()->comment('结婚时间');
$table->string('hygb', 50)->nullable()->comment('婚姻状态/广播');
$table->string('hyjb', 50)->nullable()->comment('婚姻级别');
$table->integer('i')->nullable()->comment('亲密度/属性');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('marriages');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('room_descriptions', function (Blueprint $table) {
$table->id();
$table->string('alias', 10)->unique()->comment('房间别名关联');
$table->string('describ', 254)->nullable()->comment('房间详细描述文本');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('room_descriptions');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('scroll_ads', function (Blueprint $table) {
$table->id();
$table->string('ad_title', 250)->comment('广告/公告标题');
$table->string('ad_link', 250)->nullable()->comment('链接地址');
$table->tinyInteger('ad_new_flag')->default(0)->comment('新窗口打开标识 (1是 0否)');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('scroll_ads');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_items', function (Blueprint $table) {
$table->id();
$table->string('name', 50)->index()->comment('使用者');
$table->dateTime('times')->nullable()->index()->comment('获得时间');
$table->text('gg')->nullable()->comment('道具类型/说明');
$table->integer('dayy')->nullable()->comment('道具天数/数量');
$table->string('lx', 50)->nullable()->comment('道具种类');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_items');
}
};

View File

@@ -17,9 +17,13 @@ class DatabaseSeeder extends Seeder
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
// User::factory()->create([
// 'name' => 'Test User',
// 'email' => 'test@example.com',
// ]);
$this->call([
SysParamSeeder::class,
]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Database\Seeders;
use App\Models\SysParam;
use Illuminate\Database\Seeder;
class SysParamSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$params = [
['alias' => 'sys_name', 'guidetxt' => '聊天室名称', 'body' => '飘落的流星在线聊天'],
['alias' => 'max_people', 'guidetxt' => '最大人数限制', 'body' => '500'],
['alias' => 'welcome_msg', 'guidetxt' => '欢迎信息', 'body' => '欢迎来到飘落的流星在线聊天室!'],
['alias' => 'sys_notice', 'guidetxt' => '系统公告', 'body' => '系统重构中体验Laravel 12 + Reverb全新架构。'],
['alias' => 'name_length', 'guidetxt' => '用户名最大长度', 'body' => '10'],
['alias' => 'say_length', 'guidetxt' => '发言最大长度', 'body' => '250'],
];
foreach ($params as $param) {
SysParam::updateOrCreate(
['alias' => $param['alias']],
$param
);
}
}
}

18
package-lock.json generated
View File

@@ -4,15 +4,13 @@
"requires": true,
"packages": {
"": {
"dependencies": {
"laravel-echo": "^2.3.0",
"pusher-js": "^8.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"axios": "^1.11.0",
"concurrently": "^9.0.1",
"laravel-echo": "^2.3.0",
"laravel-vite-plugin": "^2.0.0",
"pusher-js": "^8.4.0",
"tailwindcss": "^4.0.0",
"vite": "^7.0.7"
}
@@ -863,6 +861,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"dev": true,
"license": "MIT",
"peer": true
},
@@ -1311,6 +1310,7 @@
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
@@ -1371,6 +1371,7 @@
"version": "6.6.4",
"resolved": "https://registry.npmmirror.com/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
@@ -1385,6 +1386,7 @@
"version": "5.2.3",
"resolved": "https://registry.npmmirror.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
@@ -1732,6 +1734,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/laravel-echo/-/laravel-echo-2.3.0.tgz",
"integrity": "sha512-wgHPnnBvfHmu2I58xJ4asZH37Nu6P0472ku6zuoGRLc3zEWwIbpovDLYTiOshDH1SM7rA6AjZTKuu+jYoM1tpQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
@@ -2069,6 +2072,7 @@
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT",
"peer": true
},
@@ -2151,6 +2155,7 @@
"version": "8.4.0",
"resolved": "https://registry.npmmirror.com/pusher-js/-/pusher-js-8.4.0.tgz",
"integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tweetnacl": "^1.0.3"
@@ -2238,6 +2243,7 @@
"version": "4.8.3",
"resolved": "https://registry.npmmirror.com/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
@@ -2254,6 +2260,7 @@
"version": "4.2.5",
"resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
@@ -2377,6 +2384,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"dev": true,
"license": "Unlicense"
},
"node_modules/vite": {
@@ -2500,6 +2508,7 @@
"version": "8.18.3",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
@@ -2522,6 +2531,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"dev": true,
"peer": true,
"engines": {
"node": ">=0.4.0"

View File

@@ -10,12 +10,10 @@
"@tailwindcss/vite": "^4.0.0",
"axios": "^1.11.0",
"concurrently": "^9.0.1",
"laravel-echo": "^2.3.0",
"laravel-vite-plugin": "^2.0.0",
"pusher-js": "^8.4.0",
"tailwindcss": "^4.0.0",
"vite": "^7.0.7"
},
"dependencies": {
"laravel-echo": "^2.3.0",
"pusher-js": "^8.4.0"
}
}

View File

@@ -1,4 +1,19 @@
import axios from 'axios';
import axios from "axios";
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
window.axios.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest";
import Echo from "laravel-echo";
import Pusher from "pusher-js";
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: "reverb",
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 8080,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? "https") === "https",
enabledTransports: ["ws", "wss"],
});

63
resources/js/chat.js Normal file
View File

@@ -0,0 +1,63 @@
import "./bootstrap";
// 这个文件负责处理浏览器与 Laravel Reverb WebSocket 服务器的通信。
// 通过 Presence Channel 实现聊天室的核心监听。
export function initChat(roomId) {
if (!roomId) {
console.error("未提供 roomId无法初始化 WebSocket 连接。");
return;
}
// 加入带有登录人员追踪的 Presence Channel
window.Echo.join(`room.${roomId}`)
// 当自己成功连接时,获取当前在这里的所有人列表
.here((users) => {
console.log("当前房间内的在线人员:", users);
// 触发自定义事件,让具体的前端 UI 去接管渲染
window.dispatchEvent(
new CustomEvent("chat:here", { detail: users }),
);
})
// 监听其他人的加入
.joining((user) => {
console.log(user.username + " 进入了房间");
window.dispatchEvent(
new CustomEvent("chat:joining", { detail: user }),
);
})
// 监听其他人的离开
.leaving((user) => {
console.log(user.username + " 离开了房间");
window.dispatchEvent(
new CustomEvent("chat:leaving", { detail: user }),
);
})
// 监听新发送的文本消息
.listen("MessageSent", (e) => {
console.log("收到新发言:", e.message);
window.dispatchEvent(
new CustomEvent("chat:message", { detail: e.message }),
);
})
// 监听踢出事件(通常判断是不是自己被踢出了)
.listen("UserKicked", (e) => {
console.log("踢出通知:", e);
window.dispatchEvent(new CustomEvent("chat:kicked", { detail: e }));
})
// 监听封口禁言事件
.listen("UserMuted", (e) => {
console.log("禁言通知:", e);
window.dispatchEvent(new CustomEvent("chat:muted", { detail: e }));
})
// 监听房间主题被改变
.listen("RoomTitleUpdated", (e) => {
console.log("主题改变:", e);
window.dispatchEvent(
new CustomEvent("chat:title-updated", { detail: e }),
);
});
}
// 供全局调用
window.initChat = initChat;

View File

@@ -0,0 +1,47 @@
@extends('admin.layouts.app')
@section('title', '仪表盘')
@section('content')
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<h3 class="text-gray-500 text-sm font-medium mb-1">总计注册用户数</h3>
<p class="text-3xl font-bold text-gray-800">{{ $stats['total_users'] }}</p>
</div>
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<h3 class="text-gray-500 text-sm font-medium mb-1">总计聊天频道数</h3>
<p class="text-3xl font-bold text-gray-800">{{ $stats['total_rooms'] }}</p>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
<h2 class="text-lg font-bold text-gray-800">系统信息摘要</h2>
</div>
<div class="p-6">
<ul class="space-y-3">
<li class="flex items-center font-mono text-sm">
<span class="w-32 text-gray-500 inline-block font-sans">Laravel 版本:</span>
<span class="text-indigo-600">{{ app()->version() }}</span>
</li>
<li class="flex items-center font-mono text-sm">
<span class="w-32 text-gray-500 inline-block font-sans">PHP 版本:</span>
<span class="text-indigo-600">{{ PHP_VERSION }}</span>
</li>
<li class="flex items-center text-sm font-mono mt-4 pt-4 border-t">
<span class="mr-4 text-gray-500 inline-block font-sans items-center flex">队列监控面板</span>
<!-- Laravel Horizon 的默认路由前缀由开发者确认或自己改。这里默认是 /horizon -->
<a href="{{ url('/horizon') }}" target="_blank"
class="text-blue-600 hover:text-blue-800 hover:underline flex items-center">
<span>打开 Horizon 控制台</span>
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</a>
</li>
</ul>
</div>
</div>
@endsection

View File

@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>后台管理 - 飘落流星</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="bg-gray-100 flex h-screen text-gray-800">
<!-- 左侧侧边栏 -->
<aside class="w-64 bg-slate-900 text-white flex flex-col">
<div class="p-6 text-center border-b border-white/10">
<h2 class="text-2xl font-extrabold tracking-widest uppercase">Admin</h2>
<p class="text-xs text-slate-400 mt-2">飘落流星 控制台</p>
</div>
<nav class="flex-1 px-4 py-6 space-y-2 overflow-y-auto">
<a href="{{ route('admin.dashboard') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.dashboard') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
📊 仪表盘
</a>
<a href="{{ route('admin.system.edit') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.system.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
⚙️ 系统参数参数
</a>
<a href="{{ route('admin.users.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.users.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
👥 用户管理
</a>
<a href="{{ route('admin.sql.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.sql.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
💾 SQL 探针
</a>
</nav>
<div class="p-4 border-t border-white/10">
<a href="{{ route('rooms.index') }}"
class="block w-full text-center px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded transition text-sm">
返回前台大厅
</a>
</div>
</aside>
<!-- 右侧主体内容 -->
<main class="flex-1 flex flex-col h-full overflow-hidden relative">
<!-- 顶栏 -->
<header class="bg-white shadow relative z-20 flex items-center justify-between px-6 py-4">
<h1 class="text-xl font-bold text-gray-700">@yield('title', '控制台')</h1>
<div class="flex items-center space-x-4">
<span class="text-sm font-medium">当前操作人: <span
class="text-indigo-600">{{ Auth::user()->username }}</span></span>
</div>
</header>
<!-- 内容滚动区 -->
<div class="flex-1 overflow-y-auto p-6 relative z-10">
@if (session('success'))
<div class="mb-6 bg-emerald-100 border-l-4 border-emerald-500 text-emerald-700 p-4 rounded shadow-sm">
{{ session('success') }}
</div>
@endif
@if (session('error'))
<div class="mb-6 bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded shadow-sm">
{{ session('error') }}
</div>
@endif
@if ($errors->any())
<div class="mb-6 bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded shadow-sm">
<ul class="list-disc list-inside text-sm">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
@yield('content')
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,98 @@
@extends('admin.layouts.app')
@section('title', 'SQL 战术沙盒探针')
@section('content')
<div class="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded text-red-700 shadow-sm text-sm">
<p class="font-bold flex items-center">
<span class="mr-2">⚠️</span> 顶级安全警告
</p>
<p class="mt-1 ml-6">
此操作直接连通底层 MySQL 数据库。为杜绝《删库跑路》等生产事故,本控制台已硬编码拦截过滤:只会放行以 <code>SELECT</code>, <code>SHOW</code>,
<code>EXPLAIN</code> 等起手的<strong>纯只读语句</strong>。所有的增删改一律阻断。
</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden mb-6">
<div class="p-6">
<form action="{{ route('admin.sql.execute') }}" method="POST">
@csrf
<div class="mb-4">
<label class="block text-sm font-bold text-gray-700 mb-2">输入原始只读 SQL 语句</label>
<textarea name="query" rows="5" required placeholder="SELECT * FROM users ORDER BY id DESC LIMIT 10;"
class="w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-4 bg-gray-50 border font-mono resize-y">{{ old('query', $query ?? '') }}</textarea>
</div>
<div class="flex justify-end pt-2">
<button type="submit"
class="px-6 py-2 bg-slate-800 text-white rounded-md font-bold hover:bg-slate-900 shadow-sm transition flex items-center">
<span>🔥 </span>
</button>
</div>
</form>
</div>
</div>
{{-- 结果展示区 --}}
@isset($error)
<div
class="bg-red-50 border border-red-200 text-red-700 p-6 rounded-xl shadow-sm mb-6 overflow-x-auto font-mono text-sm whitespace-pre-wrap">
{{ $error }}
</div>
@endif
@isset($results)
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="bg-gray-50 px-6 py-3 border-b flex justify-between items-center text-sm font-bold text-gray-700">
<span>查询结果 ( {{ count($results) }} )</span>
</div>
<div class="overflow-x-auto p-4 max-h-[600px] custom-scrollbar overflow-y-auto">
@if (empty($results))
<div class="text-center text-gray-400 py-10 font-bold">SQL 执行成功,但返回了空结果集 (0 rows)</div>
@else
<table class="w-full text-left border-collapse text-sm">
<thead>
<tr class="border-b-2 border-indigo-500">
@foreach ($columns as $col)
<th
class="p-3 font-bold text-gray-600 whitespace-nowrap bg-indigo-50/50 sticky top-0 z-10 shadow-sm">
{{ $col }}</th>
@endforeach
</tr>
</thead>
<tbody class="divide-y divide-gray-100 font-mono">
@foreach ($results as $row)
<tr class="hover:bg-amber-50 transition">
@foreach ($columns as $col)
<td class="p-3 whitespace-nowrap text-gray-700">{{ $row->$col ?? 'NULL' }}</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
@endif
</div>
</div>
@endisset
<style>
/* 针对该表格页加深一点滚动条以便查看超长字段 */
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #94a3b8;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background-color: #f1f5f9;
}
</style>
@endsection

View File

@@ -0,0 +1,44 @@
@extends('admin.layouts.app')
@section('title', '系统参数配置')
@section('content')
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50">
<div>
<h2 class="text-lg font-bold text-gray-800">修改系统运行参数</h2>
<p class="text-xs text-gray-500 mt-1">保存后会同步更新 Redis 缓存,前台实时生效。</p>
</div>
</div>
<div class="p-6">
<form action="{{ route('admin.system.update') }}" method="POST">
@csrf
@method('PUT')
<div class="space-y-6 max-w-2xl">
@foreach ($params as $alias => $body)
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">
{{ $descriptions[$alias] ?? $alias }}
<span class="text-gray-400 font-normal ml-2">[{{ $alias }}]</span>
</label>
@if (strlen($body) > 50 || str_contains($body, "\n") || str_contains($body, '<'))
<textarea name="{{ $alias }}" rows="4"
class="w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2.5 bg-gray-50 border whitespace-pre-wrap">{{ $body }}</textarea>
@else
<input type="text" name="{{ $alias }}" value="{{ $body }}"
class="w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2.5 bg-gray-50 border">
@endif
</div>
@endforeach
</div>
<div class="mt-8 pt-6 border-t flex space-x-3">
<button type="submit"
class="px-6 py-2 bg-indigo-600 text-white rounded-md font-bold hover:bg-indigo-700 shadow-sm transition">保存并发布</button>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -0,0 +1,138 @@
@extends('admin.layouts.app')
@section('title', '用户检索与管理')
@section('content')
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden mb-6" x-data="{ showEditModal: false, editingUser: {} }">
<div class="p-6 border-b border-gray-100 bg-gray-50 flex items-center justify-between">
<form action="{{ route('admin.users.index') }}" method="GET" class="flex gap-2">
<input type="text" name="username" value="{{ request('username') }}" placeholder="搜索用户名..."
class="px-3 py-1.5 border border-gray-300 rounded shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
<button type="submit"
class="bg-indigo-600 text-white px-4 py-1.5 rounded hover:bg-indigo-700 font-bold shadow-sm transition">搜索</button>
<a href="{{ route('admin.users.index') }}"
class="px-4 py-1.5 bg-white border border-gray-300 rounded text-gray-700 hover:bg-gray-50">重置</a>
</form>
</div>
<!-- 用户表格 -->
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr
class="bg-gray-50 border-b border-gray-100 text-xs text-gray-500 uppercase font-bold tracking-wider">
<th class="p-4">ID</th>
<th class="p-4">注册名</th>
<th class="p-4">性别</th>
<th class="p-4">等级</th>
<th class="p-4">个性签名</th>
<th class="p-4">注册时间</th>
<th class="p-4 text-right">管理操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach ($users as $user)
<tr class="hover:bg-gray-50 transition">
<td class="p-4 font-mono text-xs text-gray-500">{{ $user->id }}</td>
<td class="p-4">
<div class="flex items-center space-x-3">
<img src="/images/headface/{{ $user->headface ?? '01.gif' }}"
class="w-8 h-8 rounded border object-cover">
<span class="font-bold text-gray-800">{{ $user->username }}</span>
</div>
</td>
<td class="p-4 text-sm">{{ $user->sex }}</td>
<td class="p-4">
<span
class="px-2 py-0.5 rounded-full text-xs {{ $user->user_level >= 15 ? 'bg-red-100 text-red-700 font-bold' : 'bg-gray-100 text-gray-600' }}">
LV.{{ $user->user_level }}
</span>
</td>
<td class="p-4 text-sm text-gray-500 truncate max-w-[200px]" title="{{ $user->sign }}">
{{ $user->sign ?: '-' }}</td>
<td class="p-4 text-sm font-mono text-gray-500">{{ $user->created_at->format('Y/m/d H:i') }}
</td>
<td class="p-4 text-right space-x-2 relative" x-data>
<button
@click="editingUser = { id: {{ $user->id }}, username: '{{ addslashes($user->username) }}', user_level: {{ $user->user_level }}, sex: '{{ $user->sex }}', requestUrl: '{{ route('admin.users.update', $user->id) }}' }; showEditModal = true"
class="text-xs bg-indigo-50 text-indigo-600 font-bold px-3 py-1.5 rounded hover:bg-indigo-600 hover:text-white transition cursor-pointer">
详细 / 修改
</button>
<form action="{{ route('admin.users.destroy', $user->id) }}" method="POST" class="inline"
onsubmit="return confirm('危险:确定彻底物理清除用户 [{{ $user->username }}] 吗?数据不可恢复!')">
@csrf @method('DELETE')
<button type="submit"
class="text-xs bg-red-50 text-red-600 font-bold px-3 py-1.5 rounded hover:bg-red-600 hover:text-white transition cursor-pointer">
强杀
</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<!-- 分页链接 -->
@if ($users->hasPages())
<div class="p-4 border-t border-gray-100">
{{ $users->links() }}
</div>
@endif
<!-- 弹出的修改框 -->
<div x-show="showEditModal" style="display: none;"
class="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4">
<div @click.away="showEditModal = false"
class="bg-white rounded-xl shadow-2xl w-full max-w-md transform transition-all" x-transition>
<div
class="bg-indigo-900 border-b border-indigo-800 px-6 py-4 flex justify-between items-center rounded-t-xl text-white">
<h3 class="font-bold text-lg">全量修改:<span x-text="editingUser.username" class="text-indigo-300"></span>
</h3>
<button @click="showEditModal = false" class="text-gray-400 hover:text-white">&times;</button>
</div>
<div class="p-6">
<!-- 依靠 Alpine 绑定的 AJAX 或者 Form 提交 -->
<form :action="editingUser.requestUrl" method="POST" id="adminUserUpdateForm">
@csrf @method('PUT')
<div class="mb-4">
<label class="block text-sm font-bold text-gray-700 mb-2">安全等级 (0-99)</label>
<input type="number" name="user_level" x-model="editingUser.user_level" required
class="w-full border-gray-300 rounded shadow-sm focus:ring-indigo-500">
</div>
<div class="mb-4">
<label class="block text-sm font-bold text-gray-700 mb-2">性别</label>
<select name="sex" x-model="editingUser.sex"
class="w-full border-gray-300 rounded shadow-sm focus:ring-indigo-500">
<option value=""></option>
<option value=""></option>
<option value="保密">保密</option>
</select>
</div>
<div class="mb-6">
<label
class="block text-sm font-bold pl-1 text-red-600 border-l-4 border-red-500 bg-red-50 p-2 mb-2">强制重算密码
(留空则不修改)</label>
<input type="text" name="password" placeholder="强行输入新密码覆盖"
class="w-full border-red-300 rounded shadow-sm focus:border-red-500 focus:ring-red-500 placeholder-red-300">
</div>
<div class="flex justify-end space-x-3 pt-4 border-t border-gray-100">
<button type="button" @click="showEditModal = false"
class="px-4 py-2 border rounded font-medium text-gray-600 hover:bg-gray-50">取消</button>
<button type="submit"
class="px-4 py-2 bg-indigo-600 text-white rounded font-bold hover:bg-indigo-700 shadow-sm">提交强制改写</button>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,488 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $room->name ?? '聊天室' }} - 飘落流星</title>
<!-- 引入全局 CSRF Token -->
<meta name="csrf-token" content="{{ csrf_token() }}">
<!-- 传递必要的上下文数据给 chat.js 模块使用 -->
<script>
window.chatContext = {
roomId: {{ $room->id }},
username: "{{ $user->username }}",
userLevel: {{ $user->user_level }},
sendUrl: "{{ route('chat.send', $room->id) }}",
leaveUrl: "{{ route('chat.leave', $room->id) }}"
};
</script>
<script src="https://cdn.tailwindcss.com"></script>
@vite(['resources/css/app.css', 'resources/js/app.js', 'resources/js/chat.js'])
<style>
/* 自定义滚动条样式,让界面更清爽 */
.chat-scroll::-webkit-scrollbar {
width: 6px;
}
.chat-scroll::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 4px;
}
.chat-scroll::-webkit-scrollbar-track {
background-color: transparent;
}
</style>
</head>
<body class="bg-gray-100 flex h-screen overflow-hidden text-sm">
<!-- 左侧/中间部分:主聊天区域与控制台 -->
<div class="flex-1 flex flex-col h-full bg-white relative">
<!-- 头部房间信息栏 -->
<header
class="h-14 border-b bg-gradient-to-r from-blue-500 to-indigo-600 text-white flex items-center justify-between px-6 shadow-sm z-10">
<div class="flex items-center space-x-3">
<span class="font-bold text-lg tracking-wide">{{ $room->name }}</span>
<span id="room-title-display"
class="text-xs bg-white/20 px-2 py-1 rounded-full">{{ $room->description ?? '欢迎光临!' }}</span>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm opacity-90"><i class="font-semibold">{{ $user->username }}</i>
(LV.{{ $user->user_level }})</span>
<button type="button" onclick="leaveRoom()"
class="px-3 py-1.5 bg-white/10 hover:bg-red-500 hover:text-white rounded transition text-xs font-semibold">
退出房间
</button>
</div>
</header>
<!-- 聊天记录展示区 -->
<main id="chat-messages-container" class="flex-1 overflow-y-auto p-4 chat-scroll bg-gray-50/50 space-y-3">
<div class="text-center text-xs text-gray-400 my-4">-- 以上是历史消息 --</div>
<!-- 气泡动态挂载点 -->
</main>
<!-- 底部发言控制区 -->
<footer class="h-auto min-h-24 bg-white border-t p-3 shadow-inner z-10">
<form id="chat-form" onsubmit="sendMessage(event)" class="max-w-5xl mx-auto flex flex-col space-y-2">
<div class="flex items-center space-x-3 text-xs text-gray-600 px-1">
<label class="flex items-center space-x-1 cursor-pointer">
<span></span>
<select id="to_user" name="to_user"
class="border border-gray-300 rounded px-1 py-0.5 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 bg-gray-50">
<option value="大家" selected>所有人</option>
<!-- 在线人员将动态加载到这里 -->
</select>
<span>:</span>
</label>
<label class="flex items-center space-x-1 cursor-pointer">
<span>动作:</span>
<select id="action" name="action"
class="border border-gray-300 rounded px-1 py-0.5 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 bg-gray-50">
<option value="">(无动作)</option>
<option value="微笑">微笑</option>
<option value="大笑">大笑</option>
<option value="愤怒">愤怒</option>
</select>
</label>
<label class="flex items-center space-x-1 cursor-pointer">
<span>字体色:</span>
<input type="color" id="font_color" name="font_color" value="#000000"
class="w-6 h-6 p-0 border-0 cursor-pointer rounded">
</label>
<label class="flex items-center space-x-1 cursor-pointer hover:text-blue-600 transition">
<input type="checkbox" id="is_secret" name="is_secret" value="1"
class="text-blue-500 rounded focus:ring-blue-500">
<span>悄悄话</span>
</label>
</div>
<div class="flex items-end space-x-2">
<textarea id="content" name="content" rows="2"
class="flex-1 border border-gray-300 rounded-md p-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none resize-none transition-shadow text-sm"
placeholder="在这里输入聊天内容,按 Ctrl+Enter 快捷发送..."></textarea>
<button type="submit" id="send-btn"
class="h-11 px-6 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-md shadow transition flex-shrink-0">
发送消息
</button>
</div>
</form>
</footer>
</div>
<!-- 右侧:在线人员面板 -->
<aside class="w-64 bg-gray-50 border-l flex flex-col h-full shadow-lg z-20">
<div class="h-14 border-b bg-gray-100 flex items-center justify-center font-bold text-gray-700 tracking-wider">
📶 在线人员 (<span id="online-count">0</span>)
</div>
<div class="flex-1 overflow-y-auto p-2 chat-scroll">
<ul id="online-users-list" class="space-y-1">
<!-- 在线列表渲染点 -->
<li class="flex items-center justify-center h-full text-xs text-gray-400 mt-10">加载中...</li>
</ul>
</div>
</aside>
<!-- 弹窗容器 (Alpine 作用域外置挂载) -->
<div id="user-modal-container" x-data="{
showUserModal: false,
userInfo: {},
isMuting: false,
muteDuration: 5,
// 获取用户资料
async fetchUser(username) {
try {
const res = await fetch('/user/' + encodeURIComponent(username));
this.userInfo = await res.json();
this.showUserModal = true;
this.isMuting = false;
} catch (e) {
alert('获取资料失败');
}
},
// 执行踢出
async kickUser() {
if (!confirm('确定要将 ' + this.userInfo.username + ' 踢出房间吗?')) return;
try {
const res = await fetch('/user/' + encodeURIComponent(this.userInfo.username) + '/kick', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'), 'Content-Type'
: 'application/json' , 'Accept' : 'application/json' }, body: JSON.stringify({ room_id:
window.chatContext.roomId }) }); const data=await res.json(); if(data.status === 'success') {
this.showUserModal=false; } else { alert('操作失败:' + data.message); } } catch (e) { alert('网络异常'); } }, // 执行禁言
async muteUser() { try { const res=await fetch('/user/' + encodeURIComponent(this.userInfo.username) + '/mute' ,
{ method: 'POST' , headers: { 'X-CSRF-TOKEN' :
document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'), 'Content-Type' : 'application/json'
, 'Accept' : 'application/json' }, body: JSON.stringify({ room_id: window.chatContext.roomId, duration:
this.muteDuration }) }); const data=await res.json(); if(data.status === 'success') { alert(data.message);
this.showUserModal=false; } else { alert('操作失败:' + data.message); } } catch (e) { alert('网络异常'); } } }">
<!-- 用户名片弹窗 -->
<div x-show="showUserModal" style="display: none;"
class="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div @click.away="showUserModal = false"
class="bg-white rounded-2xl shadow-2xl w-full max-w-sm overflow-hidden transform transition-all"
x-transition.scale.95>
<div class="bg-gradient-to-r from-blue-500 to-indigo-600 h-24 relative">
<button @click="showUserModal = false"
class="absolute top-3 right-3 text-white/80 hover:text-white font-bold">&times;</button>
</div>
<div class="px-6 pb-6 relative pt-12">
<!-- 头像 (暂时用占位圆圈代替,后续从 /images/headface 读取) -->
<div
class="absolute -top-12 left-6 w-20 h-20 rounded-full border-4 border-white bg-gray-200 flex items-center justify-center text-gray-400 font-bold text-xl shadow-md">
<img x-show="userInfo.headface" :src="'/images/headface/' + userInfo.headface"
class="w-full h-full rounded-full object-cover"
@error="$el.style.display='none'">
<span x-show="!userInfo.headface">Pic</span>
</div>
<div class="mt-2">
<h3 class="text-2xl font-bold text-gray-800 flex items-center space-x-2">
<span x-text="userInfo.username"></span>
<span :class="userInfo.sex === '男' ? 'bg-blue-100 text-blue-700' : (userInfo.sex === '女' ? 'bg-pink-100 text-pink-700' : 'bg-gray-100 text-gray-700')" class="text-[10px] px-2 py-0.5 rounded-full" x-text="userInfo.sex"></span>
</h3>
<p class="text-indigo-600 text-sm font-semibold mt-1">LV.<span x-text="userInfo.user_level"></span></p>
<div class="mt-4 bg-gray-50 border border-gray-100 rounded-lg p-3">
<p class="text-sm text-gray-600 italic" x-text="userInfo.sign"></p>
</div>
<p class="text-xs text-gray-400 mt-3 border-t pt-2">加入时间: <span x-text="userInfo.created_at"></span></p>
</div>
<!-- 特权操作区(仅超管或房主显示踢人操作)-->
@if (Auth::user()->user_level >= 15 || $room->master == Auth::user()->username)
<div class="mt-6 pt-4 border-t border-gray-100" x-show="userInfo.username !== window.chatContext.username && userInfo.user_level < {{ Auth::user()->user_level }}">
<p class="text-xs font-bold text-red-400 mb-2">特权操作</p>
<div class="flex space-x-2">
<button @click="kickUser()" class="flex-1 bg-red-100 text-red-700 hover:bg-red-200 py-1.5 rounded-md text-sm font-bold transition">踢出房间</button>
<button @click="isMuting = !isMuting" class="flex-1 bg-amber-100 text-amber-700 hover:bg-amber-200 py-1.5 rounded-md text-sm font-bold transition">禁言拦截</button>
</div>
<!-- 禁言表单 -->
<div x-show="isMuting" class="mt-3 bg-amber-50 rounded p-2 flex items-center space-x-2 border border-amber-200" style="display: none;">
<input type="number" x-model="muteDuration" class="w-full border-amber-300 rounded focus:ring-amber-500 text-sm px-2 py-1" placeholder="分钟" min="1">
<span class="text-xs text-amber-800 shrink-0">分钟</span>
<button @click="muteUser()" class="bg-amber-500 hover:bg-amber-600 text-white px-3 py-1 rounded text-sm font-bold shrink-0 shadow-sm">执行</button>
</div>
</div> @endif
</div>
<!-- 常规操作:飞鸽传书 私信 -->
<div class="px-6 pb-6 pt-2" x-show="userInfo.username !== window.chatContext.username">
<a :href="'{{ route('guestbook.index', ['tab' => 'outbox']) }}&to=' + encodeURIComponent(userInfo
.username)"
target="_blank"
class="w-full bg-pink-100 text-pink-700 hover:bg-pink-200 py-2.5 rounded-lg font-bold transition flex items-center justify-center shadow-sm text-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z">
</path>
</svg>
飞鸽传书 (发私信)
</a>
</div>
</div>
</div>
</div>
<script>
// 核心页面交互逻辑,连接 chat.js 抛出的自定义事件
const container = document.getElementById('chat-messages-container');
const userList = document.getElementById('online-users-list');
const toUserSelect = document.getElementById('to_user');
const onlineCount = document.getElementById('online-count');
let onlineUsers = {}; // 用于本地维护在线名单
// 辅助:滚动到底部
function scrollToBottom() {
container.scrollTop = container.scrollHeight;
}
// 辅助:渲染在线人员列表
function renderUserList() {
userList.innerHTML = '';
// 同时更新“对谁说”下拉框(保留大家选项)
toUserSelect.innerHTML = '<option value="大家">所有人</option>';
let count = 0;
for (let username in onlineUsers) {
count++;
let user = onlineUsers[username];
// 渲染右侧面板
let li = document.createElement('li');
li.className =
'px-3 py-2 hover:bg-blue-50 rounded cursor-pointer transition flex items-center justify-between border-b border-gray-100 last:border-0';
li.innerHTML = `
<div class="flex items-center space-x-2 truncate">
<span class="w-2 h-2 rounded-full bg-green-500 shrink-0 shadow-[0_0_5px_rgba(34,197,94,0.5)]"></span>
<span class="text-sm font-medium text-gray-700 truncate" title="${username}">${username}</span>
</div>
`;
// 单击右侧列表可以快速查看资料 / @ 人
li.onclick = () => {
toUserSelect.value = username;
// 触发 Alpine 挂载的查看名片方法
const modalScope = document.querySelector('[x-data]').__x.$data;
if (modalScope && username !== window.chatContext.username) {
modalScope.fetchUser(username);
}
};
userList.appendChild(li);
// 添加到“对谁说”列表
if (username !== window.chatContext.username) {
let option = document.createElement('option');
option.value = username;
option.textContent = username;
toUserSelect.appendChild(option);
}
}
onlineCount.innerText = count;
}
// 辅助:渲染单条消息气泡
function appendMessage(msg) {
const isMe = msg.from_user === window.chatContext.username;
const alignClass = isMe ? 'justify-end' : 'justify-start';
const bubbleBg = isMe ? 'bg-blue-500 text-white' : 'bg-white border border-gray-200 text-gray-800';
const textColorAttr = msg.font_color && msg.font_color !== '#000000' && msg.font_color !== '#000' && !isMe ?
`color: ${msg.font_color}` : '';
let headerText = '';
// 辅助:生成可点击的用户名 HTML
const clickableUser = (uName) =>
`<span class="cursor-pointer hover:underline hover:text-blue-600 transition" onclick="document.querySelector('[x-data]').__x.$data.fetchUser('${uName}')">${uName}</span>`;
if (msg.to_user !== '大家') {
headerText = `${clickableUser(msg.from_user)} 对 ${clickableUser(msg.to_user)} ${msg.action} 说:`;
if (msg.is_secret) headerText = `[悄悄话] ` + headerText;
} else {
headerText = `${clickableUser(msg.from_user)} ${msg.action} 说:`;
}
const div = document.createElement('div');
div.className = `flex ${alignClass} mb-3 group`;
let html = `
<div class="max-w-[75%] flex flex-col space-y-1">
<div class="text-[11px] text-gray-400 ${isMe ? 'text-right hidden group-hover:block transition-all' : 'text-left pl-1'}">${headerText} <span class="ml-2 font-mono">${msg.sent_at}</span></div>
<div class="px-4 py-2 rounded-2xl shadow-sm leading-relaxed whitespace-pre-wrap word-break ${bubbleBg}" style="${textColorAttr}">${msg.content}</div>
</div>
`;
div.innerHTML = html;
container.appendChild(div);
scrollToBottom();
}
// 🚀 初始化 WebSocket 监听器
document.addEventListener('DOMContentLoaded', () => {
if (typeof window.initChat === 'function') {
window.initChat(window.chatContext.roomId);
}
});
// 🔌 监听 WebSocket 事件
window.addEventListener('chat:here', (e) => {
const users = e.detail;
onlineUsers = {};
users.forEach(u => {
onlineUsers[u.username] = u;
});
renderUserList();
});
window.addEventListener('chat:joining', (e) => {
const user = e.detail;
onlineUsers[user.username] = user;
renderUserList();
// 可选:渲染一条系统提示“某某加入了房间”
});
window.addEventListener('chat:leaving', (e) => {
const user = e.detail;
delete onlineUsers[user.username];
renderUserList();
});
window.addEventListener('chat:message', (e) => {
const msg = e.detail;
// 过滤私聊:如果是别人对别人的悄悄话,自己不应该显示
if (msg.is_secret && msg.from_user !== window.chatContext.username && msg.to_user !== window.chatContext
.username) {
return;
}
appendMessage(msg);
});
window.addEventListener('chat:kicked', (e) => {
if (e.detail.username === window.chatContext.username) {
alert("您已被管理员踢出房间!");
window.location.href = "{{ route('home') }}";
}
});
window.addEventListener('chat:title-updated', (e) => {
document.getElementById('room-title-display').innerText = e.detail.title;
});
// 📤 发送消息逻辑
document.getElementById('content').addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
sendMessage(e);
}
});
async function sendMessage(e) {
if (e) e.preventDefault();
const form = document.getElementById('chat-form');
const formData = new FormData(form);
const contentInput = document.getElementById('content');
const submitBtn = document.getElementById('send-btn');
const content = formData.get('content').trim();
if (!content) {
contentInput.focus();
return;
}
// 锁定按钮防连点
submitBtn.disabled = true;
submitBtn.classList.add('opacity-50', 'cursor-not-allowed');
try {
const response = await fetch(window.chatContext.sendUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Accept': 'application/json'
},
body: formData
});
const data = await response.json();
if (response.ok && data.status === 'success') {
// 发送成功,清空刚才的输入并获取焦点
contentInput.value = '';
contentInput.focus();
} else {
alert('发送失败: ' + (data.message || JSON.stringify(data.errors)));
}
} catch (error) {
alert('网络连接错误,消息发送失败!');
console.error(error);
} finally {
// 解锁按钮
submitBtn.disabled = false;
submitBtn.classList.remove('opacity-50', 'cursor-not-allowed');
}
}
// 🚪 退出房间逻辑
async function leaveRoom() {
if (!confirm('确定要离开聊天室吗?')) return;
try {
await fetch(window.chatContext.leaveUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Accept': 'application/json'
}
});
} catch (e) {
console.error(e);
}
window.location.href = "{{ route('home') }}";
}
// ⏳ 自动挂机心跳 (每 3 分钟执行一次)
const HEARTBEAT_INTERVAL = 180 * 1000;
setInterval(async () => {
if (!window.chatContext || !window.chatContext.heartbeatUrl) return;
try {
const response = await fetch(window.chatContext.heartbeatUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content'),
'Accept': 'application/json'
}
});
const data = await response.json();
if (response.ok && data.status === 'success') {
// 可选:在这里如果需要更新自己的名片经验条,可触发 Alpine 等级更新(如果实现了前台独立显示自己经验的功能的话)
console.log('心跳存点成功,当前经验值:' + data.data.exp_num + ', 等级:' + data.data.user_level);
}
} catch (e) {
console.error('挂机心跳断开', e);
}
}, HEARTBEAT_INTERVAL);
</script>
</body>
</html>

View File

@@ -0,0 +1,244 @@
@extends('layouts.app')
@section('title', '星光留言板')
@section('content')
<div class="h-screen w-full flex flex-col bg-gray-50 overflow-hidden font-sans" x-data="{ showWriteForm: false, towho: '{{ $defaultTo }}' }">
<!-- 顶部导航条 -->
<header class="bg-indigo-900 border-b border-indigo-800 text-white shadow-md relative z-20 shrink-0">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- 左侧:标题与返回大厅 -->
<div class="flex items-center space-x-4">
<a href="{{ route('rooms.index') }}"
class="text-indigo-200 hover:text-white transition flex items-center group">
<svg class="w-5 h-5 mr-1 transform group-hover:-translate-x-1 transition" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
返回大厅
</a>
<div class="h-6 w-px bg-indigo-700"></div>
<div class="flex items-center space-x-2">
<span class="text-xl">✉️</span>
<h1 class="text-xl font-extrabold tracking-wider">星光留言板</h1>
</div>
</div>
<!-- 右侧:写留言按钮 -->
<button
@click="showWriteForm = !showWriteForm; if(showWriteForm) setTimeout(() => $refs.textBody.focus(), 100)"
class="bg-indigo-500 hover:bg-indigo-400 text-white px-4 py-2 rounded-lg font-bold shadow-sm transition flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z">
</path>
</svg>
<span x-text="showWriteForm ? '取消留言' : '发布留言 / 写信'"></span>
</button>
</div>
</div>
</header>
@if (session('success'))
<div class="bg-green-100 border-l-4 border-green-500 text-green-700 p-4 mb-4 mx-4 mt-4 shadow-sm"
role="alert">
<p>{{ session('success') }}</p>
</div>
@endif
@if (session('error'))
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-4 mx-4 mt-4 shadow-sm" role="alert">
<p>{{ session('error') }}</p>
</div>
@endif
@if ($errors->any())
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-4 mx-4 mt-4 shadow-sm">
<ul class="list-disc pl-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<!-- 写信/留言表单区 (Alpine 控制显隐) -->
<div x-show="showWriteForm" x-collapse class="bg-white border-b border-gray-200 shadow-inner">
<div class="max-w-4xl mx-auto p-6">
<form action="{{ route('guestbook.store') }}" method="POST" class="space-y-4">
@csrf
<div class="flex items-center space-x-4">
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 mb-1">接收人 (留空或填“大家”表示公共留言)</label>
<input type="text" name="towho" x-model="towho" placeholder="系统自动处理"
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
</div>
<div class="flex items-center h-full pt-6">
<label
class="flex items-center space-x-2 text-sm text-gray-700 cursor-pointer bg-pink-50 px-3 py-2 rounded-md hover:bg-pink-100 transition border border-pink-100">
<input type="checkbox" name="secret" value="1"
class="rounded text-pink-500 focus:ring-pink-500 w-4 h-4">
<span class="font-bold text-pink-700">🔒 悄悄话 (仅双方可见)</span>
</label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">正文内容 <span
class="text-red-500">*</span></label>
<textarea name="text_body" x-ref="textBody" rows="3" required
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="相逢何必曾相识,留下您的足迹吧..."></textarea>
</div>
<div class="flex justify-end">
<button type="submit"
class="bg-indigo-600 hover:bg-indigo-700 text-white py-2 px-6 rounded-md shadow flex items-center font-bold">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path>
</svg>
发送
</button>
</div>
</form>
</div>
</div>
<!-- 主体内容区 -->
<div class="flex-1 flex overflow-hidden">
<!-- 左侧:分类导航 -->
<div class="w-64 bg-white border-r border-gray-200 shrink-0 hidden md:block">
<div class="p-6">
<nav class="space-y-2">
<a href="{{ route('guestbook.index', ['tab' => 'public']) }}"
class="flex items-center px-4 py-3 rounded-lg font-medium transition-colors {{ $tab === 'public' ? 'bg-indigo-50 text-indigo-700' : 'text-gray-600 hover:bg-gray-50' }}">
<span class="mr-3 text-lg">🌍</span> 公共留言墙
</a>
<a href="{{ route('guestbook.index', ['tab' => 'inbox']) }}"
class="flex items-center px-4 py-3 rounded-lg font-medium transition-colors {{ $tab === 'inbox' ? 'bg-indigo-50 text-indigo-700' : 'text-gray-600 hover:bg-gray-50' }}">
<span class="mr-3 text-lg">📥</span> 我收件的
</a>
<a href="{{ route('guestbook.index', ['tab' => 'outbox']) }}"
class="flex items-center px-4 py-3 rounded-lg font-medium transition-colors {{ $tab === 'outbox' ? 'bg-indigo-50 text-indigo-700' : 'text-gray-600 hover:bg-gray-50' }}">
<span class="mr-3 text-lg">📤</span> 我发出的
</a>
</nav>
</div>
</div>
<!-- 右侧:留言流列表 -->
<main class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-gray-50">
<div class="max-w-4xl mx-auto space-y-4">
@forelse($messages as $msg)
@php
// 判断是否属于自己发或收的悄悄话,用于高亮
$isSecret = $msg->secret == 1;
$isToMe = $msg->towho === Auth::user()->username;
$isFromMe = $msg->who === Auth::user()->username;
@endphp
<div
class="bg-white rounded-xl shadow-sm border {{ $isSecret ? 'border-pink-200' : 'border-gray-200' }} p-5 relative group overflow-hidden">
<!-- 悄悄话角标 -->
@if ($isSecret)
<div
class="absolute top-0 right-0 bg-pink-100 text-pink-700 text-[10px] font-bold px-2 py-1 rounded-bl-lg">
🔒 私密信件
</div>
@endif
<div class="flex justify-between items-start mb-2">
<div class="flex items-center text-sm">
<span class="font-bold text-indigo-700">{{ $msg->who }}</span>
<span class="text-gray-400 mx-2"></span>
<span
class="font-bold {{ $msg->towho ? 'text-indigo-700' : 'text-gray-500' }}">{{ $msg->towho ?: '大家' }}</span>
<span class="text-gray-400 mx-2">留言:</span>
</div>
<div class="text-xs text-gray-400 flex items-center space-x-3">
<span>{{ \Carbon\Carbon::parse($msg->post_time)->diffForHumans() }}</span>
<!-- 删除按钮 (只有发件人、收件人、超管可见) -->
@if ($isFromMe || $isToMe || Auth::user()->user_level >= 15)
<form action="{{ route('guestbook.destroy', $msg->id) }}" method="POST"
onsubmit="return confirm('确定要抹除这条留言吗?');" class="inline">
@csrf
@method('DELETE')
<button type="submit"
class="text-red-400 hover:text-red-600 transition opacity-0 group-hover:opacity-100 bg-red-50 px-2 py-1 rounded">
删除
</button>
</form>
@endif
</div>
</div>
<div
class="text-gray-800 leading-relaxed text-sm whitespace-pre-wrap {{ $isSecret ? 'bg-pink-50 p-3 rounded-lg border border-pink-100' : 'bg-gray-50 p-3 rounded-lg border border-gray-100' }}">
{!! nl2br(e($msg->text_body)) !!}
</div>
<!-- 快捷回复按钮 -->
@if ($msg->who !== Auth::user()->username)
<div class="mt-3 flex justify-end">
<button
@click="showWriteForm = true; towho = '{{ $msg->who }}'; setTimeout(() => $refs.textBody.focus(), 100); window.scrollTo({top:0, behavior:'smooth'})"
class="text-xs text-indigo-500 hover:text-indigo-700 font-medium flex items-center transition">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"></path>
</svg>
回复TA
</button>
</div>
@endif
</div>
@empty
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
<span class="text-4xl">📭</span>
<h3 class="mt-4 text-lg font-medium text-gray-900">暂无信件</h3>
<p class="mt-2 text-sm text-gray-500">这里是空空如也的荒原。</p>
<button
@click="showWriteForm = true; towho = ''; setTimeout(() => $refs.textBody.focus(), 100)"
class="mt-4 text-indigo-600 font-bold hover:underline">
来抢沙发留言吧!
</button>
</div>
@endforelse
<!-- 分页 -->
<div class="mt-6">
{{ $messages->links() }}
</div>
</div>
</main>
</div>
</div>
<!-- 移动端底部分类栏 -->
<div class="md:hidden bg-white border-t border-gray-200 flex justify-around p-2 shrink-0">
<a href="{{ route('guestbook.index', ['tab' => 'public']) }}"
class="flex flex-col items-center {{ $tab === 'public' ? 'text-indigo-600' : 'text-gray-500' }}">
<span class="text-xl">🌍</span>
<span class="text-xs mt-1">公共墙</span>
</a>
<a href="{{ route('guestbook.index', ['tab' => 'inbox']) }}"
class="flex flex-col items-center {{ $tab === 'inbox' ? 'text-indigo-600' : 'text-gray-500' }}">
<span class="text-xl">📥</span>
<span class="text-xs mt-1">收件箱</span>
</a>
<a href="{{ route('guestbook.index', ['tab' => 'outbox']) }}"
class="flex flex-col items-center {{ $tab === 'outbox' ? 'text-indigo-600' : 'text-gray-500' }}">
<span class="text-xl">📤</span>
<span class="text-xs mt-1">发件箱</span>
</a>
</div>
@endsection

View File

@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ \App\Models\SysParam::where('alias', 'sys_name')->value('body') ?? '飘落的流星在线聊天' }} - 登录</title>
<!-- 使用现代 CDN 引入 Tailwind CSS (快速构建 UI) -->
<script src="https://cdn.tailwindcss.com"></script>
<meta name="csrf-token" content="{{ csrf_token() }}">
</head>
<body class="bg-gray-100 h-screen flex items-center justify-center">
<div class="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
<h1 class="text-2xl font-bold text-center mb-6 text-gray-800">
{{ \App\Models\SysParam::where('alias', 'sys_name')->value('body') ?? '在线聊天室' }}
</h1>
<p class="text-sm text-gray-500 text-center mb-6">
{{ \App\Models\SysParam::where('alias', 'sys_notice')->value('body') ?? '欢迎您的加入' }}
</p>
<!-- 登录提示区 -->
<div id="alert-box" class="hidden mb-4 p-3 rounded text-sm text-center"></div>
<form id="login-form" class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700">昵称 (第一次登录即注册)</label>
<input type="text" id="username" name="username" required
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="允许中英文、数字、下划线">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">密码</label>
<input type="password" id="password" name="password" required
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="请输入密码">
</div>
<div>
<label for="captcha" class="block text-sm font-medium text-gray-700">验证码</label>
<div class="mt-1 flex space-x-2">
<input type="text" id="captcha" name="captcha" required
class="block w-2/3 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="输入右侧字符">
<div class="w-1/3 cursor-pointer" onclick="refreshCaptcha()">
<!-- 验证码图片,点击刷新 -->
<img src="{{ captcha_src() }}" alt="验证码" id="captcha-img" class="h-full w-full rounded-md border border-gray-300 object-cover">
</div>
</div>
</div>
<button type="submit" id="submit-btn"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50">
进入聊天室
</button>
</form>
</div>
<script>
// 刷新验证码
function refreshCaptcha() {
document.getElementById('captcha-img').src = '{{ captcha_src() }}' + Math.random();
}
// 提交登录表单
document.getElementById('login-form').addEventListener('submit', function(e) {
e.preventDefault();
const btn = document.getElementById('submit-btn');
const alertBox = document.getElementById('alert-box');
btn.disabled = true;
btn.innerText = '正在进入...';
alertBox.classList.add('hidden');
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
fetch('{{ route("login.post") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
body: JSON.stringify(data)
})
.then(response => response.json().then(data => ({ status: response.status, body: data })))
.then(result => {
if (result.status === 200 && result.body.status === 'success') {
// 登录成功,显示成功并跳转
showAlert(result.body.message, 'success');
setTimeout(() => {
// TODO: 之后重定向到真实的聊天室页面 /chat
window.location.href = '/';
}, 1000);
} else {
// 验证失败或密码错误
const errorMsg = result.body.message || (result.body.errors ? Object.values(result.body.errors)[0][0] : '登录失败');
showAlert(errorMsg, 'error');
refreshCaptcha();
document.getElementById('captcha').value = '';
btn.disabled = false;
btn.innerText = '进入聊天室';
}
})
.catch(error => {
console.error('Error:', error);
showAlert('网络或服务器错误,请稍后再试。', 'error');
refreshCaptcha();
btn.disabled = false;
btn.innerText = '进入聊天室';
});
});
function showAlert(message, type) {
const box = document.getElementById('alert-box');
box.innerText = message;
box.classList.remove('hidden', 'bg-red-100', 'text-red-700', 'bg-green-100', 'text-green-700');
if (type === 'error') {
box.classList.add('bg-red-100', 'text-red-700');
} else {
box.classList.add('bg-green-100', 'text-green-700');
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,131 @@
@extends('layouts.app')
@section('title', '风云排行榜')
@section('content')
<div class="h-screen w-full flex flex-col bg-gray-100 overflow-hidden font-sans">
<!-- 顶部导航条 -->
<header class="bg-indigo-900 border-b border-indigo-800 text-white shadow-md relative z-20 shrink-0">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- 左侧:标题与返回大厅 -->
<div class="flex items-center space-x-4">
<a href="{{ route('rooms.index') }}"
class="text-indigo-200 hover:text-white transition flex items-center group">
<svg class="w-5 h-5 mr-1 transform group-hover:-translate-x-1 transition" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
返回大厅
</a>
<div class="h-6 w-px bg-indigo-700"></div>
<div class="flex items-center space-x-2">
<span class="text-xl">🏆</span>
<h1 class="text-xl font-extrabold tracking-wider">风云排行榜</h1>
</div>
</div>
<!-- 右侧:当前用户状态 -->
<div class="flex items-center space-x-3 text-sm">
<img src="/images/headface/{{ Auth::user()->headface ?? '01.gif' }}"
class="w-8 h-8 rounded border border-indigo-500 object-cover bg-white">
<div class="hidden sm:block">
<span class="font-bold">{{ Auth::user()->username }}</span>
<span class="text-indigo-300 ml-2">LV.{{ Auth::user()->user_level }}</span>
</div>
</div>
</div>
</div>
</header>
<!-- 说明条 -->
<div class="bg-indigo-50 border-b border-indigo-100 py-3 shrink-0">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between items-center text-sm">
<p class="text-indigo-800 font-medium"> 数据每 <span class="font-bold text-red-500">15分钟</span>
自动刷新一次。努力提升自己,让全服铭记你的名字!</p>
</div>
</div>
<!-- 滚动内容区 -->
<main class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<div class="max-w-7xl mx-auto">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- 1. 境界榜 (user_level) -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden flex flex-col">
<div
class="bg-gradient-to-r from-red-600 to-red-500 px-4 py-3 flex justify-between items-center text-white">
<h2 class="font-bold text-lg flex items-center"><span class="mr-2 text-xl">👑</span> 无上境界榜</h2>
<span class="text-xs bg-red-800/40 px-2 py-0.5 rounded">Level</span>
</div>
<div class="p-0 overflow-y-auto max-h-[600px] flex-1">
@include('leaderboard.partials.list', [
'users' => $topLevels,
'valueField' => 'user_level',
'unit' => '级',
'color' => 'text-red-600',
])
</div>
</div>
<!-- 2. 修为榜 (exp_num) -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden flex flex-col">
<div
class="bg-gradient-to-r from-amber-600 to-amber-500 px-4 py-3 flex justify-between items-center text-white">
<h2 class="font-bold text-lg flex items-center"><span class="mr-2 text-xl">🔥</span> 苦修经验榜</h2>
<span class="text-xs bg-amber-800/40 px-2 py-0.5 rounded">Exp</span>
</div>
<div class="p-0 overflow-y-auto max-h-[600px] flex-1">
@include('leaderboard.partials.list', [
'users' => $topExp,
'valueField' => 'exp_num',
'unit' => '点',
'color' => 'text-amber-600',
])
</div>
</div>
<!-- 3. 财富榜 (jjb) -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden flex flex-col">
<div
class="bg-gradient-to-r from-yellow-500 to-yellow-400 px-4 py-3 flex justify-between items-center text-white">
<h2 class="font-bold text-lg flex items-center text-yellow-900"><span
class="mr-2 text-xl">💰</span> 盖世神豪榜</h2>
<span class="text-xs bg-yellow-800/20 text-yellow-900 px-2 py-0.5 rounded">Coin</span>
</div>
<div class="p-0 overflow-y-auto max-h-[600px] flex-1">
@include('leaderboard.partials.list', [
'users' => $topWealth,
'valueField' => 'jjb',
'unit' => '万',
'color' => 'text-yellow-600',
])
</div>
</div>
<!-- 4. 魅力榜 (meili) -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden flex flex-col">
<div
class="bg-gradient-to-r from-pink-600 to-pink-500 px-4 py-3 flex justify-between items-center text-white">
<h2 class="font-bold text-lg flex items-center"><span class="mr-2 text-xl">🌸</span> 绝世名伶榜</h2>
<span class="text-xs bg-pink-800/40 px-2 py-0.5 rounded">Charm</span>
</div>
<div class="p-0 overflow-y-auto max-h-[600px] flex-1">
@include('leaderboard.partials.list', [
'users' => $topCharm,
'valueField' => 'meili',
'unit' => '点',
'color' => 'text-pink-600',
])
</div>
</div>
</div>
</div>
</main>
</div>
@endsection

View File

@@ -0,0 +1,57 @@
<ul class="divide-y divide-gray-100">
@forelse($users as $index => $user)
@php
// 前三名前景色
$rankBg = 'bg-gray-100 text-gray-500';
$rowBg = 'hover:bg-gray-50';
if ($index === 0) {
$rankBg = 'bg-yellow-400 text-yellow-900 shadow-md transform scale-110';
$rowBg = 'bg-yellow-50 hover:bg-yellow-100 border-l-4 border-yellow-400';
} elseif ($index === 1) {
$rankBg = 'bg-gray-300 text-gray-800 shadow-sm transform scale-105';
$rowBg = 'bg-gray-50 hover:bg-gray-100 border-l-4 border-gray-300';
} elseif ($index === 2) {
$rankBg = 'bg-orange-300 text-orange-900 shadow-sm';
$rowBg = 'bg-orange-50 hover:bg-orange-100 border-l-4 border-orange-300';
}
@endphp
<li class="p-3 flex items-center justify-between transition-colors duration-150 {{ $rowBg }}">
<!-- 左侧:名次与头像/名字 -->
<div class="flex items-center space-x-3 overflow-hidden">
<div
class="w-6 h-6 shrink-0 {{ $rankBg }} rounded-full flex items-center justify-center font-bold text-xs">
{{ $index + 1 }}
</div>
<div class="flex items-center space-x-2 truncate">
<img class="w-8 h-8 rounded border object-cover shrink-0"
src="/images/headface/{{ $user->headface ?? '01.gif' }}" alt="">
<div class="flex flex-col truncate">
<span class="text-sm font-bold text-gray-800 truncate" title="{{ $user->username }}">
{{ $user->username }}
@if ($user->sex == '女')
<span class="text-pink-500 text-xs ml-0.5"></span>
@elseif($user->sex == '男')
<span class="text-blue-500 text-xs ml-0.5"></span>
@endif
</span>
<span class="text-[10px] text-gray-500 truncate"
title="{{ $user->sign }}">{{ $user->sign ?: '这家伙很懒,什么也没留下' }}</span>
</div>
</div>
</div>
<!-- 右侧:数值 -->
<div class="flex flex-col items-end shrink-0 ml-2">
<span class="text-sm font-black {{ $index < 3 ? $color : 'text-gray-600' }}">
{{ number_format($user->$valueField) }}
<span
class="text-[10px] font-normal {{ $index < 3 ? $color : 'text-gray-400' }} ml-0.5">{{ $unit }}</span>
</span>
</div>
</li>
@empty
<li class="p-8 text-center text-sm text-gray-400 font-bold">
暂无数据登榜
</li>
@endforelse
</ul>

View File

@@ -0,0 +1,434 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>聊天大厅 - 飘落流星</title>
<script src="https://cdn.tailwindcss.com"></script>
<!-- 引入 Alpine.js 用于简单的无代码弹窗切换 -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background-color: transparent;
}
</style>
</head>
<body class="bg-gray-100 min-h-screen text-gray-800" x-data="{
showCreateModal: false,
showEditModal: false,
showTransferModal: false,
showProfileModal: false,
showPasswordModal: false,
currentRoom: null
}">
<!-- 顶部导航条 -->
<nav class="bg-indigo-600 px-6 py-4 shadow-md sticky top-0 z-50">
<div class="max-w-7xl mx-auto flex justify-between items-center text-white">
<div class="flex items-center space-x-4">
<span class="text-xl">🌟</span>
<h1 class="text-xl font-extrabold tracking-wider">星光大厅</h1>
</div>
<!-- 右侧操作区 -->
<div class="flex items-center space-x-3 text-sm">
<!-- 留言板入口 -->
<a href="{{ route('guestbook.index') }}"
class="text-indigo-100 hover:text-white font-bold flex items-center bg-indigo-800/40 px-3 py-1.5 rounded-full transition shadow-inner">
<span class="mr-1">✉️</span> 留言板
</a>
<!-- 风云排行榜入口 -->
<a href="{{ route('leaderboard.index') }}"
class="mr-4 text-yellow-400 hover:text-yellow-300 font-bold flex items-center bg-indigo-800/50 px-3 py-1.5 rounded-full transition shadow-inner">
<span class="mr-1">🏆</span> 风云榜
</a>
<!-- admin 后台直达 -->
@if (Auth::user()->user_level >= 15)
<a href="{{ route('admin.dashboard') }}"
class="mr-4 text-indigo-200 hover:text-white font-bold hidden sm:block">⚙️ 后台管理</a>
@endif
<!-- 点击直接在本页弹出资料卡修改 -->
<button @click="showProfileModal = true"
class="font-medium text-sm hover:text-indigo-200 transition flex items-center">
欢迎您,{{ Auth::user()->username }}
<span
class="bg-white/20 px-2 py-0.5 rounded-full text-xs ml-2 border border-white/10 shadow-sm">LV.{{ Auth::user()->user_level }}</span>
</button>
{{-- 权限按钮区 --}}
@if (Auth::user()->user_level >= 10)
<button @click="showCreateModal = true"
class="bg-emerald-500 hover:bg-emerald-400 px-4 py-2 rounded-md font-bold text-sm transition shadow-sm">
+ 新建房间
</button>
@endif
<form action="{{ route('logout') }}" method="POST" class="inline">
@csrf
<button type="submit"
class="text-sm border border-white/30 hover:bg-white/10 px-4 py-2 rounded-md transition font-medium">退出登录</button>
</form>
</div>
</div>
</nav>
<!-- 主展示区 -->
<main class="max-w-7xl mx-auto py-10 px-6">
<!-- 全局提示消息 -->
@if (session('success'))
<div class="bg-green-100 border-l-4 border-green-500 text-green-700 p-4 mb-8 rounded shadow-sm">
<p class="font-bold">操作成功</p>
<p>{{ session('success') }}</p>
</div>
@endif
@if (session('error'))
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-8 rounded shadow-sm">
<p class="font-bold">发生错误</p>
<p>{{ session('error') }}</p>
</div>
@endif
@if ($errors->any())
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-8 rounded shadow-sm">
<ul class="list-disc list-inside text-sm">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div class="mb-6 flex justify-between items-end border-b pb-4">
<h2 class="text-xl font-bold text-gray-700">公开频段 (<span
class="text-indigo-600">{{ $rooms->count() }}</span>)</h2>
</div>
<!-- 房间瀑布流网格 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
@forelse($rooms as $room)
<div
class="bg-white rounded-xl shadow-sm hover:shadow-lg transition-shadow border border-gray-100 overflow-hidden flex flex-col group {{ $room->is_system ? 'ring-2 ring-indigo-500/20' : '' }}">
{{-- 卡片头部 --}}
<div class="p-5 flex-1 relative">
@if ($room->is_system)
<span
class="absolute top-4 right-4 bg-indigo-100 text-indigo-700 text-xs font-bold px-2 py-1 rounded-sm">官方驻地</span>
@endif
<h3 class="text-xl font-bold text-gray-800 mb-2 truncate" title="{{ $room->name }}">
{{ $room->name }}</h3>
<p class="text-sm text-gray-500 line-clamp-2 h-10 mb-4">
{{ $room->description ?: '房主很懒,什么都没写...' }}</p>
<div class="flex items-center text-xs text-gray-400 font-medium">
<span>房主:<span
class="{{ $room->master == Auth::user()->username ? 'text-indigo-600 font-bold' : 'text-gray-600' }}">{{ $room->master }}</span></span>
</div>
</div>
{{-- 底部操作区 --}}
<div class="bg-gray-50 p-4 border-t border-gray-100 flex items-center justify-between">
{{-- 管理按钮组(仅房主或超管可见) --}}
<div class="flex space-x-2">
@if ($room->master == Auth::user()->username || Auth::user()->user_level >= 15)
<!-- 修改 -->
<button
@click="currentRoom = {id: {{ $room->id }}, name: '{{ addslashes($room->name) }}', description: '{{ addslashes($room->description) }}'}; showEditModal = true"
class="text-xs text-blue-600 hover:text-blue-800 font-semibold px-2 py-1 rounded hover:bg-blue-50 transition">
管理
</button>
@if (!$room->is_system)
<!-- 转让 -->
<button
@click="currentRoom = {id: {{ $room->id }}, name: '{{ addslashes($room->name) }}'}; showTransferModal = true"
class="text-xs text-amber-600 hover:text-amber-800 font-semibold px-2 py-1 rounded hover:bg-amber-50 transition">
转让
</button>
<!-- 删除 -->
<form action="{{ route('rooms.destroy', $room->id) }}" method="POST"
class="inline"
onsubmit="return confirm('警告:确实要彻底解散「{{ $room->name }}」吗?此操作不可逆!');">
@csrf @method('delete')
<button type="submit"
class="text-xs text-red-600 hover:text-red-800 font-semibold px-2 py-1 rounded hover:bg-red-50 transition">解散</button>
</form>
@endif
@endif
</div>
{{-- 进入按钮 --}}
<a href="{{ route('chat.room', $room->id) }}"
class="bg-indigo-600 text-white hover:bg-indigo-700 px-4 py-2 rounded-t-xl rounded-br-xl text-sm font-bold shadow-md hover:shadow-lg transition-all transform group-hover:-translate-y-0.5">
立刻进入 &rarr;
</a>
</div>
</div>
@empty
<div class="col-span-full py-20 text-center text-gray-400">
<p class="text-xl font-bold mb-2">大厅空空如也</p>
<p>目前还没有人创建任何房间。</p>
</div>
@endforelse
</div>
</main>
<!-- 新建房间 Modal (通过 Alpine.js 开关) -->
<div x-show="showCreateModal" style="display: none;"
class="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div @click.away="showCreateModal = false"
class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden transform transition-all"
x-transition.scale.95>
<div class="bg-gray-50 border-b px-6 py-4 flex justify-between items-center">
<h3 class="font-bold text-lg text-gray-800">新建私人聊天频道</h3>
<button @click="showCreateModal = false"
class="text-gray-400 hover:text-gray-600 font-bold text-xl">&times;</button>
</div>
<form action="{{ route('rooms.store') }}" method="POST" class="p-6">
@csrf
<div class="mb-4">
<label class="block text-sm font-bold text-gray-700 mb-2">频道名称 <span
class="text-red-500">*</span></label>
<input type="text" name="name" required maxlength="50" placeholder="例如:技术交流水群"
class="w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2.5 bg-gray-50 border">
</div>
<div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2">频道主题描述</label>
<textarea name="description" rows="3" maxlength="255" placeholder="一句话介绍这里的规矩..."
class="w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2.5 bg-gray-50 border resize-none"></textarea>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="showCreateModal = false"
class="px-4 py-2 bg-white border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 font-medium">取消</button>
<button type="submit"
class="px-6 py-2 bg-emerald-600 text-white rounded-md font-bold hover:bg-emerald-700 shadow-sm">立即创建</button>
</div>
</form>
</div>
</div>
<!-- 修改管理 Modal -->
<div x-show="showEditModal" style="display: none;"
class="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div @click.away="showEditModal = false"
class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden transform transition-all"
x-transition.scale.95>
<div class="bg-blue-50 border-b border-blue-100 px-6 py-4 flex justify-between items-center">
<h3 class="font-bold text-lg text-blue-900" x-text="'管理频道: ' + currentRoom?.name"></h3>
<button @click="showEditModal = false"
class="text-blue-400 hover:text-blue-600 font-bold text-xl">&times;</button>
</div>
<!-- 注意这里通过 Alpine 动态拼接 action 路径 -->
<form :action="'{{ url('rooms') }}/' + currentRoom?.id" method="POST" class="p-6">
@csrf @method('PUT')
<div class="mb-4">
<label class="block text-sm font-bold text-gray-700 mb-2">频道新名称 <span
class="text-red-500">*</span></label>
<input type="text" name="name" :value="currentRoom?.name" required maxlength="50"
class="w-full border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 p-2.5 bg-gray-50 border">
</div>
<div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2">更新主题描述</label>
<textarea name="description" x-text="currentRoom?.description" rows="3" maxlength="255"
class="w-full border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 p-2.5 bg-gray-50 border resize-none"></textarea>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="showEditModal = false"
class="px-4 py-2 bg-white border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 font-medium">关闭</button>
<button type="submit"
class="px-6 py-2 bg-blue-600 text-white rounded-md font-bold hover:bg-blue-700 shadow-sm">保存并广播</button>
</div>
</form>
</div>
</div>
<!-- 转让房主 Modal -->
<div x-show="showTransferModal" style="display: none;"
class="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div @click.away="showTransferModal = false"
class="bg-white rounded-2xl shadow-2xl w-full max-w-sm overflow-hidden transform transition-all"
x-transition.scale.95>
<div class="bg-amber-50 border-b border-amber-100 px-6 py-4 flex justify-between items-center">
<h3 class="font-bold text-lg text-amber-900" x-text="'转让授权: ' + currentRoom?.name"></h3>
<button @click="showTransferModal = false"
class="text-amber-400 hover:text-amber-600 font-bold text-xl">&times;</button>
</div>
<form :action="'{{ url('rooms') }}/' + currentRoom?.id + '/transfer'" method="POST" class="p-6">
@csrf
<div class="bg-amber-100 text-amber-800 text-xs p-3 rounded mb-4">
转让后您将立即失去对该房间的管理权限,请谨慎操作。
</div>
<div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2">接收人昵称 <span
class="text-red-500">*</span></label>
<input type="text" name="target_username" required placeholder="请输入对方准确用户名"
class="w-full border-gray-300 rounded-md shadow-sm focus:border-amber-500 focus:ring-amber-500 p-2.5 bg-gray-50 border">
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="showTransferModal = false"
class="px-4 py-2 bg-white border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 font-medium">取消</button>
<button type="submit"
class="px-6 py-2 bg-amber-600 text-white rounded-md font-bold hover:bg-amber-700 shadow-sm">确认转让</button>
</div>
</form>
</div>
</div>
<!-- 个人资料设置 Modal -->
<div x-show="showProfileModal" style="display: none;"
class="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div @click.away="showProfileModal = false"
class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden transform transition-all"
x-transition.scale.95>
<div
class="bg-gradient-to-r from-indigo-500 to-purple-600 px-6 py-4 flex justify-between items-center text-white">
<h3 class="font-bold text-lg">个人资料设置</h3>
<button @click="showProfileModal = false"
class="text-white/80 hover:text-white font-bold text-xl">&times;</button>
</div>
<div class="p-6" x-data="{
profileData: {
sex: '{{ Auth::user()->sex }}',
sign: '{{ Auth::user()->sign }}',
headface: '{{ Auth::user()->headface ?? '01.gif' }}'
},
isSaving: false,
async saveProfile() {
this.isSaving = true;
try {
const res = await fetch('{{ route('user.update_profile') }}', {
method: 'PUT',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'), 'Content-Type'
: 'application/json' , 'Accept' : 'application/json' }, body: JSON.stringify(this.profileData) }); const
data=await res.json(); if(res.ok && data.status === 'success') { alert(data.message);
window.location.reload(); } else { alert('保存失败: ' + (data.message || ' 输入有误')); } } catch (e) {
alert('网络异常'); } finally { this.isSaving=false; } } }">
<form @submit.prevent="saveProfile">
<div class="mb-4">
<label class="block text-sm font-bold text-gray-700 mb-2">性别</label>
<select x-model="profileData.sex"
class="w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2.5 bg-gray-50 border">
<option value="">男生</option>
<option value="">女生</option>
<option value="保密">保密</option>
</select>
</div>
<!-- 头像选择 (暂时写死输入框,后续可优化为网格选择) -->
<div class="mb-4">
<label class="block text-sm font-bold text-gray-700 mb-2">头像选择 (01.gif - 50.gif)</label>
<div class="flex items-center space-x-3">
<div class="w-12 h-12 rounded bg-gray-200 shrink-0 overflow-hidden border">
<img :src="'/images/headface/' + profileData.headface"
@error="$el.style.display='none'" class="w-full h-full object-cover">
</div>
<input type="text" x-model="profileData.headface" required
class="flex-1 border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2.5 bg-gray-50 border">
</div>
</div>
<div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2">个性签名</label>
<textarea x-model="profileData.sign" rows="2" maxlength="255" placeholder="写点什么吧..."
class="w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2.5 bg-gray-50 border resize-none"></textarea>
</div>
<div class="flex justify-between items-center border-t pt-4">
<button type="button" @click="showProfileModal = false; showPasswordModal = true"
class="text-sm text-indigo-600 hover:text-indigo-800 font-bold hover:underline">修改安全密码</button>
<div class="space-x-2">
<button type="button" @click="showProfileModal = false"
class="px-4 py-2 bg-white border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 font-medium cursor-pointer">取消</button>
<button type="submit" :disabled="isSaving"
class="px-6 py-2 bg-indigo-600 text-white rounded-md font-bold hover:bg-indigo-700 shadow-sm disabled:opacity-50 cursor-pointer">保存资料</button>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- 修改密码 Modal -->
<div x-show="showPasswordModal" style="display: none;"
class="fixed inset-0 z-[110] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div @click.away="showPasswordModal = false"
class="bg-white rounded-2xl shadow-2xl w-full max-w-sm overflow-hidden transform transition-all"
x-transition.scale.95>
<div class="bg-gray-800 px-6 py-4 flex justify-between items-center text-white">
<h3 class="font-bold text-lg">修改安全密码</h3>
<button @click="showPasswordModal = false"
class="text-gray-400 hover:text-white font-bold text-xl">&times;</button>
</div>
<div class="p-6" x-data="{
pwdData: {
old_password: '',
new_password: '',
new_password_confirmation: ''
},
isSaving: false,
async savePassword() {
if (this.pwdData.new_password !== this.pwdData.new_password_confirmation) {
alert('两次输入的新密码不一致!');
return;
}
if (this.pwdData.new_password.length < 6) {
alert('新密码最少 6 位!');
return;
}
this.isSaving = true;
try {
const res = await fetch('{{ route('user.update_password') }}', {
method: 'PUT',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content'), 'Content-Type'
: 'application/json' , 'Accept' : 'application/json' }, body: JSON.stringify(this.pwdData) }); const
data=await res.json(); if(res.ok && data.status === 'success') { alert(data.message);
window.location.href = '{{ route('home') }}'; // 改密成功重新登录 } else {
alert('密码修改失败: ' + (data.message || ' 请输入正确的旧密码')); } } catch (e) { alert('网络异常'); } finally {
this.isSaving=false; } } }">
<form @submit.prevent="savePassword">
<div class="mb-4">
<label class="block text-sm font-bold text-gray-700 mb-2">当前旧密码</label>
<input type="password" x-model="pwdData.old_password" required
class="w-full border-gray-300 rounded-md shadow-sm focus:border-gray-500 focus:ring-gray-500 p-2.5 bg-gray-50 border">
</div>
<div class="mb-4">
<label class="block text-sm font-bold text-gray-700 mb-2">新密码</label>
<input type="password" x-model="pwdData.new_password" required placeholder="至少 6 位字母或数字"
class="w-full border-gray-300 rounded-md shadow-sm focus:border-gray-500 focus:ring-gray-500 p-2.5 bg-gray-50 border">
</div>
<div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2">确认新密码</label>
<input type="password" x-model="pwdData.new_password_confirmation" required
class="w-full border-gray-300 rounded-md shadow-sm focus:border-gray-500 focus:ring-gray-500 p-2.5 bg-gray-50 border">
</div>
<div class="flex justify-end space-x-3 border-t pt-4">
<button type="button" @click="showPasswordModal = false"
class="px-4 py-2 bg-white border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 font-medium cursor-pointer">取消</button>
<button type="submit" :disabled="isSaving"
class="px-6 py-2 bg-gray-800 text-white rounded-md font-bold hover:bg-gray-900 shadow-sm disabled:opacity-50 cursor-pointer">确定修改</button>
</div>
</form>
</div>
</div>
</div>
</body>
</html>

View File

@@ -5,3 +5,19 @@ use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
return (int) $user->id === (int) $id;
});
// 聊天室房间 Presence Channel 鉴权与成员信息抓取
Broadcast::channel('room.{roomId}', function ($user, $roomId) {
// 这里未来可以增加判断:比如该房间是否被锁定,或者该用户是否在此房间的黑名单中
// 凡是通过了这个判断的人(返回一个数组),他就会成功建立 WebSocket
// 且他的这个数组信息会被 Reverb 推送给这个房间内的所有其他人 (joining / here 事件)。
return [
'id' => $user->id,
'username' => $user->username,
'user_level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
// 这里可以视情况加入更多需要前端渲染在线人员列表的字段
];
});

View File

@@ -1,7 +1,77 @@
<?php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\ChatController;
use App\Http\Controllers\RoomController;
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;
// 聊天室首页 (即登录/注册页面)
Route::get('/', function () {
return view('welcome');
return view('index'); // 指向 resources/views/index.blade.php
})->name('home');
// 处理登录/自动注册请求
Route::post('/login', [AuthController::class, 'login'])->name('login.post');
// 处理退出登录
Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
// 聊天室系统内部路由 (需要鉴权)
Route::middleware(['chat.auth'])->group(function () {
// ---- 第六阶段:大厅与房间管理 ----
Route::get('/rooms', [RoomController::class, 'index'])->name('rooms.index');
Route::post('/rooms', [RoomController::class, 'store'])->name('rooms.store');
Route::put('/rooms/{id}', [RoomController::class, 'update'])->name('rooms.update');
Route::delete('/rooms/{id}', [RoomController::class, 'destroy'])->name('rooms.destroy');
Route::post('/rooms/{id}/transfer', [RoomController::class, 'transfer'])->name('rooms.transfer');
// ---- 第九阶段:外围矩阵 - 风云排行榜 ----
Route::get('/leaderboard', [\App\Http\Controllers\LeaderboardController::class, 'index'])->name('leaderboard.index');
// ---- 第十阶段:站内信与留言板系统 ----
Route::get('/guestbook', [\App\Http\Controllers\GuestbookController::class, 'index'])->name('guestbook.index');
Route::post('/guestbook', [\App\Http\Controllers\GuestbookController::class, 'store'])->middleware('throttle:10,1')->name('guestbook.store');
Route::delete('/guestbook/{id}', [\App\Http\Controllers\GuestbookController::class, 'destroy'])->name('guestbook.destroy');
// ---- 第七阶段:用户资料与特权管理 ----
Route::get('/user/{username}', [UserController::class, 'show'])->name('user.show');
Route::put('/user/profile', [UserController::class, 'updateProfile'])->name('user.update_profile');
Route::put('/user/password', [UserController::class, 'changePassword'])->name('user.update_password');
Route::post('/user/{username}/kick', [UserController::class, 'kick'])->name('user.kick');
Route::post('/user/{username}/mute', [UserController::class, 'mute'])->name('user.mute');
// ---- 第五阶段:具体房间内部聊天核心 ----
// 进入具体房间界面的初始化
Route::get('/room/{id}', [ChatController::class, 'init'])->name('chat.room');
// 发送消息
Route::post('/room/{id}/send', [ChatController::class, 'send'])->name('chat.send');
// 挂机心跳存点 (限制每分钟最多调用 2 次防止挂机脚本当作 DDOS)
Route::post('/room/{id}/heartbeat', [ChatController::class, 'heartbeat'])
->middleware('throttle:2,1')
->name('chat.heartbeat');
// 退出房间
Route::post('/room/{id}/leave', [ChatController::class, 'leave'])->name('chat.leave');
});
// 强力特权层中间件:同时验证 chat.auth 登录态 和 chat.level:15 特权
Route::middleware(['chat.auth', 'chat.level:15'])->prefix('admin')->name('admin.')->group(function () {
// 后台首页概览
Route::get('/', [\App\Http\Controllers\Admin\DashboardController::class, 'index'])->name('dashboard');
// 系统参数配置 (替代 VIEWSYS.ASP / SetSYS.ASP)
Route::get('/system', [\App\Http\Controllers\Admin\SystemController::class, 'edit'])->name('system.edit');
Route::put('/system', [\App\Http\Controllers\Admin\SystemController::class, 'update'])->name('system.update');
// 用户大盘管理 (替代 gl/ 目录下的各种用户管理功能)
Route::get('/users', [\App\Http\Controllers\Admin\UserManagerController::class, 'index'])->name('users.index');
Route::put('/users/{id}', [\App\Http\Controllers\Admin\UserManagerController::class, 'update'])->name('users.update');
Route::delete('/users/{id}', [\App\Http\Controllers\Admin\UserManagerController::class, 'destroy'])->name('users.destroy'); // 物理封杀
// 特殊高危操作日志与运维工具 (选做或简易实现 SQL.ASP)
Route::get('/sql', [\App\Http\Controllers\Admin\SqlController::class, 'index'])->name('sql.index');
Route::post('/sql', [\App\Http\Controllers\Admin\SqlController::class, 'execute'])->name('sql.execute'); // ⚠ 强烈限制为纯 SELECT 查询
});