diff --git a/app/Http/Controllers/Api/AppsController.php b/app/Http/Controllers/Api/AppsController.php new file mode 100644 index 000000000..2b594e0db --- /dev/null +++ b/app/Http/Controllers/Api/AppsController.php @@ -0,0 +1,74 @@ + 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', '')) + )); + } +} diff --git a/app/Http/Controllers/Api/SystemController.php b/app/Http/Controllers/Api/SystemController.php index dcac260c3..576aa7c30 100755 --- a/app/Http/Controllers/Api/SystemController.php +++ b/app/Http/Controllers/Api/SystemController.php @@ -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); } diff --git a/app/Models/AppBadge.php b/app/Models/AppBadge.php new file mode 100644 index 000000000..a34e905e8 --- /dev/null +++ b/app/Models/AppBadge.php @@ -0,0 +1,48 @@ + 'integer', + 'count' => 'integer', + 'dot' => 'boolean', + ]; +} diff --git a/app/Models/Setting.php b/app/Models/Setting.php index e7d97ce86..0c585c16a 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -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; diff --git a/app/Module/Apps.php b/app/Module/Apps.php index 72cd9b08b..469319033 100644 --- a/app/Module/Apps.php +++ b/app/Module/Apps.php @@ -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). * diff --git a/app/Module/Badge.php b/app/Module/Badge.php new file mode 100644 index 000000000..19cf80d84 --- /dev/null +++ b/app/Module/Badge.php @@ -0,0 +1,332 @@ + $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); + } +} diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php index 2e7695181..460c10f1a 100644 --- a/app/Observers/UserObserver.php +++ b/app/Observers/UserObserver.php @@ -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); } } diff --git a/database/migrations/2026_06_28_000001_create_app_badges_table.php b/database/migrations/2026_06_28_000001_create_app_badges_table.php new file mode 100644 index 000000000..b9fa66c86 --- /dev/null +++ b/database/migrations/2026_06_28_000001_create_app_badges_table.php @@ -0,0 +1,43 @@ +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'); + } +} diff --git a/language/original-api.txt b/language/original-api.txt index 32042640d..00389170f 100644 --- a/language/original-api.txt +++ b/language/original-api.txt @@ -1018,3 +1018,6 @@ AI 助手 在线授权已失效,已回落到基础版 请输入邮箱 请输入邮箱和验证码 +密钥无效 +应用未安装 +菜单不存在 diff --git a/resources/ai-kb/_meta/feature-map.yaml b/resources/ai-kb/_meta/feature-map.yaml index 92b3f6a79..8b50eccb1 100644 --- a/resources/ai-kb/_meta/feature-map.yaml +++ b/resources/ai-kb/_meta/feature-map.yaml @@ -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 diff --git a/resources/ai-kb/zh/concept/micro-app/badge.md b/resources/ai-kb/zh/concept/micro-app/badge.md new file mode 100644 index 000000000..aef575749 --- /dev/null +++ b/resources/ai-kb/zh/concept/micro-app/badge.md @@ -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]] diff --git a/resources/assets/js/components/MicroApps/index.vue b/resources/assets/js/components/MicroApps/index.vue index 4cb5df955..fcb7586eb 100644 --- a/resources/assets/js/components/MicroApps/index.vue +++ b/resources/assets/js/components/MicroApps/index.vue @@ -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) diff --git a/resources/assets/js/components/Mobile/Tabbar.vue b/resources/assets/js/components/Mobile/Tabbar.vue index 873392a7f..417d12087 100644 --- a/resources/assets/js/components/Mobile/Tabbar.vue +++ b/resources/assets/js/components/Mobile/Tabbar.vue @@ -18,7 +18,8 @@ @@ -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} diff --git a/resources/assets/js/pages/manage.vue b/resources/assets/js/pages/manage.vue index 68d7111d2..e31c92fb4 100644 --- a/resources/assets/js/pages/manage.vue +++ b/resources/assets/js/pages/manage.vue @@ -157,11 +157,14 @@
  • - + +
  • + +
  • @@ -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') { diff --git a/resources/assets/js/pages/manage/application.vue b/resources/assets/js/pages/manage/application.vue index afb90fa0b..a89c3e6b2 100644 --- a/resources/assets/js/pages/manage/application.vue +++ b/resources/assets/js/pages/manage/application.vue @@ -70,6 +70,10 @@

    {{ card.micro.label }}

    @@ -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) { diff --git a/resources/assets/js/store/actions.js b/resources/assets/js/store/actions.js index 40069505f..1377df840 100644 --- a/resources/assets/js/store/actions.js +++ b/resources/assets/js/store/actions.js @@ -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 || []) } }, diff --git a/resources/assets/js/store/index.js b/resources/assets/js/store/index.js index 140ec76b3..7fba8f7a3 100644 --- a/resources/assets/js/store/index.js +++ b/resources/assets/js/store/index.js @@ -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, + } }) diff --git a/resources/assets/js/store/modules/appBadges.js b/resources/assets/js/store/modules/appBadges.js new file mode 100644 index 000000000..b5e1d5525 --- /dev/null +++ b/resources/assets/js/store/modules/appBadges.js @@ -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, + }, +} diff --git a/routes/api-map.md b/routes/api-map.md index f100a84fb..a79ac3f7f 100644 --- a/routes/api-map.md +++ b/routes/api-map.md @@ -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 | 说明 | diff --git a/routes/web.php b/routes/web.php index 38ac159a9..fe611dc7c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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);