更新
This commit is contained in:
@@ -17,6 +17,7 @@ class Dictionary extends Model
|
||||
'name',
|
||||
'code',
|
||||
'description',
|
||||
'value_type',
|
||||
'status',
|
||||
'sort',
|
||||
];
|
||||
|
||||
@@ -4,11 +4,19 @@ namespace App\Services\System;
|
||||
|
||||
use App\Models\System\Dictionary;
|
||||
use App\Models\System\DictionaryItem;
|
||||
use App\Services\WebSocket\WebSocketService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class DictionaryService
|
||||
{
|
||||
protected $webSocketService;
|
||||
|
||||
public function __construct(WebSocketService $webSocketService)
|
||||
{
|
||||
$this->webSocketService = $webSocketService;
|
||||
}
|
||||
|
||||
public function getList(array $params): array
|
||||
{
|
||||
$query = Dictionary::query();
|
||||
@@ -62,6 +70,10 @@ class DictionaryService
|
||||
$dictionary = Dictionary::with('items')->find($id);
|
||||
if ($dictionary) {
|
||||
$dictionary = $dictionary->toArray();
|
||||
// 格式化字典项值
|
||||
if (!empty($dictionary['items'])) {
|
||||
$dictionary['items'] = $this->formatItemsByType($dictionary['items'], $dictionary['value_type']);
|
||||
}
|
||||
Cache::put($cacheKey, $dictionary, 3600);
|
||||
}
|
||||
}
|
||||
@@ -75,9 +87,13 @@ class DictionaryService
|
||||
$dictionary = Cache::get($cacheKey);
|
||||
|
||||
if ($dictionary === null) {
|
||||
$dictionaryModel = Dictionary::where('code', $code)->first();
|
||||
$dictionaryModel = Dictionary::with('items')->where('code', $code)->first();
|
||||
if ($dictionaryModel) {
|
||||
$dictionary = $dictionaryModel->toArray();
|
||||
// 格式化字典项值
|
||||
if (!empty($dictionary['items'])) {
|
||||
$dictionary['items'] = $this->formatItemsByType($dictionary['items'], $dictionary['value_type']);
|
||||
}
|
||||
Cache::put($cacheKey, $dictionary, 3600);
|
||||
} else {
|
||||
$dictionary = null;
|
||||
@@ -100,6 +116,8 @@ class DictionaryService
|
||||
->orderBy('sort')
|
||||
->get()
|
||||
->toArray();
|
||||
// 格式化字典项值
|
||||
$items = $this->formatItemsByType($items, $dictionary->value_type);
|
||||
Cache::put($cacheKey, $items, 3600);
|
||||
} else {
|
||||
$items = [];
|
||||
@@ -118,6 +136,7 @@ class DictionaryService
|
||||
|
||||
$dictionary = Dictionary::create($data);
|
||||
$this->clearCache();
|
||||
$this->notifyDictionaryUpdate('create', $dictionary->toArray());
|
||||
return $dictionary;
|
||||
}
|
||||
|
||||
@@ -132,15 +151,18 @@ class DictionaryService
|
||||
|
||||
$dictionary->update($data);
|
||||
$this->clearCache();
|
||||
$this->notifyDictionaryUpdate('update', $dictionary->toArray());
|
||||
return $dictionary;
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$dictionary = Dictionary::findOrFail($id);
|
||||
$dictionaryData = $dictionary->toArray();
|
||||
DictionaryItem::where('dictionary_id', $id)->delete();
|
||||
$dictionary->delete();
|
||||
$this->clearCache();
|
||||
$this->notifyDictionaryUpdate('delete', $dictionaryData);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -149,6 +171,7 @@ class DictionaryService
|
||||
DictionaryItem::whereIn('dictionary_id', $ids)->delete();
|
||||
Dictionary::whereIn('id', $ids)->delete();
|
||||
$this->clearCache();
|
||||
$this->notifyDictionaryUpdate('batch_delete', ['ids' => $ids]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -156,6 +179,7 @@ class DictionaryService
|
||||
{
|
||||
Dictionary::whereIn('id', $ids)->update(['status' => $status]);
|
||||
$this->clearCache();
|
||||
$this->notifyDictionaryUpdate('batch_update_status', ['ids' => $ids, 'status' => $status]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -218,6 +242,7 @@ class DictionaryService
|
||||
|
||||
$item = DictionaryItem::create($data);
|
||||
$this->clearCache($data['dictionary_id']);
|
||||
$this->notifyDictionaryItemUpdate('create', $item->toArray());
|
||||
return $item;
|
||||
}
|
||||
|
||||
@@ -233,6 +258,7 @@ class DictionaryService
|
||||
|
||||
$item->update($data);
|
||||
$this->clearCache($item->dictionary_id);
|
||||
$this->notifyDictionaryItemUpdate('update', $item->toArray());
|
||||
return $item;
|
||||
}
|
||||
|
||||
@@ -240,8 +266,10 @@ class DictionaryService
|
||||
{
|
||||
$item = DictionaryItem::findOrFail($id);
|
||||
$dictionaryId = $item->dictionary_id;
|
||||
$itemData = $item->toArray();
|
||||
$item->delete();
|
||||
$this->clearCache($dictionaryId);
|
||||
$this->notifyDictionaryItemUpdate('delete', $itemData);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -257,6 +285,7 @@ class DictionaryService
|
||||
$this->clearCache($dictionaryId);
|
||||
}
|
||||
|
||||
$this->notifyDictionaryItemUpdate('batch_delete', ['ids' => $ids, 'dictionary_ids' => $dictionaryIds]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -272,6 +301,7 @@ class DictionaryService
|
||||
$this->clearCache($dictionaryId);
|
||||
}
|
||||
|
||||
$this->notifyDictionaryItemUpdate('batch_update_status', ['ids' => $ids, 'dictionary_ids' => $dictionaryIds, 'status' => $status]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -292,11 +322,16 @@ class DictionaryService
|
||||
|
||||
$result = [];
|
||||
foreach ($dictionaries as $dictionary) {
|
||||
$items = $dictionary->activeItems->toArray();
|
||||
// 格式化字典项值
|
||||
$items = $this->formatItemsByType($items, $dictionary->value_type);
|
||||
|
||||
$result[] = [
|
||||
'code' => $dictionary->code,
|
||||
'name' => $dictionary->name,
|
||||
'description' => $dictionary->description,
|
||||
'items' => $dictionary->activeItems->toArray()
|
||||
'value_type' => $dictionary->value_type,
|
||||
'items' => $items
|
||||
];
|
||||
}
|
||||
|
||||
@@ -306,4 +341,76 @@ class DictionaryService
|
||||
|
||||
return $allItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知前端字典分类已更新
|
||||
*
|
||||
* @param string $action 操作类型:create, update, delete, batch_delete, batch_update_status
|
||||
* @param array $data 字典数据
|
||||
*/
|
||||
private function notifyDictionaryUpdate(string $action, array $data): void
|
||||
{
|
||||
$this->webSocketService->broadcast([
|
||||
'type' => 'dictionary_update',
|
||||
'data' => [
|
||||
'action' => $action,
|
||||
'resource_type' => 'dictionary',
|
||||
'data' => $data,
|
||||
'timestamp' => time()
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知前端字典项已更新
|
||||
*
|
||||
* @param string $action 操作类型:create, update, delete, batch_delete, batch_update_status
|
||||
* @param array $data 字典项数据
|
||||
*/
|
||||
private function notifyDictionaryItemUpdate(string $action, array $data): void
|
||||
{
|
||||
$this->webSocketService->broadcast([
|
||||
'type' => 'dictionary_item_update',
|
||||
'data' => [
|
||||
'action' => $action,
|
||||
'resource_type' => 'dictionary_item',
|
||||
'data' => $data,
|
||||
'timestamp' => time()
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据值类型格式化字典项
|
||||
* @param array $items 字典项数组
|
||||
* @param string $valueType 值类型:string, number, boolean, json
|
||||
* @return array 格式化后的字典项数组
|
||||
*/
|
||||
private function formatItemsByType(array $items, string $valueType): array
|
||||
{
|
||||
return array_map(function ($item) use ($valueType) {
|
||||
switch ($valueType) {
|
||||
case 'number':
|
||||
// 数字类型:将值转换为数字
|
||||
$item['value'] = is_numeric($item['value']) ? (strpos($item['value'], '.') !== false ? (float)$item['value'] : (int)$item['value']) : $item['value'];
|
||||
break;
|
||||
case 'boolean':
|
||||
// 布尔类型:将 '1', 'true', 'yes' 转换为 true,其他为 false
|
||||
$item['value'] = in_array(strtolower($item['value']), ['1', 'true', 'yes', 'on']);
|
||||
break;
|
||||
case 'json':
|
||||
// JSON类型:尝试解析JSON字符串,失败则保持原值
|
||||
$decoded = json_decode($item['value'], true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$item['value'] = $decoded;
|
||||
}
|
||||
break;
|
||||
case 'string':
|
||||
default:
|
||||
// 字符串类型:保持原值
|
||||
break;
|
||||
}
|
||||
return $item;
|
||||
}, $items);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,24 @@ class WebSocketService
|
||||
*/
|
||||
public function getServer(): ?Server
|
||||
{
|
||||
return app('swoole.server');
|
||||
// Check if Laravel-S is running
|
||||
if (!class_exists('Hhxsv5\LaravelS\Illuminate\Laravel') || !defined('IN_LARAVELS')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get the Swoole server from the Laravel-S container
|
||||
$laravelS = \Hhxsv5\LaravelS\Illuminate\Laravel::getInstance();
|
||||
if ($laravelS && $laravelS->getSwooleServer()) {
|
||||
return $laravelS->getSwooleServer();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('Failed to get Swoole server instance', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,6 +64,7 @@ return new class extends Migration
|
||||
$table->string('name')->comment('字典名称');
|
||||
$table->string('code')->unique()->comment('字典编码');
|
||||
$table->text('description')->nullable()->comment('字典描述');
|
||||
$table->string('value_type')->default('string')->comment('值类型:string, number, boolean, json');
|
||||
$table->boolean('status')->default(true)->comment('状态');
|
||||
$table->integer('sort')->default(0)->comment('排序');
|
||||
$table->timestamps();
|
||||
|
||||
@@ -531,6 +531,7 @@ class SystemSeeder extends Seeder
|
||||
'name' => '用户状态',
|
||||
'code' => 'user_status',
|
||||
'description' => '用户账号状态',
|
||||
'value_type' => 'number',
|
||||
'sort' => 1,
|
||||
'status' => 1,
|
||||
],
|
||||
@@ -538,6 +539,7 @@ class SystemSeeder extends Seeder
|
||||
'name' => '性别',
|
||||
'code' => 'gender',
|
||||
'description' => '用户性别',
|
||||
'value_type' => 'number',
|
||||
'sort' => 2,
|
||||
'status' => 1,
|
||||
],
|
||||
@@ -545,6 +547,7 @@ class SystemSeeder extends Seeder
|
||||
'name' => '角色状态',
|
||||
'code' => 'role_status',
|
||||
'description' => '角色启用状态',
|
||||
'value_type' => 'number',
|
||||
'sort' => 3,
|
||||
'status' => 1,
|
||||
],
|
||||
@@ -552,6 +555,7 @@ class SystemSeeder extends Seeder
|
||||
'name' => '字典状态',
|
||||
'code' => 'dictionary_status',
|
||||
'description' => '数据字典状态',
|
||||
'value_type' => 'number',
|
||||
'sort' => 4,
|
||||
'status' => 1,
|
||||
],
|
||||
@@ -559,6 +563,7 @@ class SystemSeeder extends Seeder
|
||||
'name' => '任务状态',
|
||||
'code' => 'task_status',
|
||||
'description' => '定时任务状态',
|
||||
'value_type' => 'number',
|
||||
'sort' => 5,
|
||||
'status' => 1,
|
||||
],
|
||||
@@ -566,6 +571,7 @@ class SystemSeeder extends Seeder
|
||||
'name' => '日志类型',
|
||||
'code' => 'log_type',
|
||||
'description' => '系统日志类型',
|
||||
'value_type' => 'string',
|
||||
'sort' => 6,
|
||||
'status' => 1,
|
||||
],
|
||||
@@ -573,6 +579,7 @@ class SystemSeeder extends Seeder
|
||||
'name' => '是否',
|
||||
'code' => 'yes_no',
|
||||
'description' => '是否选项',
|
||||
'value_type' => 'boolean',
|
||||
'sort' => 7,
|
||||
'status' => 1,
|
||||
],
|
||||
@@ -580,6 +587,7 @@ class SystemSeeder extends Seeder
|
||||
'name' => '配置分组',
|
||||
'code' => 'config_group',
|
||||
'description' => '系统配置分组类型',
|
||||
'value_type' => 'string',
|
||||
'sort' => 8,
|
||||
'status' => 1,
|
||||
],
|
||||
|
||||
415
docs/DICTIONARY_CACHE_UPDATE.md
Normal file
415
docs/DICTIONARY_CACHE_UPDATE.md
Normal file
@@ -0,0 +1,415 @@
|
||||
# 字典缓存更新机制
|
||||
|
||||
## 概述
|
||||
|
||||
本文档说明前后端字典缓存的更新逻辑,确保在字典分类和字典项的增删改等操作后,前端字典缓存能够自动更新。
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 1. 后端实现
|
||||
|
||||
#### 1.1 DictionaryService 更新
|
||||
|
||||
在 `app/Services/System/DictionaryService.php` 中添加了 WebSocket 通知功能:
|
||||
|
||||
**依赖注入:**
|
||||
```php
|
||||
protected $webSocketService;
|
||||
|
||||
public function __construct(WebSocketService $webSocketService)
|
||||
{
|
||||
$this->webSocketService = $webSocketService;
|
||||
}
|
||||
```
|
||||
|
||||
**通知方法:**
|
||||
|
||||
1. **字典分类更新通知** (`notifyDictionaryUpdate`)
|
||||
- 触发时机:创建、更新、删除、批量删除、批量更新状态
|
||||
- 消息类型:`dictionary_update`
|
||||
|
||||
2. **字典项更新通知** (`notifyDictionaryItemUpdate`)
|
||||
- 触发时机:创建、更新、删除、批量删除、批量更新状态
|
||||
- 消息类型:`dictionary_item_update`
|
||||
|
||||
**修改的方法列表:**
|
||||
|
||||
- `create()` - 创建字典分类后发送通知
|
||||
- `update()` - 更新字典分类后发送通知
|
||||
- `delete()` - 删除字典分类后发送通知
|
||||
- `batchDelete()` - 批量删除字典分类后发送通知
|
||||
- `batchUpdateStatus()` - 批量更新状态后发送通知
|
||||
- `createItem()` - 创建字典项后发送通知
|
||||
- `updateItem()` - 更新字典项后发送通知
|
||||
- `deleteItem()` - 删除字典项后发送通知
|
||||
- `batchDeleteItems()` - 批量删除字典项后发送通知
|
||||
- `batchUpdateItemsStatus()` - 批量更新字典项状态后发送通知
|
||||
|
||||
#### 1.2 WebSocket 消息格式
|
||||
|
||||
**字典分类更新消息:**
|
||||
```json
|
||||
{
|
||||
"type": "dictionary_update",
|
||||
"data": {
|
||||
"action": "create|update|delete|batch_delete|batch_update_status",
|
||||
"resource_type": "dictionary",
|
||||
"data": {
|
||||
// 字典分类数据
|
||||
},
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字典项更新消息:**
|
||||
```json
|
||||
{
|
||||
"type": "dictionary_item_update",
|
||||
"data": {
|
||||
"action": "create|update|delete|batch_delete|batch_update_status",
|
||||
"resource_type": "dictionary_item",
|
||||
"data": {
|
||||
// 字典项数据
|
||||
},
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 前端实现
|
||||
|
||||
#### 2.1 WebSocket Composable
|
||||
|
||||
创建了 `resources/admin/src/composables/useWebSocket.js` 来处理 WebSocket 连接和消息监听:
|
||||
|
||||
**主要功能:**
|
||||
|
||||
1. **初始化 WebSocket 连接**
|
||||
- 检查用户登录状态
|
||||
- 验证用户信息完整性
|
||||
- 建立连接并注册消息处理器
|
||||
|
||||
2. **消息处理器**
|
||||
- `handleDictionaryUpdate` - 处理字典分类更新
|
||||
- `handleDictionaryItemUpdate` - 处理字典项更新
|
||||
|
||||
3. **缓存刷新**
|
||||
- 接收到更新通知后,自动刷新字典缓存
|
||||
- 显示成功提示消息
|
||||
|
||||
#### 2.2 App.vue 集成
|
||||
|
||||
在 `resources/admin/src/App.vue` 中集成了 WebSocket:
|
||||
|
||||
**生命周期钩子:**
|
||||
|
||||
```javascript
|
||||
onMounted(async () => {
|
||||
// ... 其他初始化代码
|
||||
|
||||
// 初始化 WebSocket 连接
|
||||
if (userStore.isLoggedIn()) {
|
||||
initWebSocket()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 关闭 WebSocket 连接
|
||||
closeWebSocket()
|
||||
})
|
||||
```
|
||||
|
||||
## 工作流程
|
||||
|
||||
### 完整流程图
|
||||
|
||||
```
|
||||
用户操作(增删改字典)
|
||||
↓
|
||||
后端 Controller 调用 Service
|
||||
↓
|
||||
Service 执行数据库操作
|
||||
↓
|
||||
Service 清理后端缓存(Redis)
|
||||
↓
|
||||
Service 发送 WebSocket 广播通知
|
||||
↓
|
||||
WebSocket 推送消息到所有在线客户端
|
||||
↓
|
||||
前端接收 WebSocket 消息
|
||||
↓
|
||||
触发相应的消息处理器
|
||||
↓
|
||||
刷新前端字典缓存
|
||||
↓
|
||||
显示成功提示
|
||||
```
|
||||
|
||||
### 详细步骤
|
||||
|
||||
1. **用户操作**
|
||||
- 管理员在后台管理界面进行字典分类或字典项的增删改操作
|
||||
- 例如:创建新字典分类、修改字典项、批量删除等
|
||||
|
||||
2. **后端处理**
|
||||
- 接收请求并验证数据
|
||||
- 执行数据库操作(INSERT/UPDATE/DELETE)
|
||||
- 清理 Redis 缓存(`DictionaryService::clearCache()`)
|
||||
- 通过 WebSocket 广播更新通知
|
||||
|
||||
3. **WebSocket 通知**
|
||||
- 服务端向所有连接的 WebSocket 客户端广播消息
|
||||
- 消息包含操作类型、资源类型和更新的数据
|
||||
|
||||
4. **前端接收**
|
||||
- App.vue 在 onMounted 时初始化 WebSocket 连接
|
||||
- 注册消息处理器监听 `dictionary_update` 和 `dictionary_item_update` 事件
|
||||
- 接收到消息后调用对应的处理器
|
||||
|
||||
5. **缓存刷新**
|
||||
- 处理器调用 `dictionaryStore.refresh(true)` 强制刷新缓存
|
||||
- 从后端 API 重新加载所有字典数据
|
||||
- 更新 Pinia store 中的字典数据
|
||||
- 持久化到本地存储
|
||||
|
||||
6. **用户反馈**
|
||||
- 显示 "字典数据已更新" 的成功提示
|
||||
- 页面上的字典数据自动更新,无需手动刷新
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 示例 1:创建新字典分类
|
||||
|
||||
```php
|
||||
// 后端代码
|
||||
$dictionary = $dictionaryService->create([
|
||||
'name' => '订单状态',
|
||||
'code' => 'order_status',
|
||||
'description' => '订单状态字典',
|
||||
'value_type' => 'string',
|
||||
'sort' => 1,
|
||||
'status' => true
|
||||
]);
|
||||
|
||||
// 自动触发:
|
||||
// 1. 清理 Redis 缓存
|
||||
// 2. 广播 WebSocket 消息
|
||||
```
|
||||
|
||||
前端自动刷新缓存并显示提示。
|
||||
|
||||
### 示例 2:更新字典项
|
||||
|
||||
```php
|
||||
// 后端代码
|
||||
$item = $dictionaryService->updateItem(1, [
|
||||
'label' => '已付款',
|
||||
'value' => 'paid',
|
||||
'sort' => 2
|
||||
]);
|
||||
|
||||
// 自动触发:
|
||||
// 1. 清理对应字典的 Redis 缓存
|
||||
// 2. 广播 WebSocket 消息
|
||||
```
|
||||
|
||||
前端自动刷新缓存并显示提示。
|
||||
|
||||
### 示例 3:批量操作
|
||||
|
||||
```php
|
||||
// 后端代码
|
||||
$dictionaryService->batchUpdateStatus([1, 2, 3], false);
|
||||
|
||||
// 自动触发:
|
||||
// 1. 清理所有相关字典的 Redis 缓存
|
||||
// 2. 广播 WebSocket 消息(包含批量更新的 ID)
|
||||
```
|
||||
|
||||
前端自动刷新缓存并显示提示。
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 1. WebSocket 连接
|
||||
|
||||
- WebSocket 仅在用户登录后建立连接
|
||||
- 连接失败会自动重试(最多 5 次)
|
||||
- 页面卸载时会自动关闭连接
|
||||
|
||||
### 2. 缓存一致性
|
||||
|
||||
- 后端缓存使用 Redis,TTL 为 3600 秒(1 小时)
|
||||
- 前端缓存使用 Pinia + 本地存储持久化
|
||||
- WebSocket 通知确保前后端缓存同步更新
|
||||
|
||||
### 3. 错误处理
|
||||
|
||||
- WebSocket 连接失败不影响页面正常使用
|
||||
- 缓存刷新失败会在控制台输出错误日志
|
||||
- 不会阻塞用户操作
|
||||
|
||||
### 4. 性能考虑
|
||||
|
||||
- 批量操作会一次性清理相关缓存
|
||||
- WebSocket 广播向所有在线用户推送
|
||||
- 前端刷新时会重新加载所有字典数据
|
||||
|
||||
## 扩展建议
|
||||
|
||||
### 1. 细粒度缓存更新
|
||||
|
||||
当前实现是全量刷新,未来可以优化为增量更新:
|
||||
|
||||
```javascript
|
||||
// 只更新受影响的字典
|
||||
async function handleDictionaryUpdate(data) {
|
||||
const { action, data: dictData } = data
|
||||
|
||||
if (action === 'update' && dictData.code) {
|
||||
// 只更新特定的字典
|
||||
await dictionaryStore.getDictionary(dictData.code, true)
|
||||
} else {
|
||||
// 全量刷新
|
||||
await dictionaryStore.refresh(true)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 权限控制
|
||||
|
||||
可以只向有权限的用户发送通知:
|
||||
|
||||
```php
|
||||
// 后端只向有字典管理权限的用户发送
|
||||
$adminUserIds = User::whereHas('roles', function($query) {
|
||||
$query->where('name', 'admin');
|
||||
})->pluck('id')->toArray();
|
||||
|
||||
$this->webSocketService->sendToUsers($adminUserIds, $message);
|
||||
```
|
||||
|
||||
### 3. 消息队列
|
||||
|
||||
对于高并发场景,可以使用消息队列异步发送 WebSocket 通知:
|
||||
|
||||
```php
|
||||
// 使用 Laravel 队列
|
||||
UpdateDictionaryCacheJob::dispatch($action, $data);
|
||||
```
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 1. 单元测试
|
||||
|
||||
测试后端 WebSocket 通知是否正确发送:
|
||||
|
||||
```php
|
||||
public function testDictionaryUpdateSendsWebSocketNotification()
|
||||
{
|
||||
$this->mockWebSocketService();
|
||||
|
||||
$dictionary = DictionaryService::create([
|
||||
'name' => 'Test',
|
||||
'code' => 'test'
|
||||
]);
|
||||
|
||||
// 验证 WebSocket 广播被调用
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 集成测试
|
||||
|
||||
1. 启动后端服务(Laravel-S)
|
||||
2. 启动前端开发服务器
|
||||
3. 在浏览器中登录系统
|
||||
4. 打开开发者工具的 Network -> WS 标签查看 WebSocket 消息
|
||||
5. 执行字典增删改操作
|
||||
6. 验证:
|
||||
- WebSocket 消息是否正确接收
|
||||
- 缓存是否自动刷新
|
||||
- 页面数据是否更新
|
||||
- 提示消息是否显示
|
||||
|
||||
### 3. 并发测试
|
||||
|
||||
1. 打开多个浏览器窗口并登录
|
||||
2. 在一个窗口中进行字典操作
|
||||
3. 验证所有窗口的缓存是否同步更新
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题 1:前端未收到 WebSocket 消息
|
||||
|
||||
**可能原因:**
|
||||
- WebSocket 服务未启动
|
||||
- 网络连接问题
|
||||
- 用户未登录
|
||||
- Laravel-S 未运行(使用普通 PHP 运行时)
|
||||
|
||||
**解决方法:**
|
||||
1. 检查 Laravel-S 服务是否启动:`php bin/laravels status`
|
||||
2. 检查浏览器控制台是否有 WebSocket 错误
|
||||
3. 确认用户已登录且有 token
|
||||
4. 确认是否在 Laravel-S 环境下运行(WebSocket 通知仅在 Laravel-S 环境下有效)
|
||||
|
||||
**注意:**
|
||||
- WebSocket 通知功能依赖于 Laravel-S (Swoole) 环境
|
||||
- 在普通 PHP 环境下运行时,WebSocket 通知会优雅降级(不发送通知,但不影响功能)
|
||||
- 仍需手动刷新页面或使用 API 轮询来获取最新数据
|
||||
|
||||
### 问题 2:后端 WebSocket 通知发送失败
|
||||
|
||||
**可能原因:**
|
||||
- Laravel-S 未运行
|
||||
- Swoole 服务器未启动
|
||||
- WebSocket 服务实例获取失败
|
||||
|
||||
**解决方法:**
|
||||
1. 确认在 Laravel-S 环境下运行:`php bin/laravels start`
|
||||
2. 检查 Laravel-S 配置文件 `config/laravels.php`
|
||||
3. 查看后端日志:`tail -f storage/logs/laravel.log`
|
||||
|
||||
**注意:**
|
||||
- 如果未在 Laravel-S 环境下运行,后端会记录警告日志,但不会报错
|
||||
- 字典数据仍会正常更新到数据库和 Redis 缓存
|
||||
- 只是前端不会收到自动更新通知
|
||||
|
||||
### 问题 3:缓存未更新
|
||||
|
||||
**可能原因:**
|
||||
- WebSocket 消息处理失败
|
||||
- API 请求失败
|
||||
- 前端未连接 WebSocket
|
||||
|
||||
**解决方法:**
|
||||
1. 查看浏览器控制台错误日志
|
||||
2. 检查网络请求是否成功
|
||||
3. 手动刷新页面验证 API 是否正常
|
||||
4. 确认 WebSocket 连接状态(浏览器开发者工具 Network -> WS)
|
||||
|
||||
### 问题 3:通知频繁弹出
|
||||
|
||||
**可能原因:**
|
||||
- 批量操作触发了多次通知
|
||||
|
||||
**解决方法:**
|
||||
1. 优化后端批量操作,只发送一次通知
|
||||
2. 前端添加防抖/节流逻辑
|
||||
|
||||
## 总结
|
||||
|
||||
通过 WebSocket 实现的字典缓存自动更新机制,确保了前后端数据的一致性,提升了用户体验。用户无需手动刷新页面即可获取最新的字典数据。
|
||||
|
||||
### 优势
|
||||
|
||||
- ✅ 实时更新,无需手动刷新
|
||||
- ✅ 多端同步,所有在线用户自动更新
|
||||
- ✅ 操作透明,用户有明确的反馈
|
||||
- ✅ 易于扩展,可应用于其他数据类型
|
||||
|
||||
### 限制
|
||||
|
||||
- 需要稳定的 WebSocket 连接
|
||||
- 当前实现为全量刷新,可以优化为增量更新
|
||||
- 依赖后端服务(Laravel-S)正常运行
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup>
|
||||
import { onMounted, computed, watch, nextTick } from 'vue'
|
||||
import { onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18nStore } from './stores/modules/i18n'
|
||||
import { useLayoutStore } from './stores/modules/layout'
|
||||
import { useUserStore } from './stores/modules/user'
|
||||
import { useWebSocket } from './composables/useWebSocket'
|
||||
import { theme } from 'ant-design-vue'
|
||||
import i18n from './i18n'
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN'
|
||||
@@ -22,6 +24,12 @@ const i18nStore = useI18nStore()
|
||||
// layout store
|
||||
const layoutStore = useLayoutStore()
|
||||
|
||||
// user store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// WebSocket
|
||||
const { initWebSocket, closeWebSocket } = useWebSocket()
|
||||
|
||||
// 解构 themeColor 以确保响应式
|
||||
const { themeColor } = storeToRefs(layoutStore)
|
||||
|
||||
@@ -82,6 +90,16 @@ onMounted(async () => {
|
||||
if (layoutStore.themeColor) {
|
||||
document.documentElement.style.setProperty('--primary-color', layoutStore.themeColor)
|
||||
}
|
||||
|
||||
// 初始化 WebSocket 连接
|
||||
if (userStore.isLoggedIn()) {
|
||||
initWebSocket()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 关闭 WebSocket 连接
|
||||
closeWebSocket()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
146
resources/admin/src/composables/useWebSocket.js
Normal file
146
resources/admin/src/composables/useWebSocket.js
Normal file
@@ -0,0 +1,146 @@
|
||||
import { ref } from 'vue'
|
||||
import { getWebSocket } from '@/utils/websocket'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { useDictionaryStore } from '@/stores/modules/dictionary'
|
||||
import { message } from 'ant-design-vue'
|
||||
import config from '@/config'
|
||||
|
||||
/**
|
||||
* WebSocket Composable
|
||||
*
|
||||
* 处理 WebSocket 连接和消息监听
|
||||
*/
|
||||
export function useWebSocket() {
|
||||
const ws = ref(null)
|
||||
const userStore = useUserStore()
|
||||
const dictionaryStore = useDictionaryStore()
|
||||
|
||||
/**
|
||||
* 初始化 WebSocket 连接
|
||||
*/
|
||||
function initWebSocket() {
|
||||
if (!userStore.token) {
|
||||
console.warn('未登录,无法初始化 WebSocket')
|
||||
return
|
||||
}
|
||||
|
||||
if (!userStore.userInfo || !userStore.userInfo.id) {
|
||||
console.warn('用户信息不完整,无法初始化 WebSocket')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用配置文件中的 WS_URL
|
||||
ws.value = getWebSocket(userStore.userInfo.id, userStore.token, {
|
||||
wsUrl: config.WS_URL,
|
||||
onOpen: handleOpen,
|
||||
onMessage: handleMessage,
|
||||
onError: handleError,
|
||||
onClose: handleClose
|
||||
})
|
||||
|
||||
// 注册消息处理器
|
||||
ws.value.on('dictionary_update', handleDictionaryUpdate)
|
||||
ws.value.on('dictionary_item_update', handleDictionaryItemUpdate)
|
||||
|
||||
// 连接
|
||||
ws.value.connect()
|
||||
} catch (error) {
|
||||
console.error('初始化 WebSocket 失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理连接打开
|
||||
*/
|
||||
function handleOpen(event) {
|
||||
console.log('WebSocket 连接已建立', event)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理接收消息
|
||||
*/
|
||||
function handleMessage(message, event) {
|
||||
console.log('收到 WebSocket 消息:', message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理错误
|
||||
*/
|
||||
function handleError(error) {
|
||||
console.error('WebSocket 错误:', error)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理连接关闭
|
||||
*/
|
||||
function handleClose(event) {
|
||||
console.log('WebSocket 连接已关闭', event)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理字典分类更新
|
||||
*/
|
||||
async function handleDictionaryUpdate(data) {
|
||||
console.log('字典分类已更新:', data)
|
||||
|
||||
const { action, resource_type, timestamp } = data
|
||||
|
||||
if (resource_type !== 'dictionary') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 刷新字典缓存
|
||||
await dictionaryStore.refresh(true)
|
||||
|
||||
// 显示通知
|
||||
message.success('字典数据已更新')
|
||||
} catch (error) {
|
||||
console.error('刷新字典缓存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理字典项更新
|
||||
*/
|
||||
async function handleDictionaryItemUpdate(data) {
|
||||
console.log('字典项已更新:', data)
|
||||
|
||||
const { action, resource_type, timestamp } = data
|
||||
|
||||
if (resource_type !== 'dictionary_item') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 刷新字典缓存
|
||||
await dictionaryStore.refresh(true)
|
||||
|
||||
// 显示通知
|
||||
message.success('字典数据已更新')
|
||||
} catch (error) {
|
||||
console.error('刷新字典缓存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 WebSocket 连接
|
||||
*/
|
||||
function closeWebSocket() {
|
||||
if (ws.value) {
|
||||
// 取消注册消息处理器
|
||||
ws.value.off('dictionary_update')
|
||||
ws.value.off('dictionary_item_update')
|
||||
|
||||
ws.value.disconnect()
|
||||
ws.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ws,
|
||||
initWebSocket,
|
||||
closeWebSocket
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const defaultConfig = {
|
||||
|
||||
//接口地址
|
||||
API_URL: 'http://127.0.0.1:8000/admin/',
|
||||
WS_URL: '127.0.0.1:8000',
|
||||
|
||||
//请求超时
|
||||
TIMEOUT: 50000,
|
||||
|
||||
@@ -12,6 +12,17 @@
|
||||
<div class="form-tip">系统唯一标识,只能包含字母、数字、下划线,且必须以字母开头</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 值类型 -->
|
||||
<a-form-item label="值类型" name="value_type" required>
|
||||
<a-select v-model:value="form.value_type" placeholder="请选择值类型" allow-clear>
|
||||
<a-select-option value="string">字符串</a-select-option>
|
||||
<a-select-option value="number">数字</a-select-option>
|
||||
<a-select-option value="boolean">布尔值</a-select-option>
|
||||
<a-select-option value="json">JSON</a-select-option>
|
||||
</a-select>
|
||||
<div class="form-tip">指定字典项值的类型,系统会根据类型自动格式化返回数据</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 排序 -->
|
||||
<a-form-item label="排序" name="sort">
|
||||
<a-input-number v-model:value="form.sort" :min="0" :max="10000" style="width: 100%" />
|
||||
@@ -79,6 +90,7 @@ const form = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
code: '',
|
||||
value_type: 'string',
|
||||
description: '',
|
||||
status: null,
|
||||
sort: 0
|
||||
@@ -114,6 +126,9 @@ const rules = {
|
||||
trigger: 'blur'
|
||||
},
|
||||
{ validator: validateCodeUnique, trigger: 'blur' }
|
||||
],
|
||||
value_type: [
|
||||
{ required: true, message: '请选择值类型', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -123,6 +138,7 @@ const resetForm = () => {
|
||||
id: '',
|
||||
name: '',
|
||||
code: '',
|
||||
value_type: 'string',
|
||||
description: '',
|
||||
status: null,
|
||||
sort: 0
|
||||
@@ -137,6 +153,7 @@ const setData = (data) => {
|
||||
id: data.id || '',
|
||||
name: data.name || '',
|
||||
code: data.code || '',
|
||||
value_type: data.value_type || 'string',
|
||||
description: data.description || '',
|
||||
status: data.status !== undefined ? data.status : null,
|
||||
sort: data.sort !== undefined ? data.sort : 0
|
||||
@@ -155,6 +172,7 @@ const handleSubmit = async () => {
|
||||
const submitData = {
|
||||
name: form.value.name,
|
||||
code: form.value.code,
|
||||
value_type: form.value.value_type,
|
||||
description: form.value.description,
|
||||
status: form.value.status,
|
||||
sort: form.value.sort
|
||||
|
||||
@@ -229,7 +229,9 @@ class WebSocketClient {
|
||||
*/
|
||||
export function createWebSocket(userId, token, options = {}) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
|
||||
// 优先使用配置的 WS_URL,否则使用当前域名
|
||||
const host = options.wsUrl || window.location.host
|
||||
const url = `${protocol}//${host}/ws?user_id=${userId}&token=${token}`
|
||||
|
||||
return new WebSocketClient(url, options)
|
||||
|
||||
Reference in New Issue
Block a user