fix(apps): 插件菜单角标补初始同步接口 badge/list

插件应用菜单来自 appstore installed(不含 count/dot),原先只有自定义微
应用经 microapp_menu 注入角标,导致插件角标只能靠实时 WS、刷新即丢。

- 新增 GET api/apps/badge/list(用户 token 鉴权)返回 userBadges 快照
  (插件 + 自定义统一覆盖),前端 updateMicroAppsStatus 拉取后 hydrateMap
- appBadges store hydrate -> hydrateMap(按角标 map 初始化,不再依赖 menu_items)
- 删除已冗余的 Badge::attachMenuBadges(调用 + 方法定义)
This commit is contained in:
kuaifan 2026-06-29 11:54:52 +00:00
parent 420d46d5cc
commit 09439b555c
7 changed files with 78 additions and 78 deletions

View File

@ -13,6 +13,7 @@ use Request;
* 动态路由routes/web.php
* api/apps/badge/set -> badge__set() 应用密钥鉴权,绝对设置/清除角标
* api/apps/badge/clear -> badge__clear() 当前用户 token 鉴权,清除自己的角标
* api/apps/badge/list -> badge__list() 当前用户 token 鉴权,拉取自己全部角标(初始同步)
*/
class AppsController extends AbstractController
{
@ -71,4 +72,23 @@ class AppsController extends AbstractController
trim(Request::input('menu_key', ''))
));
}
/**
* @api {get} api/apps/badge/list 拉取自己全部角标
*
* @apiDescription 供前端初始同步:返回当前用户全部应用(插件 + 自定义微应用)的角标快照。
* 数据结构 app_id => menu_key => {count, dot},与前端 store map 结构一致。
* @apiVersion 1.0.0
* @apiGroup apps
* @apiName badge__list
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息
* @apiSuccess {Object} data 返回数据
*/
public function badge__list()
{
$user = User::auth();
return Base::retSuccess('success', Badge::userBadges((int)$user->userid));
}
}

View File

@ -23,7 +23,6 @@ use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;
use App\Models\UserCheckinRecord;
use App\Module\Apps;
use App\Module\Badge;
use App\Module\BillMultipleExport;
use LdapRecord\LdapRecordException;
use Swoole\Coroutine;
@ -787,7 +786,6 @@ class SystemController extends AbstractController
}
$setting = Setting::filterCustomMicroAppsForUser($setting, $user);
$setting = Setting::formatCustomMicroAppsForResponse($setting);
$setting = Badge::attachMenuBadges($setting, $user ? (int)$user->userid : 0);
}
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting);
}

View File

@ -273,33 +273,6 @@ class Badge
return $map;
}
/**
* 为微应用菜单列表附带当前用户的角标 {count,dot},作为前端初始同步来源(零额外请求)。
*
* @param array $apps 微应用列表(每项含 id menu_items
* @param int $userid
* @return array
*/
public static function attachMenuBadges(array $apps, int $userid): array
{
$map = self::userBadges($userid);
foreach ($apps as &$app) {
$appId = (string)($app['id'] ?? '');
if (empty($app['menu_items']) || !is_array($app['menu_items'])) {
continue;
}
foreach ($app['menu_items'] as &$menu) {
$menuKey = (string)($menu['key'] ?? '');
$badge = $map[$appId][$menuKey] ?? ['count' => 0, 'dot' => false];
$menu['count'] = (int)$badge['count'];
$menu['dot'] = (bool)$badge['dot'];
}
unset($menu);
}
unset($app);
return $apps;
}
/**
* 向在线用户实时推送角标变更(仅投递,不补发离线)。
*

View File

@ -5376,8 +5376,13 @@ export default {
// 忽略自定义菜单加载失败
}
commit("microApps/data", apps || [])
// 用应用菜单返回的角标初始化(自定义应用由 microapp_menu 提供,插件应用由 appstore installed 提供)
commit("appBadges/hydrate", apps || [])
// 角标初始同步:一次拉取当前用户全部应用(插件 + 自定义微应用)的角标快照
try {
const {data: badgeMap} = await dispatch('call', {url: 'apps/badge/list'});
commit("appBadges/hydrateMap", badgeMap || {})
} catch (e) {
// 忽略角标初始同步失败
}
}
},

View File

@ -8,7 +8,7 @@ const EMPTY_BADGE = Object.freeze({count: 0, dot: false})
*
* 结构map[app_id][menu_key] = { count, dot }
* - menu_key 为空串表示该应用的第一个菜单
* - 初始值由 updateMicroAppsStatus 通过 hydrate 填充microapp_menu / appstore installed
* - 初始值由 updateMicroAppsStatus 通过 hydrateMap 填充apps/badge/list 一次返回插件 + 自定义全部角标
* - 运行时由 websocket 消息 appBadge set 增量更新
*/
export default {
@ -42,29 +42,32 @@ export default {
},
/**
* 应用菜单列表整体初始化角标
* 后端角标快照整体初始化apps/badge/list 返回含插件 + 自定义微应用
* @param state
* @param apps 形如 [{id, menu_items:[{key,count,dot}]}]
* @param map 形如 { app_id: { menu_key: {count, dot} } }
*/
hydrate(state, apps) {
const map = {}
;(Array.isArray(apps) ? apps : []).forEach(app => {
const appid = app && app.id
if (!appid || !Array.isArray(app.menu_items)) {
return
}
app.menu_items.forEach(menu => {
const count = Number(menu && menu.count) || 0
const dot = !!(menu && menu.dot)
if (count > 0 || dot) {
if (!map[appid]) {
map[appid] = {}
}
map[appid][(menu && menu.key) || ''] = {count, dot}
hydrateMap(state, map) {
const next = {}
if (map && typeof map === 'object') {
Object.keys(map).forEach(appid => {
const menus = map[appid]
if (!appid || !menus || typeof menus !== 'object') {
return
}
Object.keys(menus).forEach(key => {
const item = menus[key] || {}
const count = Number(item.count) || 0
const dot = !!item.dot
if (count > 0 || dot) {
if (!next[appid]) {
next[appid] = {}
}
next[appid][key || ''] = {count, dot}
}
})
})
})
state.map = map
}
state.map = next
},
/**

View File

@ -2,7 +2,7 @@
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
接口总数306
接口总数307
## 路由规则
@ -377,6 +377,7 @@ API 使用动态路由(见 `routes/web.php`URL 段映射为控制器方
| --- | --- | --- | --- |
| api/apps/badge/set | badge__set() | post | 设置角标(应用密钥鉴权) |
| api/apps/badge/clear | badge__clear() | post | 清除角标(当前用户 token 鉴权) |
| api/apps/badge/list | badge__list() | get | 拉取自己全部角标 |
## testTestController

View File

@ -33,44 +33,44 @@ use App\Http\Controllers\Api\AppsController;
*/
Route::prefix('api')->middleware(['webapi'])->group(function () {
// 会员
Route::any('users/{method}', UsersController::class);
Route::any('users/{method}/{action}', UsersController::class);
Route::any('users/{method}', UsersController::class);
Route::any('users/{method}/{action}', UsersController::class);
// 项目
Route::any('project/{method}', ProjectController::class);
Route::any('project/{method}/{action}', ProjectController::class);
Route::any('project/{method}', ProjectController::class);
Route::any('project/{method}/{action}', ProjectController::class);
// 系统
Route::any('system/{method}', SystemController::class);
Route::any('system/{method}/{action}', SystemController::class);
Route::any('system/{method}', SystemController::class);
Route::any('system/{method}/{action}', SystemController::class);
// 在线授权
Route::any('license/{method}', LicenseController::class);
Route::any('license/{method}/{action}', LicenseController::class);
Route::any('license/{method}', LicenseController::class);
Route::any('license/{method}/{action}', LicenseController::class);
// 对话
Route::any('dialog/{method}', DialogController::class);
Route::any('dialog/{method}/{action}', DialogController::class);
Route::any('dialog/{method}', DialogController::class);
Route::any('dialog/{method}/{action}', DialogController::class);
// 文件
Route::any('file/{method}', FileController::class);
Route::any('file/{method}/{action}', FileController::class);
Route::any('file/{method}', FileController::class);
Route::any('file/{method}/{action}', FileController::class);
// 报告
Route::any('report/{method}', ReportController::class);
Route::any('report/{method}/{action}', ReportController::class);
Route::any('report/{method}', ReportController::class);
Route::any('report/{method}/{action}', ReportController::class);
// 公开接口
Route::any('public/{method}', PublicController::class);
Route::any('public/{method}/{action}', PublicController::class);
Route::any('public/{method}', PublicController::class);
Route::any('public/{method}/{action}', PublicController::class);
// 助手
Route::any('assistant/{method}', AssistantController::class);
Route::any('assistant/{method}/{action}', AssistantController::class);
Route::any('assistant/{method}', AssistantController::class);
Route::any('assistant/{method}/{action}', AssistantController::class);
// 投诉
Route::any('complaint/{method}', ComplaintController::class);
Route::any('complaint/{method}/{action}', ComplaintController::class);
Route::any('complaint/{method}', ComplaintController::class);
Route::any('complaint/{method}/{action}', ComplaintController::class);
// 智能搜索
Route::any('search/{method}', SearchController::class);
Route::any('search/{method}/{action}', SearchController::class);
Route::any('search/{method}', SearchController::class);
Route::any('search/{method}/{action}', SearchController::class);
// 应用相关
Route::any('apps/{method}', AppsController::class);
Route::any('apps/{method}/{action}', AppsController::class);
Route::any('apps/{method}', AppsController::class);
Route::any('apps/{method}/{action}', AppsController::class);
// 测试
Route::any('test/{method}', TestController::class);
Route::any('test/{method}/{action}', TestController::class);
Route::any('test/{method}', TestController::class);
Route::any('test/{method}/{action}', TestController::class);
});
/**