更新
This commit is contained in:
74
app/Console/Commands/RetryUnsentNotifications.php
Normal file
74
app/Console/Commands/RetryUnsentNotifications.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
361
app/Http/Controllers/System/Admin/Notification.php
Normal file
361
app/Http/Controllers/System/Admin/Notification.php
Normal 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
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
154
app/Models/System/Notification.php
Normal file
154
app/Models/System/Notification.php
Normal 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 => '无操作',
|
||||
];
|
||||
}
|
||||
}
|
||||
548
app/Services/System/NotificationService.php
Normal file
548
app/Services/System/NotificationService.php
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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 接口
|
||||
2207
docs/README_WEBSOCKET_NOTIFICATION.md
Normal file
2207
docs/README_WEBSOCKET_NOTIFICATION.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
- 实现顶部消息组件
|
||||
- 支持中英文国际化
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 || '系统通知',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
// 显示任务抽屉
|
||||
|
||||
643
resources/admin/src/pages/system/notifications/index.vue
Normal file
643
resources/admin/src/pages/system/notifications/index.vue
Normal 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>
|
||||
431
resources/admin/src/stores/modules/notification.js
Normal file
431
resources/admin/src/stores/modules/notification.js
Normal 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: [] // 不自动持久化,通知数据从服务器获取
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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 管理
|
||||
|
||||
Reference in New Issue
Block a user