feat: 增加自定义头像上传、自动压缩与自动清理功能,统一全站头像路径读取逻辑
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
* 前端收到后弹出 Toast 通知展示到账金额。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 前端接收后显示红包卡片弹窗,并在聊天窗口追加系统公告。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 仅限 superlevel 以上管理员访问。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ class SmtpController extends Controller
|
||||
public function test(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'test_email' => 'required|email'
|
||||
'test_email' => 'required|email',
|
||||
]);
|
||||
|
||||
$testEmail = $request->input('test_email');
|
||||
@@ -78,7 +78,7 @@ class SmtpController extends Controller
|
||||
|
||||
return redirect()->route('admin.smtp.edit')->with('success', "测试邮件已成功发送至 {$testEmail},请注意查收。");
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('admin.smtp.edit')->with('error', "测试发出失败,原因:" . $e->getMessage());
|
||||
return redirect()->route('admin.smtp.edit')->with('error', '测试发出失败,原因:'.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class VerificationController extends Controller
|
||||
public function sendEmailCode(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email'
|
||||
'email' => 'required|email',
|
||||
]);
|
||||
|
||||
$email = $request->input('email');
|
||||
@@ -27,23 +27,24 @@ class VerificationController extends Controller
|
||||
if (SysParam::where('alias', 'smtp_enabled')->value('body') !== '1') {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '抱歉,当前系统未开启外部邮件发信服务,请联系管理员。'
|
||||
'message' => '抱歉,当前系统未开启外部邮件发信服务,请联系管理员。',
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 2. 检查是否有频率限制(同一用户或同一邮箱,60秒只允许发1次)
|
||||
$throttleKey = 'email_throttle_' . $user->id;
|
||||
$throttleKey = 'email_throttle_'.$user->id;
|
||||
if (Cache::has($throttleKey)) {
|
||||
$ttl = Cache::ttl($throttleKey);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => "发送过于频繁,请等待 {$ttl} 秒后再试。"
|
||||
'message' => "发送过于频繁,请等待 {$ttl} 秒后再试。",
|
||||
], 429);
|
||||
}
|
||||
|
||||
// 3. 生成 6 位随机验证码并缓存,有效期 5 分钟
|
||||
$code = mt_rand(100000, 999999);
|
||||
$codeKey = 'email_verify_code_' . $user->id . '_' . $email;
|
||||
$codeKey = 'email_verify_code_'.$user->id.'_'.$email;
|
||||
Cache::put($codeKey, $code, now()->addMinutes(5));
|
||||
|
||||
// 设置频率锁,过期时间 60 秒
|
||||
@@ -57,14 +58,15 @@ class VerificationController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '验证码已发送,请注意查收邮件。'
|
||||
'message' => '验证码已发送,请注意查收邮件。',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// 如果发信失败,主动接触频率限制锁方便用户下一次立重试
|
||||
Cache::forget($throttleKey);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => '邮件系统发送异常,请稍后再试: ' . $e->getMessage()
|
||||
'message' => '邮件系统发送异常,请稍后再试: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,10 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
class ChatController extends Controller
|
||||
{
|
||||
@@ -683,9 +686,12 @@ class ChatController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '头像文件不存在'], 422);
|
||||
}
|
||||
|
||||
// 更新用户头像
|
||||
// 更新前如为自定义头像,将其从磁盘删除,节约空间
|
||||
if ($user->usersf !== $headface) {
|
||||
$user->deleteCustomAvatar();
|
||||
$user->usersf = $headface;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
// 将新头像同步到 Redis 在线用户列表中(所有房间)
|
||||
// 通过更新 Redis 的用户信息,使得其他用户和自己刷新后都能看到新头像
|
||||
@@ -710,6 +716,71 @@ class ChatController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传自定义头像
|
||||
*/
|
||||
public function uploadAvatar(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|image|mimes:jpeg,png,jpg,gif,webp|max:2048',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return response()->json(['status' => 'error', 'message' => '未登录'], 401);
|
||||
}
|
||||
|
||||
$file = $request->file('file');
|
||||
|
||||
try {
|
||||
$manager = new ImageManager(new Driver);
|
||||
$image = $manager->read($file);
|
||||
|
||||
// 裁剪正方形并压缩为 112x112
|
||||
$image->cover(112, 112);
|
||||
|
||||
// 生成相对路径
|
||||
$filename = 'custom_'.$user->id.'_'.time().'.'.$file->extension();
|
||||
$path = 'avatars/'.$filename;
|
||||
|
||||
// 保存以高质量 JPG 或原格式
|
||||
Storage::disk('public')->put($path, (string) $image->encode());
|
||||
|
||||
$dbValue = 'storage/'.$path;
|
||||
|
||||
// 更新前如为自定义头像,将其从磁盘删除,节约空间
|
||||
if ($user->usersf !== $dbValue) {
|
||||
$user->deleteCustomAvatar();
|
||||
$user->usersf = $dbValue;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
// 同步 Redis 状态
|
||||
$superLevel = (int) Sysparam::getValue('superlevel', '100');
|
||||
$rooms = $this->chatState->getUserRooms($user->username);
|
||||
foreach ($rooms as $roomId) {
|
||||
$this->chatState->userJoin((int) $roomId, $user->username, [
|
||||
'level' => $user->user_level,
|
||||
'sex' => $user->sex,
|
||||
'headface' => $user->headface, // Use accessor
|
||||
'vip_icon' => $user->vipIcon(),
|
||||
'vip_name' => $user->vipName(),
|
||||
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
|
||||
'is_admin' => $user->user_level >= $superLevel,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => '头像上传成功!',
|
||||
'headface' => $user->headface,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['status' => 'error', 'message' => '上传失败: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置房间公告/祝福语(滚动显示在聊天室顶部)
|
||||
* 需要房间主人或等级达到 level_announcement 配置值
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* 5. 公屏广播结果(中奖/踩雷)
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
@@ -105,7 +106,7 @@ class MysteryBoxController extends Controller
|
||||
$source = $reward >= 0 ? CurrencySource::MYSTERY_BOX : CurrencySource::MYSTERY_BOX_TRAP;
|
||||
$remark = $reward >= 0
|
||||
? "神秘箱子【{$box->typeName()}】奖励"
|
||||
: "神秘箱子【黑化箱】陷阱扣除";
|
||||
: '神秘箱子【黑化箱】陷阱扣除';
|
||||
|
||||
$this->currency->change($user, 'gold', $reward, $source, $remark, $box->room_id);
|
||||
|
||||
@@ -128,7 +129,7 @@ class MysteryBoxController extends Controller
|
||||
'balance' => $user->jjb ?? 0,
|
||||
'message' => $reward >= 0
|
||||
? "🎉 恭喜!开箱获得 +{$reward} 金币!"
|
||||
: "☠️ 中了黑化陷阱!扣除 " . abs($reward) . ' 金币!',
|
||||
: '☠️ 中了黑化陷阱!扣除 '.abs($reward).' 金币!',
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -147,11 +148,11 @@ class MysteryBoxController extends Controller
|
||||
|
||||
if ($reward >= 0) {
|
||||
$content = "{$emoji}【开箱播报】恭喜 【{$username}】 抢到了神秘{$typeName}!"
|
||||
. "获得 💰" . number_format($reward) . " 金币!";
|
||||
.'获得 💰'.number_format($reward).' 金币!';
|
||||
$color = $box->box_type === 'rare' ? '#c4b5fd' : '#34d399';
|
||||
} else {
|
||||
$content = "☠️【黑化陷阱】haha!【{$username}】 中了神秘黑化箱的陷阱!"
|
||||
. "被扣除 💰" . number_format(abs($reward)) . " 金币!点背~";
|
||||
.'被扣除 💰'.number_format(abs($reward)).' 金币!点背~';
|
||||
$color = '#f87171';
|
||||
}
|
||||
|
||||
|
||||
@@ -170,6 +170,10 @@ class UserController extends Controller
|
||||
\Illuminate\Support\Facades\Cache::forget($codeKey);
|
||||
}
|
||||
|
||||
if (isset($data['headface']) && $data['headface'] !== $user->headface) {
|
||||
$user->deleteCustomAvatar();
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
|
||||
return response()->json(['status' => 'success', 'message' => '资料更新成功。']);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* 3. 设定定时关闭任务(windows_seconds 秒后过期)
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
@@ -94,7 +95,7 @@ class DropMysteryBoxJob implements ShouldQueue
|
||||
$source = $this->droppedBy ? '管理员' : '系统';
|
||||
|
||||
$content = "{$emoji}【{$typeName}】{$source}投放了一个神秘箱子!"
|
||||
. "发送暗号「{$passcode}」即可开箱!限时 {$claimWindow} 秒,先到先得!";
|
||||
."发送暗号「{$passcode}」即可开箱!限时 {$claimWindow} 秒,先到先得!";
|
||||
|
||||
$msg = [
|
||||
'id' => $chatState->nextMessageId($targetRoom),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 在箱子到期后将其状态更新为 expired(若尚未被领取),并向公屏广播过期通知。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 对应表:mystery_boxes
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 对应表:mystery_box_claims
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* envelope_id + user_id 联合唯一约束保证幂等性。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 先到先得,领完或超时后自动关闭。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* 对应原版 ASP 聊天室的 sysparam 配置表
|
||||
* 管理员可在后台修改等级经验阈值等系统参数
|
||||
*
|
||||
* @package App\Models
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
@@ -29,7 +29,6 @@ class Sysparam extends Model
|
||||
*
|
||||
* @param string $alias 参数别名
|
||||
* @param string $default 默认值
|
||||
* @return string
|
||||
*/
|
||||
public static function getValue(string $alias, string $default = ''): string
|
||||
{
|
||||
|
||||
@@ -90,18 +90,61 @@ class User extends Authenticatable
|
||||
* 头像文件名访问器
|
||||
*
|
||||
* 原 ASP 系统的头像文件名存储在 usersf 字段中(如 "75.gif"),
|
||||
* 但项目中各处通过 $user->headface 来引用头像。
|
||||
* 此 accessor 将 headface 属性映射到 usersf 字段,保持代码一致性。
|
||||
* 同时也支持用户自定义上传的头像,保存在 Laravel Storage 的 public 磁盘下。
|
||||
* 此 accessor 将 headface 属性映射到 usersf 字段,如果包含 storage/ 则当作独立路径,自动转换旧版后缀小写。
|
||||
*/
|
||||
protected function headface(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
// 自动将后缀转小写,兼容数据库中的 .GIF 大写存量
|
||||
get: fn () => strtolower($this->usersf ?: '1.gif'),
|
||||
set: fn (string $value) => ['usersf' => strtolower($value)],
|
||||
get: function () {
|
||||
$val = $this->usersf ?: '1.gif';
|
||||
if (str_starts_with($val, 'storage/')) {
|
||||
return $val;
|
||||
}
|
||||
|
||||
// 仅对非 storage 下的旧头像做小写处理,兼容旧库数据
|
||||
return strtolower($val);
|
||||
},
|
||||
set: function ($value) {
|
||||
if (str_starts_with($value, 'storage/')) {
|
||||
return tap($value, fn () => $this->attributes['usersf'] = $value);
|
||||
}
|
||||
|
||||
return tap(strtolower($value), fn () => $this->attributes['usersf'] = strtolower($value));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取带前缀的完整头像 URL
|
||||
* 避免前端多处硬编码 '/images/headface/'
|
||||
*/
|
||||
protected function headfaceUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
$hf = $this->headface;
|
||||
if (str_starts_with((string) $hf, 'storage/')) {
|
||||
return '/'.$hf;
|
||||
}
|
||||
|
||||
return '/images/headface/'.$hf;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 如果当前头像是自定义上传的图片,则从本地存储中删除此文件
|
||||
*/
|
||||
public function deleteCustomAvatar(): void
|
||||
{
|
||||
$hf = (string) $this->usersf;
|
||||
if (str_starts_with($hf, 'storage/')) {
|
||||
$path = substr($hf, 8); // 去除 'storage/' 前缀
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:用户所属的 VIP 会员等级
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 只读写,不允许 update(流水记录不可更改)
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
@@ -41,7 +42,7 @@ class UserCurrencyLog extends Model
|
||||
*/
|
||||
protected $casts = [
|
||||
'amount' => 'integer',
|
||||
'balance_after'=> 'integer',
|
||||
'balance_after' => 'integer',
|
||||
'room_id' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 本服务负责:原子性更新用户属性、写入流水记录、提供统计与排行数据。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
@@ -76,7 +77,7 @@ class UserCurrencyService
|
||||
'username' => $user->username,
|
||||
'currency' => $currency,
|
||||
'amount' => $amount,
|
||||
'balance_after'=> $balanceAfter,
|
||||
'balance_after' => $balanceAfter,
|
||||
'source' => $source->value,
|
||||
'remark' => $remark,
|
||||
'room_id' => $roomId,
|
||||
@@ -89,8 +90,6 @@ class UserCurrencyService
|
||||
* 每位用户仍独立走事务,单人失败不影响其他人。
|
||||
*
|
||||
* @param array $items [['user' => User, 'changes' => ['exp'=>1,'gold'=>2]], ...]
|
||||
* @param CurrencySource $source
|
||||
* @param int|null $roomId
|
||||
*/
|
||||
public function batchChange(array $items, CurrencySource $source, ?int $roomId = null): void
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"geoip2/geoip2": "^3.3",
|
||||
"intervention/image": "^3.11",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/horizon": "^5.45",
|
||||
"laravel/reverb": "^1.8",
|
||||
|
||||
2
composer.lock
generated
2
composer.lock
generated
@@ -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": "e501ac28571f87b0f192898f912648f5",
|
||||
"content-hash": "56338775768722c90ec723eb5b939be1",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
|
||||
@@ -48,21 +48,21 @@ return new class extends Migration
|
||||
['alias' => 'level_warn', 'body' => '5', 'guidetxt' => '警告所需等级', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'level_mute', 'body' => '50', 'guidetxt' => '禁言所需等级', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'level_kick', 'body' => '60', 'guidetxt' => '踢人所需等级', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'level_announcement','body' => '60', 'guidetxt' => '设置公告所需等级', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'level_announcement', 'body' => '60', 'guidetxt' => '设置公告所需等级', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'level_ban', 'body' => '80', 'guidetxt' => '封号所需等级', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'level_banip', 'body' => '90', 'guidetxt' => '封IP所需等级', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'level_freeze', 'body' => '14', 'guidetxt' => '冻结账号所需等级', 'created_at' => $now, 'updated_at' => $now],
|
||||
|
||||
// ── 随机事件 ──────────────────────────────────────────────
|
||||
['alias' => 'auto_event_chance','body' => '10', 'guidetxt' => '随机事件触发概率(百分比,1-100)', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'auto_event_chance', 'body' => '10', 'guidetxt' => '随机事件触发概率(百分比,1-100)', 'created_at' => $now, 'updated_at' => $now],
|
||||
|
||||
// ── 魅力系统 ──────────────────────────────────────────────
|
||||
['alias' => 'charm_cross_sex', 'body' => '2', 'guidetxt' => '异性聊天每条消息增加的魅力值', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'charm_same_sex', 'body' => '1', 'guidetxt' => '同性聊天每条消息增加的魅力值', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'charm_hourly_limit','body' => '20', 'guidetxt' => '每小时通过聊天获取的魅力值上限(防刷屏)', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'charm_hourly_limit', 'body' => '20', 'guidetxt' => '每小时通过聊天获取的魅力值上限(防刷屏)', 'created_at' => $now, 'updated_at' => $now],
|
||||
|
||||
// ── 排行榜 ────────────────────────────────────────────────
|
||||
['alias' => 'leaderboard_limit','body' => '20', 'guidetxt' => '🏆 排行榜每榜显示人数', 'created_at' => $now, 'updated_at' => $now],
|
||||
['alias' => 'leaderboard_limit', 'body' => '20', 'guidetxt' => '🏆 排行榜每榜显示人数', 'created_at' => $now, 'updated_at' => $now],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* username_blacklist — 用户改名后的旧名称保留黑名单
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 记录所有用户经验/金币/魅力的变动来源与金额,支持今日排行与活动统计
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ return new class extends Migration
|
||||
public function down(): void
|
||||
{
|
||||
// 先把现有 NULL 行补一个兜底值,再改回 NOT NULL
|
||||
DB::statement("UPDATE `username_blacklist` SET `reserved_until` = NOW() + INTERVAL 365 DAY WHERE `reserved_until` IS NULL");
|
||||
DB::statement("ALTER TABLE `username_blacklist` MODIFY `reserved_until` TIMESTAMP NOT NULL");
|
||||
DB::statement('UPDATE `username_blacklist` SET `reserved_until` = NOW() + INTERVAL 365 DAY WHERE `reserved_until` IS NULL');
|
||||
DB::statement('ALTER TABLE `username_blacklist` MODIFY `reserved_until` TIMESTAMP NOT NULL');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -47,7 +47,7 @@ return new class extends Migration
|
||||
'max' => 365,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* red_packet_claims:红包领取记录(先到先得,每人只能领一次)
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 默认 gold,兼容已有记录。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 记录每次系统/管理员投放的神秘箱信息,包含类型、暗号、奖惩范围及领取状态。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 记录每个箱子被哪位用户在何时用什么暗号领取,以及实际奖励金额。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 记录每期彩票的开奖状态、号码、奖池金额、派奖结果。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 透明记录每期奖池的每笔变动(售票入池、派奖扣除、滚存、系统注入)。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 记录每用户每注的选号、中奖等级、派奖金额。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
/**
|
||||
* 文件功能:商店初始商品数据填充器
|
||||
* 初始化9种商品:4种单次特效卡 + 4种周卡 + 改名卡
|
||||
*
|
||||
* @package Database\Seeders
|
||||
*/
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<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' }}"
|
||||
<img src="{{ $user->headface_url ?? '/images/headface/1.gif' }}"
|
||||
class="w-8 h-8 rounded border object-cover">
|
||||
<span class="font-bold text-gray-800">{{ $user->username }}</span>
|
||||
@if ($user->isVip())
|
||||
|
||||
@@ -415,7 +415,8 @@
|
||||
|
||||
const avatar = document.createElement('img');
|
||||
avatar.className = 'fp-avatar';
|
||||
avatar.src = '/images/headface/' + (f.headface || '1.gif');
|
||||
let hf = f.headface || '1.gif';
|
||||
avatar.src = hf.startsWith('storage/') ? '/' + hf : '/images/headface/' + hf;
|
||||
avatar.alt = f.username;
|
||||
|
||||
const name = document.createElement('span');
|
||||
@@ -456,7 +457,8 @@
|
||||
|
||||
const avatar = document.createElement('img');
|
||||
avatar.className = 'fp-avatar';
|
||||
avatar.src = '/images/headface/' + (p.headface || '1.gif');
|
||||
let hf = p.headface || '1.gif';
|
||||
avatar.src = hf.startsWith('storage/') ? '/' + hf : '/images/headface/' + hf;
|
||||
avatar.alt = p.username;
|
||||
|
||||
const name = document.createElement('span');
|
||||
|
||||
@@ -59,14 +59,22 @@
|
||||
|
||||
{{-- 预览区 --}}
|
||||
<div
|
||||
style="padding:10px 16px; background:#f0f6ff; border-bottom:1px solid #ddd; display:flex; align-items:center; gap:12px;">
|
||||
style="padding:10px 16px; background:#f0f6ff; border-bottom:1px solid #ddd; display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
|
||||
<span style="font-size:12px; color:#666;">当前选中:</span>
|
||||
<img id="avatar-preview" src="/images/headface/{{ $user->usersf ?: '1.gif' }}"
|
||||
style="width:40px; height:40px; border:2px solid #336699; border-radius:4px;">
|
||||
<img id="avatar-preview" src="{{ str_starts_with($user->usersf, 'storage/') ? '/' . $user->usersf : '/images/headface/' . ($user->usersf ?: '1.gif') }}"
|
||||
style="width:40px; height:40px; border:2px solid #336699; border-radius:4px; object-fit: cover;">
|
||||
<span id="avatar-selected-name" style="font-size:12px; color:#333;">{{ $user->usersf ?: '未设置' }}</span>
|
||||
<button id="avatar-save-btn" disabled onclick="saveAvatar()"
|
||||
style="margin-left:auto; padding:5px 16px; background:#336699; color:#fff; border:none;
|
||||
border-radius:3px; font-size:12px; cursor:pointer;">确定更换</button>
|
||||
border-radius:3px; font-size:12px; cursor:pointer;">确定更换系统头像</button>
|
||||
<div style="width:100%; height:1px; background:#ddd; margin: 4px 0;"></div>
|
||||
<div style="display:flex; align-items:center; gap:8px; width:100%;">
|
||||
<span style="font-size:12px; color:#666; font-weight:bold;">自定义头像上传(112x112):</span>
|
||||
<input type="file" id="avatar-upload-input" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none;" onchange="handleAvatarUpload(this)">
|
||||
<button id="avatar-upload-btn" onclick="document.getElementById('avatar-upload-input').click()"
|
||||
style="padding:5px 16px; background:#16a34a; color:#fff; border:none;
|
||||
border-radius:3px; font-size:12px; cursor:pointer;">选择本地图片上传</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 头像网格 --}}
|
||||
@@ -250,6 +258,74 @@
|
||||
document.getElementById('avatar-save-btn').dataset.file = file;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理本地头像上传
|
||||
*/
|
||||
async function handleAvatarUpload(input) {
|
||||
if (!input.files || !input.files[0]) return;
|
||||
|
||||
const file = input.files[0];
|
||||
|
||||
// 简单的前端校验
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
window.chatDialog.alert('图片大小不可超过 2MB', '上传失败', '#cc4444');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('avatar-upload-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '上传中...';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/headface/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.status === 'success') {
|
||||
window.chatDialog.alert('自定义头像上传成功!', '提示', '#16a34a');
|
||||
|
||||
// 更新预览图和显示名称
|
||||
const previewImg = document.getElementById('avatar-preview');
|
||||
const relativeUrl = '/' + data.headface;
|
||||
previewImg.src = relativeUrl;
|
||||
document.getElementById('avatar-selected-name').textContent = data.headface;
|
||||
|
||||
// 同步在线列表自己
|
||||
const myName = window.chatContext.username;
|
||||
if (typeof onlineUsers !== 'undefined' && onlineUsers[myName]) {
|
||||
onlineUsers[myName].headface = data.headface;
|
||||
}
|
||||
if (typeof renderUserList === 'function') {
|
||||
renderUserList();
|
||||
}
|
||||
|
||||
// 清除系统头像选中状态
|
||||
document.querySelectorAll('.avatar-option.selected').forEach(el => el.classList.remove('selected'));
|
||||
document.getElementById('avatar-save-btn').disabled = true;
|
||||
|
||||
closeAvatarPicker();
|
||||
} else {
|
||||
window.chatDialog.alert(data.message || '上传失败', '操作失败', '#cc4444');
|
||||
}
|
||||
} catch (e) {
|
||||
window.chatDialog.alert('网络错误,上传失败', '网络异常', '#cc4444');
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = '选择本地图片上传';
|
||||
input.value = ''; // 清空 file input,允许重复选中同一文件
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存选中的头像(调用 API 更新,成功后刷新用户列表)
|
||||
*/
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
item.dataset.username = username;
|
||||
|
||||
const headface = (user.headface || '1.gif').toLowerCase();
|
||||
const headImgSrc = headface.startsWith('storage/') ? '/' + headface : '/images/headface/' + headface;
|
||||
|
||||
// 徽章优先级:职务图标 > 管理员 > VIP
|
||||
let badges = '';
|
||||
@@ -224,7 +225,7 @@
|
||||
// 女生名字使用玫粉色
|
||||
const nameColor = (user.sex == 2) ? 'color:#e91e8c;' : '';
|
||||
item.innerHTML = `
|
||||
<img class="user-head" src="/images/headface/${headface}" onerror="this.src='/images/headface/1.gif'">
|
||||
<img class="user-head" src="${headImgSrc}" onerror="this.src='/images/headface/1.gif'">
|
||||
<span class="user-name" style="${nameColor}">${username}</span>${badges}
|
||||
`;
|
||||
|
||||
@@ -369,7 +370,7 @@
|
||||
const buggleUsers = ['钓鱼播报', '星海小博士', '送花播报', '系统传音', '系统公告'];
|
||||
const senderInfo = onlineUsers[msg.from_user];
|
||||
const senderHead = ((senderInfo && senderInfo.headface) || '1.gif').toLowerCase();
|
||||
let headImgSrc = `/images/headface/${senderHead}`;
|
||||
let headImgSrc = senderHead.startsWith('storage/') ? '/' + senderHead : `/images/headface/${senderHead}`;
|
||||
if (msg.from_user === 'AI小班长') {
|
||||
headImgSrc = '/images/ai_bot.png';
|
||||
} else if (buggleUsers.includes(msg.from_user)) {
|
||||
|
||||
@@ -597,7 +597,7 @@
|
||||
<div class="modal-body">
|
||||
<div class="profile-row">
|
||||
<img class="profile-avatar" x-show="userInfo.headface"
|
||||
:src="'/images/headface/' + (userInfo.headface || '1.gif').toLowerCase()"
|
||||
:src="(userInfo.headface || '1.gif').toLowerCase().startsWith('storage/') ? '/' + (userInfo.headface || '1.gif').toLowerCase() : '/images/headface/' + (userInfo.headface || '1.gif').toLowerCase()"
|
||||
x-on:error="$el.style.display='none'">
|
||||
<div class="profile-info">
|
||||
<h4>
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
@foreach ($position->activeUserPositions as $up)
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-gray-50 border border-gray-100 rounded-xl">
|
||||
<img src="/images/headface/{{ strtolower($up->user?->headface ?? '1.gif') }}"
|
||||
<img src="{{ $up->user?->headface_url ?? '/images/headface/1.gif' }}"
|
||||
class="w-7 h-7 rounded-full border border-purple-100 object-cover bg-white"
|
||||
onerror="this.src='/images/headface/1.gif'">
|
||||
<div>
|
||||
@@ -210,7 +210,7 @@
|
||||
|
||||
{{-- 成员 --}}
|
||||
<div class="col-span-4 flex items-center gap-3">
|
||||
<img src="/images/headface/{{ strtolower($row->user?->headface ?? '1.gif') }}"
|
||||
<img src="{{ $row->user?->headface_url ?? '/images/headface/1.gif' }}"
|
||||
class="w-9 h-9 rounded-full border-2 border-purple-100 object-cover bg-white"
|
||||
onerror="this.src='/images/headface/1.gif'">
|
||||
<div>
|
||||
@@ -266,7 +266,7 @@
|
||||
<span class="text-xs font-bold text-gray-300">{{ $i + 1 }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<img src="/images/headface/{{ strtolower($row->user?->headface ?? '1.gif') }}"
|
||||
<img src="{{ $row->user?->headface_url ?? '/images/headface/1.gif' }}"
|
||||
class="w-8 h-8 rounded-full border border-purple-100 object-cover bg-white shrink-0"
|
||||
onerror="this.src='/images/headface/1.gif'">
|
||||
<div class="flex-1 min-w-0">
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<div class="col-span-1 flex justify-center">
|
||||
<div
|
||||
class="w-10 h-10 rounded-md overflow-hidden bg-white border border-gray-200 shadow-sm shrink-0">
|
||||
<img src="/images/headface/{{ strtolower($inviter->headface ?: '1.gif') }}"
|
||||
<img src="{{ $inviter->headface_url ?? '/images/headface/1.gif' }}"
|
||||
onerror="this.style.display='none'" class="w-full h-full object-cover">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
@auth
|
||||
<div class="flex items-center space-x-3 ml-2 pl-2 border-l border-indigo-700">
|
||||
<div class="flex items-center space-x-2">
|
||||
<img src="/images/headface/{{ Auth::user()->headface ?? '01.gif' }}"
|
||||
<img src="{{ Auth::user()->headface_url ?? '/images/headface/1.gif' }}"
|
||||
class="w-7 h-7 rounded border border-indigo-500 object-cover bg-white">
|
||||
<span class="font-bold hidden sm:inline">{{ Auth::user()->username }}</span>
|
||||
<span
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</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="">
|
||||
src="{{ $user->headface_url }}" alt="">
|
||||
<div class="flex flex-col truncate">
|
||||
<span class="text-sm font-bold text-gray-800 truncate" title="{{ $user->username }}">
|
||||
{{ $user->username }}
|
||||
|
||||
@@ -289,7 +289,7 @@
|
||||
<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"
|
||||
<img :src="profileData.headface.startsWith('storage/') ? '/' + profileData.headface : '/images/headface/' + profileData.headface"
|
||||
@@error="$el.style.display='none'"
|
||||
class="w-full h-full object-cover">
|
||||
</div>
|
||||
|
||||
@@ -254,6 +254,9 @@ Route::middleware(['chat.auth'])->group(function () {
|
||||
// 修改头像
|
||||
Route::post('/headface/change', [ChatController::class, 'changeAvatar'])->name('headface.change');
|
||||
|
||||
// 上传自定义头像
|
||||
Route::post('/headface/upload', [ChatController::class, 'uploadAvatar'])->name('headface.upload');
|
||||
|
||||
// 设置房间公告/祝福语
|
||||
Route::post('/room/{id}/announcement', [ChatController::class, 'setAnnouncement'])->name('chat.announcement');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user