This commit is contained in:
2026-02-18 19:41:03 +08:00
parent a0c2350662
commit 6543e2ccdd
18 changed files with 4885 additions and 1196 deletions

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Services\System\NotificationService;
use Illuminate\Support\Facades\Log;
class RetryUnsentNotifications extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'notifications:retry-unsent {--limit=100 : Maximum number of notifications to retry}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Retry sending unsent notifications via WebSocket';
/**
* @var NotificationService
*/
protected $notificationService;
/**
* RetryUnsentNotifications constructor
*/
public function __construct(NotificationService $notificationService)
{
parent::__construct();
$this->notificationService = $notificationService;
}
/**
* Execute the console command.
*/
public function handle(): int
{
$limit = (int) $this->option('limit');
$this->info('开始重试未发送的通知...');
$this->info("最大处理数量: {$limit}");
try {
$sentCount = $this->notificationService->retryUnsentNotifications($limit);
if ($sentCount > 0) {
$this->info("成功发送 {$sentCount} 条通知");
} else {
$this->info('没有需要重试的通知');
}
Log::info('重试未发送通知完成', [
'sent_count' => $sentCount,
'limit' => $limit
]);
return self::SUCCESS;
} catch (\Exception $e) {
$this->error('重试未发送通知失败: ' . $e->getMessage());
Log::error('重试未发送通知失败', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return self::FAILURE;
}
}
}

View File

@@ -0,0 +1,361 @@
<?php
namespace App\Http\Controllers\System\Admin;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Services\System\NotificationService;
use App\Http\Controllers\Controller;
class Notification extends Controller
{
/**
* @var NotificationService
*/
protected $notificationService;
/**
* Notification constructor
*/
public function __construct(NotificationService $notificationService)
{
$this->notificationService = $notificationService;
}
/**
* 获取通知列表
*
* @param Request $request
* @return JsonResponse
*/
public function index(Request $request): JsonResponse
{
$params = $request->all();
// 如果没有指定user_id使用当前登录用户
if (empty($params['user_id'])) {
$params['user_id'] = auth('admin')->id();
}
$result = $this->notificationService->getList($params);
return response()->json([
'code' => 200,
'message' => 'success',
'data' => $result
]);
}
/**
* 获取未读通知
*
* @param Request $request
* @return JsonResponse
*/
public function unread(Request $request): JsonResponse
{
$userId = auth('admin')->id();
$limit = $request->input('limit', 10);
$notifications = $this->notificationService->getUnreadNotifications($userId, $limit);
return response()->json([
'code' => 200,
'message' => 'success',
'data' => [
'list' => $notifications
]
]);
}
/**
* 获取未读通知数量
*
* @return JsonResponse
*/
public function unreadCount(): JsonResponse
{
$userId = auth('admin')->id();
$count = $this->notificationService->getUnreadCount($userId);
return response()->json([
'code' => 200,
'message' => 'success',
'data' => [
'count' => $count
]
]);
}
/**
* 获取通知详情
*
* @param int $id
* @return JsonResponse
*/
public function show(int $id): JsonResponse
{
$notification = $this->notificationService->getById($id);
if (!$notification) {
return response()->json([
'code' => 404,
'message' => 'Notification not found',
'data' => null
], 404);
}
// 检查权限
if ($notification->user_id !== auth('admin')->id()) {
return response()->json([
'code' => 403,
'message' => 'Access denied',
'data' => null
], 403);
}
return response()->json([
'code' => 200,
'message' => 'success',
'data' => $notification
]);
}
/**
* 标记通知为已读
*
* @param Request $request
* @param int $id
* @return JsonResponse
*/
public function markAsRead(Request $request, int $id): JsonResponse
{
$userId = auth('admin')->id();
$result = $this->notificationService->markAsRead($id, $userId);
if (!$result) {
return response()->json([
'code' => 404,
'message' => 'Notification not found or access denied',
'data' => null
], 404);
}
return response()->json([
'code' => 200,
'message' => 'Notification marked as read',
'data' => null
]);
}
/**
* 批量标记通知为已读
*
* @param Request $request
* @return JsonResponse
*/
public function batchMarkAsRead(Request $request): JsonResponse
{
$request->validate([
'ids' => 'required|array',
'ids.*' => 'integer'
]);
$userId = auth('admin')->id();
$ids = $request->input('ids');
$count = $this->notificationService->batchMarkAsRead($ids, $userId);
return response()->json([
'code' => 200,
'message' => 'Notifications marked as read',
'data' => [
'count' => $count
]
]);
}
/**
* 标记所有通知为已读
*
* @return JsonResponse
*/
public function markAllAsRead(): JsonResponse
{
$userId = auth('admin')->id();
$count = $this->notificationService->markAllAsRead($userId);
return response()->json([
'code' => 200,
'message' => 'All notifications marked as read',
'data' => [
'count' => $count
]
]);
}
/**
* 删除通知
*
* @param int $id
* @return JsonResponse
*/
public function destroy(int $id): JsonResponse
{
$userId = auth('admin')->id();
$result = $this->notificationService->delete($id, $userId);
if (!$result) {
return response()->json([
'code' => 404,
'message' => 'Notification not found or access denied',
'data' => null
], 404);
}
return response()->json([
'code' => 200,
'message' => 'Notification deleted',
'data' => null
]);
}
/**
* 批量删除通知
*
* @param Request $request
* @return JsonResponse
*/
public function batchDelete(Request $request): JsonResponse
{
$request->validate([
'ids' => 'required|array',
'ids.*' => 'integer'
]);
$userId = auth('admin')->id();
$ids = $request->input('ids');
$count = $this->notificationService->batchDelete($ids, $userId);
return response()->json([
'code' => 200,
'message' => 'Notifications deleted',
'data' => [
'count' => $count
]
]);
}
/**
* 清空已读通知
*
* @return JsonResponse
*/
public function clearRead(): JsonResponse
{
$userId = auth('admin')->id();
$count = $this->notificationService->clearReadNotifications($userId);
return response()->json([
'code' => 200,
'message' => 'Read notifications cleared',
'data' => [
'count' => $count
]
]);
}
/**
* 获取通知统计
*
* @return JsonResponse
*/
public function statistics(): JsonResponse
{
$userId = auth('admin')->id();
$stats = $this->notificationService->getStatistics($userId);
return response()->json([
'code' => 200,
'message' => 'success',
'data' => $stats
]);
}
/**
* 发送通知(管理员功能)
*
* @param Request $request
* @return JsonResponse
*/
public function send(Request $request): JsonResponse
{
$request->validate([
'user_ids' => 'nullable|array',
'user_ids.*' => 'integer',
'title' => 'required|string|max:200',
'content' => 'required|string',
'type' => 'required|string|in:info,success,warning,error,task,system',
'category' => 'nullable|string|in:system,task,message,reminder,announcement',
'data' => 'nullable|array',
'action_type' => 'nullable|string|in:link,modal,none',
'action_data' => 'nullable|array',
]);
$userIds = $request->input('user_ids');
$title = $request->input('title');
$content = $request->input('content');
$type = $request->input('type', 'info');
$category = $request->input('category', 'system');
$extraData = $request->input('data', []);
// 如果没有指定user_ids则发送给所有用户
if (empty($userIds)) {
$result = $this->notificationService->broadcast(
$title,
$content,
$type,
$category,
$extraData
);
} else {
$result = $this->notificationService->sendToUsers(
$userIds,
$title,
$content,
$type,
$category,
$extraData
);
}
return response()->json([
'code' => 200,
'message' => 'Notification sent successfully',
'data' => [
'count' => count($result)
]
]);
}
/**
* 重试发送未发送的通知(管理员功能)
*
* @param Request $request
* @return JsonResponse
*/
public function retryUnsent(Request $request): JsonResponse
{
$limit = $request->input('limit', 100);
$count = $this->notificationService->retryUnsentNotifications($limit);
return response()->json([
'code' => 200,
'message' => 'Unsent notifications retried',
'data' => [
'count' => $count
]
]);
}
}

View File

@@ -0,0 +1,154 @@
<?php
namespace App\Models\System;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Notification extends Model
{
use ModelTrait, SoftDeletes;
protected $table = 'system_notifications';
protected $fillable = [
'user_id',
'title',
'content',
'type',
'category',
'data',
'action_type',
'action_data',
'is_read',
'read_at',
'sent_via_websocket',
'sent_at',
'retry_count',
];
protected $casts = [
'data' => 'array',
'is_read' => 'boolean',
'sent_via_websocket' => 'boolean',
'read_at' => 'datetime',
'sent_at' => 'datetime',
'retry_count' => 'integer',
];
/**
* 通知类型常量
*/
const TYPE_INFO = 'info';
const TYPE_SUCCESS = 'success';
const TYPE_WARNING = 'warning';
const TYPE_ERROR = 'error';
const TYPE_TASK = 'task';
const TYPE_SYSTEM = 'system';
/**
* 通知分类常量
*/
const CATEGORY_SYSTEM = 'system';
const CATEGORY_TASK = 'task';
const CATEGORY_MESSAGE = 'message';
const CATEGORY_REMINDER = 'reminder';
const CATEGORY_ANNOUNCEMENT = 'announcement';
/**
* 操作类型常量
*/
const ACTION_LINK = 'link';
const ACTION_MODAL = 'modal';
const ACTION_NONE = 'none';
/**
* 关联用户
*/
public function user(): BelongsTo
{
return $this->belongsTo(\App\Models\Auth\User::class, 'user_id');
}
/**
* 标记为已读
*/
public function markAsRead(): bool
{
$this->is_read = true;
$this->read_at = now();
return $this->save();
}
/**
* 标记为未读
*/
public function markAsUnread(): bool
{
$this->is_read = false;
$this->read_at = null;
return $this->save();
}
/**
* 标记已通过WebSocket发送
*/
public function markAsSent(): bool
{
$this->sent_via_websocket = true;
$this->sent_at = now();
return $this->save();
}
/**
* 增加重试次数
*/
public function incrementRetry(): bool
{
$this->increment('retry_count');
return true;
}
/**
* 获取通知类型选项
*/
public static function getTypeOptions(): array
{
return [
self::TYPE_INFO => '信息',
self::TYPE_SUCCESS => '成功',
self::TYPE_WARNING => '警告',
self::TYPE_ERROR => '错误',
self::TYPE_TASK => '任务',
self::TYPE_SYSTEM => '系统',
];
}
/**
* 获取通知分类选项
*/
public static function getCategoryOptions(): array
{
return [
self::CATEGORY_SYSTEM => '系统通知',
self::CATEGORY_TASK => '任务通知',
self::CATEGORY_MESSAGE => '消息通知',
self::CATEGORY_REMINDER => '提醒通知',
self::CATEGORY_ANNOUNCEMENT => '公告通知',
];
}
/**
* 获取操作类型选项
*/
public static function getActionTypeOptions(): array
{
return [
self::ACTION_LINK => '跳转链接',
self::ACTION_MODAL => '弹窗显示',
self::ACTION_NONE => '无操作',
];
}
}

View File

@@ -0,0 +1,548 @@
<?php
namespace App\Services\System;
use App\Models\System\Notification;
use App\Services\WebSocket\WebSocketService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class NotificationService
{
/**
* @var WebSocketService
*/
protected $webSocketService;
/**
* NotificationService constructor
*/
public function __construct()
{
$this->webSocketService = app(WebSocketService::class);
}
/**
* 获取通知列表
*
* @param array $params
* @return array
*/
public function getList(array $params): array
{
$query = Notification::query();
// 过滤用户ID
if (!empty($params['user_id'])) {
$query->where('user_id', $params['user_id']);
}
// 关键字搜索
if (!empty($params['keyword'])) {
$query->where(function ($q) use ($params) {
$q->where('title', 'like', '%' . $params['keyword'] . '%')
->orWhere('content', 'like', '%' . $params['keyword'] . '%');
});
}
// 过滤阅读状态
if (isset($params['is_read']) && $params['is_read'] !== '') {
$query->where('is_read', $params['is_read']);
}
// 过滤通知类型
if (!empty($params['type'])) {
$query->where('type', $params['type']);
}
// 过滤通知分类
if (!empty($params['category'])) {
$query->where('category', $params['category']);
}
// 日期范围
if (!empty($params['start_date'])) {
$query->where('created_at', '>=', $params['start_date']);
}
if (!empty($params['end_date'])) {
$query->where('created_at', '<=', $params['end_date']);
}
$query->orderBy('created_at', 'desc');
$pageSize = $params['page_size'] ?? 20;
$list = $query->paginate($pageSize);
return [
'list' => $list->items(),
'total' => $list->total(),
'page' => $list->currentPage(),
'page_size' => $list->perPage(),
];
}
/**
* 获取未读通知列表
*
* @param int $userId
* @param int $limit
* @return array
*/
public function getUnreadNotifications(int $userId, int $limit = 10): array
{
return Notification::where('user_id', $userId)
->where('is_read', false)
->orderBy('created_at', 'desc')
->limit($limit)
->get()
->toArray();
}
/**
* 获取未读通知数量
*
* @param int $userId
* @return int
*/
public function getUnreadCount(int $userId): int
{
return Notification::where('user_id', $userId)
->where('is_read', false)
->count();
}
/**
* 根据ID获取通知
*
* @param int $id
* @return Notification|null
*/
public function getById(int $id): ?Notification
{
return Notification::find($id);
}
/**
* 创建通知
*
* @param array $data
* @return Notification
*/
public function create(array $data): Notification
{
$notification = Notification::create($data);
// 如果用户在线立即通过WebSocket发送
if ($this->webSocketService->isUserOnline($data['user_id'])) {
$this->sendViaWebSocket($notification);
}
return $notification;
}
/**
* 批量创建通知
*
* @param array $notificationsData
* @return array
*/
public function batchCreate(array $notificationsData): array
{
$notifications = [];
DB::beginTransaction();
try {
foreach ($notificationsData as $data) {
$notification = Notification::create($data);
$notifications[] = $notification;
// 如果用户在线立即通过WebSocket发送
if ($this->webSocketService->isUserOnline($data['user_id'])) {
$this->sendViaWebSocket($notification);
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('批量创建通知失败', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
throw $e;
}
return $notifications;
}
/**
* 发送系统通知给单个用户
*
* @param int $userId
* @param string $title
* @param string $content
* @param string $type
* @param string $category
* @param array $extraData
* @return Notification
*/
public function sendToUser(
int $userId,
string $title,
string $content,
string $type = Notification::TYPE_INFO,
string $category = Notification::CATEGORY_SYSTEM,
array $extraData = []
): Notification {
$data = [
'user_id' => $userId,
'title' => $title,
'content' => $content,
'type' => $type,
'category' => $category,
'data' => $extraData,
'is_read' => false,
];
return $this->create($data);
}
/**
* 发送系统通知给多个用户
*
* @param array $userIds
* @param string $title
* @param string $content
* @param string $type
* @param string $category
* @param array $extraData
* @return array
*/
public function sendToUsers(
array $userIds,
string $title,
string $content,
string $type = Notification::TYPE_INFO,
string $category = Notification::CATEGORY_SYSTEM,
array $extraData = []
): array {
$notificationsData = [];
foreach ($userIds as $userId) {
$notificationsData[] = [
'user_id' => $userId,
'title' => $title,
'content' => $content,
'type' => $type,
'category' => $category,
'data' => $extraData,
'is_read' => false,
'created_at' => now(),
'updated_at' => now(),
];
}
return $this->batchCreate($notificationsData);
}
/**
* 发送系统广播通知(所有用户)
*
* @param string $title
* @param string $content
* @param string $type
* @param string $category
* @param array $extraData
* @return array
*/
public function broadcast(
string $title,
string $content,
string $type = Notification::TYPE_INFO,
string $category = Notification::CATEGORY_ANNOUNCEMENT,
array $extraData = []
): array {
// 获取所有用户ID
$userIds = \App\Models\Auth\User::where('status', 1)->pluck('id')->toArray();
return $this->sendToUsers($userIds, $title, $content, $type, $category, $extraData);
}
/**
* 通过WebSocket发送通知
*
* @param Notification $notification
* @return bool
*/
protected function sendViaWebSocket(Notification $notification): bool
{
$data = [
'type' => 'notification',
'data' => [
'id' => $notification->id,
'title' => $notification->title,
'content' => $notification->content,
'type' => $notification->type,
'category' => $notification->category,
'data' => $notification->data,
'action_type' => $notification->action_type,
'action_data' => $notification->action_data,
'timestamp' => $notification->created_at->timestamp,
]
];
$result = $this->webSocketService->sendToUser($notification->user_id, $data);
if ($result) {
$notification->markAsSent();
} else {
$notification->incrementRetry();
}
return $result;
}
/**
* 重试发送未发送的通知
*
* @param int $limit
* @return int
*/
public function retryUnsentNotifications(int $limit = 100): int
{
$notifications = Notification::where('sent_via_websocket', false)
->where('retry_count', '<', 3)
->where('created_at', '>', now()->subHours(24))
->orderBy('created_at', 'desc')
->limit($limit)
->get();
$sentCount = 0;
foreach ($notifications as $notification) {
if ($this->webSocketService->isUserOnline($notification->user_id)) {
if ($this->sendViaWebSocket($notification)) {
$sentCount++;
}
}
}
return $sentCount;
}
/**
* 标记通知为已读
*
* @param int $id
* @param int $userId
* @return bool
*/
public function markAsRead(int $id, int $userId): bool
{
$notification = Notification::where('id', $id)
->where('user_id', $userId)
->first();
if (!$notification) {
return false;
}
return $notification->markAsRead();
}
/**
* 批量标记通知为已读
*
* @param array $ids
* @param int $userId
* @return int
*/
public function batchMarkAsRead(array $ids, int $userId): int
{
return Notification::where('user_id', $userId)
->whereIn('id', $ids)
->update([
'is_read' => true,
'read_at' => now(),
]);
}
/**
* 标记所有通知为已读
*
* @param int $userId
* @return int
*/
public function markAllAsRead(int $userId): int
{
return Notification::where('user_id', $userId)
->where('is_read', false)
->update([
'is_read' => true,
'read_at' => now(),
]);
}
/**
* 删除通知
*
* @param int $id
* @param int $userId
* @return bool
*/
public function delete(int $id, int $userId): bool
{
$notification = Notification::where('id', $id)
->where('user_id', $userId)
->first();
if (!$notification) {
return false;
}
return $notification->delete();
}
/**
* 批量删除通知
*
* @param array $ids
* @param int $userId
* @return int
*/
public function batchDelete(array $ids, int $userId): int
{
return Notification::where('user_id', $userId)
->whereIn('id', $ids)
->delete();
}
/**
* 清空已读通知
*
* @param int $userId
* @return int
*/
public function clearReadNotifications(int $userId): int
{
return Notification::where('user_id', $userId)
->where('is_read', true)
->delete();
}
/**
* 获取通知统计
*
* @param int $userId
* @return array
*/
public function getStatistics(int $userId): array
{
$total = Notification::where('user_id', $userId)->count();
$unread = Notification::where('user_id', $userId)->where('is_read', false)->count();
$read = Notification::where('user_id', $userId)->where('is_read', true)->count();
// 按类型统计
$byType = Notification::where('user_id', $userId)
->selectRaw('type, COUNT(*) as count')
->groupBy('type')
->pluck('count', 'type')
->toArray();
// 按分类统计
$byCategory = Notification::where('user_id', $userId)
->selectRaw('category, COUNT(*) as count')
->groupBy('category')
->pluck('count', 'category')
->toArray();
return [
'total' => $total,
'unread' => $unread,
'read' => $read,
'by_type' => $byType,
'by_category' => $byCategory,
];
}
/**
* 发送任务通知
*
* @param int $userId
* @param string $title
* @param string $content
* @param array $taskData
* @return Notification
*/
public function sendTaskNotification(int $userId, string $title, string $content, array $taskData = []): Notification
{
return $this->sendToUser(
$userId,
$title,
$content,
Notification::TYPE_TASK,
Notification::CATEGORY_TASK,
array_merge(['task' => $taskData], $taskData)
);
}
/**
* 发送系统维护通知
*
* @param string $title
* @param string $content
* @param array $maintenanceData
* @return array
*/
public function sendMaintenanceNotification(string $title, string $content, array $maintenanceData = []): array
{
return $this->broadcast(
$title,
$content,
Notification::TYPE_WARNING,
Notification::CATEGORY_ANNOUNCEMENT,
array_merge(['maintenance' => $maintenanceData], $maintenanceData)
);
}
/**
* 发送新消息通知
*
* @param int $userId
* @param string $title
* @param string $content
* @param array $messageData
* @return Notification
*/
public function sendNewMessageNotification(int $userId, string $title, string $content, array $messageData = []): Notification
{
return $this->sendToUser(
$userId,
$title,
$content,
Notification::TYPE_INFO,
Notification::CATEGORY_MESSAGE,
array_merge(['message' => $messageData], $messageData)
);
}
/**
* 发送提醒通知
*
* @param int $userId
* @param string $title
* @param string $content
* @param array $reminderData
* @return Notification
*/
public function sendReminderNotification(int $userId, string $title, string $content, array $reminderData = []): Notification
{
return $this->sendToUser(
$userId,
$title,
$content,
Notification::TYPE_WARNING,
Notification::CATEGORY_REMINDER,
array_merge(['reminder' => $reminderData], $reminderData)
);
}
}

View File

@@ -3,10 +3,25 @@
namespace App\Services\System;
use App\Models\System\Task;
use App\Services\System\NotificationService;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Log;
class TaskService
{
/**
* @var NotificationService
*/
protected $notificationService;
/**
* TaskService constructor
*/
public function __construct(NotificationService $notificationService)
{
$this->notificationService = $notificationService;
}
public function getList(array $params): array
{
$query = Task::query();
@@ -144,6 +159,9 @@ class TaskService
'last_output' => substr($output, 0, 10000),
]);
// 发送任务执行结果通知
$this->sendTaskNotification($task, $status, $errorMessage, $executionTime);
return [
'status' => $status,
'output' => $output,
@@ -164,4 +182,110 @@ class TaskService
'inactive' => $inactive,
];
}
/**
* 发送任务执行结果通知
*
* @param Task $task
* @param string $status
* @param string|null $errorMessage
* @param int $executionTime
* @return void
*/
protected function sendTaskNotification(Task $task, string $status, ?string $errorMessage, int $executionTime): void
{
try {
// 只对失败的任务或重要的成功任务发送通知
// 这里可以根据实际需求调整通知策略
if ($status === 'error') {
// 任务失败通知
$title = '任务执行失败: ' . $task->name;
$content = sprintf(
"任务 %s 执行失败,错误信息:%s\n执行时间:%d 毫秒",
$task->name,
$errorMessage ?: '未知错误',
$executionTime
);
// 获取管理员用户ID列表
$adminUserIds = $this->getAdminUserIds();
if (!empty($adminUserIds)) {
$this->notificationService->sendToUsers(
$adminUserIds,
$title,
$content,
\App\Models\System\Notification::TYPE_ERROR,
\App\Models\System\Notification::CATEGORY_TASK,
[
'task_id' => $task->id,
'task_name' => $task->name,
'command' => $task->command,
'error_message' => $errorMessage,
'execution_time' => $executionTime,
'last_run_at' => $task->last_run_at?->toDateTimeString()
]
);
}
} elseif ($executionTime > 60000) {
// 执行时间超过1分钟的成功任务发送通知
$title = '任务执行完成: ' . $task->name;
$content = sprintf(
"任务 %s 执行成功,耗时:%.2f 秒",
$task->name,
$executionTime / 1000
);
$adminUserIds = $this->getAdminUserIds();
if (!empty($adminUserIds)) {
$this->notificationService->sendToUsers(
$adminUserIds,
$title,
$content,
\App\Models\System\Notification::TYPE_SUCCESS,
\App\Models\System\Notification::CATEGORY_TASK,
[
'task_id' => $task->id,
'task_name' => $task->name,
'execution_time' => $executionTime,
'last_run_at' => $task->last_run_at?->toDateTimeString()
]
);
}
}
} catch (\Exception $e) {
Log::error('发送任务通知失败', [
'task_id' => $task->id,
'error' => $e->getMessage()
]);
}
}
/**
* 获取管理员用户ID列表
*
* @return array
*/
protected function getAdminUserIds(): array
{
try {
// 获取拥有管理员权限的用户
// 这里可以根据实际业务逻辑调整,例如获取特定角色的用户
$adminUserIds = \App\Models\Auth\User::where('status', 1)
->whereHas('roles', function ($query) {
$query->where('name', 'admin');
})
->pluck('id')
->toArray();
return $adminUserIds;
} catch (\Exception $e) {
Log::error('获取管理员用户列表失败', [
'error' => $e->getMessage()
]);
return [];
}
}
}

View File

@@ -21,6 +21,16 @@ class WebSocketHandler implements WebSocketHandlerInterface
*/
protected $userOnlineService;
/**
* Get wsTable instance
*
* @return \Swoole\Table
*/
protected function getWsTable(): \Swoole\Table
{
return app('swoole')->wsTable;
}
/**
* WebSocketHandler constructor
*/
@@ -54,12 +64,12 @@ class WebSocketHandler implements WebSocketHandlerInterface
if ($userId && $token) {
// Store user connection mapping
$server->wsTable->set('uid:' . $userId, [
$this->getWsTable()->set('uid:' . $userId, [
'value' => $fd,
'expiry' => time() + 3600, // 1 hour expiry
]);
$server->wsTable->set('fd:' . $fd, [
$this->getWsTable()->set('fd:' . $fd, [
'value' => $userId,
'expiry' => time() + 3600
]);
@@ -231,7 +241,7 @@ class WebSocketHandler implements WebSocketHandlerInterface
$token = $data['token'] ?? null;
// Get the user ID from wsTable (set during connection)
$storedUserId = $server->wsTable->get('fd:' . $fd)['value'] ?? null;
$storedUserId = $this->getWsTable()->get('fd:' . $fd)['value'] ?? null;
if ($storedUserId && $storedUserId == $userId) {
// Authentication confirmed, send success response
@@ -291,13 +301,13 @@ class WebSocketHandler implements WebSocketHandlerInterface
}
// Get target user's connection
$targetFd = $server->wsTable->get('uid:' . $toUserId);
$targetFd = $this->getWsTable()->get('uid:' . $toUserId);
if ($targetFd && $targetFd['value']) {
$server->push((int)$targetFd['value'], json_encode([
'type' => 'chat',
'data' => [
'from_user_id' => $server->wsTable->get('fd:' . $fd)['value'] ?? null,
'from_user_id' => $this->getWsTable()->get('fd:' . $fd)['value'] ?? null,
'content' => $content,
'timestamp' => time()
]
@@ -334,7 +344,7 @@ class WebSocketHandler implements WebSocketHandlerInterface
protected function handleBroadcast(Server $server, int $fd, array $data): void
{
$message = $data['message'] ?? '';
$userId = $server->wsTable->get('fd:' . $fd)['value'] ?? null;
$userId = $this->getWsTable()->get('fd:' . $fd)['value'] ?? null;
// TODO: Check if user has admin permission to broadcast
// For now, allow any authenticated user
@@ -400,7 +410,7 @@ class WebSocketHandler implements WebSocketHandlerInterface
}
// Store subscription in wsTable
$server->wsTable->set('channel:' . $channel . ':fd:' . $fd, [
$this->getWsTable()->set('channel:' . $channel . ':fd:' . $fd, [
'value' => 1,
'expiry' => time() + 7200 // 2 hours
]);
@@ -443,7 +453,7 @@ class WebSocketHandler implements WebSocketHandlerInterface
}
// Remove subscription from wsTable
$server->wsTable->del('channel:' . $channel . ':fd:' . $fd);
$this->getWsTable()->del('channel:' . $channel . ':fd:' . $fd);
$server->push($fd, json_encode([
'type' => 'unsubscribed',
@@ -476,12 +486,12 @@ class WebSocketHandler implements WebSocketHandlerInterface
]);
// Get user ID from wsTable
$userId = $server->wsTable->get('fd:' . $fd)['value'] ?? null;
$userId = $this->getWsTable()->get('fd:' . $fd)['value'] ?? null;
if ($userId) {
// Remove user connection mapping
$server->wsTable->del('uid:' . $userId);
$server->wsTable->del('fd:' . $fd);
$this->getWsTable()->del('uid:' . $userId);
$this->getWsTable()->del('fd:' . $fd);
// Update user online status
$this->userOnlineService->updateUserOnlineStatus($userId, $fd, false);

View File

@@ -50,12 +50,14 @@ class WebSocketService
{
$server = $this->getServer();
if (!$server || !isset($server->wsTable)) {
if (!$server) {
Log::warning('WebSocket server not available', ['user_id' => $userId]);
return false;
}
$fdInfo = $server->wsTable->get('uid:' . $userId);
$wsTable = app('swoole')->wsTable;
$fdInfo = $wsTable->get('uid:' . $userId);
if (!$fdInfo || !$fdInfo['value']) {
Log::info('User not connected to WebSocket', ['user_id' => $userId]);
@@ -67,8 +69,8 @@ class WebSocketService
if (!$server->isEstablished($fd)) {
Log::info('WebSocket connection not established', ['user_id' => $userId, 'fd' => $fd]);
// Clean up stale connection
$server->wsTable->del('uid:' . $userId);
$server->wsTable->del('fd:' . $fd);
$wsTable->del('uid:' . $userId);
$wsTable->del('fd:' . $fd);
return false;
}
@@ -119,6 +121,7 @@ class WebSocketService
return 0;
}
$wsTable = app('swoole')->wsTable;
$message = json_encode($data);
$count = 0;
@@ -129,7 +132,7 @@ class WebSocketService
// Check if we should exclude this user
if ($excludeUserId) {
$fdInfo = $server->wsTable->get('fd:' . $fd);
$fdInfo = $wsTable->get('fd:' . $fd);
if ($fdInfo && $fdInfo['value'] == $excludeUserId) {
continue;
}
@@ -159,11 +162,12 @@ class WebSocketService
{
$server = $this->getServer();
if (!$server || !isset($server->wsTable)) {
if (!$server) {
Log::warning('WebSocket server not available for channel broadcast', ['channel' => $channel]);
return 0;
}
$wsTable = app('swoole')->wsTable;
$count = 0;
$message = json_encode($data);
@@ -173,7 +177,7 @@ class WebSocketService
continue;
}
$subscription = $server->wsTable->get('channel:' . $channel . ':fd:' . $fd);
$subscription = $wsTable->get('channel:' . $channel . ':fd:' . $fd);
if ($subscription) {
$server->push($fd, $message);
@@ -224,11 +228,13 @@ class WebSocketService
{
$server = $this->getServer();
if (!$server || !isset($server->wsTable)) {
if (!$server) {
return false;
}
$fdInfo = $server->wsTable->get('uid:' . $userId);
$wsTable = app('swoole')->wsTable;
$fdInfo = $wsTable->get('uid:' . $userId);
if (!$fdInfo || !$fdInfo['value']) {
return false;
@@ -249,11 +255,13 @@ class WebSocketService
{
$server = $this->getServer();
if (!$server || !isset($server->wsTable)) {
if (!$server) {
return false;
}
$fdInfo = $server->wsTable->get('uid:' . $userId);
$wsTable = app('swoole')->wsTable;
$fdInfo = $wsTable->get('uid:' . $userId);
if (!$fdInfo || !$fdInfo['value']) {
return false;
@@ -274,8 +282,8 @@ class WebSocketService
$server->disconnect($fd);
// Clean up
$server->wsTable->del('uid:' . $userId);
$server->wsTable->del('fd:' . $fd);
$wsTable->del('uid:' . $userId);
$wsTable->del('fd:' . $fd);
Log::info('User disconnected from WebSocket by server', [
'user_id' => $userId,
@@ -297,10 +305,12 @@ class WebSocketService
{
$server = $this->getServer();
if (!$server || !isset($server->wsTable)) {
if (!$server) {
return [];
}
$wsTable = app('swoole')->wsTable;
$userIds = [];
foreach ($server->connections as $fd) {
@@ -308,7 +318,7 @@ class WebSocketService
continue;
}
$fdInfo = $server->wsTable->get('fd:' . $fd);
$fdInfo = $wsTable->get('fd:' . $fd);
if ($fdInfo && $fdInfo['value']) {
$userIds[] = (int)$fdInfo['value'];

View File

@@ -292,7 +292,7 @@ return [
'swoole' => [
'daemonize' => env('LARAVELS_DAEMONIZE', false),
'dispatch_mode' => env('LARAVELS_DISPATCH_MODE', 3),
'dispatch_mode' => env('LARAVELS_DISPATCH_MODE', 2),
'worker_num' => env('LARAVELS_WORKER_NUM', 30),
//'task_worker_num' => env('LARAVELS_TASK_WORKER_NUM', 10),
'task_ipc_mode' => 1,

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
// 系统通知表
Schema::create('system_notifications', function (Blueprint $table) {
$table->comment('系统通知表');
$table->id();
$table->unsignedBigInteger('user_id')->comment('用户ID');
$table->string('title')->comment('通知标题');
$table->text('content')->comment('通知内容');
$table->string('type')->default('info')->comment('通知类型info, success, warning, error, task, system');
$table->string('category')->nullable()->comment('通知分类system, task, message, reminder, announcement');
$table->json('data')->nullable()->comment('附加数据(JSON格式)');
$table->string('action_type')->nullable()->comment('操作类型link, modal, none');
$table->text('action_data')->nullable()->comment('操作数据');
$table->boolean('is_read')->default(false)->comment('是否已读');
$table->timestamp('read_at')->nullable()->comment('阅读时间');
$table->boolean('sent_via_websocket')->default(false)->comment('是否已通过WebSocket发送');
$table->timestamp('sent_at')->nullable()->comment('发送时间');
$table->integer('retry_count')->default(0)->comment('重试次数');
$table->timestamps();
$table->softDeletes();
$table->index('user_id');
$table->index('is_read');
$table->index('type');
$table->index('category');
$table->index('created_at');
});
}
public function down()
{
Schema::dropIfExists('system_notifications');
}
};

View File

@@ -1,684 +0,0 @@
# WebSocket 功能文档
## 概述
本项目基于 Laravel-S 和 Swoole 实现了完整的 WebSocket 功能,支持实时通信、消息推送、广播等功能。
## 功能特性
- ✅ 实时双向通信
- ✅ 用户连接管理
- ✅ 点对点消息发送
- ✅ 群发消息/广播
- ✅ 频道订阅/取消订阅
- ✅ 心跳机制
- ✅ 自动重连
- ✅ 在线状态管理
- ✅ 系统通知推送
- ✅ 数据更新推送
## 架构设计
### 后端组件
#### 1. WebSocketHandler (`app/Services/WebSocket/WebSocketHandler.php`)
WebSocket 处理器,实现了 Swoole 的 `WebSocketHandlerInterface` 接口。
**主要方法:**
- `onOpen()`: 处理连接建立事件
- `onMessage()`: 处理消息接收事件
- `onClose()`: 处理连接关闭事件
**支持的消息类型:**
- `ping/pong`: 心跳检测
- `heartbeat`: 心跳确认
- `chat`: 私聊消息
- `broadcast`: 广播消息
- `subscribe/unsubscribe`: 频道订阅/取消订阅
#### 2. WebSocketService (`app/Services/WebSocket/WebSocketService.php`)
WebSocket 服务类,提供便捷的 WebSocket 操作方法。
**主要方法:**
- `sendToUser($userId, $data)`: 发送消息给指定用户
- `sendToUsers($userIds, $data)`: 发送消息给多个用户
- `broadcast($data, $excludeUserId)`: 广播消息给所有用户
- `sendToChannel($channel, $data)`: 发送消息给指定频道
- `getOnlineUserCount()`: 获取在线用户数
- `isUserOnline($userId)`: 检查用户是否在线
- `sendSystemNotification()`: 发送系统通知
- `pushDataUpdate()`: 推送数据更新
#### 3. WebSocketController (`app/Http/Controllers/System/WebSocket.php`)
WebSocket API 控制器,提供 HTTP 接口用于管理 WebSocket 连接。
### 前端组件
#### WebSocketClient (`resources/admin/src/utils/websocket.js`)
WebSocket 客户端封装类。
**功能:**
- 自动连接和重连
- 心跳机制
- 消息类型路由
- 事件监听
- 连接状态管理
## 配置说明
### Laravel-S 配置 (`config/laravels.php`)
```php
'websocket' => [
'enable' => env('LARAVELS_WEBSOCKET', true),
'handler' => \App\Services\WebSocket\WebSocketHandler::class,
],
'swoole_tables' => [
'wsTable' => [
'size' => 102400,
'column' => [
['name' => 'value', 'type' => \Swoole\Table::TYPE_STRING, 'size' => 1024],
['name' => 'expiry', 'type' => \Swoole\Table::TYPE_INT, 'size' => 4],
],
],
],
```
### 环境变量
`.env` 文件中添加:
```env
LARAVELS_WEBSOCKET=true
```
## API 接口
### 1. 获取在线用户数
```
GET /admin/websocket/online-count
```
**响应:**
```json
{
"code": 200,
"message": "success",
"data": {
"online_count": 10
}
}
```
### 2. 获取在线用户列表
```
GET /admin/websocket/online-users
```
**响应:**
```json
{
"code": 200,
"message": "success",
"data": {
"user_ids": [1, 2, 3, 4, 5],
"count": 5
}
}
```
### 3. 检查用户在线状态
```
POST /admin/websocket/check-online
```
**请求参数:**
```json
{
"user_id": 1
}
```
**响应:**
```json
{
"code": 200,
"message": "success",
"data": {
"user_id": 1,
"is_online": true
}
}
```
### 4. 发送消息给指定用户
```
POST /admin/websocket/send-to-user
```
**请求参数:**
```json
{
"user_id": 1,
"type": "notification",
"data": {
"title": "新消息",
"message": "您有一条新消息"
}
}
```
### 5. 发送消息给多个用户
```
POST /admin/websocket/send-to-users
```
**请求参数:**
```json
{
"user_ids": [1, 2, 3],
"type": "notification",
"data": {
"title": "系统通知",
"message": "系统将在今晚进行维护"
}
}
```
### 6. 广播消息
```
POST /admin/websocket/broadcast
```
**请求参数:**
```json
{
"type": "notification",
"data": {
"title": "公告",
"message": "欢迎使用新版本"
},
"exclude_user_id": 1 // 可选:排除某个用户
}
```
### 7. 发送消息到频道
```
POST /admin/websocket/send-to-channel
```
**请求参数:**
```json
{
"channel": "orders",
"type": "data_update",
"data": {
"order_id": 123,
"status": "paid"
}
}
```
### 8. 发送系统通知
```
POST /admin/websocket/send-notification
```
**请求参数:**
```json
{
"title": "系统维护",
"message": "系统将于今晚 23:00-24:00 进行维护",
"type": "warning",
"extra_data": {
"start_time": "23:00",
"end_time": "24:00"
}
}
```
### 9. 发送通知给指定用户
```
POST /admin/websocket/send-notification-to-users
```
**请求参数:**
```json
{
"user_ids": [1, 2, 3],
"title": "订单更新",
"message": "您的订单已发货",
"type": "success"
}
```
### 10. 推送数据更新
```
POST /admin/websocket/push-data-update
```
**请求参数:**
```json
{
"user_ids": [1, 2, 3],
"resource_type": "order",
"action": "update",
"data": {
"id": 123,
"status": "shipped"
}
}
```
### 11. 推送数据更新到频道
```
POST /admin/websocket/push-data-update-channel
```
**请求参数:**
```json
{
"channel": "orders",
"resource_type": "order",
"action": "create",
"data": {
"id": 124,
"customer": "张三",
"amount": 100.00
}
}
```
### 12. 断开用户连接
```
POST /admin/websocket/disconnect-user
```
**请求参数:**
```json
{
"user_id": 1
}
```
## 前端使用示例
### 1. 基本连接
```javascript
import { getWebSocket, closeWebSocket } from '@/utils/websocket'
import { useUserStore } from '@/stores/modules/user'
const userStore = useUserStore()
// 连接 WebSocket
const ws = getWebSocket(userStore.userInfo.id, userStore.token, {
onOpen: (event) => {
console.log('WebSocket 已连接')
},
onMessage: (message) => {
console.log('收到消息:', message)
},
onError: (error) => {
console.error('WebSocket 错误:', error)
},
onClose: (event) => {
console.log('WebSocket 已关闭')
}
})
// 连接
ws.connect()
```
### 2. 监听特定消息类型
```javascript
// 监听通知消息
ws.on('notification', (data) => {
message.success(data.title, data.message)
})
// 监听数据更新
ws.on('data_update', (data) => {
console.log('数据更新:', data.resource_type, data.action)
// 刷新数据
loadData()
})
```
### 3. 发送消息
```javascript
// 发送心跳
ws.send('heartbeat', { timestamp: Date.now() })
// 发送私聊消息
ws.send('chat', {
to_user_id: 2,
content: '你好,这是一条私聊消息'
})
// 订阅频道
ws.send('subscribe', { channel: 'orders' })
// 取消订阅
ws.send('unsubscribe', { channel: 'orders' })
```
### 4. 发送广播消息
```javascript
ws.send('broadcast', {
message: '这是一条广播消息'
})
```
### 5. 断开连接
```javascript
// 断开连接
ws.disconnect()
// 或使用全局方法
closeWebSocket()
```
### 6. 在 Vue 组件中使用
```vue
<template>
<div>
<a-button @click="connectWebSocket">连接 WebSocket</a-button>
<a-button @click="disconnectWebSocket">断开连接</a-button>
<a-button @click="sendMessage">发送消息</a-button>
<div>连接状态: {{ connectionStatus }}</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { getWebSocket } from '@/utils/websocket'
import { useUserStore } from '@/stores/modules/user'
const userStore = useUserStore()
const ws = ref(null)
const connectionStatus = ref('未连接')
const connectWebSocket = () => {
ws.value = getWebSocket(userStore.userInfo.id, userStore.token, {
onOpen: () => {
connectionStatus.value = '已连接'
},
onMessage: (message) => {
handleMessage(message)
},
onClose: () => {
connectionStatus.value = '已断开'
}
})
ws.value.connect()
}
const disconnectWebSocket = () => {
if (ws.value) {
ws.value.disconnect()
connectionStatus.value = '已断开'
}
}
const sendMessage = () => {
if (ws.value && ws.value.isConnected) {
ws.value.send('chat', {
to_user_id: 2,
content: '测试消息'
})
}
}
const handleMessage = (message) => {
switch (message.type) {
case 'notification':
message.success(message.data.title, message.data.message)
break
case 'data_update':
// 处理数据更新
break
case 'chat':
// 处理聊天消息
break
}
}
onMounted(() => {
connectWebSocket()
})
onUnmounted(() => {
disconnectWebSocket()
})
</script>
```
## 消息格式
### 服务端发送的消息格式
```json
{
"type": "notification",
"data": {
"title": "标题",
"message": "内容",
"type": "info",
"timestamp": 1641234567
}
}
```
### 客户端发送的消息格式
```json
{
"type": "chat",
"data": {
"to_user_id": 2,
"content": "消息内容"
}
}
```
## 启动和停止
### 启动 Laravel-S 服务
```bash
php bin/laravels start
```
### 停止 Laravel-S 服务
```bash
php bin/laravels stop
```
### 重启 Laravel-S 服务
```bash
php bin/laravels restart
```
### 重载 Laravel-S 服务(平滑重启)
```bash
php bin/laravels reload
```
### 查看服务状态
```bash
php bin/laravels status
```
## WebSocket 连接地址
### 开发环境
```
ws://localhost:5200/ws?user_id={user_id}&token={token}
```
### 生产环境
```
wss://yourdomain.com/ws?user_id={user_id}&token={token}
```
## Nginx 配置示例
```nginx
server {
listen 80;
server_name yourdomain.com;
root /path/to/your/project/public;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# WebSocket 代理配置
location /ws {
proxy_pass http://127.0.0.1:5200;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
```
## 使用场景
### 1. 实时通知
```php
// 发送系统通知
$webSocketService->sendSystemNotification(
'系统维护',
'系统将于今晚进行维护',
'warning'
);
```
### 2. 订单状态更新
```php
// 推送订单状态更新给相关人员
$webSocketService->pushDataUpdate(
[$order->user_id],
'order',
'update',
[
'id' => $order->id,
'status' => $order->status,
'updated_at' => $order->updated_at
]
);
```
### 3. 实时聊天
```javascript
// 发送私聊消息
ws.send('chat', {
to_user_id: 2,
content: '你好'
})
```
### 4. 数据监控
```php
// 推送系统监控数据到特定频道
$webSocketService->sendToChannel('system_monitor', 'monitor', [
'cpu_usage' => 75,
'memory_usage' => 80,
'disk_usage' => 60
]);
```
## 注意事项
1. **连接认证**: WebSocket 连接时需要提供 `user_id``token` 参数
2. **心跳机制**: 客户端默认每 30 秒发送一次心跳
3. **自动重连**: 连接断开后会自动尝试重连,最多重试 5 次
4. **并发限制**: Swoole Table 最多支持 102,400 个连接
5. **内存管理**: 注意内存泄漏问题,定期重启服务
6. **安全性**: 生产环境建议使用 WSS (WebSocket Secure)
7. **日志监控**: 查看日志文件 `storage/logs/swoole-YYYY-MM.log`
## 故障排查
### 1. 无法连接 WebSocket
- 检查 Laravel-S 服务是否启动
- 检查端口 5200 是否被占用
- 检查防火墙设置
- 查看日志文件
### 2. 连接频繁断开
- 检查网络稳定性
- 调整心跳间隔
- 检查服务器资源使用情况
### 3. 消息发送失败
- 检查用户是否在线
- 检查消息格式是否正确
- 查看错误日志
## 参考资料
- [Laravel-S 文档](https://github.com/hhxsv5/laravel-s)
- [Swoole 文档](https://www.swoole.com/)
- [WebSocket API](https://developer.mozilla.org/zh-CN/docs/Web/API/WebSocket)
## 更新日志
### 2024-02-08
- ✅ 初始版本发布
- ✅ 实现基础 WebSocket 功能
- ✅ 实现消息推送功能
- ✅ 实现频道订阅功能
- ✅ 实现前端客户端封装
- ✅ 实现管理 API 接口

File diff suppressed because it is too large Load Diff

View File

@@ -1,444 +0,0 @@
# WebSocket 功能文档
## 概述
本系统使用 WebSocket 实现实时通信功能,支持消息推送、数据更新通知等实时功能。
## 技术栈
- **前端**: 原生 WebSocket API + Vue 3 Composable
- **后端**: Laravel-S (Swoole) WebSocket Server
- **协议**: WS/WSS
## 功能特性
### 1. 自动连接管理
- 登录后自动建立 WebSocket 连接
- 用户信息加载完成后自动重试连接
- 支持断线自动重连(最多 5 次)
- 退出登录时自动关闭连接
### 2. 消息推送
- 系统通知推送
- 数据更新通知
- 字典数据更新通知
- 任务提醒
### 3. 心跳机制
- 客户端每 30 秒发送心跳
- 保持连接活跃状态
- 检测连接状态
### 4. 消息管理
- 消息持久化存储localStorage
- 未读消息计数
- 消息分类筛选
- 消息分页显示
- 标记已读/删除消息
## 前端使用
### 1. 在组件中使用 WebSocket
```javascript
import { useWebSocket } from '@/composables/useWebSocket'
const { initWebSocket, closeWebSocket, isConnected, send } = useWebSocket()
// 初始化连接
onMounted(() => {
initWebSocket()
})
// 检查连接状态
if (isConnected()) {
console.log('WebSocket 已连接')
}
// 发送消息
send('message_type', { data: 'your data' })
// 关闭连接
onUnmounted(() => {
closeWebSocket()
})
```
### 2. 在 App.vue 中自动初始化
WebSocket 已在 App.vue 中自动集成,无需手动调用:
- 监听用户信息变化,自动初始化连接
- 组件卸载时自动关闭连接
- 消息数据自动恢复
### 3. 使用消息 Store
```javascript
import { useMessageStore } from '@/stores/modules/message'
const messageStore = useMessageStore()
// 添加消息
messageStore.addMessage({
type: 'notification',
title: '系统通知',
content: '这是一条通知'
})
// 标记已读
messageStore.markAsRead(messageId)
// 标记所有已读
messageStore.markAllAsRead()
// 删除消息
messageStore.removeMessage(messageId)
// 清空所有消息
messageStore.clearAll()
// 获取消息列表(分页)
const { list, total, page } = messageStore.getMessages({
page: 1,
pageSize: 10,
type: 'notification' // 可选:按类型过滤
})
// 格式化消息时间
const timeStr = messageStore.formatMessageTime(timestamp)
```
## 消息类型
### 支持的消息类型
| 类型 | 说明 | 枚举值 |
|------|------|--------|
| 系统通知 | 系统级别的通知消息 | `notification` |
| 任务提醒 | 任务相关提醒 | `task` |
| 警告消息 | 警告类消息 | `warning` |
| 错误消息 | 错误类消息 | `error` |
| 成功消息 | 成功类消息 | `success` |
| 信息消息 | 一般信息 | `info` |
### 消息优先级
| 优先级 | 说明 | 枚举值 |
|--------|------|--------|
| 低 | 低优先级消息 | `low` |
| 中 | 中等优先级消息 | `medium` |
| 高 | 高优先级消息 | `high` |
| 紧急 | 紧急消息 | `urgent` |
## WebSocket 消息格式
### 客户端发送格式
```json
{
"type": "message_type",
"data": {
"key": "value"
}
}
```
### 服务端推送格式
```json
{
"type": "notification",
"data": {
"title": "消息标题",
"content": "消息内容",
"type": "success",
"timestamp": 1234567890
}
}
```
### 数据更新消息格式
```json
{
"type": "data_update",
"data": {
"resource_type": "dictionary",
"action": "update",
"data": {},
"timestamp": 1234567890
}
}
```
## 后端使用
### 1. 发送消息给特定用户
```php
use App\Services\WebSocket\WebSocketService;
$wsService = app(WebSocketService::class);
// 发送给单个用户
$wsService->sendToUser($userId, [
'type' => 'notification',
'data' => [
'title' => '系统通知',
'content' => '这是一条通知',
'type' => 'info',
'timestamp' => time()
]
]);
// 发送给多个用户
$userIds = [1, 2, 3];
$sentTo = $wsService->sendToUsers($userIds, $data);
```
### 2. 广播消息
```php
// 广播给所有在线用户
$count = $wsService->broadcast([
'type' => 'notification',
'data' => [
'title' => '系统维护通知',
'content' => '系统将在 10 分钟后进行维护',
'type' => 'warning',
'timestamp' => time()
]
]);
// 广播给所有在线用户(排除指定用户)
$count = $wsService->broadcast($data, $excludeUserId);
```
### 3. 发送系统通知
```php
// 发送系统通知
$count = $wsService->sendSystemNotification(
'新版本发布',
'系统已更新到 v2.0',
'success'
);
// 发送通知给特定用户
$count = $wsService->sendNotificationToUsers(
[1, 2, 3],
'任务分配',
'您有新的待处理任务',
'task'
);
```
### 4. 发送数据更新通知
```php
// 发送数据更新给特定用户
$userIds = [1, 2, 3];
$sentTo = $wsService->pushDataUpdate(
$userIds,
'dictionary',
'update',
['id' => 1, 'name' => 'test']
);
// 发送数据更新到频道
$count = $wsService->pushDataUpdateToChannel(
'system_admin',
'user',
'create',
['id' => 10, 'username' => 'newuser']
);
```
### 5. 检查用户在线状态
```php
// 检查用户是否在线
$isOnline = $wsService->isUserOnline($userId);
// 获取在线用户数量
$count = $wsService->getOnlineUserCount();
// 获取所有在线用户 ID
$userIds = $wsService->getOnlineUserIds();
// 强制断开用户连接
$wsService->disconnectUser($userId);
```
## 顶部消息组件
### 功能说明
顶部消息组件位于 `layouts/components/userbar.vue`,提供以下功能:
1. **消息通知铃铛**
- 显示未读消息数量
- 点击打开消息列表
2. **消息列表**
- 按类型筛选消息(全部/通知/任务/警告)
- 显示消息标题、内容、时间
- 点击消息标记为已读
- 悬浮显示删除按钮
- 分页浏览
3. **操作按钮**
- 全部标为已读
- 清空全部消息
### 消息样式
- **未读消息**: 蓝色背景 + 左侧蓝条 + 红点标记
- **已读消息**: 普通样式
- **删除按钮**: 悬浮时显示
## 配置说明
### WebSocket URL 配置
`resources/admin/src/config/index.js` 中配置:
```javascript
export default {
// WebSocket URL如果不配置则使用当前域名
WS_URL: 'ws://localhost:8080',
// 其他配置...
}
```
### 后端 WebSocket 配置
`config/laravels.php` 中配置:
```php
'swoole' => [
'enable_coroutine' => true,
'worker_num' => 4,
'max_request' => 5000,
'max_request_grace' => 500,
// ... 其他配置
]
```
## 故障排查
### 1. WebSocket 连接失败
**可能原因**:
- WebSocket 服务未启动
- 端口被占用
- 防火墙阻止连接
- URL 配置错误
**解决方法**:
```bash
# 检查 Laravel-S 是否运行
php bin/laravels status
# 启动 Laravel-S
php bin/laravels start
# 检查端口是否被占用
netstat -ano | findstr :8080
```
### 2. 登录后 WebSocket 未连接
**可能原因**:
- 用户信息未加载完成
- token 无效
**解决方法**:
- 检查控制台日志
- 确认 `userStore.isUserInfoComplete()` 返回 true
- 查看 `getWebSocket` 调用参数
### 3. 消息未收到
**可能原因**:
- 消息处理器未注册
- 消息类型不匹配
- 网络问题
**解决方法**:
- 检查 `useWebSocket.js` 中的消息处理器注册
- 确认消息类型格式正确
- 查看网络面板 WebSocket 帧
## 开发建议
### 1. 测试 WebSocket 功能
```javascript
// 在浏览器控制台测试
import { useWebSocket } from '@/composables/useWebSocket'
import { useMessageStore } from '@/stores/modules/message'
const { send } = useWebSocket()
const messageStore = useMessageStore()
// 手动添加测试消息
messageStore.addMessage({
type: 'notification',
title: '测试消息',
content: '这是一条测试消息',
timestamp: Date.now()
})
// 发送测试消息到服务器
send('test', { message: 'hello' })
```
### 2. 添加新的消息类型
1. 在 `message.js` store 中添加类型枚举
2. 在 `userbar.vue` 中添加对应的 Tab
3. 在 `i18n` 中添加翻译
4. 在 `useWebSocket.js` 中添加处理逻辑
### 3. 自定义消息处理
```javascript
// 在 useWebSocket.js 中注册自定义处理器
ws.value.on('custom_event', handleCustomEvent)
function handleCustomEvent(data) {
console.log('收到自定义消息:', data)
// 处理逻辑
}
```
## 性能优化
1. **消息限制**: 最多存储 100 条消息,超出后自动删除旧消息
2. **分页加载**: 消息列表使用分页,避免一次性加载过多数据
3. **心跳机制**: 保持连接活跃,减少不必要的重连
4. **延迟加载**: 用户信息加载完成后才初始化连接
## 安全考虑
1. **Token 验证**: WebSocket 连接时发送 token 进行验证
2. **用户隔离**: 每个用户只能接收自己的消息
3. **消息过滤**: 根据权限过滤敏感消息
4. **连接限制**: 限制单个用户的连接数量
## 更新日志
### v1.0.0 (2024-01-18)
- 初始版本
- 实现基础 WebSocket 连接功能
- 实现消息推送和接收
- 实现消息管理 Store
- 实现顶部消息组件
- 支持中英文国际化

View File

@@ -338,6 +338,75 @@ export default {
},
},
// 通知管理
notifications: {
list: {
get: async function (params) {
return await request.get('system/notifications', { params })
},
},
unread: {
get: async function (params) {
return await request.get('system/notifications/unread', { params })
},
},
unreadCount: {
get: async function () {
return await request.get('system/notifications/unread-count')
},
},
detail: {
get: async function (id) {
return await request.get(`system/notifications/${id}`)
},
},
markAsRead: {
post: async function (id) {
return await request.post(`system/notifications/${id}/read`)
},
},
batchMarkAsRead: {
post: async function (params) {
return await request.post('system/notifications/batch-read', params)
},
},
markAllAsRead: {
post: async function () {
return await request.post('system/notifications/read-all')
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/notifications/${id}`)
},
},
batchDelete: {
post: async function (params) {
return await request.post('system/notifications/batch-delete', params)
},
},
clearRead: {
post: async function () {
return await request.post('system/notifications/clear-read')
},
},
statistics: {
get: async function () {
return await request.get('system/notifications/statistics')
},
},
send: {
post: async function (params) {
return await request.post('system/notifications/send', params)
},
},
retryUnsent: {
post: async function (params) {
return await request.post('system/notifications/retry-unsent', params)
},
},
},
// 公共接口 (无需认证)
public: {
configs: {

View File

@@ -3,6 +3,7 @@ import { getWebSocket } from '@/utils/websocket'
import { useUserStore } from '@/stores/modules/user'
import { useMessageStore } from '@/stores/modules/message'
import { useDictionaryStore } from '@/stores/modules/dictionary'
import { useNotificationStore } from '@/stores/modules/notification'
import { message, notification } from 'ant-design-vue'
import config from '@/config'
@@ -16,6 +17,7 @@ export function useWebSocket() {
const userStore = useUserStore()
const messageStore = useMessageStore()
const dictionaryStore = useDictionaryStore()
const notificationStore = useNotificationStore()
const reconnectTimer = ref(null)
/**
@@ -167,9 +169,27 @@ export function useWebSocket() {
function handleNotification(data) {
console.log('收到系统通知:', data)
const { title, message: content, type, timestamp } = data
const { title, message: content, type, timestamp, ...rest } = data
// 添加到消息 store
// 构建通知数据格式
const notificationData = {
id: rest.id || Date.now(),
title: title || '系统通知',
content: content || '',
type: type || 'info',
category: rest.category || 'system',
is_read: false,
created_at: rest.created_at || new Date().toISOString(),
...rest
}
// 更新通知 store
notificationStore.handleWebSocketMessage({
type: 'notification',
data: notificationData
})
// 添加到消息 store保持兼容性
messageStore.addMessage({
type: type || 'notification',
title: title || '系统通知',

View File

@@ -8,7 +8,7 @@
</a-tooltip>
<!-- 消息通知 -->
<a-dropdown v-model:open="messageVisible" :trigger="['click']" placement="bottomRight">
<a-dropdown v-model:open="messageVisible" :trigger="['click']" placement="bottomRight" @openChange="handleMessageDropdownOpen">
<a-badge :count="messageCount" :offset="[-5, 5]">
<a-button type="text" class="action-btn">
<BellOutlined />
@@ -51,9 +51,9 @@
<div class="message-content">
<div class="message-title">{{ msg.title }}</div>
<div class="message-content-text">{{ msg.content }}</div>
<div class="message-time">{{ messageStore.formatMessageTime(msg.timestamp) }}</div>
<div class="message-time">{{ notificationStore.formatNotificationTime(msg.created_at) }}</div>
</div>
<a-badge v-if="!msg.read" dot />
<a-badge v-if="!msg.is_read" dot />
<a-button
type="text"
size="small"
@@ -160,11 +160,12 @@ import { useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import { useUserStore } from '@/stores/modules/user'
import { useI18nStore } from '@/stores/modules/i18n'
import { useMessageStore, MessageType } from '@/stores/modules/message'
import { useNotificationStore } from '@/stores/modules/notification'
import { DownOutlined, UserOutlined, LogoutOutlined, FullscreenOutlined, FullscreenExitOutlined, BellOutlined, CheckSquareOutlined, GlobalOutlined, SearchOutlined, SettingOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import { useI18n } from 'vue-i18n'
import search from './search.vue'
import task from './task.vue'
import { useWebSocket } from '@/composables/useWebSocket'
// 定义组件名称(多词命名)
defineOptions({
@@ -175,7 +176,7 @@ const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const i18nStore = useI18nStore()
const messageStore = useMessageStore()
const notificationStore = useNotificationStore()
const isFullscreen = ref(false)
const searchVisible = ref(false)
@@ -184,28 +185,16 @@ const messageVisible = ref(false)
const currentMessageType = ref('all')
const messagesPage = ref(1)
const messagesPageSize = ref(10)
// 从 store 获取消息数据
const messages = computed(() => {
const result = messageStore.getMessages({
page: messagesPage.value,
pageSize: messagesPageSize.value,
type: currentMessageType.value === 'all' ? null : currentMessageType.value
})
return result.list
})
// 消息总数(用于分页)
const messagesTotal = computed(() => {
return messageStore.getMessages({
page: messagesPage.value,
pageSize: messagesPageSize.value,
type: currentMessageType.value === 'all' ? null : currentMessageType.value
}).total
})
const notificationsList = ref([])
// 未读消息数量
const messageCount = computed(() => messageStore.unreadCount)
const messageCount = computed(() => notificationStore.unreadCount)
// 消息总数(用于分页)
const messagesTotal = computed(() => notificationStore.total)
// 从 store 获取消息数据
const messages = computed(() => notificationsList.value)
// 任务数据
const tasks = ref([
@@ -232,8 +221,58 @@ const handleFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement
}
// 加载未读通知
const loadNotifications = async () => {
try {
await notificationStore.fetchUnreadCount()
await loadUnreadNotifications()
} catch (error) {
console.error('加载通知失败:', error)
}
}
// 加载未读通知列表
const loadUnreadNotifications = async () => {
try {
const res = await notificationStore.fetchUnreadNotifications({
page: messagesPage.value,
page_size: messagesPageSize.value,
type: currentMessageType.value === 'all' ? null : currentMessageType.value
})
notificationsList.value = res.list || []
} catch (error) {
console.error('加载未读通知列表失败:', error)
}
}
// 加载所有通知
const loadAllNotifications = async () => {
try {
await notificationStore.fetchNotifications({
page: messagesPage.value,
page_size: messagesPageSize.value,
type: currentMessageType.value === 'all' ? null : currentMessageType.value
})
notificationsList.value = notificationStore.notifications
} catch (error) {
console.error('加载通知列表失败:', error)
}
}
// WebSocket 消息处理
const handleWebSocketMessage = (data) => {
notificationStore.handleWebSocketMessage(data)
}
onMounted(() => {
document.addEventListener('fullscreenchange', handleFullscreenChange)
// 加载通知数据
loadNotifications()
// 连接 WebSocket
const { initWebSocket } = useWebSocket()
initWebSocket()
})
onUnmounted(() => {
@@ -246,46 +285,113 @@ const showSearch = () => {
}
// 清除消息
const clearMessages = () => {
const clearMessages = async () => {
Modal.confirm({
title: t('common.confirmClear'),
content: t('common.confirmClearMessages'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk: () => {
messageStore.clearAll()
message.success(t('common.cleared'))
onOk: async () => {
try {
await notificationStore.clearReadNotifications()
message.success(t('common.cleared'))
notificationsList.value = []
} catch (error) {
console.error('清空消息失败:', error)
}
},
})
}
// 标记消息为已读
const handleMessageRead = (msg) => {
if (!msg.read) {
messageStore.markAsRead(msg.id)
const handleMessageRead = async (msg) => {
if (!msg.is_read) {
try {
await notificationStore.markAsRead(msg.id)
// 更新本地状态
const notification = notificationsList.value.find(n => n.id === msg.id)
if (notification) {
notification.is_read = true
notification.read_at = new Date().toISOString()
}
} catch (error) {
console.error('标记已读失败:', error)
}
}
}
// 标记所有消息为已读
const markAllAsRead = () => {
messageStore.markAllAsRead()
message.success(t('common.markedAsRead'))
const markAllAsRead = async () => {
Modal.confirm({
title: '确认全部已读',
content: '确定要将所有消息标记为已读吗?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
await notificationStore.markAllAsRead()
message.success(t('common.markedAsRead'))
// 更新本地状态
notificationsList.value.forEach(n => {
n.is_read = true
n.read_at = n.read_at || new Date().toISOString()
})
} catch (error) {
console.error('标记全部已读失败:', error)
}
}
})
}
// 删除消息
const handleDeleteMessage = (msgId) => {
messageStore.removeMessage(msgId)
const handleDeleteMessage = async (msgId) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这条消息吗?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
await notificationStore.deleteNotification(msgId)
message.success('删除成功')
// 更新本地状态
const index = notificationsList.value.findIndex(n => n.id === msgId)
if (index !== -1) {
notificationsList.value.splice(index, 1)
}
} catch (error) {
console.error('删除消息失败:', error)
}
}
})
}
// 切换消息类型
const changeMessageType = (type) => {
const changeMessageType = async (type) => {
currentMessageType.value = type
messagesPage.value = 1
if (type === 'all') {
await loadAllNotifications()
} else {
await loadUnreadNotifications()
}
}
// 分页变化
const handleMessagePageChange = (page) => {
const handleMessagePageChange = async (page) => {
messagesPage.value = page
if (currentMessageType.value === 'all') {
await loadAllNotifications()
} else {
await loadUnreadNotifications()
}
}
// 下拉框打开时加载数据
const handleMessageDropdownOpen = async (open) => {
if (open) {
await loadNotifications()
}
}
// 显示任务抽屉

View File

@@ -0,0 +1,643 @@
<template>
<div class="pages-base-layout system-notifications-page">
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input v-model:value="searchForm.keyword" placeholder="通知标题/内容" allow-clear style="width: 180px" />
<a-select
v-model:value="searchForm.is_read"
placeholder="阅读状态"
allow-clear
style="width: 120px"
>
<a-select-option :value="true">已读</a-select-option>
<a-select-option :value="false">未读</a-select-option>
</a-select>
<a-select
v-model:value="searchForm.type"
placeholder="通知类型"
allow-clear
style="width: 120px"
:options="typeOptions"
/>
<a-select
v-model:value="searchForm.category"
placeholder="通知分类"
allow-clear
style="width: 120px"
:options="categoryOptions"
/>
<a-button type="primary" @click="handleSearch">
<template #icon><search-outlined /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><redo-outlined /></template>
重置
</a-button>
</a-space>
</div>
<div class="right-panel">
<a-space>
<a-badge :count="unreadCount" :offset="[-5, 5]" :number-style="{ backgroundColor: '#f5222d' }">
<a-button @click="handleMarkAllRead">
<template #icon><check-outlined /></template>
全部已读
</a-button>
</a-badge>
<a-button @click="handleClearRead">
<template #icon><delete-outlined /></template>
清空已读
</a-button>
<a-dropdown>
<a-button>
批量操作
<down-outlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleBatchMarkRead">
<check-outlined />
批量已读
</a-menu-item>
<a-menu-item @click="handleBatchDelete">
<delete-outlined />
批量删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</div>
</div>
<div class="table-content">
<scTable
ref="tableRef"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
:row-selection="rowSelection"
:row-key="(record) => record.id"
@refresh="refreshTable"
@paginationChange="handlePaginationChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div class="notification-title" :class="{ unread: !record.is_read }" @click="handleViewDetail(record)">
<WarningOutlined v-if="!record.is_read" class="unread-icon" />
<span>{{ record.title }}</span>
</div>
</template>
<template v-if="column.key === 'type'">
<a-tag :color="getTypeColor(record.type)">
<component :is="getTypeIcon(record.type)" class="type-icon" />
{{ getTypeText(record.type) }}
</a-tag>
</template>
<template v-if="column.key === 'category'">
<a-tag color="blue">{{ getCategoryText(record.category) }}</a-tag>
</template>
<template v-if="column.key === 'is_read'">
<a-badge :status="record.is_read ? 'default' : 'processing'" :text="record.is_read ? '已读' : '未读'" />
</template>
<template v-if="column.key === 'created_at'">
<span>{{ formatTime(record.created_at) }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewDetail(record)">
<eye-outlined />
查看
</a-button>
<a-button
v-if="!record.is_read"
type="link"
size="small"
@click="handleMarkRead(record)"
>
<check-outlined />
标为已读
</a-button>
<a-button type="link" size="small" danger @click="handleDelete(record)">
<delete-outlined />
删除
</a-button>
</a-space>
</template>
</template>
</scTable>
</div>
<!-- 通知详情弹窗 -->
<a-drawer v-model:open="showDetailDrawer" title="通知详情" placement="right" width="600">
<template v-if="currentNotification">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="标题">
{{ currentNotification.title }}
</a-descriptions-item>
<a-descriptions-item label="类型">
<a-tag :color="getTypeColor(currentNotification.type)">
<component :is="getTypeIcon(currentNotification.type)" class="type-icon" />
{{ getTypeText(currentNotification.type) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="分类">
<a-tag color="blue">{{ getCategoryText(currentNotification.category) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-badge
:status="currentNotification.is_read ? 'default' : 'processing'"
:text="currentNotification.is_read ? '已读' : '未读'"
/>
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formatTime(currentNotification.created_at) }}
</a-descriptions-item>
<a-descriptions-item v-if="currentNotification.read_at" label="阅读时间">
{{ formatTime(currentNotification.read_at) }}
</a-descriptions-item>
</a-descriptions>
<div class="notification-content">
<div class="content-label">通知内容:</div>
<div class="content-text">{{ currentNotification.content }}</div>
</div>
<div v-if="currentNotification.data && Object.keys(currentNotification.data).length > 0" class="notification-data">
<div class="data-label">附加数据:</div>
<pre class="data-text">{{ JSON.stringify(currentNotification.data, null, 2) }}</pre>
</div>
<div v-if="currentNotification.action_type && currentNotification.action_type !== 'none'" class="notification-action">
<a-button type="primary" @click="handleAction">
<template #icon><arrow-right-outlined /></template>
{{ getActionText(currentNotification.action_type) }}
</a-button>
</div>
</template>
</a-drawer>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, computed, h } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
SearchOutlined,
RedoOutlined,
CheckOutlined,
DeleteOutlined,
DownOutlined,
EyeOutlined,
WarningOutlined,
InfoCircleOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
CloseCircleOutlined,
BellOutlined,
MessageOutlined,
ClockCircleOutlined,
BulbOutlined,
ArrowRightOutlined
} from '@ant-design/icons-vue'
import scTable from '@/components/scTable/index.vue'
import { useTable } from '@/hooks/useTable'
import systemApi from '@/api/system'
import { useWebSocket } from '@/composables/useWebSocket'
// 表格引用
const tableRef = ref(null)
// WebSocket
const ws = useWebSocket()
let unreadCountInterval = null
// 搜索表单
const searchForm = reactive({
keyword: '',
is_read: undefined,
type: undefined,
category: undefined
})
// 未读数量
const unreadCount = ref(0)
// 当前通知
const currentNotification = ref(null)
// 显示详情抽屉
const showDetailDrawer = ref(false)
// 通知类型选项
const typeOptions = [
{ label: '信息', value: 'info' },
{ label: '成功', value: 'success' },
{ label: '警告', value: 'warning' },
{ label: '错误', value: 'error' },
{ label: '任务', value: 'task' },
{ label: '系统', value: 'system' }
]
// 通知分类选项
const categoryOptions = [
{ label: '系统通知', value: 'system' },
{ label: '任务通知', value: 'task' },
{ label: '消息通知', value: 'message' },
{ label: '提醒通知', value: 'reminder' },
{ label: '公告通知', value: 'announcement' }
]
// 使用 useTable Hook
const { tableData, loading, pagination, rowSelection, handleSearch, handleReset, handlePaginationChange, refreshTable } =
useTable({
api: systemApi.notifications.list.get,
searchForm,
needPagination: true
})
// 表格列配置
const columns = [
{
title: '通知标题',
dataIndex: 'title',
key: 'title',
width: 300,
ellipsis: true
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 120,
align: 'center'
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 120,
align: 'center'
},
{
title: '状态',
dataIndex: 'is_read',
key: 'is_read',
width: 100,
align: 'center'
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180
},
{
title: '操作',
key: 'action',
width: 180,
fixed: 'right'
}
]
// 获取类型颜色
const getTypeColor = (type) => {
const colors = {
info: 'blue',
success: 'green',
warning: 'orange',
error: 'red',
task: 'purple',
system: 'cyan'
}
return colors[type] || 'default'
}
// 获取类型图标
const getTypeIcon = (type) => {
const icons = {
info: InfoCircleOutlined,
success: CheckCircleOutlined,
warning: ExclamationCircleOutlined,
error: CloseCircleOutlined,
task: BellOutlined,
system: MessageOutlined
}
return icons[type] || InfoCircleOutlined
}
// 获取类型文本
const getTypeText = (type) => {
const texts = {
info: '信息',
success: '成功',
warning: '警告',
error: '错误',
task: '任务',
system: '系统'
}
return texts[type] || type
}
// 获取分类文本
const getCategoryText = (category) => {
const texts = {
system: '系统通知',
task: '任务通知',
message: '消息通知',
reminder: '提醒通知',
announcement: '公告通知'
}
return texts[category] || category
}
// 获取操作文本
const getActionText = (actionType) => {
const texts = {
link: '查看详情',
modal: '打开弹窗',
none: ''
}
return texts[actionType] || '查看'
}
// 格式化时间
const formatTime = (time) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 获取未读数量
const loadUnreadCount = async () => {
try {
const res = await systemApi.notifications.unreadCount.get()
unreadCount.value = res.data.count
} catch (error) {
console.error('获取未读数量失败:', error)
}
}
// 查看详情
const handleViewDetail = async (record) => {
currentNotification.value = { ...record }
showDetailDrawer.value = true
// 自动标记为已读
if (!record.is_read) {
await handleMarkRead(record)
}
}
// 标记已读
const handleMarkRead = async (record) => {
try {
await systemApi.notifications.markAsRead.post(record.id)
message.success('已标记为已读')
if (!record.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
refreshTable()
} catch (error) {
message.error(error.message || '操作失败')
}
}
// 批量标记已读
const handleBatchMarkRead = () => {
const selectedRowKeys = rowSelection.selectedRowKeys
if (selectedRowKeys.length === 0) {
message.warning('请先选择要操作的通知')
return
}
Modal.confirm({
title: '确认标记为已读',
content: `确定要将选中的 ${selectedRowKeys.length} 条通知标记为已读吗?`,
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const res = await systemApi.notifications.batchMarkAsRead.post({ ids: selectedRowKeys })
message.success('批量标记成功')
rowSelection.selectedRowKeys = []
unreadCount.value = Math.max(0, unreadCount.value - res.data.count)
refreshTable()
} catch (error) {
message.error(error.message || '操作失败')
}
}
})
}
// 标记全部已读
const handleMarkAllRead = () => {
Modal.confirm({
title: '确认全部已读',
content: '确定要将所有未读通知标记为已读吗?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const res = await systemApi.notifications.markAllAsRead.post()
message.success('已标记全部为已读')
unreadCount.value = 0
refreshTable()
} catch (error) {
message.error(error.message || '操作失败')
}
}
})
}
// 删除
const handleDelete = (record) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除通知"${record.title}"吗?`,
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
await systemApi.notifications.delete.delete(record.id)
message.success('删除成功')
if (!record.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
refreshTable()
} catch (error) {
message.error(error.message || '删除失败')
}
}
})
}
// 批量删除
const handleBatchDelete = () => {
const selectedRowKeys = rowSelection.selectedRowKeys
if (selectedRowKeys.length === 0) {
message.warning('请先选择要删除的通知')
return
}
Modal.confirm({
title: '确认删除',
content: `确定要删除选中的 ${selectedRowKeys.length} 条通知吗?`,
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const res = await systemApi.notifications.batchDelete.post({ ids: selectedRowKeys })
message.success('批量删除成功')
rowSelection.selectedRowKeys = []
loadUnreadCount()
refreshTable()
} catch (error) {
message.error(error.message || '批量删除失败')
}
}
})
}
// 清空已读
const handleClearRead = () => {
Modal.confirm({
title: '确认清空',
content: '确定要清空所有已读通知吗?此操作不可恢复。',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
await systemApi.notifications.clearRead.post()
message.success('已清空已读通知')
refreshTable()
} catch (error) {
message.error(error.message || '操作失败')
}
}
})
}
// 处理操作
const handleAction = () => {
const notification = currentNotification.value
if (!notification) return
if (notification.action_type === 'link' && notification.action_data?.url) {
window.open(notification.action_data.url, '_blank')
} else if (notification.action_type === 'modal') {
// 打开弹窗的逻辑
message.info('打开弹窗功能')
}
}
// WebSocket 消息处理
const handleWebSocketMessage = (msg) => {
if (msg.type === 'notification') {
// 收到新通知
const notification = msg.data
message.info(`新通知: ${notification.title}`)
unreadCount.value++
refreshTable()
}
}
// 初始化
onMounted(() => {
loadUnreadCount()
// 连接 WebSocket
ws.connect()
ws.onMessage(handleWebSocketMessage)
// 定时刷新未读数量每30秒
unreadCountInterval = setInterval(() => {
loadUnreadCount()
}, 30000)
})
// 清理
onUnmounted(() => {
if (unreadCountInterval) {
clearInterval(unreadCountInterval)
}
})
</script>
<style scoped lang="scss">
.system-notifications-page {
.notification-title {
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: color 0.3s;
&.unread {
color: #1890ff;
font-weight: 500;
}
.unread-icon {
color: #f5222d;
}
&:hover {
color: #40a9ff;
}
}
.type-icon {
margin-right: 4px;
}
.notification-content {
margin-top: 20px;
.content-label {
font-weight: 500;
margin-bottom: 8px;
color: #333;
}
.content-text {
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
line-height: 1.6;
color: #666;
white-space: pre-wrap;
}
}
.notification-data {
margin-top: 20px;
.data-label {
font-weight: 500;
margin-bottom: 8px;
color: #333;
}
.data-text {
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
overflow-x: auto;
font-size: 12px;
color: #666;
}
}
.notification-action {
margin-top: 20px;
text-align: right;
}
}
</style>

View File

@@ -0,0 +1,431 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { customStorage } from '../persist'
import systemApi from '@/api/system'
import { message } from 'ant-design-vue'
/**
* 通知类型枚举
*/
export const NotificationType = {
INFO: 'info', // 信息
SUCCESS: 'success', // 成功
WARNING: 'warning', // 警告
ERROR: 'error', // 错误
TASK: 'task', // 任务
SYSTEM: 'system' // 系统
}
/**
* 通知分类枚举
*/
export const NotificationCategory = {
SYSTEM: 'system', // 系统通知
TASK: 'task', // 任务通知
MESSAGE: 'message', // 消息通知
REMINDER: 'reminder', // 提醒通知
ANNOUNCEMENT: 'announcement' // 公告通知
}
/**
* 通知操作类型枚举
*/
export const NotificationActionType = {
NONE: 'none', // 无操作
LINK: 'link', // 跳转链接
MODAL: 'modal' // 打开弹窗
}
export const useNotificationStore = defineStore(
'notification',
() => {
// 通知列表
const notifications = ref([])
// 未读数量
const unreadCount = ref(0)
// 加载状态
const loading = ref(false)
// 当前页码
const currentPage = ref(1)
// 每页数量
const pageSize = ref(20)
// 总数量
const total = ref(0)
// 获取未读数量(计算属性)
const unreadCountComputed = computed(() => unreadCount.value)
// 获取已读数量
const readCount = computed(() => total.value - unreadCount.value)
// 获取通知列表
async function fetchNotifications(params = {}) {
try {
loading.value = true
const res = await systemApi.notifications.list.get({
page: params.page || currentPage.value,
page_size: params.page_size || pageSize.value,
...params
})
notifications.value = res.data.list || []
total.value = res.data.total || 0
currentPage.value = res.data.page || 1
pageSize.value = res.data.page_size || 20
return res.data
} catch (error) {
message.error(error.message || '获取通知列表失败')
throw error
} finally {
loading.value = false
}
}
// 获取未读通知列表
async function fetchUnreadNotifications(params = {}) {
try {
loading.value = true
const res = await systemApi.notifications.unread.get({
page: params.page || 1,
page_size: params.page_size || 10,
...params
})
return res.data
} catch (error) {
message.error(error.message || '获取未读通知失败')
throw error
} finally {
loading.value = false
}
}
// 获取未读数量
async function fetchUnreadCount() {
try {
const res = await systemApi.notifications.unreadCount.get()
unreadCount.value = res.data.count || 0
return unreadCount.value
} catch (error) {
console.error('获取未读数量失败:', error)
return 0
}
}
// 标记为已读
async function markAsRead(id) {
try {
await systemApi.notifications.markAsRead.post(id)
// 更新本地状态
const notification = notifications.value.find(n => n.id === id)
if (notification && !notification.is_read) {
notification.is_read = true
notification.read_at = new Date().toISOString()
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
return true
} catch (error) {
message.error(error.message || '标记已读失败')
throw error
}
}
// 批量标记为已读
async function batchMarkAsRead(ids) {
try {
const res = await systemApi.notifications.batchMarkAsRead.post({ ids })
// 更新本地状态
ids.forEach(id => {
const notification = notifications.value.find(n => n.id === id)
if (notification && !notification.is_read) {
notification.is_read = true
notification.read_at = new Date().toISOString()
}
})
// 更新未读数量
unreadCount.value = Math.max(0, unreadCount.value - (res.data.count || ids.length))
return res.data
} catch (error) {
message.error(error.message || '批量标记已读失败')
throw error
}
}
// 标记全部为已读
async function markAllAsRead() {
try {
await systemApi.notifications.markAllAsRead.post()
// 更新本地状态
notifications.value.forEach(n => {
n.is_read = true
n.read_at = n.read_at || new Date().toISOString()
})
unreadCount.value = 0
return true
} catch (error) {
message.error(error.message || '标记全部已读失败')
throw error
}
}
// 删除通知
async function deleteNotification(id) {
try {
await systemApi.notifications.delete.delete(id)
// 更新本地状态
const index = notifications.value.findIndex(n => n.id === id)
if (index !== -1) {
const notification = notifications.value[index]
if (!notification.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
notifications.value.splice(index, 1)
total.value = Math.max(0, total.value - 1)
}
return true
} catch (error) {
message.error(error.message || '删除通知失败')
throw error
}
}
// 批量删除通知
async function batchDeleteNotification(ids) {
try {
const res = await systemApi.notifications.batchDelete.post({ ids })
// 更新本地状态
const deletedCount = 0
ids.forEach(id => {
const index = notifications.value.findIndex(n => n.id === id)
if (index !== -1) {
const notification = notifications.value[index]
if (!notification.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
notifications.value.splice(index, 1)
}
})
total.value = Math.max(0, total.value - ids.length)
return res.data
} catch (error) {
message.error(error.message || '批量删除通知失败')
throw error
}
}
// 清空已读通知
async function clearReadNotifications() {
try {
await systemApi.notifications.clearRead.post()
// 更新本地状态
notifications.value = notifications.value.filter(n => !n.is_read)
total.value = notifications.value.length
return true
} catch (error) {
message.error(error.message || '清空已读通知失败')
throw error
}
}
// 获取通知详情
async function getNotificationDetail(id) {
try {
const res = await systemApi.notifications.detail.get(id)
return res.data
} catch (error) {
message.error(error.message || '获取通知详情失败')
throw error
}
}
// 发送通知(管理员功能)
async function sendNotification(params) {
try {
const res = await systemApi.notifications.send.post(params)
message.success('发送通知成功')
return res.data
} catch (error) {
message.error(error.message || '发送通知失败')
throw error
}
}
// 重试发送失败的通知(管理员功能)
async function retryUnsentNotifications(params = {}) {
try {
const res = await systemApi.notifications.retryUnsent.post(params)
message.success('重试发送成功')
return res.data
} catch (error) {
message.error(error.message || '重试发送失败')
throw error
}
}
// 获取通知统计信息
async function fetchStatistics() {
try {
const res = await systemApi.notifications.statistics.get()
return res.data
} catch (error) {
message.error(error.message || '获取统计信息失败')
throw error
}
}
// 处理 WebSocket 消息
function handleWebSocketMessage(data) {
if (data.type === 'notification') {
const notification = data.data
// 添加到通知列表顶部
notifications.value.unshift(notification)
total.value++
// 如果是未读,增加未读数量
if (!notification.is_read) {
unreadCount.value++
}
// 限制本地存储的通知数量
if (notifications.value.length > 100) {
notifications.value = notifications.value.slice(0, 100)
}
// 显示通知提示
message.info(`新通知: ${notification.title}`)
}
}
// 格式化通知时间
function formatNotificationTime(timestamp) {
if (!timestamp) return '-'
const now = Date.now()
const diff = now - new Date(timestamp).getTime()
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) {
return '刚刚'
} else if (diff < hour) {
return `${Math.floor(diff / minute)}分钟前`
} else if (diff < day) {
return `${Math.floor(diff / hour)}小时前`
} else if (diff < 7 * day) {
return `${Math.floor(diff / day)}天前`
} else {
const date = new Date(timestamp)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
}
// 获取通知类型文本
function getNotificationTypeText(type) {
const texts = {
info: '信息',
success: '成功',
warning: '警告',
error: '错误',
task: '任务',
system: '系统'
}
return texts[type] || type
}
// 获取通知类型颜色
function getNotificationTypeColor(type) {
const colors = {
info: 'blue',
success: 'green',
warning: 'orange',
error: 'red',
task: 'purple',
system: 'cyan'
}
return colors[type] || 'default'
}
// 获取通知分类文本
function getNotificationCategoryText(category) {
const texts = {
system: '系统通知',
task: '任务通知',
message: '消息通知',
reminder: '提醒通知',
announcement: '公告通知'
}
return texts[category] || category
}
// 重置状态
function reset() {
notifications.value = []
unreadCount.value = 0
currentPage.value = 1
total.value = 0
loading.value = false
}
return {
notifications,
unreadCount,
loading,
currentPage,
pageSize,
total,
unreadCountComputed,
readCount,
fetchNotifications,
fetchUnreadNotifications,
fetchUnreadCount,
markAsRead,
batchMarkAsRead,
markAllAsRead,
deleteNotification,
batchDeleteNotification,
clearReadNotifications,
getNotificationDetail,
sendNotification,
retryUnsentNotifications,
fetchStatistics,
handleWebSocketMessage,
formatNotificationTime,
getNotificationTypeText,
getNotificationTypeColor,
getNotificationCategoryText,
reset,
NotificationType,
NotificationCategory,
NotificationActionType
}
},
{
persist: {
key: 'notification-store',
storage: customStorage,
pick: [] // 不自动持久化,通知数据从服务器获取
}
}
)

View File

@@ -173,6 +173,23 @@ Route::middleware(['auth.check:admin', 'log.request'])->group(function () {
Route::post('/delete', [\App\Http\Controllers\System\Admin\Upload::class, 'delete']);
Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Upload::class, 'batchDelete']);
});
// 通知管理
Route::prefix('notifications')->group(function () {
Route::get('/', [\App\Http\Controllers\System\Admin\Notification::class, 'index']);
Route::get('/unread', [\App\Http\Controllers\System\Admin\Notification::class, 'unread']);
Route::get('/unread-count', [\App\Http\Controllers\System\Admin\Notification::class, 'unreadCount']);
Route::get('/{id}', [\App\Http\Controllers\System\Admin\Notification::class, 'show']);
Route::post('/{id}/read', [\App\Http\Controllers\System\Admin\Notification::class, 'markAsRead']);
Route::post('/batch-read', [\App\Http\Controllers\System\Admin\Notification::class, 'batchMarkAsRead']);
Route::post('/read-all', [\App\Http\Controllers\System\Admin\Notification::class, 'markAllAsRead']);
Route::delete('/{id}', [\App\Http\Controllers\System\Admin\Notification::class, 'destroy']);
Route::post('/batch-delete', [\App\Http\Controllers\System\Admin\Notification::class, 'batchDelete']);
Route::post('/clear-read', [\App\Http\Controllers\System\Admin\Notification::class, 'clearRead']);
Route::get('/statistics', [\App\Http\Controllers\System\Admin\Notification::class, 'statistics']);
Route::post('/send', [\App\Http\Controllers\System\Admin\Notification::class, 'send']);
Route::post('/retry-unsent', [\App\Http\Controllers\System\Admin\Notification::class, 'retryUnsent']);
});
});
// WebSocket 管理