功能:字体颜色持久化、等级体系升级至99级、钓鱼小游戏、补充系统参数

- 字体颜色:s_color 改为 varchar,发消息时保存颜色,进入聊天室自动恢复
- 等级体系:maxlevel 15→99,superlevel 16→100,99级经验阶梯(幂次曲线)
- 管理权限等级按比例调整:禁言50、踢人60、设公告60、封号80、封IP90
- 钓鱼小游戏:FishingController(抛竿扣金币+收竿随机结果+广播)
- 补充6个缺失的 sysparam 参数 + 4个钓鱼参数
- 用户列表点击用户名后自动聚焦输入框
- Pint 格式化
This commit is contained in:
2026-02-26 21:10:34 +08:00
parent d884853968
commit ea06328885
652 changed files with 5013 additions and 1274 deletions

View File

@@ -0,0 +1,97 @@
<?php
/**
* 文件功能:自动事件管理控制器
* 管理员可在后台增删改随机事件(好运/坏运/经验/金币奖惩等)
* 复刻原版 ASP 聊天室的 autoact 管理功能
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Autoact;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AutoactController extends Controller
{
/**
* 显示所有自动事件列表
*/
public function index(): View
{
$events = Autoact::orderByDesc('id')->get();
return view('admin.autoact.index', compact('events'));
}
/**
* 保存新事件
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'text_body' => 'required|string|max:500',
'event_type' => 'required|in:good,bad,neutral',
'exp_change' => 'required|integer',
'jjb_change' => 'required|integer',
]);
$data['enabled'] = true;
Autoact::create($data);
return redirect()->route('admin.autoact.index')->with('success', '事件添加成功!');
}
/**
* 更新事件
*/
public function update(Request $request, int $id): RedirectResponse
{
$event = Autoact::findOrFail($id);
$data = $request->validate([
'text_body' => 'required|string|max:500',
'event_type' => 'required|in:good,bad,neutral',
'exp_change' => 'required|integer',
'jjb_change' => 'required|integer',
]);
$event->update($data);
return redirect()->route('admin.autoact.index')->with('success', '事件修改成功!');
}
/**
* 切换事件启用/禁用状态
*/
public function toggle(int $id): JsonResponse
{
$event = Autoact::findOrFail($id);
$event->enabled = ! $event->enabled;
$event->save();
return response()->json([
'status' => 'success',
'enabled' => $event->enabled,
'message' => $event->enabled ? '已启用' : '已禁用',
]);
}
/**
* 删除事件
*/
public function destroy(int $id): RedirectResponse
{
Autoact::findOrFail($id)->delete();
return redirect()->route('admin.autoact.index')->with('success', '事件已删除!');
}
}

View File

@@ -0,0 +1,68 @@
<?php
/**
* 文件功能:后台房间管理控制器
* 管理员可查看、编辑房间信息(名称、介绍、公告等)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Room;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class RoomManagerController extends Controller
{
/**
* 显示所有房间列表
*/
public function index(): View
{
$rooms = Room::orderBy('id')->get();
return view('admin.rooms.index', compact('rooms'));
}
/**
* 更新房间信息
*/
public function update(Request $request, int $id): RedirectResponse
{
$room = Room::findOrFail($id);
$data = $request->validate([
'room_name' => 'required|string|max:100',
'room_des' => 'nullable|string|max:500',
'announcement' => 'nullable|string|max:500',
'room_owner' => 'nullable|string|max:50',
'permit_level' => 'required|integer|min:0|max:15',
'door_open' => 'required|boolean',
]);
$room->update($data);
return redirect()->route('admin.rooms.index')->with('success', "房间 [{$room->room_name}] 信息已更新!");
}
/**
* 删除房间(非系统房间)
*/
public function destroy(int $id): RedirectResponse
{
$room = Room::findOrFail($id);
if ($room->room_keep) {
return redirect()->route('admin.rooms.index')->with('error', '系统房间不允许删除!');
}
$room->delete();
return redirect()->route('admin.rooms.index')->with('success', "房间 [{$room->room_name}] 已删除!");
}
}

View File

@@ -1,76 +0,0 @@
<?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

@@ -53,6 +53,9 @@ class SystemController extends Controller
// 写入 Cache 保证极速读取
$this->chatState->setSysParam($alias, $body);
// 同时清除 Sysparam 模型的内部缓存
SysParam::clearCache($alias);
}
return redirect()->route('admin.system.edit')->with('success', '系统参数已成功更新并生效!');

View File

@@ -52,17 +52,23 @@ class UserManagerController extends Controller
return response()->json(['status' => 'error', 'message' => '权限不足:您无法修改同级或高级管理人员资料。'], 403);
}
// 管理员级别 = 最高等级 + 1后台编辑最高可设到管理员级别
$adminLevel = (int) \App\Models\Sysparam::getValue('maxlevel', '15') + 1;
$validated = $request->validate([
'sex' => 'sometimes|in:男,女,保密',
'user_level' => 'sometimes|integer|min:0',
'sex' => 'sometimes|integer|in:0,1,2',
'user_level' => "sometimes|integer|min:0|max:{$adminLevel}",
'exp_num' => 'sometimes|integer|min:0',
'jjb' => 'sometimes|integer|min:0',
'meili' => 'sometimes|integer|min:0',
'qianming' => 'sometimes|nullable|string|max:255',
'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);
}
@@ -72,12 +78,21 @@ class UserManagerController extends Controller
if (isset($validated['sex'])) {
$targetUser->sex = $validated['sex'];
}
if (isset($validated['exp_num'])) {
$targetUser->exp_num = $validated['exp_num'];
}
if (isset($validated['jjb'])) {
$targetUser->jjb = $validated['jjb'];
}
if (isset($validated['meili'])) {
$targetUser->meili = $validated['meili'];
}
if (array_key_exists('qianming', $validated)) {
$targetUser->qianming = $validated['qianming'];
}
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']);

View File

@@ -70,7 +70,7 @@ class AuthController extends Controller
'last_ip' => $ip,
'user_level' => 1, // 默认普通用户等级
'sex' => 0, // 默认性别: 0保密 1男 2女
// 如果原表里还有其他必填字段,在这里初始化默认值
'usersf' => '1.GIF', // 默认头像
]);
$this->performLogin($newUser, $ip);

View File

@@ -16,12 +16,15 @@ use App\Events\UserJoined;
use App\Events\UserLeft;
use App\Http\Requests\SendMessageRequest;
use App\Jobs\SaveMessageJob;
use App\Models\Autoact;
use App\Models\Room;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use App\Services\MessageFilterService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
use Illuminate\View\View;
class ChatController extends Controller
@@ -42,6 +45,9 @@ class ChatController extends Controller
$room = Room::findOrFail($id);
$user = Auth::user();
// 房间人气 +1每次访问递增复刻原版人气计数
$room->increment('visit_num');
// 1. 将当前用户加入到 Redis 房间在线列表
$this->chatState->userJoin($id, $user->username, [
'level' => $user->user_level,
@@ -76,6 +82,18 @@ class ChatController extends Controller
$data = $request->validated();
$user = Auth::user();
// 0. 检查用户是否被禁言Redis TTL 自动过期)
$muteKey = "mute:{$id}:{$user->username}";
if (Redis::exists($muteKey)) {
$ttl = Redis::ttl($muteKey);
$minutes = ceil($ttl / 60);
return response()->json([
'status' => 'error',
'message' => "您正在禁言中,还需等待约 {$minutes} 分钟。",
], 403);
}
// 1. 过滤净化消息体
$pureContent = $this->filter->filter($data['content'] ?? '');
if (empty($pureContent)) {
@@ -104,6 +122,13 @@ class ChatController extends Controller
// 5. 丢进异步列队,慢慢持久化到 MySQL保护数据库连接池
SaveMessageJob::dispatch($messageData);
// 6. 如果用户更换了字体颜色,顺便保存到 s_color 字段,下次进入时恢复
$chosenColor = $data['font_color'] ?? '';
if ($chosenColor && $chosenColor !== ($user->s_color ?? '')) {
$user->s_color = $chosenColor;
$user->save();
}
return response()->json(['status' => 'success']);
}
@@ -120,19 +145,22 @@ class ChatController extends Controller
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;
// 1. 每次心跳增加经验(可在 sysparam 后台配置)
$expGain = (int) Sysparam::getValue('exp_per_heartbeat', '1');
$user->exp_num += $expGain;
// 2. 使用 sysparam 表中可配置的等级-经验阈值计算等级
// 管理员superlevel 及以上)不参与自动升降级,等级由后台手动设置
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$oldLevel = $user->user_level;
$leveledUp = false;
if ($user->exp_num >= $requiredExpForNextLevel) {
$user->user_level += 1;
$leveledUp = true;
if ($oldLevel < $superLevel) {
$newLevel = Sysparam::calculateLevel($user->exp_num);
if ($newLevel !== $oldLevel && $newLevel < $superLevel) {
$user->user_level = $newLevel;
$leveledUp = ($newLevel > $oldLevel);
}
}
$user->save(); // 存点入库
@@ -167,12 +195,61 @@ class ChatController extends Controller
SaveMessageJob::dispatch($sysMsg);
}
// 5. 随机事件触发(复刻原版 autoact 系统,概率可在后台配置)
$autoEvent = null;
$eventChance = (int) Sysparam::getValue('auto_event_chance', '10');
if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
$autoEvent = Autoact::randomEvent();
if ($autoEvent) {
// 应用经验/金币变化(不低于 0
if ($autoEvent->exp_change !== 0) {
$user->exp_num = max(0, $user->exp_num + $autoEvent->exp_change);
}
if ($autoEvent->jjb_change !== 0) {
$user->jjb = max(0, ($user->jjb ?? 0) + $autoEvent->jjb_change);
}
$user->save();
// 重新计算等级(经验可能因事件而变化,但管理员不参与自动升降级)
if ($user->user_level < $superLevel) {
$recalcLevel = Sysparam::calculateLevel($user->exp_num);
if ($recalcLevel !== $user->user_level && $recalcLevel < $superLevel) {
$user->user_level = $recalcLevel;
$user->save();
}
}
// 广播随机事件消息到聊天室
$eventMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '星海小博士',
'to_user' => '大家',
'content' => $autoEvent->renderText($user->username),
'is_secret' => false,
'font_color' => match ($autoEvent->event_type) {
'good' => '#16a34a', // 绿色(好运)
'bad' => '#dc2626', // 红色(坏运)
default => '#7c3aed', // 紫色(中性)
},
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $eventMsg);
broadcast(new MessageSent($id, $eventMsg));
SaveMessageJob::dispatch($eventMsg);
}
}
return response()->json([
'status' => 'success',
'data' => [
'exp_num' => $user->exp_num,
'user_level' => $user->user_level,
'leveled_up' => $leveledUp,
'is_max_level' => $user->user_level >= $superLevel,
'auto_event' => $autoEvent ? $autoEvent->renderText($user->username) : null,
],
]);
}
@@ -197,4 +274,105 @@ class ChatController extends Controller
return response()->json(['status' => 'success']);
}
/**
* 获取可用头像列表(返回 JSON
* 扫描 /public/images/headface/ 目录,返回所有可用头像文件名
*/
public function headfaceList(): JsonResponse
{
$dir = public_path('images/headface');
$files = [];
if (is_dir($dir)) {
$all = scandir($dir);
foreach ($all as $file) {
// 只包含图片文件
if (preg_match('/\.(gif|jpg|jpeg|png|bmp)$/i', $file)) {
$files[] = $file;
}
}
}
// 自然排序1, 2, 3... 10, 11...
natsort($files);
return response()->json(['headfaces' => array_values($files)]);
}
/**
* 修改头像(原版 fw.asp 功能)
* 用户选择一个头像文件名,更新到 usersf 字段
*/
public function changeAvatar(Request $request): JsonResponse
{
$user = auth()->user();
$headface = $request->input('headface', '');
if (empty($headface)) {
return response()->json(['status' => 'error', 'message' => '请选择一个头像'], 422);
}
// 验证文件确实存在
if (! file_exists(public_path('images/headface/'.$headface))) {
return response()->json(['status' => 'error', 'message' => '头像文件不存在'], 422);
}
// 更新用户头像
$user->usersf = $headface;
$user->save();
return response()->json([
'status' => 'success',
'message' => '头像修改成功!',
'headface' => $headface,
]);
}
/**
* 设置房间公告/祝福语(滚动显示在聊天室顶部)
* 需要房间主人或等级达到 level_announcement 配置值
*
* @param int $id 房间ID
*/
public function setAnnouncement(Request $request, int $id): JsonResponse
{
$user = Auth::user();
$room = Room::findOrFail($id);
// 权限检查:房间主人 或 等级 >= level_announcement
$requiredLevel = (int) Sysparam::getValue('level_announcement', '10');
if ($user->username !== $room->master && $user->user_level < $requiredLevel) {
return response()->json(['status' => 'error', 'message' => '权限不足,无法修改公告'], 403);
}
$request->validate([
'announcement' => 'required|string|max:500',
]);
$room->announcement = $request->input('announcement');
$room->save();
// 广播公告更新到所有在线用户
$sysMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "📢 {$user->username} 更新了房间公告:{$room->announcement}",
'is_secret' => false,
'font_color' => '#cc0000',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $sysMsg);
broadcast(new MessageSent($id, $sysMsg));
return response()->json([
'status' => 'success',
'message' => '公告已更新!',
'announcement' => $room->announcement,
]);
}
}

View File

@@ -0,0 +1,210 @@
<?php
/**
* 文件功能:钓鱼小游戏控制器
* 复刻原版 ASP 聊天室 diaoyu/ 目录下的钓鱼功能
* 简化掉鱼竿道具系统,用 Redis 控制冷却,随机奖惩经验/金币
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Events\MessageSent;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
class FishingController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 抛竿 检查冷却和金币,扣除金币,返回随机等待时间
*
* @param int $id 房间ID
*/
public function cast(Request $request, int $id): JsonResponse
{
$user = Auth::user();
if (! $user) {
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
}
// 1. 检查冷却时间Redis TTL
$cooldownKey = "fishing:cd:{$user->id}";
if (Redis::exists($cooldownKey)) {
$ttl = Redis::ttl($cooldownKey);
return response()->json([
'status' => 'error',
'message' => "钓鱼冷却中,还需等待 {$ttl} 秒。",
'cooldown' => $ttl,
], 429);
}
// 2. 检查金币是否足够
$cost = (int) Sysparam::getValue('fishing_cost', '5');
if (($user->jjb ?? 0) < $cost) {
return response()->json([
'status' => 'error',
'message' => "金币不足!钓鱼需要 {$cost} 金币,您当前只有 {$user->jjb} 金币。",
], 422);
}
// 3. 扣除金币
$user->jjb = max(0, ($user->jjb ?? 0) - $cost);
$user->save();
// 4. 设置"正在钓鱼"标记防止重复抛竿30秒后自动过期
Redis::setex("fishing:active:{$user->id}", 30, time());
// 5. 计算随机等待时间
$waitMin = (int) Sysparam::getValue('fishing_wait_min', '8');
$waitMax = (int) Sysparam::getValue('fishing_wait_max', '15');
$waitTime = rand($waitMin, $waitMax);
return response()->json([
'status' => 'success',
'message' => "已花费 {$cost} 金币,鱼竿已抛出!等待鱼儿上钩...",
'wait_time' => $waitTime,
'cost' => $cost,
'jjb' => $user->jjb,
]);
}
/**
* 收竿 随机计算钓鱼结果,更新经验/金币,广播到聊天室
*
* @param int $id 房间ID
*/
public function reel(Request $request, int $id): JsonResponse
{
$user = Auth::user();
if (! $user) {
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
}
// 1. 检查是否有"正在钓鱼"标记
$activeKey = "fishing:active:{$user->id}";
if (! Redis::exists($activeKey)) {
return response()->json([
'status' => 'error',
'message' => '您还没有抛竿,或者鱼已经跑了!',
], 422);
}
// 清除钓鱼标记
Redis::del($activeKey);
// 2. 设置冷却时间
$cooldown = (int) Sysparam::getValue('fishing_cooldown', '300');
Redis::setex("fishing:cd:{$user->id}", $cooldown, time());
// 3. 随机决定钓鱼结果
$result = $this->randomFishResult();
// 4. 更新用户经验和金币(不低于 0
if ($result['exp'] !== 0) {
$user->exp_num = max(0, ($user->exp_num ?? 0) + $result['exp']);
}
if ($result['jjb'] !== 0) {
$user->jjb = max(0, ($user->jjb ?? 0) + $result['jjb']);
}
$user->save();
// 5. 广播钓鱼结果到聊天室
$sysMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '钓鱼播报',
'to_user' => '大家',
'content' => "{$result['emoji']} {$user->username}{$result['message']}",
'is_secret' => false,
'font_color' => $result['exp'] >= 0 ? '#16a34a' : '#dc2626',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $sysMsg);
broadcast(new MessageSent($id, $sysMsg));
return response()->json([
'status' => 'success',
'result' => $result,
'exp_num' => $user->exp_num,
'jjb' => $user->jjb,
]);
}
/**
* 随机钓鱼结果(复刻原版概率分布)
*
* @return array{emoji: string, message: string, exp: int, jjb: int}
*/
private function randomFishResult(): array
{
$roll = rand(1, 100);
// 概率分布(总计 100%
// 1-15: 大鲨鱼 (+100exp, +20金)
// 16-30: 娃娃鱼 (+0exp, +30金)
// 31-50: 大草鱼 (+50exp)
// 51-70: 小鲤鱼 (+50exp, +10金)
// 71-85: 落水 (-50exp)
// 86-95: 被打 (-20exp, -3金)
// 96-100:大丰收 (+150exp, +50金)
return match (true) {
$roll <= 15 => [
'emoji' => '🦈',
'message' => '钓到一条大鲨鱼增加经验100、金币20',
'exp' => 100,
'jjb' => 20,
],
$roll <= 30 => [
'emoji' => '🐟',
'message' => '钓到一条娃娃鱼到集市卖得30个金币',
'exp' => 0,
'jjb' => 30,
],
$roll <= 50 => [
'emoji' => '🐠',
'message' => '钓到一只大草鱼吃下增加经验50',
'exp' => 50,
'jjb' => 0,
],
$roll <= 70 => [
'emoji' => '🐡',
'message' => '钓到一条小鲤鱼增加经验50、金币10',
'exp' => 50,
'jjb' => 10,
],
$roll <= 85 => [
'emoji' => '💧',
'message' => '鱼没钓到摔到河里经验减少50',
'exp' => -50,
'jjb' => 0,
],
$roll <= 95 => [
'emoji' => '👊',
'message' => '偷钓鱼塘被主人发现一阵殴打经验减少20、金币减少3',
'exp' => -20,
'jjb' => -3,
],
default => [
'emoji' => '🎉',
'message' => '运气爆棚!钓到大鲨鱼、大草鱼、小鲤鱼各一条!经验+150金币+50',
'exp' => 150,
'jjb' => 50,
],
};
}
}

View File

@@ -28,7 +28,7 @@ class LeaderboardController extends Controller
// 1. 境界榜 (以 user_level 为尊)
$topLevels = Cache::remember('leaderboard:top_levels', $ttl, function () {
return User::select('id', 'username', 'headface', 'user_level', 'sex')
return User::select('id', 'username', 'usersf', 'user_level', 'sex')
->where('user_level', '>', 0)
->orderByDesc('user_level')
->orderBy('id')
@@ -38,7 +38,7 @@ class LeaderboardController extends Controller
// 2. 修为榜 (以 exp_num 为尊)
$topExp = Cache::remember('leaderboard:top_exp', $ttl, function () {
return User::select('id', 'username', 'headface', 'exp_num', 'sex', 'user_level')
return User::select('id', 'username', 'usersf', 'exp_num', 'sex', 'user_level')
->where('exp_num', '>', 0)
->orderByDesc('exp_num')
->orderBy('id')
@@ -48,7 +48,7 @@ class LeaderboardController extends Controller
// 3. 财富榜 (以 jjb-交友币 为尊)
$topWealth = Cache::remember('leaderboard:top_wealth', $ttl, function () {
return User::select('id', 'username', 'headface', 'jjb', 'sex', 'user_level')
return User::select('id', 'username', 'usersf', 'jjb', 'sex', 'user_level')
->where('jjb', '>', 0)
->orderByDesc('jjb')
->orderBy('id')
@@ -58,7 +58,7 @@ class LeaderboardController extends Controller
// 4. 魅力榜 (以 meili 为尊)
$topCharm = Cache::remember('leaderboard:top_charm', $ttl, function () {
return User::select('id', 'username', 'headface', 'meili', 'sex', 'user_level')
return User::select('id', 'username', 'usersf', 'meili', 'sex', 'user_level')
->where('meili', '>', 0)
->orderByDesc('meili')
->orderBy('id')

View File

@@ -2,11 +2,17 @@
/**
* 文件功能:用户中心与管理控制器
* 接管原版 USERinfo.ASP, USERSET.ASP, chpasswd.asp, KILLUSER.ASP
* 接管原版 USERinfo.ASP, USERSET.ASP, chpasswd.asp, KILLUSER.ASP, LOCKIP.ASP
*
* 权限等级通过 sysparam 表动态配置:
* level_kick - 踢人所需等级
* level_mute - 禁言所需等级
* level_ban - 封号所需等级
* level_banip - 封IP所需等级
*
* @author ChatRoom Laravel
*
* @version 1.0.0
* @version 1.1.0
*/
namespace App\Http\Controllers;
@@ -16,11 +22,13 @@ use App\Events\UserMuted;
use App\Http\Requests\ChangePasswordRequest;
use App\Http\Requests\UpdateProfileRequest;
use App\Models\Room;
use App\Models\Sysparam;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redis;
class UserController extends Controller
{
@@ -36,7 +44,11 @@ class UserController extends Controller
'username' => $user->username,
'sex' => $user->sex,
'headface' => $user->headface,
'usersf' => $user->usersf,
'user_level' => $user->user_level,
'exp_num' => $user->exp_num ?? 0,
'jjb' => $user->jjb ?? 0,
'qianming' => $user->qianming,
'sign' => $user->sign ?? '这个人很懒,什么都没留下。',
'created_at' => $user->created_at->format('Y-m-d'),
]);
@@ -85,7 +97,41 @@ class UserController extends Controller
}
/**
* 管理员/房主操作:踢出房间 (对应 KILLUSER.ASP)
* 通用权限校验:检查操作者是否有权操作目标用户
*
* @param object $operator 操作者
* @param string $targetUsername 目标用户名
* @param int $roomId 房间ID
* @param string $levelKey sysparam中的等级键名 level_kick
* @param string $actionName 操作名称(用于错误提示)
* @return array{room: Room, target: User}|JsonResponse
*/
private function checkPermission(object $operator, string $targetUsername, int $roomId, string $levelKey, string $actionName): array|JsonResponse
{
$room = Room::findOrFail($roomId);
$requiredLevel = (int) Sysparam::getValue($levelKey, '15');
// 鉴权:操作者要是房间房主或达到所需等级
if ($room->master !== $operator->username && $operator->user_level < $requiredLevel) {
return response()->json(['status' => 'error', 'message' => "权限不足(需要{$requiredLevel}级),无法执行{$actionName}操作。"], 403);
}
$targetUser = User::where('username', $targetUsername)->first();
if (! $targetUser) {
return response()->json(['status' => 'error', 'message' => '目标用户不存在。'], 404);
}
// 防误伤:不能操作等级 >= 自己的人
if ($targetUser->user_level >= $operator->user_level) {
return response()->json(['status' => 'error', 'message' => "权限不足,无法对同级或高级用户执行{$actionName}"], 403);
}
return ['room' => $room, 'target' => $targetUser];
}
/**
* 踢出房间 (对应 KILLUSER.ASP)
* 所需等级由 sysparam level_kick 配置
*/
public function kick(Request $request, string $username): JsonResponse
{
@@ -96,54 +142,113 @@ class UserController extends Controller
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);
$result = $this->checkPermission($operator, $username, $roomId, 'level_kick', '踢出');
if ($result instanceof JsonResponse) {
return $result;
}
$targetUser = User::where('username', $username)->first();
if (! $targetUser) {
return response()->json(['status' => 'error', 'message' => '目标用户不存在。'], 404);
}
// 广播踢出事件
broadcast(new UserKicked($roomId, $result['target']->username, "管理员 [{$operator->username}] 将 [{$result['target']->username}] 踢出了聊天室。"));
// 防误伤高管
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} 踢出房间。"]);
return response()->json(['status' => 'success', 'message' => "已成功将 {$result['target']->username} 踢出房间。"]);
}
/**
* 管理员/具有道具者操作:禁言 (对应新加的限制功能)
* 禁言 (对应原版限制功能)
* 所需等级由 sysparam level_mute 配置
* 禁言信息存入 RedisTTL 到期自动解除
*/
public function mute(Request $request, string $username): JsonResponse
{
$operator = Auth::user();
$roomId = $request->input('room_id');
$duration = $request->input('duration', 5); // 默认封停分钟数
$duration = (int) $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);
$result = $this->checkPermission($operator, $username, $roomId, 'level_mute', '禁言');
if ($result instanceof JsonResponse) {
return $result;
}
// 后续可以在 Redis 中写入一个 `mute:{$username}` 并附带 `TTL` 以在后台拦截
// 写入 Redis 禁言标记TTL = 禁言分钟数 * 60
Redis::setex("mute:{$roomId}:{$username}", $duration * 60, json_encode([
'operator' => $operator->username,
'reason' => '管理员禁言',
'until' => now()->addMinutes($duration)->toDateTimeString(),
]));
// 立刻向房间发送 Muted 事件
// 广播禁言事件
broadcast(new UserMuted($roomId, $username, $duration));
return response()->json(['status' => 'success', 'message' => "已对 {$username} 实施封口 {$duration} 分钟。"]);
return response()->json(['status' => 'success', 'message' => "已对 {$username} 实施禁言 {$duration} 分钟。"]);
}
/**
* 封号(禁止登录)
* 所需等级由 sysparam level_ban 配置
* 将用户等级设为 -1 表示封禁
*/
public function ban(Request $request, string $username): JsonResponse
{
$operator = Auth::user();
$roomId = $request->input('room_id');
if (! $roomId) {
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
}
$result = $this->checkPermission($operator, $username, $roomId, 'level_ban', '封号');
if ($result instanceof JsonResponse) {
return $result;
}
// 封号:设置等级为 -1
$result['target']->user_level = -1;
$result['target']->save();
// 踢出聊天室
broadcast(new UserKicked($roomId, $username, "管理员 [{$operator->username}] 已封禁用户 [{$username}] 的账号。"));
return response()->json(['status' => 'success', 'message' => "用户 {$username} 已被封号。"]);
}
/**
* 封IP记录IP到黑名单并踢出
* 所需等级由 sysparam level_banip 配置
*/
public function banIp(Request $request, string $username): JsonResponse
{
$operator = Auth::user();
$roomId = $request->input('room_id');
if (! $roomId) {
return response()->json(['status' => 'error', 'message' => '缺少房间参数。'], 422);
}
$result = $this->checkPermission($operator, $username, $roomId, 'level_banip', '封IP');
if ($result instanceof JsonResponse) {
return $result;
}
$targetIp = $result['target']->last_ip;
if ($targetIp) {
// 将IP加入 Redis 黑名单(永久)
Redis::sadd('banned_ips', $targetIp);
}
// 同时封号
$result['target']->user_level = -1;
$result['target']->save();
// 踢出聊天室
broadcast(new UserKicked($roomId, $username, "管理员 [{$operator->username}] 已封禁用户 [{$username}] 的IP地址。"));
$ipInfo = $targetIp ? "IP: {$targetIp}" : '未记录IP';
return response()->json(['status' => 'success', 'message' => "用户 {$username} 已被封号并封IP{$ipInfo}"]);
}
}

View File

@@ -2,6 +2,7 @@
/**
* 文件功能:用户等级权限验证中间件
* 支持传入固定数字等级 'super' 关键字(动态读取 sysparam superlevel
*
* @author ChatRoom Laravel
*
@@ -10,6 +11,7 @@
namespace App\Http\Middleware;
use App\Models\Sysparam;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -18,21 +20,26 @@ use Symfony\Component\HttpFoundation\Response;
class LevelRequired
{
/**
* Handle an incoming request.
* 校验当前登录用户的等级是否大于或等于要求等级。
* $level 'super' 时,动态从 sysparam 表读取 superlevel 值。
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param int $level 要求达到的最低等级 (例如 15 为室主)
* @param string $level 要求的最低等级(数字 'super'
*/
public function handle(Request $request, Closure $next, int $level): Response
public function handle(Request $request, Closure $next, string $level = 'super'): Response
{
if (! Auth::check()) {
return redirect()->route('home');
}
// 动态解析等级要求:'super' → 从 sysparam 读取,数字 → 直接使用
$requiredLevel = ($level === 'super')
? (int) Sysparam::getValue('superlevel', '100')
: (int) $level;
$user = Auth::user();
if ($user->user_level < $level) {
if ($user->user_level < $requiredLevel) {
if ($request->expectsJson()) {
return response()->json(['message' => '权限不足', 'status' => 'error'], 403);
}

View File

@@ -39,8 +39,7 @@ class LoginRequest extends FormRequest
'regex:/^[^<>\'"]+$/u',
],
'password' => ['required', 'string', 'min:1'],
// 'captcha' => ['required', 'captcha'],
'captcha' => ['nullable'], // 【本地调试临时绕过验证码强校验跳过session丢失问题】
'captcha' => ['required', 'captcha'],
];
}

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

@@ -0,0 +1,57 @@
<?php
/**
* 文件功能:自动动作事件模型
* 对应原版 ASP 聊天室的 autoact
* 存储系统随机事件(好运/坏运/经验/金币奖惩)
* 管理员可在后台管理这些事件
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Autoact extends Model
{
/** @var string 表名 */
protected $table = 'autoact';
/** @var array 可批量赋值的字段 */
protected $fillable = [
'text_body',
'event_type',
'exp_change',
'jjb_change',
'enabled',
];
/** @var array 类型转换 */
protected $casts = [
'exp_change' => 'integer',
'jjb_change' => 'integer',
'enabled' => 'boolean',
];
/**
* 随机获取一条已启用的事件
*/
public static function randomEvent(): ?self
{
return static::where('enabled', true)->inRandomOrder()->first();
}
/**
* 将事件文本中的变量替换为实际值
*
* @param string $username 当前用户名
* @return string 替换后的文本
*/
public function renderText(string $username): string
{
return str_replace('{username}', $username, $this->text_body);
}
}

View File

@@ -36,6 +36,7 @@ class Room extends Model
'build_time',
'permit_level',
'door_open',
'announcement',
];
/**

View File

@@ -2,28 +2,89 @@
/**
* 文件功能:系统参数模型
* 对应原版 ASP 聊天室的 sysparam 配置表
* 管理员可在后台修改等级经验阈值等系统参数
*
* 对应原 ASP 文件sysparam
*
* @author ChatRoom Laravel
*
* @version 1.0.0
* @package App\Models
* @author ChatRoom Laravel
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class SysParam extends Model
class Sysparam extends Model
{
/** @var string 表名 */
protected $table = 'sysparam';
/** @var array 可批量赋值的字段 */
protected $fillable = ['alias', 'body', 'guidetxt'];
/**
* The attributes that are mass assignable.
* 获取指定参数的值
* 带缓存,避免频繁查库
*
* @var array<int, string>
* @param string $alias 参数别名
* @param string $default 默认值
* @return string
*/
protected $fillable = [
'alias',
'guidetxt',
'body',
];
public static function getValue(string $alias, string $default = ''): string
{
return Cache::remember("sysparam:{$alias}", 300, function () use ($alias, $default) {
$param = static::where('alias', $alias)->first();
return $param ? ($param->body ?? $default) : $default;
});
}
/**
* 获取等级经验阈值数组
* 返回格式:[0 => 10, 1 => 50, 2 => 150, ...] 索引即为目标等级-1
*
* @return array<int, int>
*/
public static function getLevelExpThresholds(): array
{
$str = static::getValue('levelexp', '10,50,150,400,800,1500,3000,5000,8000,12000,18000,25000,35000,50000,80000');
return array_map('intval', explode(',', $str));
}
/**
* 根据经验值计算应该达到的等级
*
* @param int $expNum 当前经验值
* @return int 对应的等级
*/
public static function calculateLevel(int $expNum): int
{
$thresholds = static::getLevelExpThresholds();
$level = 0;
foreach ($thresholds as $threshold) {
if ($expNum >= $threshold) {
$level++;
} else {
break;
}
}
// 不超过最大等级
$maxLevel = (int) static::getValue('maxlevel', '99');
return min($level, $maxLevel);
}
/**
* 清除指定参数的缓存
*
* @param string $alias 参数别名
*/
public static function clearCache(string $alias): void
{
Cache::forget("sysparam:{$alias}");
}
}

View File

@@ -12,6 +12,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@@ -34,6 +35,7 @@ class User extends Authenticatable
'room_id',
'first_ip',
'last_ip',
'usersf',
];
/**
@@ -69,4 +71,18 @@ class User extends Authenticatable
'q3_time' => 'datetime',
];
}
/**
* 头像文件名访问器
*
* ASP 系统的头像文件名存储在 usersf 字段中(如 "75.GIF"
* 但项目中各处通过 $user->headface 来引用头像。
* accessor headface 属性映射到 usersf 字段,保持代码一致性。
*/
protected function headface(): Attribute
{
return Attribute::make(
get: fn () => $this->usersf ?: '1.GIF',
);
}
}

BIN
assets/backgrounds/01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
assets/backgrounds/02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
assets/backgrounds/03.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
assets/backgrounds/04.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/backgrounds/05.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
assets/backgrounds/06.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
assets/backgrounds/07.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
assets/backgrounds/08.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
assets/backgrounds/09.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
assets/backgrounds/10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
assets/backgrounds/11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

BIN
assets/backgrounds/12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

BIN
assets/fonts/Asap_700.ttf Normal file

Binary file not shown.

BIN
assets/fonts/Khand_500.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1 @@
Copyright (c) <dates>, <Copyright Holder> (<URL|email>),

View File

@@ -0,0 +1,96 @@
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.

View File

@@ -2,20 +2,20 @@
return [
'disable' => env('CAPTCHA_DISABLE', false),
'characters' => ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O',
'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd',
'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's',
't', 'u', 'v', 'w', 'x', 'y', 'z', 0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
'characters' => [2, 3, 4, 5, 6, 7, 8, 9],
'fontsDirectory' => dirname(__DIR__).'/assets/fonts',
'bgsDirectory' => dirname(__DIR__).'/assets/backgrounds',
'default' => [
'length' => 6,
'width' => 345,
'height' => 65,
'length' => 4,
'width' => 120,
'height' => 36,
'quality' => 90,
'math' => false,
'expire' => 60,
'encrypt' => false,
'lines' => 2,
'bgImage' => false,
'bgColor' => '#ffffff',
],
'flat' => [
'length' => 6,

View File

@@ -0,0 +1,34 @@
<?php
/**
* 文件功能:为 rooms 表添加 visit_num人气/访问量)字段
* 用于记录房间的总访问人次,每次用户进入房间时递增
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 添加 visit_num 字段到 rooms
*/
public function up(): void
{
Schema::table('rooms', function (Blueprint $table) {
$table->unsignedBigInteger('visit_num')->default(0)->after('ooooo')
->comment('房间人气(总访问人次)');
});
}
/**
* 回滚:移除 visit_num 字段
*/
public function down(): void
{
Schema::table('rooms', function (Blueprint $table) {
$table->dropColumn('visit_num');
});
}
};

View File

@@ -0,0 +1,71 @@
<?php
/**
* 文件功能创建系统参数表sysparam
* 复刻原版 ASP 聊天室的 sysparam 系统配置表
* 存储等级-经验阈值、系统开关等管理员可配置项
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建 sysparam 表并插入默认等级经验配置
*/
public function up(): void
{
Schema::create('sysparam', function (Blueprint $table) {
$table->id();
$table->string('alias', 100)->unique()->comment('参数别名(唯一标识)');
$table->text('body')->nullable()->comment('参数值');
$table->string('guidetxt', 500)->nullable()->comment('参数说明(后台显示)');
$table->timestamps();
});
// 插入默认的等级经验配置(管理员可在后台修改)
// 格式:逗号分隔,每项为"等级所需的累计经验值"
// 例如等级1需要10经验等级2需要50等级3需要150...
DB::table('sysparam')->insert([
[
'alias' => 'levelexp',
'body' => '10,50,150,400,800,1500,3000,5000,8000,12000,18000,25000,35000,50000,80000',
'guidetxt' => '等级经验阈值配置逗号分隔。第N个数字表示升到N级所需的累计经验值。例如10,50,150 表示1级需10经验2级需50经验3级需150经验。',
'created_at' => now(),
'updated_at' => now(),
],
[
'alias' => 'exp_per_heartbeat',
'body' => '1',
'guidetxt' => '每次心跳约60秒增加的经验值',
'created_at' => now(),
'updated_at' => now(),
],
[
'alias' => 'superlevel',
'body' => '16',
'guidetxt' => '管理员级别(= 最高等级 + 1拥有最高权限的等级阈值',
'created_at' => now(),
'updated_at' => now(),
],
[
'alias' => 'maxlevel',
'body' => '15',
'guidetxt' => '用户最高可达等级',
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* 回滚:删除 sysparam
*/
public function down(): void
{
Schema::dropIfExists('sysparam');
}
};

View File

@@ -0,0 +1,73 @@
<?php
/**
* 文件功能创建自动动作事件表autoact
* 复刻原版 ASP 聊天室的 autoact
* 存储系统随机事件(好运/坏运/经验/金币奖惩等)
* 管理员可在后台增删改这些事件
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建 autoact 表并插入默认随机事件
*/
public function up(): void
{
Schema::create('autoact', function (Blueprint $table) {
$table->id();
$table->string('text_body', 500)->comment('事件文本内容(支持变量替换)');
$table->string('event_type', 20)->default('neutral')->comment('事件类型good=好运, bad=坏运, neutral=中性');
$table->integer('exp_change')->default(0)->comment('经验变化值(正=奖励, 负=惩罚)');
$table->integer('jjb_change')->default(0)->comment('金币变化值(正=奖励, 负=惩罚)');
$table->boolean('enabled')->default(true)->comment('是否启用');
$table->timestamps();
});
// 插入默认随机事件
$events = [
// 好运事件(奖励经验/金币)
['text_body' => '🎉 恭喜【{username}】路遇仙人指点,获得 100 经验值!', 'event_type' => 'good', 'exp_change' => 100, 'jjb_change' => 0],
['text_body' => '💰 【{username}】在路边捡到一袋金币,获得 500 金币!', 'event_type' => 'good', 'exp_change' => 0, 'jjb_change' => 500],
['text_body' => '🌟 天降祥瑞!【{username}】获得 200 经验 + 200 金币!', 'event_type' => 'good', 'exp_change' => 200, 'jjb_change' => 200],
['text_body' => '🎊 【{username}】参加武林大会获胜,奖励 300 经验!', 'event_type' => 'good', 'exp_change' => 300, 'jjb_change' => 0],
['text_body' => '🏆 【{username}】完成了一个神秘任务,获得 150 金币!', 'event_type' => 'good', 'exp_change' => 0, 'jjb_change' => 150],
['text_body' => '✨ 【{username}】领悟了一招绝学,经验暴增 500', 'event_type' => 'good', 'exp_change' => 500, 'jjb_change' => 0],
['text_body' => '🎁 系统随机赠送【{username}】100 金币,请查收!', 'event_type' => 'good', 'exp_change' => 0, 'jjb_change' => 100],
// 坏运事件(扣除经验/金币)
['text_body' => '💀 【{username}】不小心踩到陷阱,损失 50 经验!', 'event_type' => 'bad', 'exp_change' => -50, 'jjb_change' => 0],
['text_body' => '😱 【{username}】遭遇山贼打劫,被抢走 100 金币!', 'event_type' => 'bad', 'exp_change' => 0, 'jjb_change' => -100],
['text_body' => '🌧️ 【{username}】在雨中迷路,消耗 80 经验值。', 'event_type' => 'bad', 'exp_change' => -80, 'jjb_change' => 0],
['text_body' => '🐍 【{username}】被毒蛇咬了一口,损失 30 经验和 50 金币!', 'event_type' => 'bad', 'exp_change' => -30, 'jjb_change' => -50],
// 中性事件(纯文字,不奖惩)
['text_body' => '🔮 星海小博士:【{username}】今天运势不错,继续加油!', 'event_type' => 'neutral', 'exp_change' => 0, 'jjb_change' => 0],
['text_body' => '📜 星海小博士:有人说"坚持就是胜利",【{username}】觉得呢?', 'event_type' => 'neutral', 'exp_change' => 0, 'jjb_change' => 0],
['text_body' => '🌈 星海小博士:【{username}】今天心情如何?记得多喝水哦!', 'event_type' => 'neutral', 'exp_change' => 0, 'jjb_change' => 0],
['text_body' => '🎵 星海小博士:送给【{username}】一首歌,祝你天天开心!', 'event_type' => 'neutral', 'exp_change' => 0, 'jjb_change' => 0],
];
$now = now();
foreach ($events as &$event) {
$event['enabled'] = true;
$event['created_at'] = $now;
$event['updated_at'] = $now;
}
DB::table('autoact')->insert($events);
}
/**
* 回滚:删除 autoact
*/
public function down(): void
{
Schema::dropIfExists('autoact');
}
};

View File

@@ -0,0 +1,33 @@
<?php
/**
* 文件功能:为 rooms 表添加 announcement公告/祝福语)字段
* 有权限的用户可以设置房间公告,显示在聊天室顶部滚动条
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 添加 announcement 字段
*/
public function up(): void
{
Schema::table('rooms', function (Blueprint $table) {
$table->string('announcement', 500)->nullable()->after('room_des')->comment('房间公告/祝福语(滚动显示)');
});
}
/**
* 回滚
*/
public function down(): void
{
Schema::table('rooms', function (Blueprint $table) {
$table->dropColumn('announcement');
});
}
};

View File

@@ -0,0 +1,33 @@
<?php
/**
* 文件功能:将 users 表的 s_color 字段从 integer 改为 varchar
* 以便直接存储 hex 颜色字符串(如 #ff0000与前端颜色选择器格式一致
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 执行迁移s_color integer 改为 varchar(10)
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('s_color', 10)->nullable()->comment('发言颜色hex如 #ff0000')->change();
});
}
/**
* 回滚s_color varchar 改回 integer
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->integer('s_color')->nullable()->comment('发言颜色')->change();
});
}
};

View File

@@ -0,0 +1,81 @@
<?php
/**
* 文件功能:补充缺失的 sysparam 系统参数记录
* 代码中引用了多个参数但原始迁移只种子了 4 条,
* 此迁移使用 insertOrIgnore 补充缺失的参数配置
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* 补充缺失的系统参数记录(使用 insertOrIgnore 避免重复)
*/
public function up(): void
{
$now = now();
DB::table('sysparam')->insertOrIgnore([
[
'alias' => 'level_kick',
'body' => '10',
'guidetxt' => '踢人所需等级(管理员可在聊天室踢出用户的最低等级)',
'created_at' => $now,
'updated_at' => $now,
],
[
'alias' => 'level_mute',
'body' => '8',
'guidetxt' => '禁言所需等级(管理员可在聊天室禁言用户的最低等级)',
'created_at' => $now,
'updated_at' => $now,
],
[
'alias' => 'level_ban',
'body' => '12',
'guidetxt' => '封号所需等级(管理员可封禁用户账号的最低等级)',
'created_at' => $now,
'updated_at' => $now,
],
[
'alias' => 'level_banip',
'body' => '14',
'guidetxt' => '封IP所需等级管理员可封禁用户IP地址的最低等级',
'created_at' => $now,
'updated_at' => $now,
],
[
'alias' => 'level_announcement',
'body' => '10',
'guidetxt' => '设置公告所需等级(可修改房间公告/祝福语的最低等级)',
'created_at' => $now,
'updated_at' => $now,
],
[
'alias' => 'auto_event_chance',
'body' => '10',
'guidetxt' => '随机事件触发概率百分比1-100每次心跳时按此概率触发随机事件',
'created_at' => $now,
'updated_at' => $now,
],
]);
}
/**
* 回滚:删除补充的参数记录
*/
public function down(): void
{
DB::table('sysparam')->whereIn('alias', [
'level_kick',
'level_mute',
'level_ban',
'level_banip',
'level_announcement',
'auto_event_chance',
])->delete();
}
};

View File

@@ -0,0 +1,95 @@
<?php
/**
* 文件功能:将等级系统从 15 级扩展到 99
* - maxlevel: 15 99(用户最高等级)
* - superlevel: 16 100(管理员等级)
* - levelexp: 重新生成 99 级的经验阶梯
* - 管理权限等级按比例调整到新体系
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* 更新等级系统参数至 99 级体系
*/
public function up(): void
{
// 生成 99 级经验阶梯(使用 level^2.5 的幂次曲线,确保平滑递增)
$thresholds = [];
for ($level = 1; $level <= 99; $level++) {
// 公式10 * level^2.5,取整到十位数,保持数据整洁
$exp = (int) (ceil(10 * pow($level, 2.5) / 10) * 10);
$thresholds[] = $exp;
}
$levelExpStr = implode(',', $thresholds);
$now = now();
// 更新核心等级参数
DB::table('sysparam')->where('alias', 'maxlevel')
->update(['body' => '99', 'guidetxt' => '用户最高可达等级', 'updated_at' => $now]);
DB::table('sysparam')->where('alias', 'superlevel')
->update(['body' => '100', 'guidetxt' => '管理员级别(= 最高等级 + 1拥有最高权限的等级阈值', 'updated_at' => $now]);
DB::table('sysparam')->where('alias', 'levelexp')
->update(['body' => $levelExpStr, 'updated_at' => $now]);
// 按比例调整管理权限等级(原体系 /15新体系 /99
// 禁言8/15 ≈ 53% → 50级
DB::table('sysparam')->where('alias', 'level_mute')
->update(['body' => '50', 'updated_at' => $now]);
// 踢人10/15 ≈ 67% → 60级
DB::table('sysparam')->where('alias', 'level_kick')
->update(['body' => '60', 'updated_at' => $now]);
// 设公告10/15 ≈ 67% → 60级
DB::table('sysparam')->where('alias', 'level_announcement')
->update(['body' => '60', 'updated_at' => $now]);
// 封号12/15 = 80% → 80级
DB::table('sysparam')->where('alias', 'level_ban')
->update(['body' => '80', 'updated_at' => $now]);
// 封IP14/15 ≈ 93% → 90级
DB::table('sysparam')->where('alias', 'level_banip')
->update(['body' => '90', 'updated_at' => $now]);
}
/**
* 回滚:恢复到原始 15 级体系
*/
public function down(): void
{
$now = now();
DB::table('sysparam')->where('alias', 'maxlevel')
->update(['body' => '15', 'updated_at' => $now]);
DB::table('sysparam')->where('alias', 'superlevel')
->update(['body' => '16', 'updated_at' => $now]);
DB::table('sysparam')->where('alias', 'levelexp')
->update(['body' => '10,50,150,400,800,1500,3000,5000,8000,12000,18000,25000,35000,50000,80000', 'updated_at' => $now]);
DB::table('sysparam')->where('alias', 'level_mute')
->update(['body' => '8', 'updated_at' => $now]);
DB::table('sysparam')->where('alias', 'level_kick')
->update(['body' => '10', 'updated_at' => $now]);
DB::table('sysparam')->where('alias', 'level_announcement')
->update(['body' => '10', 'updated_at' => $now]);
DB::table('sysparam')->where('alias', 'level_ban')
->update(['body' => '12', 'updated_at' => $now]);
DB::table('sysparam')->where('alias', 'level_banip')
->update(['body' => '14', 'updated_at' => $now]);
}
};

View File

@@ -0,0 +1,64 @@
<?php
/**
* 文件功能:添加钓鱼系统参数到 sysparam
* 配置钓鱼花费金币、冷却时间、等待上钩时间范围
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* 插入钓鱼相关系统参数
*/
public function up(): void
{
$now = now();
DB::table('sysparam')->insertOrIgnore([
[
'alias' => 'fishing_cost',
'body' => '5',
'guidetxt' => '每次钓鱼花费金币数量',
'created_at' => $now,
'updated_at' => $now,
],
[
'alias' => 'fishing_cooldown',
'body' => '300',
'guidetxt' => '钓鱼冷却时间默认300秒=5分钟',
'created_at' => $now,
'updated_at' => $now,
],
[
'alias' => 'fishing_wait_min',
'body' => '8',
'guidetxt' => '钓鱼最短等待上钩时间(秒)',
'created_at' => $now,
'updated_at' => $now,
],
[
'alias' => 'fishing_wait_max',
'body' => '15',
'guidetxt' => '钓鱼最长等待上钩时间(秒)',
'created_at' => $now,
'updated_at' => $now,
],
]);
}
/**
* 回滚:删除钓鱼参数
*/
public function down(): void
{
DB::table('sysparam')->whereIn('alias', [
'fishing_cost',
'fishing_cooldown',
'fishing_wait_min',
'fishing_wait_max',
])->delete();
}
};

629
public/css/chat.css Normal file
View File

@@ -0,0 +1,629 @@
/**
* 文件功能聊天室主界面样式表
* 复刻原版海军蓝聊天室色系 (CHAT.CSS)
* 包含布局消息窗格输入工具栏用户列表弹窗等所有聊天界面样式
*
* @author ChatRoom Laravel
* @version 1.0.0
*/
/*
原版海军蓝聊天室色系 (CHAT.CSS 复刻)
*/
:root {
--bg-main: #EAF2FF;
--bg-bar: #b0d8ff;
--bg-header: #4488aa;
--bg-toolbar: #5599aa;
--text-link: #336699;
--text-navy: #112233;
--text-white: #ffffff;
--border-blue: #336699;
--scrollbar-base: #b0d8ff;
--scrollbar-arrow: #336699;
--scrollbar-track: #EAF2FF;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Microsoft YaHei", "SimSun", "宋体", sans-serif;
font-size: 10pt;
background: var(--bg-main);
color: var(--text-navy);
overflow: hidden;
height: 100vh;
}
/* 全局链接样式 */
a {
color: var(--text-link);
text-decoration: none;
}
a:hover {
color: #009900;
}
/* 自定义滚动条 - 模拟原版 */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-base);
border-radius: 2px;
border: 1px solid var(--scrollbar-arrow);
}
::-webkit-scrollbar-track {
background-color: var(--scrollbar-track);
}
::-webkit-scrollbar-corner {
background-color: var(--scrollbar-base);
}
/* ── 主布局 ─────────────────────────────────────── */
.chat-layout {
display: flex;
height: 100vh;
width: 100vw;
}
/* 左侧主区域 */
.chat-left {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
/* 竖向工具条 */
.chat-toolbar {
width: 28px;
background: var(--bg-toolbar);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 40px 0 2px 0;
border-left: 1px solid var(--border-blue);
border-right: 1px solid var(--border-blue);
overflow-y: auto;
flex-shrink: 0;
}
.chat-toolbar .tool-btn {
writing-mode: vertical-rl;
text-orientation: upright;
font-size: 11px;
color: #ddd;
cursor: pointer;
padding: 4px 0;
text-align: center;
transition: all 0.15s;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
letter-spacing: 2px;
}
.chat-toolbar .tool-btn:hover {
color: #fff;
background: #5599cc;
}
/* 右侧用户列表面板 */
.chat-right {
width: 150px;
display: flex;
flex-direction: column;
border-left: 1px solid var(--border-blue);
background: var(--bg-main);
flex-shrink: 0;
padding-top: 2px;
}
/* ── 标题栏 ─────────────────────────────────────── */
.room-title-bar {
background: var(--bg-header);
color: var(--text-white);
text-align: center;
padding: 6px 10px;
font-size: 12px;
line-height: 1.6;
flex-shrink: 0;
border-bottom: 1px solid var(--border-blue);
}
.room-title-bar .room-name {
font-weight: bold;
font-size: 14px;
}
.room-title-bar .room-desc {
color: #cee8ff;
font-size: 11px;
}
/* 公告/祝福语滚动条(复刻原版 marquee */
.room-announcement-bar {
background: linear-gradient(to right, #a8d8a8, #c8f0c8, #a8d8a8);
padding: 2px 6px;
border-bottom: 1px solid var(--border-blue);
flex-shrink: 0;
height: 20px;
line-height: 20px;
overflow: hidden;
}
/* ── 消息窗格 ───────────────────────────────────── */
.message-panes {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.message-pane {
flex: 1;
overflow-y: auto;
padding: 5px 8px;
background: var(--bg-main);
font-size: 10pt;
line-height: 170%;
min-height: 0;
}
.message-pane.say1 {
border-bottom: 2px solid var(--border-blue);
}
.message-pane.say2 {
display: block;
flex: 0.4;
border-top: 2px solid var(--border-blue);
}
.message-pane.say1 {
flex: 0.6;
}
/* 分屏模式下两个窗格各占一半 */
.message-panes.split-h .message-pane.say1,
.message-panes.split-h .message-pane.say2 {
flex: 1;
}
.message-panes.split-h .message-pane.say2 {
flex: 1;
}
.message-panes.split-h .message-pane.say1 {
flex: 1;
border-bottom: 2px solid var(--border-blue);
}
/* 消息行样式 */
.msg-line {
margin: 2px 0;
word-wrap: break-word;
word-break: break-all;
}
.msg-line .msg-time {
font-size: 9px;
color: #999;
}
.msg-line .msg-user {
cursor: pointer;
font-weight: bold;
}
.msg-line .msg-user:hover {
text-decoration: underline;
}
.msg-line .msg-action {
color: #009900;
}
.msg-line .msg-secret {
color: #cc00cc;
font-style: italic;
}
.msg-line .msg-content {
margin-left: 4px;
}
.msg-line.sys-msg {
color: #cc0000;
text-align: center;
font-size: 9pt;
}
/* ── 底部输入工具栏 ─────────────────────────────── */
.input-bar {
height: auto;
min-height: 75px;
background: var(--bg-bar);
border-top: 2px solid var(--border-blue);
padding: 3px 6px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.input-bar select,
.input-bar input[type="text"],
.input-bar input[type="checkbox"] {
font-size: 12px;
border: 1px solid navy;
color: #224466;
padding: 1px 2px;
background: #fff;
}
.input-bar select {
max-width: 100px;
}
.input-bar .input-row {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.input-bar .say-input {
flex: 1;
min-width: 200px;
font-size: 12px;
border: 1px solid navy;
color: #224466;
padding: 3px 5px;
outline: none;
}
.input-bar .say-input:focus {
border-color: #0066cc;
box-shadow: 0 0 3px rgba(0, 102, 204, 0.3);
}
.input-bar .send-btn {
background: var(--bg-header);
color: white;
border: 1px solid var(--border-blue);
padding: 3px 12px;
font-size: 12px;
font-weight: bold;
cursor: pointer;
transition: background 0.15s;
}
.input-bar .send-btn:hover {
background: #336699;
}
.input-bar .send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.input-bar label {
font-size: 12px;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
gap: 2px;
}
/* ── 右侧用户列表 Tab 栏 ──────────────────────── */
.right-tabs {
display: flex;
background: var(--bg-bar);
border-bottom: 2px solid navy;
flex-shrink: 0;
}
.right-tabs .tab-btn {
flex: 1;
text-align: center;
font-size: 12px;
padding: 3px 0;
cursor: pointer;
color: navy;
background: var(--bg-bar);
border: none;
font-family: inherit;
transition: all 0.15s;
user-select: none;
}
.right-tabs .tab-btn.active {
background: navy;
color: white;
}
.right-tabs .tab-btn:hover:not(.active) {
background: #99c8ee;
}
/* 用户列表内容 */
.user-list-content {
flex: 1;
overflow-y: auto;
padding: 4px;
}
.user-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 4px;
cursor: pointer;
font-size: 12px;
border-bottom: 1px dotted #cde;
transition: background 0.15s;
}
.user-item:hover {
background: #d0e8ff;
}
.user-item .user-head {
width: 36px;
height: 36px;
border-radius: 2px;
object-fit: cover;
background: #ddd;
flex-shrink: 0;
}
.user-item .user-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-link);
font-weight: bold;
}
.user-item .user-sex {
font-size: 10px;
}
.user-item .user-level {
font-size: 9px;
color: #999;
}
/* 在线人数统计 */
.online-stats {
background: var(--bg-bar);
text-align: center;
font-size: 11px;
padding: 3px;
border-top: 1px solid var(--border-blue);
color: navy;
flex-shrink: 0;
}
/* ── 用户名片弹窗 ───────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-card {
background: white;
border: 2px solid var(--border-blue);
border-radius: 8px;
width: 340px;
max-width: 90vw;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.modal-header {
background: linear-gradient(135deg, var(--bg-header), var(--border-blue));
color: white;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
font-size: 16px;
font-weight: bold;
}
.modal-close {
background: none;
border: none;
color: white;
font-size: 20px;
cursor: pointer;
opacity: 0.8;
}
.modal-close:hover {
opacity: 1;
}
.modal-body {
padding: 16px;
}
.modal-body .profile-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.modal-body .profile-avatar {
width: 56px;
height: 56px;
border-radius: 6px;
object-fit: cover;
border: 2px solid var(--bg-bar);
background: #eee;
}
.modal-body .profile-info h4 {
font-size: 16px;
font-weight: bold;
color: var(--text-navy);
}
.modal-body .profile-info .level-badge {
display: inline-block;
background: var(--bg-bar);
color: var(--border-blue);
font-size: 11px;
padding: 1px 6px;
border-radius: 10px;
font-weight: bold;
margin-left: 4px;
}
.modal-body .profile-info .sex-badge {
font-size: 11px;
margin-left: 2px;
}
.modal-body .profile-detail {
background: #f5f9ff;
border: 1px solid #e0ecff;
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
color: #666;
margin-bottom: 12px;
}
.modal-actions {
padding: 0 16px 16px;
display: flex;
gap: 8px;
}
.modal-actions button,
.modal-actions a {
flex: 1;
text-align: center;
padding: 6px 0;
border-radius: 6px;
font-size: 12px;
font-weight: bold;
cursor: pointer;
border: 1px solid;
transition: all 0.15s;
text-decoration: none;
display: block;
}
.btn-kick {
background: #fee;
color: #c00;
border-color: #fcc;
}
.btn-kick:hover {
background: #fcc;
}
.btn-mute {
background: #fff8e6;
color: #b86e00;
border-color: #ffe0a0;
}
.btn-mute:hover {
background: #ffe0a0;
}
.btn-mail {
background: #f0e8ff;
color: #6633cc;
border-color: #d8c8ff;
}
.btn-mail:hover {
background: #d8c8ff;
}
.btn-whisper {
background: #e8f5ff;
color: var(--text-link);
border-color: var(--bg-bar);
}
.btn-whisper:hover {
background: var(--bg-bar);
}
/* 禁言输入行 */
.mute-form {
display: flex;
align-items: center;
gap: 6px;
padding: 0 16px 12px;
}
.mute-form input {
width: 60px;
border: 1px solid #ffe0a0;
border-radius: 4px;
padding: 3px 6px;
font-size: 12px;
}
.mute-form button {
background: #b86e00;
color: white;
border: none;
border-radius: 4px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
}
/* ── 头像选择器 ─────────────────────────────────── */
.avatar-option {
width: 36px;
height: 36px;
cursor: pointer;
border: 2px solid transparent;
border-radius: 3px;
transition: border-color 0.15s, transform 0.15s;
}
.avatar-option:hover {
border-color: #88bbdd;
transform: scale(1.15);
}
.avatar-option.selected {
border-color: #336699;
box-shadow: 0 0 6px rgba(51, 102, 153, 0.5);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Some files were not shown because too many files have changed in this diff Show More