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:
kuaifan 2026-06-29 02:32:19 +00:00
parent eb672eaef1
commit 420d46d5cc
20 changed files with 961 additions and 18 deletions

View 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', ''))
));
}
}

View File

@ -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
View 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',
];
}

View File

@ -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;

View File

@ -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
View 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 行(仅存非清除态)
* - 通过 WebSocketPushTask向在线用户实时推送 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);
}
}

View File

@ -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);
}
}

View File

@ -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('应用IDappstore 插件 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');
}
}

View File

@ -1018,3 +1018,6 @@ AI 助手
在线授权已失效,已回落到基础版
请输入邮箱
请输入邮箱和验证码
密钥无效
应用未安装
菜单不存在

View File

@ -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

View 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]]

View File

@ -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)

View File

@ -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}

View File

@ -157,11 +157,14 @@
<li @click="toggleRoute('application')" :class="classNameRoute('application')">
<i class="taskfont">&#xe60c;</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') {

View File

@ -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) {

View File

@ -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 || [])
}
},

View File

@ -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,
}
})

View 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,
},
}

View File

@ -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 | 搜索消息 |
## appsAppsController
| URL | 方法名 | HTTP | 说明 |
| --- | --- | --- | --- |
| api/apps/badge/set | badge__set() | post | 设置角标(应用密钥鉴权) |
| api/apps/badge/clear | badge__clear() | post | 清除角标(当前用户 token 鉴权) |
## testTestController
| URL | 方法名 | HTTP | 说明 |

View File

@ -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);