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 @@
{{ card.micro.label }}