feat: 任命/撤销通知系统 + 用户名片UI优化

- 任命/撤销事件增加 type 字段区分类型
- 任命:全屏礼花 + 紫色弹窗 + 紫色系统消息
- 撤销:灰色弹窗 + 灰色系统消息,无礼花
- 消息分发:操作者/被操作者显示在私聊面板,其他人显示在公屏
- 系统消息加随机鼓励语(各5条轮换)
- ChatStateService 修复 Redis key 前缀扫描问题(getAllActiveRoomIds)
- 用户名片折叠优化:管理员视野、职务履历均可折叠
- 管理操作 + 职务操作合并为「🔧 管理操作」折叠区
- 悄悄话改为「🎁 送礼物」按钮,礼物面板内联展开
This commit is contained in:
2026-02-28 23:44:38 +08:00
parent a599047cf0
commit 5f30220609
80 changed files with 8579 additions and 473 deletions
@@ -0,0 +1,202 @@
<?php
/**
* 文件功能:后台任命管理控制器
* 管理员可以在此查看所有在职人员、进行新增任命和撤销职务
* 任命/撤销通过 AppointmentService 执行,权限日志自动记录
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Events\AppointmentAnnounced;
use App\Http\Controllers\Controller;
use App\Models\Department;
use App\Models\Position;
use App\Models\User;
use App\Models\UserPosition;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AppointmentController extends Controller
{
/**
* 注入任命服务
*/
public function __construct(
private readonly AppointmentService $appointmentService,
private readonly ChatStateService $chatState,
) {}
/**
* 任命管理主列表(当前全部在职人员)
*/
public function index(): View
{
// 所有在职记录(按部门 rank 倒序、职务 rank 倒序)
$activePositions = UserPosition::query()
->where('is_active', true)
->with([
'user',
'position.department',
'appointedBy',
])
->join('positions', 'user_positions.position_id', '=', 'positions.id')
->join('departments', 'positions.department_id', '=', 'departments.id')
->orderByDesc('departments.rank')
->orderByDesc('positions.rank')
->select('user_positions.*')
->get();
// 部门+职务(供新增任命弹窗下拉选择,带在职人数统计)
$departments = Department::with([
'positions' => fn ($q) => $q->withCount('activeUserPositions')->ordered(),
])->ordered()->get();
return view('admin.appointments.index', compact('activePositions', 'departments'));
}
/**
* 执行新增任命
* 管理员在后台直接任命用户,操作人为当前登录管理员
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'username' => 'required|string|exists:users,username',
'position_id' => 'required|exists:positions,id',
'remark' => 'nullable|string|max:255',
]);
$operator = Auth::user();
$target = User::where('username', $request->username)->firstOrFail();
$targetPosition = Position::with('department')->findOrFail($request->position_id);
$result = $this->appointmentService->appoint($operator, $target, $targetPosition, $request->remark);
if ($result['ok']) {
// 向所有当前有人在线的聊天室广播礼花公告(后台操作人不在聊天室内)
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
broadcast(new AppointmentAnnounced(
roomId: $roomId,
targetUsername: $target->username,
positionIcon: $targetPosition->icon ?? '🎖️',
positionName: $targetPosition->name,
departmentName: $targetPosition->department?->name ?? '',
operatorName: $operator->username,
));
}
return redirect()->route('admin.appointments.index')->with('success', $result['message']);
}
return redirect()->route('admin.appointments.index')->with('error', $result['message']);
}
/**
* 撤销职务
*/
public function revoke(Request $request, UserPosition $userPosition): RedirectResponse
{
$operator = Auth::user();
$target = $userPosition->user;
// 撤销前先记录职务信息(撤销后关联就断了)
$userPosition->load('position.department');
$posIcon = $userPosition->position?->icon ?? '🎖️';
$posName = $userPosition->position?->name ?? '';
$deptName = $userPosition->position?->department?->name ?? '';
$result = $this->appointmentService->revoke($operator, $target, $request->remark);
if ($result['ok']) {
// 向所有活跃房间广播撤销公告
if ($posName) {
foreach ($this->chatState->getAllActiveRoomIds() as $roomId) {
broadcast(new AppointmentAnnounced(
roomId: $roomId,
targetUsername: $target->username,
positionIcon: $posIcon,
positionName: $posName,
departmentName: $deptName,
operatorName: $operator->username,
type: 'revoke',
));
}
}
return redirect()->route('admin.appointments.index')->with('success', $result['message']);
}
return redirect()->route('admin.appointments.index')->with('error', $result['message']);
}
/**
* 查看某任职记录的在职登录日志
*/
public function dutyLogs(UserPosition $userPosition): View
{
$userPosition->load(['user', 'position.department', 'appointedBy']);
$logs = $userPosition->dutyLogs()
->orderByDesc('login_at')
->paginate(30);
return view('admin.appointments.duty-logs', compact('userPosition', 'logs'));
}
/**
* 查看某任职记录的权限操作日志
*/
public function authorityLogs(UserPosition $userPosition): View
{
$userPosition->load(['user', 'position.department']);
$logs = $userPosition->authorityLogs()
->with(['targetUser', 'targetPosition'])
->orderByDesc('created_at')
->paginate(30);
return view('admin.appointments.authority-logs', compact('userPosition', 'logs'));
}
/**
* 历史任职记录(全部 is_active=false 的记录)
*/
public function history(): View
{
$history = UserPosition::query()
->where('is_active', false)
->with(['user', 'position.department', 'appointedBy', 'revokedBy'])
->orderByDesc('revoked_at')
->paginate(30);
return view('admin.appointments.history', compact('history'));
}
/**
* 搜索用户(供任命弹窗 Ajax 快速查找)
*/
public function searchUsers(Request $request): JsonResponse
{
$keyword = $request->input('q', '');
$users = User::query()
->where('id', '!=', 1) // 排除超级管理员
->where('username', 'like', "%{$keyword}%")
->whereDoesntHave('activePosition') // 排除已有职务的用户
->select('id', 'username', 'user_level')
->limit(10)
->get();
return response()->json($users);
}
}
@@ -52,10 +52,12 @@ class AutoactController extends Controller
/**
* 更新事件
*
* @param Autoact $autoact 路由模型自动注入
*/
public function update(Request $request, int $id): RedirectResponse
public function update(Request $request, Autoact $autoact): RedirectResponse
{
$event = Autoact::findOrFail($id);
$event = $autoact;
$data = $request->validate([
'text_body' => 'required|string|max:500',
@@ -71,26 +73,29 @@ class AutoactController extends Controller
/**
* 切换事件启用/禁用状态
*
* @param Autoact $autoact 路由模型自动注入
*/
public function toggle(int $id): JsonResponse
public function toggle(Autoact $autoact): JsonResponse
{
$event = Autoact::findOrFail($id);
$event->enabled = ! $event->enabled;
$event->save();
$autoact->enabled = ! $autoact->enabled;
$autoact->save();
return response()->json([
'status' => 'success',
'enabled' => $event->enabled,
'message' => $event->enabled ? '已启用' : '已禁用',
'enabled' => $autoact->enabled,
'message' => $autoact->enabled ? '已启用' : '已禁用',
]);
}
/**
* 删除事件
*
* @param Autoact $autoact 路由模型自动注入
*/
public function destroy(int $id): RedirectResponse
public function destroy(Autoact $autoact): RedirectResponse
{
Autoact::findOrFail($id)->delete();
$autoact->delete();
return redirect()->route('admin.autoact.index')->with('success', '事件已删除!');
}
@@ -0,0 +1,191 @@
<?php
/**
* 文件功能:后台开发日志管理控制器(仅 id=1 超级管理员可访问)
* 提供开发日志的 CRUD 功能,发布时可选择向 Room 1 大厅广播通知
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Events\ChangelogPublished;
use App\Http\Controllers\Controller;
use App\Jobs\SaveMessageJob;
use App\Models\DevChangelog;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
class ChangelogController extends Controller
{
/**
* 后台日志列表(含草稿)
*/
public function index(): View
{
$logs = DevChangelog::orderByDesc('created_at')->paginate(20);
return view('admin.changelog.index', compact('logs'));
}
/**
* 新增日志表单页(预填今日日期)
*/
public function create(): View
{
// 预填今日日期为版本号
$todayVersion = now()->format('Y-m-d');
return view('admin.changelog.form', [
'log' => null,
'todayVersion' => $todayVersion,
'typeOptions' => DevChangelog::TYPE_CONFIG,
'isCreate' => true,
]);
}
/**
* 保存新日志
* 若勾选"立即发布",则记录 published_at 并可选向大厅广播通知
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'version' => 'required|string|max:30',
'title' => 'required|string|max:200',
'type' => 'required|in:feature,fix,improve,other',
'content' => 'required|string',
'is_published' => 'nullable|boolean',
'notify_chat' => 'nullable|boolean',
]);
$isPublished = (bool) ($data['is_published'] ?? false);
$notifyChat = (bool) ($data['notify_chat'] ?? false);
/** @var DevChangelog $log */
$log = DevChangelog::create([
'version' => $data['version'],
'title' => $data['title'],
'type' => $data['type'],
'content' => $data['content'],
'is_published' => $isPublished,
// 只有同时勾选"通知"才记录 notify_chat,否则置 false
'notify_chat' => $isPublished && $notifyChat,
// 首次发布时记录发布时间
'published_at' => $isPublished ? Carbon::now() : null,
]);
// 如果发布且勾选了"通知大厅用户",则触发 WebSocket 广播 + 持久化到消息库
if ($isPublished && $notifyChat) {
event(new ChangelogPublished($log));
$this->saveChangelogNotification($log);
}
return redirect()->route('admin.changelogs.index')
->with('success', '开发日志创建成功!'.($isPublished ? '(已发布)' : '(草稿已保存)'));
}
/**
* 编辑日志表单页
*
* @param DevChangelog $changelog 路由模型自动注入
*/
public function edit(DevChangelog $changelog): View
{
return view('admin.changelog.form', [
'log' => $changelog,
'todayVersion' => $changelog->version,
'typeOptions' => DevChangelog::TYPE_CONFIG,
'isCreate' => false,
]);
}
/**
* 更新日志内容(编辑操作不更新 published_at,不触发通知)
*
* @param DevChangelog $changelog 路由模型自动注入
*/
public function update(Request $request, DevChangelog $changelog): RedirectResponse
{
$log = $changelog;
$data = $request->validate([
'version' => 'required|string|max:30',
'title' => 'required|string|max:200',
'type' => 'required|in:feature,fix,improve,other',
'content' => 'required|string',
'is_published' => 'nullable|boolean',
'notify_chat' => 'nullable|boolean',
]);
$isPublished = (bool) ($data['is_published'] ?? false);
// 如果从草稿切换为发布,记录首次发布时间
$publishedAt = $log->published_at;
if ($isPublished && ! $log->is_published) {
$publishedAt = Carbon::now();
} elseif (! $isPublished) {
// 从发布退回草稿,清除发布时间
$publishedAt = null;
}
$log->update([
'version' => $data['version'],
'title' => $data['title'],
'type' => $data['type'],
'content' => $data['content'],
'is_published' => $isPublished,
'published_at' => $publishedAt,
]);
// 若勾选了「通知大厅用户」且当前已发布,则广播通知 + 持久化到消息库
$notifyChat = (bool) ($data['notify_chat'] ?? false);
if ($notifyChat && $isPublished) {
event(new ChangelogPublished($log));
$this->saveChangelogNotification($log);
}
return redirect()->route('admin.changelogs.index')
->with('success', '开发日志已更新!');
}
/**
* 删除日志
*
* @param DevChangelog $changelog 路由模型自动注入
*/
public function destroy(DevChangelog $changelog): RedirectResponse
{
$changelog->delete();
return redirect()->route('admin.changelogs.index')
->with('success', '日志已删除。');
}
/**
* 将版本更新通知持久化为 Room 1 系统消息
* 确保用户重进聊天室时仍能在历史消息中看到该通知
*
* @param DevChangelog $log 已发布的日志
*/
private function saveChangelogNotification(DevChangelog $log): void
{
$typeLabel = DevChangelog::TYPE_CONFIG[$log->type]['label'] ?? '更新';
$url = url('/changelog').'#v'.$log->version;
SaveMessageJob::dispatch([
'room_id' => 1,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "📢 【版本更新 {$typeLabel}】v{$log->version}{$log->title}》— <a href=\"{$url}\" target=\"_blank\" class=\"underline\">点击查看详情</a>",
'is_secret' => false,
'font_color' => '#7c3aed',
'action' => '',
'sent_at' => now()->toIso8601String(),
]);
}
}
@@ -0,0 +1,91 @@
<?php
/**
* 文件功能:后台部门管理控制器
* 提供部门的 CRUD 功能(增删改查)
* 部门是职务的上级分类,包含位阶、颜色、描述等配置
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Department;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DepartmentController extends Controller
{
/**
* 部门列表页
*/
public function index(): View
{
$departments = Department::withCount(['positions'])
->orderBy('sort_order')
->orderByDesc('rank')
->get();
return view('admin.departments.index', compact('departments'));
}
/**
* 创建部门
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:50|unique:departments,name',
'rank' => 'required|integer|min:0|max:99',
'color' => 'required|string|max:10',
'sort_order' => 'required|integer|min:0',
'description' => 'nullable|string|max:255',
]);
Department::create($data);
return redirect()->route('admin.departments.index')->with('success', "部门【{$data['name']}】创建成功!");
}
/**
* 更新部门
*/
public function update(Request $request, Department $department): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:50|unique:departments,name,'.$department->id,
'rank' => 'required|integer|min:0|max:99',
'color' => 'required|string|max:10',
'sort_order' => 'required|integer|min:0',
'description' => 'nullable|string|max:255',
]);
$department->update($data);
return redirect()->route('admin.departments.index')->with('success', "部门【{$data['name']}】更新成功!");
}
/**
* 删除部门(级联删除职务)
*/
public function destroy(Department $department): RedirectResponse
{
// 检查是否有在职人员
$activeMemberCount = $department->positions()
->whereHas('activeUserPositions')
->count();
if ($activeMemberCount > 0) {
return redirect()->route('admin.departments.index')
->with('error', "部门【{$department->name}】下尚有在职人员,请先撤销所有职务后再删除。");
}
$department->delete();
return redirect()->route('admin.departments.index')->with('success', "部门【{$department->name}】已删除!");
}
}
@@ -0,0 +1,112 @@
<?php
/**
* 文件功能:后台用户反馈管理控制器(仅 id=1 超级管理员可访问)
* 提供反馈列表查看、处理状态修改、官方回复功能
* 侧边栏显示待处理数量徽标
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\FeedbackItem;
use App\Models\FeedbackReply;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class FeedbackManagerController extends Controller
{
/**
* 后台反馈列表页(支持类型+状态筛选)
*
* @param Request $request type/status 筛选参数
*/
public function index(Request $request): View
{
$type = $request->input('type');
$status = $request->input('status');
$query = FeedbackItem::with(['replies'])
->orderByDesc('created_at');
// 按类型筛选
if ($type && in_array($type, ['bug', 'suggestion'])) {
$query->ofType($type);
}
// 按状态筛选
if ($status && array_key_exists($status, FeedbackItem::STATUS_CONFIG)) {
$query->ofStatus($status);
}
$feedbacks = $query->paginate(20)->withQueryString();
// 待处理数量(用于侧边栏徽标)
$pendingCount = FeedbackItem::pending()->count();
return view('admin.feedback.index', [
'feedbacks' => $feedbacks,
'pendingCount' => $pendingCount,
'statusConfig' => FeedbackItem::STATUS_CONFIG,
'typeConfig' => FeedbackItem::TYPE_CONFIG,
'currentType' => $type,
'currentStatus' => $status,
]);
}
/**
* 更新反馈处理状态和官方回复(Ajax + 表单双模式)
* Ajax 返回 JSON,普通表单提交返回重定向
*
* @param Request $request status/admin_remark 字段
* @param int $id 反馈 ID
*/
public function update(Request $request, int $id): JsonResponse|RedirectResponse
{
$feedback = FeedbackItem::findOrFail($id);
$data = $request->validate([
'status' => 'required|in:'.implode(',', array_keys(FeedbackItem::STATUS_CONFIG)),
'admin_remark' => 'nullable|string|max:2000',
]);
$feedback->update([
'status' => $data['status'],
'admin_remark' => $data['admin_remark'] ?? $feedback->admin_remark,
]);
// 如果有新的官方回复内容,同时写入 feedback_replies(带 is_admin 标记)
if (! empty($data['admin_remark']) && $data['admin_remark'] !== $feedback->getOriginal('admin_remark')) {
DB::transaction(function () use ($feedback, $data): void {
FeedbackReply::create([
'feedback_id' => $feedback->id,
'user_id' => 1,
'username' => '🛡️ 开发者',
'content' => $data['admin_remark'],
'is_admin' => true,
]);
$feedback->increment('replies_count');
});
}
// Ajax 请求返回 JSON
if ($request->expectsJson()) {
return response()->json([
'status' => 'success',
'new_status' => $data['status'],
'status_label' => FeedbackItem::STATUS_CONFIG[$data['status']]['icon'].' '.FeedbackItem::STATUS_CONFIG[$data['status']]['label'],
'status_color' => FeedbackItem::STATUS_CONFIG[$data['status']]['color'],
]);
}
return redirect()->route('admin.feedback.index')
->with('success', '反馈状态已更新!');
}
}
@@ -0,0 +1,110 @@
<?php
/**
* 文件功能:后台职务管理控制器
* 提供职务的 CRUD 功能,包含任命权限白名单(多选 position_appoint_limits)的同步
* 职务属于部门,包含等级、图标、人数上限、奖励上限等配置
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Department;
use App\Models\Position;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class PositionController extends Controller
{
/**
* 职务列表页
*/
public function index(): View
{
// 按部门分组展示
$departments = Department::with([
'positions' => fn ($q) => $q->withCount(['activeUserPositions'])->ordered(),
])->ordered()->get();
// 全部职务(供任命白名单多选框使用)
$allPositions = Position::with('department')->orderByDesc('rank')->get();
return view('admin.positions.index', compact('departments', 'allPositions'));
}
/**
* 创建职务(同时同步任命白名单)
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'department_id' => 'required|exists:departments,id',
'name' => 'required|string|max:50',
'icon' => 'nullable|string|max:10',
'rank' => 'required|integer|min:0|max:99',
'level' => 'required|integer|min:1|max:100',
'max_persons' => 'nullable|integer|min:1',
'max_reward' => 'nullable|integer|min:0',
'sort_order' => 'required|integer|min:0',
'appointable_ids' => 'nullable|array',
'appointable_ids.*' => 'exists:positions,id',
]);
$appointableIds = $data['appointable_ids'] ?? [];
unset($data['appointable_ids']);
$position = Position::create($data);
// 同步任命白名单(有选则写,没选则清空=无任命权)
$position->appointablePositions()->sync($appointableIds);
return redirect()->route('admin.positions.index')->with('success', "职务【{$data['name']}】创建成功!");
}
/**
* 更新职务(含任命白名单同步)
*/
public function update(Request $request, Position $position): RedirectResponse
{
$data = $request->validate([
'department_id' => 'required|exists:departments,id',
'name' => 'required|string|max:50',
'icon' => 'nullable|string|max:10',
'rank' => 'required|integer|min:0|max:99',
'level' => 'required|integer|min:1|max:100',
'max_persons' => 'nullable|integer|min:1',
'max_reward' => 'nullable|integer|min:0',
'sort_order' => 'required|integer|min:0',
'appointable_ids' => 'nullable|array',
'appointable_ids.*' => 'exists:positions,id',
]);
$appointableIds = $data['appointable_ids'] ?? [];
unset($data['appointable_ids']);
$position->update($data);
$position->appointablePositions()->sync($appointableIds);
return redirect()->route('admin.positions.index')->with('success', "职务【{$data['name']}】更新成功!");
}
/**
* 删除职务(有在职人员时拒绝)
*/
public function destroy(Position $position): RedirectResponse
{
if ($position->isFull() || $position->activeUserPositions()->exists()) {
return redirect()->route('admin.positions.index')
->with('error', "职务【{$position->name}】尚有在职人员,请先撤销后再删除。");
}
$position->delete();
return redirect()->route('admin.positions.index')->with('success', "职务【{$position->name}】已删除!");
}
}
@@ -31,11 +31,11 @@ class RoomManagerController extends Controller
/**
* 更新房间信息
*
* @param Room $room 路由模型自动注入
*/
public function update(Request $request, int $id): RedirectResponse
public function update(Request $request, Room $room): RedirectResponse
{
$room = Room::findOrFail($id);
$data = $request->validate([
'room_name' => 'required|string|max:100',
'room_des' => 'nullable|string|max:500',
@@ -52,11 +52,11 @@ class RoomManagerController extends Controller
/**
* 删除房间(非系统房间)
*
* @param Room $room 路由模型自动注入
*/
public function destroy(int $id): RedirectResponse
public function destroy(Room $room): RedirectResponse
{
$room = Room::findOrFail($id);
if ($room->room_keep) {
return redirect()->route('admin.rooms.index')->with('error', '系统房间不允许删除!');
}
@@ -11,8 +11,8 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Enums\CurrencySource;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
@@ -30,6 +30,7 @@ class UserManagerController extends Controller
public function __construct(
private readonly UserCurrencyService $currencyService,
) {}
/**
* 显示用户列表及搜索(支持按等级/经验/金币/魅力排序)
*/
@@ -42,11 +43,15 @@ class UserManagerController extends Controller
}
// 排序:允许的字段白名单,防止 SQL 注入
$sortable = ['user_level', 'exp_num', 'jjb', 'meili', 'id'];
$sortBy = in_array($request->input('sort_by'), $sortable) ? $request->input('sort_by') : 'id';
$sortDir = $request->input('sort_dir') === 'asc' ? 'asc' : 'desc';
$sortable = ['user_level', 'exp_num', 'jjb', 'meili', 'id'];
$sortBy = in_array($request->input('sort_by'), $sortable) ? $request->input('sort_by') : 'id';
$sortDir = $request->input('sort_dir') === 'asc' ? 'asc' : 'desc';
$users = $query->orderBy($sortBy, $sortDir)->paginate(20)->withQueryString();
$users = $query
->with(['activePosition.position.department', 'vipLevel'])
->orderBy($sortBy, $sortDir)
->paginate(20)
->withQueryString();
// VIP 等级选项列表(供编辑弹窗使用)
$vipLevels = \App\Models\VipLevel::orderBy('sort_order')->get();
@@ -56,10 +61,12 @@ class UserManagerController extends Controller
/**
* 修改用户资料、等级或密码 (AJAX 或表单)
*
* @param User $user 路由模型自动注入
*/
public function update(Request $request, int $id): JsonResponse|RedirectResponse
public function update(Request $request, User $user): JsonResponse|RedirectResponse
{
$targetUser = User::findOrFail($id);
$targetUser = $user;
$currentUser = Auth::user();
// 越权防护:不能修改 等级大于或等于自己 的目标(除非修改自己)
@@ -67,12 +74,8 @@ 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|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',
@@ -83,17 +86,6 @@ class UserManagerController extends Controller
'hy_time' => 'sometimes|nullable|date',
]);
// 如果传了且没超权,直接赋予
if (isset($validated['user_level'])) {
if ($currentUser->id !== $targetUser->id) {
// 修改别人:只有真正的创始人 (ID=1) 才能修改别人的等级
if ($currentUser->id !== 1) {
return response()->json(['status' => 'error', 'message' => '权限越界:只有星系创始人(站长)才能调整其他用户的行政等级!'], 403);
}
}
$targetUser->user_level = $validated['user_level'];
}
if (isset($validated['sex'])) {
$targetUser->sex = $validated['sex'];
}
@@ -158,10 +150,12 @@ class UserManagerController extends Controller
/**
* 物理删除杀封用户
*
* @param User $user 路由模型自动注入
*/
public function destroy(Request $request, int $id): RedirectResponse
public function destroy(Request $request, User $user): RedirectResponse
{
$targetUser = User::findOrFail($id);
$targetUser = $user;
$currentUser = Auth::user();
// 越权防护:不允许删除同级或更高等级的账号
+6 -7
View File
@@ -60,11 +60,11 @@ class VipController extends Controller
/**
* 更新会员等级
*
* @param int $id 等级ID
* @param VipLevel $vip 路由模型自动注入
*/
public function update(Request $request, int $id): RedirectResponse
public function update(Request $request, VipLevel $vip): RedirectResponse
{
$level = VipLevel::findOrFail($id);
$level = $vip;
$data = $request->validate([
'name' => 'required|string|max:50',
@@ -90,12 +90,11 @@ class VipController extends Controller
/**
* 删除会员等级(关联用户的 vip_level_id 会自动置 null
*
* @param int $id 等级ID
* @param VipLevel $vip 路由模型自动注入
*/
public function destroy(int $id): RedirectResponse
public function destroy(VipLevel $vip): RedirectResponse
{
$level = VipLevel::findOrFail($id);
$level->delete();
$vip->delete();
return redirect()->route('admin.vip.index')->with('success', '会员等级已删除!');
}
@@ -0,0 +1,69 @@
<?php
/**
* 文件功能:开发日志前台控制器
* 对应独立页面 /changelog,展示已发布的版本更新日志
* 支持懒加载(IntersectionObserver + 游标分页)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Models\DevChangelog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ChangelogController extends Controller
{
/** 每次加载的条数 */
private const PAGE_SIZE = 10;
/**
* 更新日志列表页(SSR首屏)
* 预加载最新 10 条已发布日志
*/
public function index(): View
{
$changelogs = DevChangelog::published()
->limit(self::PAGE_SIZE)
->get();
return view('changelog.index', compact('changelogs'));
}
/**
* 懒加载更多日志(JSON API
* 游标分页:传入已加载的最后一条 ID,返回更旧的 10
*
* @param Request $request after_id 参数
*/
public function loadMoreChangelogs(Request $request): JsonResponse
{
$afterId = (int) $request->input('after_id', PHP_INT_MAX);
$items = DevChangelog::published()
->after($afterId)
->limit(self::PAGE_SIZE)
->get();
$data = $items->map(fn (DevChangelog $log) => [
'id' => $log->id,
'version' => $log->version,
'title' => $log->title,
'type_label' => $log->type_label,
'type_color' => $log->type_color,
'content_html' => $log->content_html,
'summary' => $log->summary,
'published_at' => $log->published_at?->format('Y-m-d'),
]);
return response()->json([
'items' => $data,
'has_more' => $items->count() === self::PAGE_SIZE,
]);
}
}
@@ -0,0 +1,147 @@
<?php
/**
* 文件功能:聊天室内快速任命/撤销控制器
* 供有职务的管理员在聊天室用户名片弹窗中快速任命或撤销目标用户的职务。
* 权限校验委托给 AppointmentService,本控制器只做请求解析和返回 JSON。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Events\AppointmentAnnounced;
use App\Models\Position;
use App\Models\User;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ChatAppointmentController extends Controller
{
/**
* 注入任命服务
*/
public function __construct(
private readonly AppointmentService $appointmentService,
private readonly ChatStateService $chatState,
) {}
/**
* 获取可用职务列表(供名片弹窗下拉选择)
* 返回操作人有权限任命的职务
*/
public function positions(): JsonResponse
{
$operator = Auth::user();
$superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
$operatorPosition = $operator->activePosition?->position;
$query = Position::query()
->with('department')
->orderByDesc('rank');
// 仅有具体职务(非 superlevel 直通)的操作人才限制 rank 范围
if ($operatorPosition && $operator->user_level < $superLevel) {
$query->where('rank', '<', $operatorPosition->rank);
}
$positions = $query->get()->map(fn ($p) => [
'id' => $p->id,
'name' => $p->name,
'icon' => $p->icon,
'rank' => $p->rank,
'department' => $p->department?->name,
]);
return response()->json(['status' => 'success', 'positions' => $positions]);
}
/**
* 快速任命:将目标用户任命为指定职务
* 成功后向操作人所在聊天室广播任命公告
*/
public function appoint(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string|exists:users,username',
'position_id' => 'required|exists:positions,id',
'remark' => 'nullable|string|max:100',
'room_id' => 'nullable|integer|exists:rooms,id',
]);
$operator = Auth::user();
$target = User::where('username', $request->username)->firstOrFail();
$position = Position::with('department')->findOrFail($request->position_id);
$result = $this->appointmentService->appoint($operator, $target, $position, $request->remark);
// 任命成功后广播礼花通知:优先用前端传来的 room_id,否则从 Redis 查操作人所在房间
if ($result['ok']) {
$roomId = $request->integer('room_id') ?: ($this->chatState->getUserRooms($operator->username)[0] ?? null);
if ($roomId) {
broadcast(new AppointmentAnnounced(
roomId: (int) $roomId,
targetUsername: $target->username,
positionIcon: $position->icon ?? '🎖️',
positionName: $position->name,
departmentName: $position->department?->name ?? '',
operatorName: $operator->username,
));
}
}
return response()->json([
'status' => $result['ok'] ? 'success' : 'error',
'message' => $result['message'],
], $result['ok'] ? 200 : 422);
}
/**
* 快速撤销:撤销目标用户当前的职务
*/
public function revoke(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string|exists:users,username',
'remark' => 'nullable|string|max:100',
'room_id' => 'nullable|integer|exists:rooms,id',
]);
$operator = Auth::user();
$target = User::where('username', $request->username)->firstOrFail();
// 撤销前先取目标当前职务信息(撤销后就查不到了)
$activeUp = $target->activePosition?->load('position.department');
$posIcon = $activeUp?->position?->icon ?? '🎖️';
$posName = $activeUp?->position?->name ?? '';
$deptName = $activeUp?->position?->department?->name ?? '';
$result = $this->appointmentService->revoke($operator, $target, $request->remark);
// 撤销成功后广播通知到聊天室
if ($result['ok'] && $posName) {
$roomId = $request->integer('room_id') ?: ($this->chatState->getUserRooms($operator->username)[0] ?? null);
if ($roomId) {
broadcast(new AppointmentAnnounced(
roomId: (int) $roomId,
targetUsername: $target->username,
positionIcon: $posIcon,
positionName: $posName,
departmentName: $deptName,
operatorName: $operator->username,
type: 'revoke',
));
}
}
return response()->json([
'status' => $result['ok'] ? 'success' : 'error',
'message' => $result['message'],
], $result['ok'] ? 200 : 422);
}
}
+200 -84
View File
@@ -11,38 +11,43 @@
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Events\UserJoined;
use App\Events\UserLeft;
use App\Enums\CurrencySource;
use App\Http\Requests\SendMessageRequest;
use App\Jobs\SaveMessageJob;
use App\Models\Autoact;
use App\Models\Gift;
use App\Models\PositionDutyLog;
use App\Models\Room;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\AppointmentService;
use App\Services\ChatStateService;
use App\Services\MessageFilterService;
use App\Services\RoomBroadcastService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\View\View;
class ChatController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly ChatStateService $chatState,
private readonly MessageFilterService $filter,
private readonly VipService $vipService,
private readonly \App\Services\ShopService $shopService,
private readonly VipService $vipService,
private readonly \App\Services\ShopService $shopService,
private readonly UserCurrencyService $currencyService,
private readonly AppointmentService $appointmentService,
private readonly RoomBroadcastService $broadcast,
) {}
/**
* 进入房间初始化 (等同于原版 INIT.ASP)
*
@@ -56,12 +61,14 @@ class ChatController extends Controller
// 房间人气 +1(每次访问递增,复刻原版人气计数)
$room->increment('visit_num');
// 用户进房时间刷新
$user->update(['in_time' => now()]);
// 1. 将当前用户加入到 Redis 房间在线列表(包含 VIP 和管理员信息)
$superLevel = (int) Sysparam::getValue('superlevel', '100');
// 获取当前在职职务信息(用于内容显示)
$activePosition = $user->activePosition;
$userData = [
'level' => $user->user_level,
'sex' => $user->sex,
@@ -70,6 +77,8 @@ class ChatController extends Controller
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
'position_icon' => $activePosition?->position?->icon ?? '',
'position_name' => $activePosition?->position?->name ?? '',
];
$this->chatState->userJoin($id, $user->username, $userData);
@@ -77,88 +86,123 @@ class ChatController extends Controller
broadcast(new UserJoined($id, $user->username, $userData))->toOthers();
// 3. 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告
$newbieEffect = null;
if (! $user->has_received_new_gift) {
// 通过统一积分服务发放新人礼包 6666 金币并记录流水
$this->currencyService->change(
$user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
);
$user->update(['has_received_new_gift' => true]);
$newbieEffect = null;
if (! $user->has_received_new_gift) {
// 通过统一积分服务发放新人礼包 6666 金币并记录流水
$this->currencyService->change(
$user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id,
);
$user->update(['has_received_new_gift' => true]);
// 发送新人专属欢迎公告
$newbieMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "🎉 缤纷礼花满天飞,热烈欢迎新朋友 <b>{$user->username}</b> 首次驾临本聊天室!系统已自动赠送 6666 金币新人大礼包!",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $newbieMsg);
broadcast(new MessageSent($id, $newbieMsg));
// 发送新人专属欢迎公告
$newbieMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "🎉 缤纷礼花满天飞,热烈欢迎新朋友 {$user->username} 首次驾临本聊天室!系统已自动赠送 6666 金币新人大礼包!",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $newbieMsg);
broadcast(new MessageSent($id, $newbieMsg));
// 广播烟花特效给此时已在房间的其他用户
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers();
// 广播烟花特效给此时已在房间的其他用户
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers();
// 传给前端,让新人自己的屏幕上也燃放烟花
$newbieEffect = 'fireworks';
}
// 4. 管理员(superlevel)进入时:触发全房间烟花特效 + 公屏欢迎公告
if ($user->user_level >= $superLevel) {
// 广播烟花特效给所有在线用户
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username));
// 发送欢迎公告消息(使用系统公告样式)
$welcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "🎉 欢迎管理员 <b>{$user->username}</b> 驾临本聊天室!请各位文明聊天!",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $welcomeMsg);
broadcast(new MessageSent($id, $welcomeMsg));
}
// 5. 获取历史消息并过滤,只保留和当前用户相关的消息用于初次渲染
// 规则:公众发言(to_user=大家 或空)保留;私聊/系统通知只保留涉及本人的
$allHistory = $this->chatState->getNewMessages($id, 0);
$username = $user->username;
$historyMessages = array_values(array_filter($allHistory, function ($msg) use ($username) {
$toUser = $msg['to_user'] ?? '';
$fromUser = $msg['from_user'] ?? '';
$isSecret = !empty($msg['is_secret']);
// 公众发言(对大家说):所有人都可以看到
if ($toUser === '大家' || $toUser === '') {
return true;
// 传给前端,让新人自己的屏幕上也燃放烟花
$newbieEffect = 'fireworks';
}
// 私信 / 悄悄话:只显示发给自己或自己发出的
if ($isSecret) {
// 4. superlevel 管理员进入:触发全房间烟花 + 系统公告,其他人走通用播报
// 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条
$this->chatState->removeOldWelcomeMessages($id, $user->username);
if ($user->user_level >= $superLevel) {
// 管理员专属:全房间烟花
broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username));
$welcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "🎉 欢迎管理员 【{$user->username}】 驾临本聊天室!请各位文明聊天!",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => 'admin_welcome',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $welcomeMsg);
broadcast(new MessageSent($id, $welcomeMsg));
} else {
// 5. 非站长:生成通用播报(有职务 > 有VIP > 普通随机词)
[$text, $color] = $this->broadcast->buildEntryBroadcast($user);
$generalWelcomeMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '进出播报',
'to_user' => '大家',
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>",
'is_secret' => false,
'font_color' => $color,
'action' => 'system_welcome',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($id, $generalWelcomeMsg);
broadcast(new MessageSent($id, $generalWelcomeMsg))->toOthers();
}
// 6. 获取历史消息并过滤,只保留和当前用户相关的消息用于初次渲染
// 规则:公众发言(to_user=大家 或空)保留;私聊/系统通知只保留涉及本人的
$allHistory = $this->chatState->getNewMessages($id, 0);
$username = $user->username;
$historyMessages = array_values(array_filter($allHistory, function ($msg) use ($username) {
$toUser = $msg['to_user'] ?? '';
$fromUser = $msg['from_user'] ?? '';
$isSecret = ! empty($msg['is_secret']);
// 公众发言(对大家说):所有人都可以看到
if ($toUser === '大家' || $toUser === '') {
return true;
}
// 私信 / 悄悄话:只显示发给自己或自己发出的
if ($isSecret) {
return $fromUser === $username || $toUser === $username;
}
// 对特定人说话:只显示发给自己或自己发出的(含系统通知)
return $fromUser === $username || $toUser === $username;
}));
// 渲染主聊天框架视图
return view('chat.frame', [
'room' => $room,
'user' => $user,
'weekEffect' => $this->shopService->getActiveWeekEffect($user),
'newbieEffect' => $newbieEffect,
'historyMessages' => $historyMessages,
]);
// 最后:如果用户有在职职务,开始记录这次入场的在职登录
// 此时用户局部变量已初始化,可以安全读取 in_time
$activeUP = $user->activePosition;
if ($activeUP) {
PositionDutyLog::create([
'user_id' => $user->id,
'user_position_id' => $activeUP->id,
'login_at' => now(),
'ip_address' => request()->ip(),
'room_id' => $id,
]);
}
// 对特定人说话:只显示发给自己或自己发出的(含系统通知)
return $fromUser === $username || $toUser === $username;
}));
// 渲染主聊天框架视图
return view('chat.frame', [
'room' => $room,
'user' => $user,
'weekEffect' => $this->shopService->getActiveWeekEffect($user), // 周卡特效(登录自动播放)
'newbieEffect' => $newbieEffect, // 新人入场专属特效
'historyMessages' => $historyMessages, // 把历史消息附带给前端
]);
}
/**
@@ -183,6 +227,20 @@ class ChatController extends Controller
], 403);
}
// 0.5 检查接收方是否在线(防幽灵消息)
$toUser = $data['to_user'] ?? '大家';
if ($toUser !== '大家' && ! in_array($toUser, ['系统公告', '系统传音', '送花播报', '进出播报', '钓鱼播报', '星海小博士', 'AI小班长'])) {
// Redis 保存的在线列表
$isOnline = Redis::hexists("room:{$id}:users", $toUser);
if (! $isOnline) {
// 使用 200 状态码,避免 Nginx 拦截非 2xx 响应后触发重定向导致 405 Method Not Allowed
return response()->json([
'status' => 'error',
'message' => "{$toUser}】目前已离开聊天室或不在线,消息未发出。",
], 200);
}
}
// 1. 过滤净化消息体
$pureContent = $this->filter->filter($data['content'] ?? '');
if (empty($pureContent)) {
@@ -284,6 +342,7 @@ class ChatController extends Controller
// 3. 将新的等级反馈给当前用户的在线名单上
// 确保刚刚升级后别人查看到的也是最准确等级
$activePosition = $user->activePosition;
$this->chatState->userJoin($id, $user->username, [
'level' => $user->user_level,
'sex' => $user->sex,
@@ -292,6 +351,8 @@ class ChatController extends Controller
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => $user->user_level >= $superLevel,
'position_icon' => $activePosition?->position?->icon ?? '',
'position_name' => $activePosition?->position?->name ?? '',
]);
// 4. 如果突破境界,向全房系统喊话广播!
@@ -405,11 +466,49 @@ class ChatController extends Controller
// 记录退出时间和退出信息
$user->update([
'out_time' => now(),
'out_info' => "正常退出了房间",
'out_info' => '正常退出了房间',
]);
// 2. 广播通知他人
// 关闭该用户尚未结束的在职登录记录(结算在线时长)
$this->closeDutyLog($user->id);
// 2. 发送离场播报
$superLevel = (int) Sysparam::getValue('superlevel', '100');
if ($user->user_level >= $superLevel) {
// 管理员离场:系统公告
$leaveMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '系统公告',
'to_user' => '大家',
'content' => "👋 管理员 【{$user->username}】 已离开聊天室。",
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => 'admin_welcome',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
} else {
[$leaveText, $color] = $this->broadcast->buildLeaveBroadcast($user);
$leaveMsg = [
'id' => $this->chatState->nextMessageId($id),
'room_id' => $id,
'from_user' => '进出播报',
'to_user' => '大家',
'content' => "<span style=\"color: {$color}; font-weight: bold;\">{$leaveText}</span>",
'is_secret' => false,
'font_color' => $color,
'action' => 'system_welcome',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
}
$this->chatState->pushMessage($id, $leaveMsg);
// 3. 广播通知他人 (UserLeft 更新用户名单列表,MessageSent 更新消息记录)
broadcast(new UserLeft($id, $user->username))->toOthers();
broadcast(new MessageSent($id, $leaveMsg))->toOthers();
return response()->json(['status' => 'success']);
}
@@ -601,7 +700,7 @@ class ChatController extends Controller
'room_id' => $roomId,
'from_user' => '送花播报',
'to_user' => $toUsername,
'content' => "{$gift->emoji} {$user->username}{$toUsername} 送出了{$countText}{$gift->name}】!魅力 +{$totalCharm}",
'content' => "{$gift->emoji} {$user->username}{$toUsername} 送出了{$countText}{$gift->name}】!魅力 +{$totalCharm}",
'is_secret' => false,
'font_color' => '#e91e8f',
'action' => '',
@@ -707,4 +806,21 @@ class ChatController extends Controller
return max(0, (int) $value);
}
/**
* 关闭该用户尚未结束的在职登录记录(结算在线时长)
* 在用户退出房间或心跳超时时调用
*
* @param int $userId 用户 ID
*/
private function closeDutyLog(int $userId): void
{
PositionDutyLog::query()
->where('user_id', $userId)
->whereNull('logout_at')
->update([
'logout_at' => now(),
'duration_seconds' => DB::raw('TIMESTAMPDIFF(SECOND, login_at, NOW())'),
]);
}
}
@@ -0,0 +1,79 @@
<?php
/**
* 文件功能:勤务台页面控制器
* 左侧五个子菜单:任职列表、日榜、周榜、月榜、总榜
* 路由:GET /duty-hall?tab=roster|day|week|month|all
*
* @author ChatRoom Laravel
*
* @version 1.1.0
*/
namespace App\Http\Controllers;
use App\Models\Department;
use App\Models\PositionDutyLog;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DutyHallController extends Controller
{
/**
* 勤务台主页(根据 tab 切换内容)
*/
public function index(Request $request): View
{
$tab = $request->input('tab', 'roster');
// ── 任职列表:按部门→职务展示全部(含空缺) ────────────────────
$currentStaff = null;
if ($tab === 'roster') {
$currentStaff = Department::query()
->with([
'positions' => fn ($q) => $q->orderByDesc('rank'),
'positions.activeUserPositions.user',
])
->orderByDesc('rank')
->get();
}
// ── 日/周/月/总榜:勤务时长排行 ──────────────────────────────────
$leaderboard = null;
if (in_array($tab, ['day', 'week', 'month', 'all'])) {
$query = PositionDutyLog::query()
->selectRaw('user_id, SUM(duration_seconds) as total_seconds, COUNT(*) as checkin_count');
// 按时间段过滤
match ($tab) {
'day' => $query->whereDate('login_at', today()),
'week' => $query->whereBetween('login_at', [now()->startOfWeek(), now()->endOfWeek()]),
'month' => $query->whereYear('login_at', now()->year)->whereMonth('login_at', now()->month),
'all' => null, // 不加时间限制
};
$leaderboard = $query
->groupBy('user_id')
->orderByDesc('total_seconds')
->limit(20)
->with('user')
->get();
}
// 各榜标签配置
$tabs = [
'roster' => ['label' => '任职列表', 'icon' => '🏛️'],
'day' => ['label' => '日榜', 'icon' => '☀️'],
'week' => ['label' => '周榜', 'icon' => '📆'],
'month' => ['label' => '月榜', 'icon' => '🗓️'],
'all' => ['label' => '总榜', 'icon' => '🏆'],
];
return view('duty-hall.index', compact(
'tab',
'tabs',
'currentStaff',
'leaderboard',
));
}
}
+298
View File
@@ -0,0 +1,298 @@
<?php
/**
* 文件功能:用户反馈前台控制器
* 对应独立页面 /feedback,处理用户提交 Bug报告/功能建议、
* 赞同(Toggle)、补充评论、删除等操作
* 所有写操作均需登录(chat.auth 中间件保护)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Models\FeedbackItem;
use App\Models\FeedbackReply;
use App\Models\FeedbackVote;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class FeedbackController extends Controller
{
/** 每次懒加载的条数 */
private const PAGE_SIZE = 10;
/**
* 用户反馈列表页(SSR首屏)
* 预加载按赞同数倒序的 10 条反馈
*/
public function index(): View
{
$feedbacks = FeedbackItem::with(['replies'])
->orderByDesc('votes_count')
->orderByDesc('created_at')
->limit(self::PAGE_SIZE)
->get();
// 当前用户已赞同的反馈 ID 集合(前端切换按钮状态用)
$myVotedIds = FeedbackVote::where('user_id', Auth::id())
->whereIn('feedback_id', $feedbacks->pluck('id'))
->pluck('feedback_id')
->toArray();
return view('feedback.index', compact('feedbacks', 'myVotedIds'));
}
/**
* 懒加载更多反馈(JSON API
* 支持按类型筛选(bug / suggestion
*
* @param Request $request after_id / type 筛选参数
*/
public function loadMore(Request $request): JsonResponse
{
$afterId = (int) $request->input('after_id', PHP_INT_MAX);
$type = $request->input('type'); // bug|suggestion|null(全部)
$query = FeedbackItem::with(['replies'])
->where('id', '<', $afterId)
->orderByDesc('votes_count')
->orderByDesc('created_at');
if ($type && in_array($type, ['bug', 'suggestion'])) {
$query->ofType($type);
}
$items = $query->limit(self::PAGE_SIZE)->get();
// 当前用户已赞同的 ID(用于切换按钮状态)
$myVotedIds = FeedbackVote::where('user_id', Auth::id())
->whereIn('feedback_id', $items->pluck('id'))
->pluck('feedback_id')
->toArray();
return response()->json([
'items' => $this->formatItems($items, $myVotedIds),
'has_more' => $items->count() === self::PAGE_SIZE,
]);
}
/**
* 提交新反馈(Bug报告或功能建议)
*
* @param Request $request type/title/content 字段
*/
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'type' => 'required|in:bug,suggestion',
'title' => 'required|string|max:200',
'content' => 'required|string|max:2000',
]);
/** @var \App\Models\User $user */
$user = Auth::user();
$item = FeedbackItem::create([
'user_id' => $user->id,
'username' => $user->username,
'type' => $data['type'],
'title' => $data['title'],
'content' => $data['content'],
'status' => 'pending',
]);
return response()->json([
'status' => 'success',
'message' => '反馈已提交,感谢您的贡献!',
'item' => $this->formatItem($item, false),
]);
}
/**
* 赞同/取消赞同反馈(Toggle 操作)
* 每人每条只能赞同一次,再次点击则取消
* 使用数据库事务保证 votes_count 冗余字段与记录一致
*
* @param int $id 反馈 ID
*/
public function vote(int $id): JsonResponse
{
$feedback = FeedbackItem::findOrFail($id);
$userId = Auth::id();
// 不能赞同自己提交的反馈
if ($feedback->user_id === $userId) {
return response()->json([
'status' => 'error',
'message' => '不能赞同自己的反馈',
], 422);
}
$voted = false;
DB::transaction(function () use ($feedback, $userId, &$voted): void {
$existing = FeedbackVote::where('feedback_id', $feedback->id)
->where('user_id', $userId)
->first();
if ($existing) {
// 已赞同 → 取消赞同
$existing->delete();
$feedback->decrement('votes_count');
$voted = false;
} else {
// 未赞同 → 新增赞同
FeedbackVote::create([
'feedback_id' => $feedback->id,
'user_id' => $userId,
]);
$feedback->increment('votes_count');
$voted = true;
}
});
return response()->json([
'status' => 'success',
'voted' => $voted,
'votes_count' => $feedback->fresh()->votes_count,
]);
}
/**
* 提交补充评论
* id=1 管理员的回复自动标记 is_admin=true(前台特殊展示)
*
* @param Request $request content 字段
* @param int $id 反馈 ID
*/
public function reply(Request $request, int $id): JsonResponse
{
$feedback = FeedbackItem::findOrFail($id);
$data = $request->validate([
'content' => 'required|string|max:1000',
]);
/** @var \App\Models\User $user */
$user = Auth::user();
/** @var FeedbackReply $reply */
$reply = null;
DB::transaction(function () use ($feedback, $data, $user, &$reply): void {
$reply = FeedbackReply::create([
'feedback_id' => $feedback->id,
'user_id' => $user->id,
'username' => $user->username,
'content' => $data['content'],
'is_admin' => $user->id === 1,
]);
$feedback->increment('replies_count');
});
return response()->json([
'status' => 'success',
'message' => '评论已提交',
'reply' => [
'id' => $reply->id,
'username' => $reply->username,
'content' => $reply->content,
'is_admin' => $reply->is_admin,
'created_at' => $reply->created_at->diffForHumans(),
],
]);
}
/**
* 删除反馈
* 普通用户:仅24小时内可删除自己的反馈
* 管理员(id=1):任意时间可删除任意反馈
*
* @param int $id 反馈 ID
*/
public function destroy(int $id): JsonResponse
{
$feedback = FeedbackItem::findOrFail($id);
/** @var \App\Models\User $user */
$user = Auth::user();
$isOwner = $feedback->user_id === $user->id;
$isAdmin = $user->id === 1;
if (! $isOwner && ! $isAdmin) {
return response()->json(['status' => 'error', 'message' => '无权删除'], 403);
}
if ($isOwner && ! $isAdmin && ! $feedback->is_within_24_hours) {
return response()->json([
'status' => 'error',
'message' => '超过 24 小时的反馈无法删除',
], 422);
}
// 级联删除关联的赞同记录和评论记录
DB::transaction(function () use ($feedback): void {
FeedbackVote::where('feedback_id', $feedback->id)->delete();
FeedbackReply::where('feedback_id', $feedback->id)->delete();
$feedback->delete();
});
return response()->json(['status' => 'success', 'message' => '已删除']);
}
// ═══════════════ 私有辅助方法 ═══════════════
/**
* 格式化单条反馈数据(供 JSON 返回给前端)
*
* @param FeedbackItem $item 反馈实例
* @param bool $voted 当前用户是否已赞同
*/
private function formatItem(FeedbackItem $item, bool $voted): array
{
return [
'id' => $item->id,
'type' => $item->type,
'type_label' => $item->type_label,
'title' => $item->title,
'content' => $item->content,
'status' => $item->status,
'status_label' => $item->status_label,
'status_color' => $item->status_config['color'],
'admin_remark' => $item->admin_remark,
'votes_count' => $item->votes_count,
'replies_count' => $item->replies_count,
'username' => $item->username,
'created_at' => $item->created_at->diffForHumans(),
'voted' => $voted,
'replies' => ($item->relationLoaded('replies') ? $item->replies : collect())->map(fn ($r) => [
'id' => $r->id,
'username' => $r->username,
'content' => $r->content,
'is_admin' => $r->is_admin,
'created_at' => $r->created_at->diffForHumans(),
])->values()->toArray(),
];
}
/**
* 批量格式化反馈数据集合
*
* @param \Illuminate\Support\Collection<int, FeedbackItem> $items
* @param array<int> $myVotedIds 当前用户已赞同的 ID 列表
*/
private function formatItems(\Illuminate\Support\Collection $items, array $myVotedIds): array
{
return $items->map(fn (FeedbackItem $item) => $this->formatItem(
$item,
in_array($item->id, $myVotedIds)
))->values()->toArray();
}
}
+4 -4
View File
@@ -12,8 +12,8 @@
namespace App\Http\Controllers;
use App\Events\MessageSent;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
@@ -26,8 +26,8 @@ use Illuminate\Support\Facades\Redis;
class FishingController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
) {}
@@ -148,7 +148,7 @@ class FishingController extends Controller
'room_id' => $id,
'from_user' => '钓鱼播报',
'to_user' => '大家',
'content' => "{$result['emoji']} {$user->username}{$result['message']}",
'content' => "{$result['emoji']} {$user->username}{$result['message']}",
'is_secret' => false,
'font_color' => $result['exp'] >= 0 ? '#16a34a' : '#dc2626',
'action' => '',
+31 -14
View File
@@ -30,8 +30,6 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redis;
class UserController extends Controller
{
/**
@@ -43,6 +41,7 @@ class UserController extends Controller
$operator = Auth::user();
// 基础公开信息
$activePosition = $targetUser->activePosition?->load('position.department')->position;
$data = [
'username' => $targetUser->username,
'sex' => $targetUser->sex,
@@ -52,6 +51,10 @@ class UserController extends Controller
'qianming' => $targetUser->qianming,
'sign' => $targetUser->sign ?? '这个人很懒,什么都没留下。',
'created_at' => $targetUser->created_at->format('Y-m-d'),
// 在职职务(供名片弹窗任命/撤销判断)
'position_name' => $activePosition?->name ?? '',
'position_icon' => $activePosition?->icon ?? '',
'department_name' => $activePosition?->department?->name ?? '',
];
// 只有等级不低于对方,或者自己看自己时,才能看到详细的财富、经验资产
@@ -61,6 +64,20 @@ class UserController extends Controller
$data['meili'] = $targetUser->meili ?? 0;
}
// 职务履历(所有任职记录,按任命时间倒序;positions() 关系已含 with
$data['position_history'] = $targetUser->positions
->map(fn ($up) => [
'position_icon' => $up->position?->icon ?? '',
'position_name' => $up->position?->name ?? '',
'department_name' => $up->position?->department?->name ?? '',
'appointed_at' => $up->appointed_at?->format('Y-m-d'),
'revoked_at' => $up->revoked_at?->format('Y-m-d'),
'is_active' => $up->is_active,
'duration_days' => $up->duration_days,
])
->values()
->all();
// 拥有封禁IPlevel_banip)或踢人以上权限的管理,可以查看IP和归属地
$levelBanIp = (int) Sysparam::getValue('level_banip', '15');
if ($operator && $operator->user_level >= $levelBanIp) {
@@ -72,24 +89,24 @@ class UserController extends Controller
if ($ipToLookup) {
try {
// 不传路径,使用 zoujingli/ip2region 包自带的内置数据库
$ip2r = new \Ip2Region();
$ip2r = new \Ip2Region;
$info = $ip2r->getIpInfo($ipToLookup);
if ($info) {
$country = $info['country'] ?? '';
$country = $info['country'] ?? '';
$province = $info['province'] ?? '';
$city = $info['city'] ?? '';
$city = $info['city'] ?? '';
// 过滤掉占位符 "0"
$province = ($province === '0') ? '' : $province;
$city = ($city === '0') ? '' : $city;
$city = ($city === '0') ? '' : $city;
if ($country === '中国') {
$data['location'] = trim($province . ($province !== $city ? ' ' . $city : ''));
$data['location'] = trim($province.($province !== $city ? ' '.$city : ''));
} else {
$data['location'] = $country ?: '未知区域';
}
if (empty($data['location'])) {
$data['location'] = '未知区域';
}
@@ -117,7 +134,7 @@ class UserController extends Controller
{
$user = Auth::user();
$data = $request->validated();
// 当用户试图更新邮箱,并且新邮箱不等于当前旧邮箱时启动验证码拦截
if (isset($data['email']) && $data['email'] !== $user->email) {
// 首先判断系统开关是否开启,没开启直接禁止修改邮箱
@@ -131,10 +148,10 @@ class UserController extends Controller
}
// 获取缓存的验证码
$codeKey = 'email_verify_code_' . $user->id . '_' . $data['email'];
$codeKey = 'email_verify_code_'.$user->id.'_'.$data['email'];
$cachedCode = \Illuminate\Support\Facades\Cache::get($codeKey);
if (!$cachedCode || $cachedCode != $emailCode) {
if (! $cachedCode || $cachedCode != $emailCode) {
return response()->json(['status' => 'error', 'message' => '验证码不正确或已过期(有效期5分钟),请重新获取。'], 422);
}