mirror of
https://github.com/kuaifan/dootask.git
synced 2026-07-02 20:35:11 +00:00
feat(apps): 新增应用菜单角标(数字/红点,per-user 实时推送)
插件/微应用可在自己的菜单入口显示数字或红点角标,插件未打开也生效。 - 后端:新增 app_badges 表 + AppBadge 模型 + Module/Badge 业务编排 + AppsController(badge__set 应用密钥鉴权 / badge__clear 用户鉴权) - 每应用独立密钥 APP_SECRET:按 appid 持久化于 appstore config.yml,鉴权校验 - 推送:复用 PushTask 下发 appBadge WS 消息;microapp_menu 附带初始角标 - 前端:appBadges Vuex module + WS 处理 + 三处菜单渲染(应用卡片/主菜单入口/ 父『应用』入口聚合)+ 移动端 Tabbar + 打开即清(badge_clear_on_open) - 用户离职级联清理;同步 ai-kb 角标知识
This commit is contained in:
parent
eb672eaef1
commit
420d46d5cc
74
app/Http/Controllers/Api/AppsController.php
Normal file
74
app/Http/Controllers/Api/AppsController.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Module\Badge;
|
||||
use App\Module\Base;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
* 插件 / 微应用相关接口。
|
||||
*
|
||||
* 动态路由(routes/web.php):
|
||||
* api/apps/badge/set -> badge__set() 应用密钥鉴权,绝对设置/清除角标
|
||||
* api/apps/badge/clear -> badge__clear() 当前用户 token 鉴权,清除自己的角标
|
||||
*/
|
||||
class AppsController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @api {post} api/apps/badge/set 设置角标(应用密钥鉴权)
|
||||
*
|
||||
* @apiDescription 由插件服务端使用 APP_SECRET 调用,对 (appid, 菜单, 每个 userid) 绝对设置角标(幂等覆盖)。
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup apps
|
||||
* @apiName badge__set
|
||||
*
|
||||
* @apiParam {String} appid 应用ID
|
||||
* @apiParam {String} secret 应用密钥(APP_SECRET)
|
||||
* @apiParam {Number|Number[]} userid 目标用户ID(单个或数组)
|
||||
* @apiParam {String} [menu_key] 菜单稳定标识;留空表示该应用第一个菜单
|
||||
* @apiParam {Number} [count=0] 角标数字
|
||||
* @apiParam {Boolean} [dot=false] 是否显示红点(count=0 且 dot=false 即清除)
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function badge__set()
|
||||
{
|
||||
return Base::retSuccess('success', Badge::set(
|
||||
trim(Request::input('appid', '')),
|
||||
trim(Request::input('secret', '')),
|
||||
Request::input('userid'),
|
||||
trim(Request::input('menu_key', '')),
|
||||
Request::input('count', 0),
|
||||
Request::input('dot', false)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/apps/badge/clear 清除角标(当前用户 token 鉴权)
|
||||
*
|
||||
* @apiDescription 供前端在 badge_clear_on_open=true 的菜单打开时调用,清除当前用户在该应用该菜单的角标。
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup apps
|
||||
* @apiName badge__clear
|
||||
*
|
||||
* @apiParam {String} appid 应用ID
|
||||
* @apiParam {String} [menu_key] 菜单稳定标识;留空表示该应用第一个菜单
|
||||
*
|
||||
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
|
||||
* @apiSuccess {String} msg 返回信息
|
||||
* @apiSuccess {Object} data 返回数据
|
||||
*/
|
||||
public function badge__clear()
|
||||
{
|
||||
$user = User::auth();
|
||||
return Base::retSuccess('success', Badge::clearForUser(
|
||||
(int)$user->userid,
|
||||
trim(Request::input('appid', '')),
|
||||
trim(Request::input('menu_key', ''))
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,7 @@ 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;
|
||||
@ -786,6 +787,7 @@ 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);
|
||||
}
|
||||
|
||||
48
app/Models/AppBadge.php
Normal file
48
app/Models/AppBadge.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* App\Models\AppBadge
|
||||
*
|
||||
* 插件/微应用菜单角标(每个 (app_id, menu_key, userid) 一行,仅存非清除态)
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $app_id 应用ID
|
||||
* @property string $menu_key 菜单稳定标识(空串=第一个菜单)
|
||||
* @property int $userid 用户ID
|
||||
* @property int $count 角标数字
|
||||
* @property bool $dot 是否显示红点
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge query()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereAppId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereCount($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereDot($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereMenuKey($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AppBadge whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class AppBadge extends AbstractModel
|
||||
{
|
||||
protected $table = 'app_badges';
|
||||
|
||||
const CREATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'app_id',
|
||||
'menu_key',
|
||||
'userid',
|
||||
'count',
|
||||
'dot',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'userid' => 'integer',
|
||||
'count' => 'integer',
|
||||
'dot' => 'boolean',
|
||||
];
|
||||
}
|
||||
@ -365,6 +365,8 @@ class Setting extends AbstractModel
|
||||
$menu['disable_scope_css'] = (bool)($menu['disable_scope_css'] ?? false);
|
||||
$menu['auto_dark_theme'] = isset($menu['auto_dark_theme']) ? (bool)$menu['auto_dark_theme'] : true;
|
||||
$menu['transparent'] = (bool)($menu['transparent'] ?? false);
|
||||
$menu['key'] = isset($menu['key']) ? (string)$menu['key'] : '';
|
||||
$menu['badge_clear_on_open'] = (bool)($menu['badge_clear_on_open'] ?? false);
|
||||
if (isset($menu['visible_to'])) {
|
||||
unset($menu['visible_to']);
|
||||
}
|
||||
@ -454,6 +456,9 @@ class Setting extends AbstractModel
|
||||
if (!empty($menu['capsule']) && is_array($menu['capsule'])) {
|
||||
$payload['capsule'] = Base::newTrim($menu['capsule']);
|
||||
}
|
||||
// 角标:菜单稳定标识 与 打开时是否自动清零
|
||||
$payload['key'] = Base::newTrim($menu['key'] ?? '');
|
||||
$payload['badge_clear_on_open'] = (bool)($menu['badge_clear_on_open'] ?? false);
|
||||
return $payload;
|
||||
}
|
||||
|
||||
@ -462,7 +467,7 @@ class Setting extends AbstractModel
|
||||
* @param mixed $value
|
||||
* @return array
|
||||
*/
|
||||
protected static function normalizeCustomMicroVisible($value)
|
||||
public static function normalizeCustomMicroVisible($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$list = array_filter(array_map('trim', $value));
|
||||
@ -485,7 +490,7 @@ class Setting extends AbstractModel
|
||||
* @param int $userId
|
||||
* @return bool
|
||||
*/
|
||||
protected static function isCustomMicroVisibleTo(array $visible, bool $isAdmin, int $userId)
|
||||
public static function isCustomMicroVisibleTo(array $visible, bool $isAdmin, int $userId)
|
||||
{
|
||||
if (in_array('all', $visible)) {
|
||||
return true;
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Module;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDepartment;
|
||||
use App\Services\RequestContext;
|
||||
@ -29,14 +30,7 @@ class Apps
|
||||
return (bool) RequestContext::get($key, false);
|
||||
}
|
||||
|
||||
$configFile = base_path('docker/appstore/config/' . $appId . '/config.yml');
|
||||
$installed = false;
|
||||
if (file_exists($configFile)) {
|
||||
$configData = Yaml::parseFile($configFile);
|
||||
$installed = $configData['status'] === 'installed';
|
||||
}
|
||||
|
||||
return RequestContext::save($key, $installed);
|
||||
return RequestContext::save($key, self::loadInstalledConfig($appId) !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -62,6 +56,180 @@ class Apps
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* appstore 目录下的绝对路径(统一 docker/appstore 前缀)。
|
||||
*
|
||||
* @param string $relative 相对 docker/appstore 的路径
|
||||
* @return string
|
||||
*/
|
||||
private static function appstorePath(string $relative): string
|
||||
{
|
||||
return base_path('docker/appstore/' . ltrim($relative, '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并校验某应用的 appstore config.yml;仅当文件存在且 status=installed 时返回解析后的配置数组。
|
||||
*
|
||||
* @param string $appId
|
||||
* @return array|null
|
||||
*/
|
||||
private static function loadInstalledConfig(string $appId): ?array
|
||||
{
|
||||
$appId = trim($appId);
|
||||
if ($appId === '' || $appId === 'appstore') {
|
||||
return null;
|
||||
}
|
||||
$configFile = self::appstorePath("config/{$appId}/config.yml");
|
||||
if (!file_exists($configFile)) {
|
||||
return null;
|
||||
}
|
||||
$config = Yaml::parseFile($configFile);
|
||||
if (!is_array($config) || ($config['status'] ?? '') !== 'installed') {
|
||||
return null;
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单个 menu_items 项映射为角标用的菜单配置。
|
||||
*
|
||||
* @param array $menu
|
||||
* @param mixed $visibleDefault 应用级默认可见范围
|
||||
* @return array ['key'=>string,'visible'=>array,'badge_clear_on_open'=>bool]
|
||||
*/
|
||||
private static function mapMenuItem(array $menu, $visibleDefault): array
|
||||
{
|
||||
return [
|
||||
'key' => trim((string)($menu['key'] ?? '')),
|
||||
'visible' => Setting::normalizeCustomMicroVisible($menu['visible_to'] ?? $visibleDefault),
|
||||
'badge_clear_on_open' => (bool)($menu['badge_clear_on_open'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取(必要时生成并持久化)应用的独立密钥 APP_SECRET。
|
||||
*
|
||||
* 与全局 APP_KEY 不同,APP_SECRET 每个已安装应用独立、唯一,持久化在应用自身的
|
||||
* docker/appstore/config/{appid}/config.yml(与 KB_INGEST_TOKEN 等每应用参数同源),
|
||||
* 由 appstore 安装链路按内置 compose 变量 APP_SECRET 注入插件容器。
|
||||
* 此处主程序侧负责生成/持久化与校验;首次需要时若不存在则惰性生成,保证主程序可独立验证。
|
||||
*
|
||||
* @param string $appId 应用ID
|
||||
* @return string 应用密钥;应用未安装或非插件应用时返回空字符串
|
||||
*/
|
||||
public static function appSecret(string $appId): string
|
||||
{
|
||||
$appId = trim($appId);
|
||||
$config = self::loadInstalledConfig($appId);
|
||||
if ($config === null) {
|
||||
return '';
|
||||
}
|
||||
$secret = trim((string)($config['app_secret'] ?? ''));
|
||||
if ($secret !== '') {
|
||||
return $secret;
|
||||
}
|
||||
// 首次需要时生成并持久化(按 appid 唯一)
|
||||
$secret = Base::generatePassword(48);
|
||||
$config['app_secret'] = $secret;
|
||||
try {
|
||||
file_put_contents(self::appstorePath("config/{$appId}/config.yml"), Yaml::dump($config, 4, 2));
|
||||
} catch (\Throwable $e) {
|
||||
info('[app_badge] persist app_secret fail', ['appid' => $appId, 'error' => $e->getMessage()]);
|
||||
return '';
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析应用的菜单角标配置(菜单 key 列表与各自的可见范围)。
|
||||
*
|
||||
* 同时覆盖两类应用:
|
||||
* - 插件应用:读取 docker/appstore/apps/{appid}/{version}/config.yml 的 menu_items
|
||||
* - 自定义微应用:读取 microapp_menu 设置
|
||||
*
|
||||
* @param string $appId 应用ID
|
||||
* @return array|null ['source'=>'plugin'|'custom', 'menus'=>[['key'=>string,'visible'=>array,'badge_clear_on_open'=>bool], ...]];应用不存在返回 null
|
||||
*/
|
||||
public static function appMenuConfig(string $appId): ?array
|
||||
{
|
||||
$appId = trim($appId);
|
||||
if ($appId === '') {
|
||||
return null;
|
||||
}
|
||||
// 插件应用
|
||||
$config = self::loadInstalledConfig($appId);
|
||||
if ($config !== null) {
|
||||
$version = trim((string)($config['install_version'] ?? ''));
|
||||
return [
|
||||
'source' => 'plugin',
|
||||
'menus' => self::readPluginMenus($appId, $version),
|
||||
];
|
||||
}
|
||||
// 自定义微应用
|
||||
$apps = Base::setting('microapp_menu');
|
||||
if (is_array($apps)) {
|
||||
foreach ($apps as $app) {
|
||||
if (!is_array($app) || trim((string)($app['id'] ?? '')) !== $appId) {
|
||||
continue;
|
||||
}
|
||||
$appVisibleDefault = $app['visible_to'] ?? 'admin';
|
||||
$menus = [];
|
||||
foreach (($app['menu_items'] ?? []) as $menu) {
|
||||
if (!is_array($menu)) {
|
||||
continue;
|
||||
}
|
||||
$menus[] = self::mapMenuItem($menu, $appVisibleDefault);
|
||||
}
|
||||
return ['source' => 'custom', 'menus' => $menus];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取插件包 config.yml 的菜单配置。
|
||||
*
|
||||
* @param string $appId
|
||||
* @param string $version 已安装版本
|
||||
* @return array
|
||||
*/
|
||||
private static function readPluginMenus(string $appId, string $version): array
|
||||
{
|
||||
$paths = [];
|
||||
if ($version !== '') {
|
||||
$paths[] = self::appstorePath("apps/{$appId}/{$version}/config.yml");
|
||||
}
|
||||
$paths[] = self::appstorePath("apps/{$appId}/config.yml");
|
||||
$pkg = null;
|
||||
foreach ($paths as $p) {
|
||||
if (file_exists($p) && is_readable($p)) {
|
||||
try {
|
||||
$pkg = Yaml::parseFile($p);
|
||||
} catch (\Throwable $e) {
|
||||
$pkg = null;
|
||||
}
|
||||
if (is_array($pkg)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$menus = [];
|
||||
if (is_array($pkg) && !empty($pkg['menu_items']) && is_array($pkg['menu_items'])) {
|
||||
$appVisibleDefault = $pkg['visible_to'] ?? 'all';
|
||||
foreach ($pkg['menu_items'] as $menu) {
|
||||
if (!is_array($menu)) {
|
||||
continue;
|
||||
}
|
||||
$menus[] = self::mapMenuItem($menu, $appVisibleDefault);
|
||||
}
|
||||
}
|
||||
if (empty($menus)) {
|
||||
// 读不到包配置(权限/缺失)时退化为单一默认菜单,仍可对第一个菜单设角标
|
||||
$menus[] = ['key' => '', 'visible' => ['all'], 'badge_clear_on_open' => false];
|
||||
}
|
||||
return $menus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch user lifecycle hook to appstore (user_onboard/user_offboard/user_update).
|
||||
*
|
||||
|
||||
332
app/Module/Badge.php
Normal file
332
app/Module/Badge.php
Normal file
@ -0,0 +1,332 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\AppBadge;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Tasks\PushTask;
|
||||
|
||||
/**
|
||||
* 插件 / 微应用菜单角标业务编排。
|
||||
*
|
||||
* 角标真值归插件(应用密钥写入),主程序仅作为存储与分发:
|
||||
* - 绝对设置/清除 app_badges 行(仅存非清除态)
|
||||
* - 通过 WebSocket(PushTask)向在线用户实时推送 appBadge 消息
|
||||
* - 提供初始同步所需的用户角标快照
|
||||
*/
|
||||
class Badge
|
||||
{
|
||||
/**
|
||||
* 设置角标(应用密钥鉴权场景):校验密钥/菜单/可见性,绝对设置并实时推送。
|
||||
*
|
||||
* @param string $appId
|
||||
* @param string $secret 请求携带的应用密钥
|
||||
* @param mixed $userid 目标用户ID(单个或数组)
|
||||
* @param string $menuKeyInput 请求中的 menu_key(可空,留空取第一个菜单)
|
||||
* @param mixed $count 角标数字
|
||||
* @param mixed $dot 是否红点
|
||||
* @return array 响应数据
|
||||
* @throws ApiException 参数/密钥/应用/菜单校验失败
|
||||
*/
|
||||
public static function set(string $appId, string $secret, $userid, string $menuKeyInput, $count, $dot): array
|
||||
{
|
||||
if ($appId === '') {
|
||||
throw new ApiException('参数错误');
|
||||
}
|
||||
if ($secret === '') {
|
||||
throw new ApiException('密钥无效');
|
||||
}
|
||||
$expect = Apps::appSecret($appId);
|
||||
if ($expect === '' || !hash_equals($expect, $secret)) {
|
||||
throw new ApiException('密钥无效');
|
||||
}
|
||||
$menu = self::resolveAppMenu($appId, $menuKeyInput);
|
||||
$menuKey = (string)($menu['key'] ?? '');
|
||||
$userids = self::normalizeUserids($userid);
|
||||
if (empty($userids)) {
|
||||
throw new ApiException('参数错误');
|
||||
}
|
||||
// 仅保留对该应用菜单有可见权限的用户
|
||||
$userids = self::filterVisibleUserids($menu, $userids);
|
||||
if (empty($userids)) {
|
||||
return ['appid' => $appId, 'menu_key' => $menuKey, 'affected' => 0];
|
||||
}
|
||||
$count = max(0, intval($count));
|
||||
$dot = filter_var($dot, FILTER_VALIDATE_BOOLEAN);
|
||||
self::applySet($appId, $menuKey, $userids, $count, $dot);
|
||||
self::push($appId, $menuKey, $userids, $count, $dot);
|
||||
return [
|
||||
'appid' => $appId,
|
||||
'menu_key' => $menuKey,
|
||||
'count' => $count,
|
||||
'dot' => $dot,
|
||||
'affected' => count($userids),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定用户在某应用某菜单的角标(用户 token 鉴权场景),并推送多端一致。
|
||||
*
|
||||
* @param int $userid 当前用户ID
|
||||
* @param string $appId
|
||||
* @param string $menuKeyInput 请求中的 menu_key(可空)
|
||||
* @return array 响应数据
|
||||
* @throws ApiException 参数/应用/菜单校验失败
|
||||
*/
|
||||
public static function clearForUser(int $userid, string $appId, string $menuKeyInput): array
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
throw new ApiException('参数错误');
|
||||
}
|
||||
if ($appId === '') {
|
||||
throw new ApiException('参数错误');
|
||||
}
|
||||
$menu = self::resolveAppMenu($appId, $menuKeyInput);
|
||||
$menuKey = (string)($menu['key'] ?? '');
|
||||
AppBadge::whereAppId($appId)->whereMenuKey($menuKey)->whereUserid($userid)->delete();
|
||||
// 推送给该用户的所有在线端,保证多端一致
|
||||
self::push($appId, $menuKey, [$userid], 0, false);
|
||||
return [
|
||||
'appid' => $appId,
|
||||
'menu_key' => $menuKey,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析应用菜单配置并定位目标菜单。
|
||||
*
|
||||
* @param string $appId
|
||||
* @param string $menuKeyInput
|
||||
* @return array 命中的菜单配置
|
||||
* @throws ApiException 应用未安装或菜单不存在
|
||||
*/
|
||||
private static function resolveAppMenu(string $appId, string $menuKeyInput): array
|
||||
{
|
||||
$config = Apps::appMenuConfig($appId);
|
||||
if ($config === null) {
|
||||
throw new ApiException('应用未安装');
|
||||
}
|
||||
$menu = self::resolveMenu($config['menus'], $menuKeyInput);
|
||||
if ($menu === null) {
|
||||
throw new ApiException('菜单不存在');
|
||||
}
|
||||
return $menu;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化目标用户ID:单值/数组 -> 去重去零的整型数组。
|
||||
*
|
||||
* @param mixed $userid
|
||||
* @return int[]
|
||||
*/
|
||||
private static function normalizeUserids($userid): array
|
||||
{
|
||||
if (is_string($userid) || is_numeric($userid)) {
|
||||
$userid = [$userid];
|
||||
}
|
||||
if (!is_array($userid)) {
|
||||
return [];
|
||||
}
|
||||
return self::intIds($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 数组 -> 去重去零的整型数组。
|
||||
*
|
||||
* @param array $ids
|
||||
* @return int[]
|
||||
*/
|
||||
private static function intIds(array $ids): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析目标菜单:menu_key 为空时取第一个菜单;否则必须命中已声明的菜单 key。
|
||||
*
|
||||
* @param array $menus appMenuConfig 返回的 menus
|
||||
* @param string $menuKey 请求中的 menu_key(可空)
|
||||
* @return array|null 命中的菜单配置;非法 menu_key 返回 null
|
||||
*/
|
||||
private static function resolveMenu(array $menus, string $menuKey): ?array
|
||||
{
|
||||
if ($menuKey === '') {
|
||||
return $menus[0] ?? ['key' => '', 'visible' => ['all'], 'badge_clear_on_open' => false];
|
||||
}
|
||||
foreach ($menus as $menu) {
|
||||
if (($menu['key'] ?? '') === $menuKey) {
|
||||
return $menu;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按菜单可见范围过滤目标用户,仅保留对该应用菜单有权限的用户。
|
||||
*
|
||||
* @param array $menu 命中的菜单配置(含 visible)
|
||||
* @param int[] $userids
|
||||
* @return int[] 允许的用户ID
|
||||
*/
|
||||
private static function filterVisibleUserids(array $menu, array $userids): array
|
||||
{
|
||||
if (empty($userids)) {
|
||||
return [];
|
||||
}
|
||||
$visible = $menu['visible'] ?? ['all'];
|
||||
if (in_array('all', $visible)) {
|
||||
return $userids;
|
||||
}
|
||||
$allowed = [];
|
||||
$users = User::whereIn('userid', $userids)->get(['userid', 'identity']);
|
||||
foreach ($users as $user) {
|
||||
if (Setting::isCustomMicroVisibleTo($visible, $user->isAdmin(), (int)$user->userid)) {
|
||||
$allowed[] = (int)$user->userid;
|
||||
}
|
||||
}
|
||||
return $allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绝对设置角标(幂等)。count=0 且 dot=false 即清除(删行)。
|
||||
*
|
||||
* @param string $appId
|
||||
* @param string $menuKey
|
||||
* @param int[] $userids
|
||||
* @param int $count
|
||||
* @param bool $dot
|
||||
* @return void
|
||||
*/
|
||||
private static function applySet(string $appId, string $menuKey, array $userids, int $count, bool $dot): void
|
||||
{
|
||||
if (empty($userids)) {
|
||||
return;
|
||||
}
|
||||
// 清除态:一条 whereIn 删除
|
||||
if ($count === 0 && !$dot) {
|
||||
AppBadge::whereAppId($appId)->whereMenuKey($menuKey)->whereIn('userid', $userids)->delete();
|
||||
return;
|
||||
}
|
||||
// 非清除态:依赖唯一键 (app_id,menu_key,userid) 批量 upsert
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$rows = array_map(fn($uid) => [
|
||||
'app_id' => $appId,
|
||||
'menu_key' => $menuKey,
|
||||
'userid' => (int)$uid,
|
||||
'count' => $count,
|
||||
'dot' => $dot,
|
||||
'updated_at' => $now,
|
||||
], $userids);
|
||||
AppBadge::upsert($rows, ['app_id', 'menu_key', 'userid'], ['count', 'dot', 'updated_at']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除某应用的全部角标(应用卸载时)。
|
||||
*
|
||||
* @param string $appId
|
||||
* @return void
|
||||
*/
|
||||
public static function clearByApp(string $appId): void
|
||||
{
|
||||
$appId = trim($appId);
|
||||
if ($appId === '') {
|
||||
return;
|
||||
}
|
||||
AppBadge::whereAppId($appId)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除某用户的全部角标(用户离职时)。
|
||||
*
|
||||
* @param int $userid
|
||||
* @return void
|
||||
*/
|
||||
public static function clearByUser(int $userid): void
|
||||
{
|
||||
if ($userid <= 0) {
|
||||
return;
|
||||
}
|
||||
AppBadge::whereUserid($userid)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户当前全部角标快照,用于前端初始同步。
|
||||
*
|
||||
* @param int $userid
|
||||
* @return array app_id => menu_key => ['count'=>int,'dot'=>bool]
|
||||
*/
|
||||
public static function userBadges(int $userid): array
|
||||
{
|
||||
$map = [];
|
||||
if ($userid <= 0) {
|
||||
return $map;
|
||||
}
|
||||
$rows = AppBadge::whereUserid($userid)->get(['app_id', 'menu_key', 'count', 'dot']);
|
||||
foreach ($rows as $row) {
|
||||
$map[$row->app_id][$row->menu_key] = [
|
||||
'count' => (int)$row->count,
|
||||
'dot' => (bool)$row->dot,
|
||||
];
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 向在线用户实时推送角标变更(仅投递,不补发离线)。
|
||||
*
|
||||
* @param string $appId
|
||||
* @param string $menuKey
|
||||
* @param int[] $userids
|
||||
* @param int $count
|
||||
* @param bool $dot
|
||||
* @return void
|
||||
*/
|
||||
public static function push(string $appId, string $menuKey, array $userids, int $count, bool $dot): void
|
||||
{
|
||||
$userids = self::intIds($userids);
|
||||
if (empty($userids)) {
|
||||
return;
|
||||
}
|
||||
PushTask::push([
|
||||
'userid' => $userids,
|
||||
'msg' => [
|
||||
'type' => 'appBadge',
|
||||
'data' => [
|
||||
'appid' => $appId,
|
||||
'menu_key' => $menuKey,
|
||||
'count' => $count,
|
||||
'dot' => $dot,
|
||||
],
|
||||
],
|
||||
], false);
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ namespace App\Observers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Badge;
|
||||
use App\Tasks\ManticoreSyncTask;
|
||||
|
||||
class UserObserver extends AbstractObserver
|
||||
@ -80,6 +81,8 @@ class UserObserver extends AbstractObserver
|
||||
} elseif (!$originalDisableAt && $currentDisableAt) {
|
||||
// disable_at 从 null 变为有值 → 离职 (offboarded)
|
||||
Apps::dispatchUserHook($user, 'user_offboard', 'offboarded');
|
||||
// 离职清除该用户全部应用角标
|
||||
Badge::clearByUser((int)$user->userid);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@ -122,6 +125,9 @@ class UserObserver extends AbstractObserver
|
||||
if (!$user->bot) {
|
||||
Apps::dispatchUserHook($user, 'user_offboard', 'delete');
|
||||
}
|
||||
|
||||
// 清除该用户全部应用角标
|
||||
Badge::clearByUser((int)$user->userid);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateAppBadgesTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
if (Schema::hasTable('app_badges')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('app_badges', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('app_id', 100)->default('')->comment('应用ID(appstore 插件 appid 或自定义微应用 id)');
|
||||
$table->string('menu_key', 100)->default('')->comment('菜单稳定标识;空串表示该应用的第一个菜单');
|
||||
$table->bigInteger('userid')->comment('用户ID');
|
||||
$table->integer('count')->default(0)->comment('角标数字');
|
||||
$table->boolean('dot')->default(false)->comment('是否显示红点');
|
||||
$table->timestamp('updated_at')->nullable()->comment('更新时间');
|
||||
//
|
||||
$table->unique(['app_id', 'menu_key', 'userid'], 'app_badges_unique');
|
||||
$table->index('userid', 'app_badges_userid');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('app_badges');
|
||||
}
|
||||
}
|
||||
@ -1018,3 +1018,6 @@ AI 助手
|
||||
在线授权已失效,已回落到基础版
|
||||
请输入邮箱
|
||||
请输入邮箱和验证码
|
||||
密钥无效
|
||||
应用未安装
|
||||
菜单不存在
|
||||
|
||||
@ -343,11 +343,11 @@ features:
|
||||
- app-admin.entry.menu-map
|
||||
|
||||
- id: micro-app
|
||||
name: 微应用通用概念(安装/卸载/排序/自定义菜单)
|
||||
name: 微应用通用概念(安装/卸载/排序/自定义菜单/菜单角标)
|
||||
scope: end-user
|
||||
batch: B3
|
||||
priority: P0
|
||||
chunk_count_est: 8
|
||||
chunk_count_est: 9
|
||||
owner: ~
|
||||
status: drafted
|
||||
chunks:
|
||||
@ -355,6 +355,7 @@ features:
|
||||
- micro-app.menu.concept
|
||||
- micro-app.concept
|
||||
- micro-app.permission.concept
|
||||
- micro-app.badge.concept
|
||||
- micro-app.install.howto
|
||||
- micro-app.sort.howto
|
||||
- micro-app.uninstall.howto
|
||||
|
||||
57
resources/ai-kb/zh/concept/micro-app/badge.md
Normal file
57
resources/ai-kb/zh/concept/micro-app/badge.md
Normal file
@ -0,0 +1,57 @@
|
||||
---
|
||||
id: micro-app.badge.concept
|
||||
title: 插件/微应用菜单角标
|
||||
type: concept
|
||||
feature: micro-app
|
||||
scope: end-user
|
||||
locale: zh
|
||||
aliases:
|
||||
- 应用角标
|
||||
- 菜单红点
|
||||
- 插件未读数
|
||||
- 应用未读提醒
|
||||
- 应用菜单数字
|
||||
- badge
|
||||
- 应用入口小红点
|
||||
related_tools: []
|
||||
related_pages: [application]
|
||||
prerequisites: []
|
||||
negative:
|
||||
- 角标真值归插件,主程序不自己产生数字;插件未接入则不会有角标
|
||||
- 角标不是消息未读数,具体含义由各插件自行定义
|
||||
- 自定义微应用菜单(纯外链)默认不会有角标,除非其后端接入了角标接口
|
||||
last_verified: v1.8.45
|
||||
---
|
||||
|
||||
# 插件/微应用菜单角标
|
||||
|
||||
## 定义
|
||||
插件/微应用可以在自己的菜单上显示「角标」——一个数字或一个小红点,用来提醒用户有待处理事项。角标真值由插件决定,DooTask 主程序只负责存储、下发与实时推送。
|
||||
|
||||
## 展示位置
|
||||
- **应用中心卡片**:对应插件/微应用卡片右上角显示数字或红点
|
||||
- **主导航平铺入口**:被平铺到左侧主菜单的单个插件入口显示其角标
|
||||
- **父『应用』入口(聚合)**:左侧「应用」一级入口与移动端底部「应用」标签显示聚合角标
|
||||
- 聚合规则:总数 = 工作报告未读数 + 所有插件角标数字之和
|
||||
- 总数 > 0 显示数字(超过 999 显示 999+);总数为 0 时若任一插件存在红点则显示红点
|
||||
|
||||
## 关键属性(菜单配置 menu_items)
|
||||
- **key**:菜单稳定标识,用于定位某个菜单的角标;留空时表示该应用的第一个菜单
|
||||
- **badge_clear_on_open**:是否在该菜单被打开时自动清零
|
||||
- `true`:打开对应应用菜单时角标自动清除
|
||||
- `false`(默认):永不自动清零,角标的增减完全由插件控制
|
||||
|
||||
## 自动清零
|
||||
当菜单设置了 `badge_clear_on_open: true`,用户打开该菜单时,前端会立即本地清零并调用接口持久化清除;多端会通过实时推送保持一致。
|
||||
|
||||
## 实时性
|
||||
角标变化通过 WebSocket 实时推送给在线用户;离线用户在下次进入时由应用菜单接口随初始数据一同同步,无需额外请求。
|
||||
|
||||
## 生命周期
|
||||
- 应用被卸载:该应用的全部角标清除
|
||||
- 用户离职:该用户的全部角标清除
|
||||
|
||||
## 相关
|
||||
- 微应用菜单:[[micro-app.menu.concept]]
|
||||
- 微应用权限(角标只发给对该应用菜单可见的用户):[[micro-app.permission.concept]]
|
||||
- 在哪能看到:[[micro-app.entry.menu-map]]
|
||||
@ -419,6 +419,17 @@ export default {
|
||||
// 备份配置
|
||||
this.backupConfigs[config.name] = $A.cloneJSON(config);
|
||||
|
||||
// 角标:打开 badge_clear_on_open=true 的菜单时自动清零(本地 + 服务端持久化)
|
||||
if (config.badge_clear_on_open === true && config.id) {
|
||||
const menuKey = typeof config.key === 'string' ? config.key : '';
|
||||
this.$store.commit('appBadges/clearMenu', {appid: config.id, menu_key: menuKey});
|
||||
this.$store.dispatch('call', {
|
||||
url: 'apps/badge/clear',
|
||||
method: 'post',
|
||||
data: {appid: config.id, menu_key: menuKey},
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// 从缓存读取胶囊配置
|
||||
const capsuleCache = await $A.IDBJson("microAppsCapsuleCache");
|
||||
if ($A.isJson(capsuleCache[config.name])) {
|
||||
@ -791,6 +802,12 @@ export default {
|
||||
if (ids.length === 0) {
|
||||
return
|
||||
}
|
||||
// 卸载的应用本地清除其角标
|
||||
apps.forEach(item => {
|
||||
if (item.type === 'uninstall') {
|
||||
this.$store.commit('appBadges/clearApp', item.id)
|
||||
}
|
||||
})
|
||||
this.microApps.forEach(app => {
|
||||
if (ids.includes(app.id)) {
|
||||
this.closeMicroApp(app.name, true)
|
||||
|
||||
@ -18,7 +18,8 @@
|
||||
<Badge class="tabbar-badge" :overflow-count="999" :text="msgUnreadMention"/>
|
||||
</template>
|
||||
<template v-else-if="item.name === 'application'">
|
||||
<Badge class="tabbar-badge" :overflow-count="999" :count="reportUnreadNumber"/>
|
||||
<Badge v-if="applicationBadgeCount > 0" class="tabbar-badge" :overflow-count="999" :count="applicationBadgeCount"/>
|
||||
<Badge v-else-if="applicationBadgeDot" class="tabbar-badge" dot/>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
@ -54,9 +55,15 @@ export default {
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['cacheDialogs', 'reportUnreadNumber']),
|
||||
...mapState(['cacheDialogs']),
|
||||
...mapGetters(['dashboardTask']),
|
||||
|
||||
// 父『应用』入口聚合角标(规则收敛在 appBadges 模块 getter)
|
||||
...mapGetters('appBadges', {
|
||||
applicationBadgeCount: 'applicationCount',
|
||||
applicationBadgeDot: 'applicationDot',
|
||||
}),
|
||||
|
||||
/**
|
||||
* 综合数(未读、提及、待办)
|
||||
* @returns {string|string}
|
||||
|
||||
@ -157,11 +157,14 @@
|
||||
<li @click="toggleRoute('application')" :class="classNameRoute('application')">
|
||||
<i class="taskfont"></i>
|
||||
<div class="menu-title">{{$L('应用')}}</div>
|
||||
<Badge class="menu-badge" :overflow-count="999" :text="String(reportUnreadNumber || '')"/>
|
||||
<Badge v-if="applicationBadgeCount > 0" class="menu-badge" :overflow-count="999" :count="applicationBadgeCount"/>
|
||||
<Badge v-else-if="applicationBadgeDot" class="menu-badge" dot/>
|
||||
</li>
|
||||
<li v-for="(item, key) in filterMicroAppsMenusMain" :key="key" @click="onTabbarClick('microApp', item)">
|
||||
<div class="apply-icon no-dark-content" :style="{backgroundImage: `url(${item.icon})`}"></div>
|
||||
<div class="menu-title">{{item.label}}</div>
|
||||
<Badge v-if="microBadge(item).count > 0" class="menu-badge" :overflow-count="999" :count="microBadge(item).count"/>
|
||||
<Badge v-else-if="microBadge(item).dot" class="menu-badge" dot/>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="ownerProjectTabsVisible" class="owner-project-tabs">
|
||||
@ -653,6 +656,12 @@ export default {
|
||||
|
||||
...mapGetters(['dashboardTask', "filterMicroAppsMenusMain"]),
|
||||
|
||||
// 父『应用』入口聚合角标(规则收敛在 appBadges 模块 getter)
|
||||
...mapGetters('appBadges', {
|
||||
applicationBadgeCount: 'applicationCount',
|
||||
applicationBadgeDot: 'applicationDot',
|
||||
}),
|
||||
|
||||
aiInstalled() {
|
||||
return this.microAppsIds?.includes('ai');
|
||||
},
|
||||
@ -1025,6 +1034,10 @@ export default {
|
||||
|
||||
methods: {
|
||||
transformEmojiToHtml,
|
||||
// 插件/微应用菜单角标 {count, dot}
|
||||
microBadge(menu) {
|
||||
return this.$store.getters['appBadges/badge'](menu && menu.id, menu && menu.key);
|
||||
},
|
||||
onMenuResizeChange({event}) {
|
||||
this.menuResizing = event !== 'up';
|
||||
if (event === 'up') {
|
||||
|
||||
@ -70,6 +70,10 @@
|
||||
<div class="apply-item" :class="{'is-sorting': sortingMode}" @click="handleCardClick(card)">
|
||||
<div class="logo">
|
||||
<div class="apply-icon no-dark-content" :style="{backgroundImage: `url(${card.micro.icon})`}"></div>
|
||||
<div v-if="!sortingMode" class="apply-box-top-report">
|
||||
<Badge v-if="microBadge(card.micro).count > 0" :overflow-count="999" :count="microBadge(card.micro).count"/>
|
||||
<Badge v-else-if="microBadge(card.micro).dot" dot/>
|
||||
</div>
|
||||
</div>
|
||||
<p>{{ card.micro.label }}</p>
|
||||
</div>
|
||||
@ -1039,6 +1043,10 @@ export default {
|
||||
}
|
||||
return item.value == type && num > 0
|
||||
},
|
||||
// 插件/微应用菜单角标 {count, dot}
|
||||
microBadge(menu) {
|
||||
return this.$store.getters['appBadges/badge'](menu && menu.id, menu && menu.key)
|
||||
},
|
||||
// 点击应用
|
||||
applyClick(item, params = '') {
|
||||
switch (item.value) {
|
||||
|
||||
13
resources/assets/js/store/actions.js
vendored
13
resources/assets/js/store/actions.js
vendored
@ -4642,7 +4642,7 @@ export default {
|
||||
* @param state
|
||||
* @param dispatch
|
||||
*/
|
||||
websocketConnection({state, dispatch}) {
|
||||
websocketConnection({state, dispatch, commit}) {
|
||||
clearTimeout(state.wsTimeout);
|
||||
if (state.ws) {
|
||||
state.ws.close();
|
||||
@ -4997,6 +4997,13 @@ export default {
|
||||
}
|
||||
})(msgDetail);
|
||||
break;
|
||||
|
||||
/**
|
||||
* 应用菜单角标
|
||||
*/
|
||||
case "appBadge":
|
||||
commit("appBadges/set", msgDetail.data || {});
|
||||
break;
|
||||
}
|
||||
break
|
||||
}
|
||||
@ -5293,6 +5300,7 @@ export default {
|
||||
// 组装打开微应用所需的最终 config(用于 MicroApps 组件渲染/启动)
|
||||
const config = {
|
||||
id: microAppId,
|
||||
key: typeof data.key == 'string' ? data.key : '',
|
||||
name: data.name,
|
||||
title: data.label || data.title || data.name,
|
||||
url: $A.mainUrl(url),
|
||||
@ -5304,6 +5312,7 @@ export default {
|
||||
auto_dark_theme: typeof data.auto_dark_theme == 'boolean' ? data.auto_dark_theme : true,
|
||||
keep_alive: typeof data.keep_alive == 'boolean' ? data.keep_alive : true,
|
||||
immersive: typeof data.immersive == 'boolean' ? data.immersive : false,
|
||||
badge_clear_on_open: typeof data.badge_clear_on_open == 'boolean' ? data.badge_clear_on_open : false,
|
||||
props: $A.isJson(data.props) ? data.props : {},
|
||||
}
|
||||
|
||||
@ -5367,6 +5376,8 @@ export default {
|
||||
// 忽略自定义菜单加载失败
|
||||
}
|
||||
commit("microApps/data", apps || [])
|
||||
// 用应用菜单返回的角标初始化(自定义应用由 microapp_menu 提供,插件应用由 appstore installed 提供)
|
||||
commit("appBadges/hydrate", apps || [])
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
6
resources/assets/js/store/index.js
vendored
6
resources/assets/js/store/index.js
vendored
@ -5,6 +5,7 @@ import state from './state'
|
||||
import getters from './getters'
|
||||
import actions from './actions'
|
||||
import mutations from './mutations'
|
||||
import appBadges from './modules/appBadges'
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
@ -12,5 +13,8 @@ export default new Vuex.Store({
|
||||
state,
|
||||
getters,
|
||||
mutations,
|
||||
actions
|
||||
actions,
|
||||
modules: {
|
||||
appBadges,
|
||||
}
|
||||
})
|
||||
|
||||
133
resources/assets/js/store/modules/appBadges.js
vendored
Normal file
133
resources/assets/js/store/modules/appBadges.js
vendored
Normal file
@ -0,0 +1,133 @@
|
||||
import Vue from 'vue'
|
||||
|
||||
// 无角标时的共享只读对象,避免 badge getter 每次调用都新建对象
|
||||
const EMPTY_BADGE = Object.freeze({count: 0, dot: false})
|
||||
|
||||
/**
|
||||
* 应用菜单角标(插件 / 自定义微应用)
|
||||
*
|
||||
* 结构:map[app_id][menu_key] = { count, dot }
|
||||
* - menu_key 为空串表示该应用的第一个菜单
|
||||
* - 初始值由 updateMicroAppsStatus 通过 hydrate 填充(microapp_menu / appstore installed)
|
||||
* - 运行时由 websocket 消息 appBadge 经 set 增量更新
|
||||
*/
|
||||
export default {
|
||||
namespaced: true,
|
||||
|
||||
state: () => ({
|
||||
map: {},
|
||||
}),
|
||||
|
||||
mutations: {
|
||||
/**
|
||||
* 增量设置单个菜单角标
|
||||
* @param state
|
||||
* @param appid
|
||||
* @param menu_key
|
||||
* @param count
|
||||
* @param dot
|
||||
*/
|
||||
set(state, {appid, menu_key, count, dot}) {
|
||||
if (!appid) {
|
||||
return
|
||||
}
|
||||
const key = menu_key || ''
|
||||
if (!state.map[appid]) {
|
||||
Vue.set(state.map, appid, {})
|
||||
}
|
||||
Vue.set(state.map[appid], key, {
|
||||
count: Number(count) || 0,
|
||||
dot: !!dot,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 由应用菜单列表整体初始化角标
|
||||
* @param state
|
||||
* @param apps 形如 [{id, menu_items:[{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}
|
||||
}
|
||||
})
|
||||
})
|
||||
state.map = map
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除某应用某菜单的角标
|
||||
*/
|
||||
clearMenu(state, {appid, menu_key}) {
|
||||
const key = menu_key || ''
|
||||
if (appid && state.map[appid]) {
|
||||
Vue.delete(state.map[appid], key)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除某应用全部角标(卸载/更新时)
|
||||
*/
|
||||
clearApp(state, appid) {
|
||||
if (appid && state.map[appid]) {
|
||||
Vue.delete(state.map, appid)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
getters: {
|
||||
/**
|
||||
* 取单个菜单角标
|
||||
* @returns {function(appid, menuKey): {count, dot}}
|
||||
*/
|
||||
badge: (state) => (appid, menuKey) => {
|
||||
return (state.map[appid] && state.map[appid][menuKey || '']) || EMPTY_BADGE
|
||||
},
|
||||
|
||||
/**
|
||||
* 所有应用角标数字之和
|
||||
*/
|
||||
totalCount: (state) => {
|
||||
let total = 0
|
||||
Object.keys(state.map).forEach(appid => {
|
||||
Object.keys(state.map[appid]).forEach(key => {
|
||||
total += Number(state.map[appid][key].count) || 0
|
||||
})
|
||||
})
|
||||
return total
|
||||
},
|
||||
|
||||
/**
|
||||
* 是否存在任一红点
|
||||
*/
|
||||
anyDot: (state) => {
|
||||
return Object.keys(state.map).some(appid => {
|
||||
return Object.keys(state.map[appid]).some(key => !!state.map[appid][key].dot)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 父『应用』入口聚合数字:工作报告未读 + 所有插件角标数字之和
|
||||
*/
|
||||
applicationCount: (state, getters, rootState) => {
|
||||
return (Number(rootState.reportUnreadNumber) || 0) + getters.totalCount
|
||||
},
|
||||
|
||||
/**
|
||||
* 父『应用』入口聚合红点:总数为 0 时,任一插件存在红点则显示
|
||||
*/
|
||||
applicationDot: (state, getters) => getters.anyDot,
|
||||
},
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
|
||||
|
||||
接口总数:304
|
||||
接口总数:306
|
||||
|
||||
## 路由规则
|
||||
|
||||
@ -371,6 +371,13 @@ API 使用动态路由(见 `routes/web.php`),URL 段映射为控制器方
|
||||
| api/search/file | file() | get | 搜索文件 |
|
||||
| api/search/message | message() | get | 搜索消息 |
|
||||
|
||||
## apps(AppsController)
|
||||
|
||||
| URL | 方法名 | HTTP | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| api/apps/badge/set | badge__set() | post | 设置角标(应用密钥鉴权) |
|
||||
| api/apps/badge/clear | badge__clear() | post | 清除角标(当前用户 token 鉴权) |
|
||||
|
||||
## test(TestController)
|
||||
|
||||
| URL | 方法名 | HTTP | 说明 |
|
||||
|
||||
@ -14,6 +14,7 @@ use App\Http\Controllers\Api\AssistantController;
|
||||
use App\Http\Controllers\Api\ProjectController;
|
||||
use App\Http\Controllers\Api\ComplaintController;
|
||||
use App\Http\Controllers\Api\SearchController;
|
||||
use App\Http\Controllers\Api\AppsController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@ -64,6 +65,9 @@ Route::prefix('api')->middleware(['webapi'])->group(function () {
|
||||
// 智能搜索
|
||||
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('test/{method}', TestController::class);
|
||||
Route::any('test/{method}/{action}', TestController::class);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user