diff --git a/app/Http/Controllers/Api/SystemController.php b/app/Http/Controllers/Api/SystemController.php index df5dd82f6..707338d0e 100755 --- a/app/Http/Controllers/Api/SystemController.php +++ b/app/Http/Controllers/Api/SystemController.php @@ -722,6 +722,47 @@ class SystemController extends AbstractController return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting); } + /** + * @api {post} api/system/microapp_menu 自定义应用菜单 + * + * @apiDescription 获取或保存自定义微应用菜单,仅管理员可配置 + * @apiVersion 1.0.0 + * @apiGroup system + * @apiName microapp_menu + * + * @apiParam {String} type + * - get: 获取(默认) + * - save: 保存(限管理员) + * @apiParam {Array} list 菜单列表,格式:[{id,name,version,menu_items}] + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data 返回数据 + */ + public function microapp_menu() + { + $type = trim(Request::input('type')); + $user = User::auth(); + if ($type == 'save') { + User::auth('admin'); + $list = Request::input('list'); + if (empty($list) || !is_array($list)) { + $list = []; + } + $apps = Setting::normalizeCustomMicroApps($list); + $setting = Base::setting('microapp_menu', $apps); + $setting = Setting::formatCustomMicroAppsForResponse($setting); + } else { + $setting = Base::setting('microapp_menu'); + if (!is_array($setting)) { + $setting = []; + } + $setting = Setting::filterCustomMicroAppsForUser($setting, $user); + $setting = Setting::formatCustomMicroAppsForResponse($setting); + } + return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting); + } + /** * @api {post} api/system/column/template 创建项目模板 * diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 8303aad29..eccf46a69 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -164,6 +164,213 @@ class Setting extends AbstractModel return $array; } + /** + * 规范自定义微应用配置 + * @param array $list + * @return array + */ + public static function normalizeCustomMicroApps($list) + { + if (!is_array($list)) { + return []; + } + $apps = []; + foreach ($list as $item) { + $app = self::normalizeCustomMicroAppItem($item); + if ($app) { + $apps[] = $app; + } + } + return $apps; + } + + /** + * 根据用户身份过滤可见的自定义微应用 + * @param array $apps + * @param \App\Models\User|null $user + * @return array + */ + public static function filterCustomMicroAppsForUser(array $apps, $user) + { + if (empty($apps)) { + return []; + } + $isAdmin = $user ? $user->isAdmin() : false; + $userId = $user ? intval($user->userid) : 0; + $filtered = []; + foreach ($apps as $app) { + $visible = self::normalizeCustomMicroVisible($app['visible_to'] ?? ['admin']); + if (!self::isCustomMicroVisibleTo($visible, $isAdmin, $userId)) { + continue; + } + if (empty($app['menu_items']) || !is_array($app['menu_items'])) { + continue; + } + $menus = array_values(array_filter($app['menu_items'], function ($menu) use ($isAdmin, $userId) { + if (!isset($menu['visible_to'])) { + return true; + } + $visible = self::normalizeCustomMicroVisible($menu['visible_to']); + return self::isCustomMicroVisibleTo($visible, $isAdmin, $userId); + })); + if (empty($menus)) { + continue; + } + $app['menu_items'] = $menus; + $filtered[] = $app; + } + return $filtered; + } + + /** + * 将存储结构转换成 appstore 接口同款格式 + * @param array $apps + * @return array + */ + public static function formatCustomMicroAppsForResponse(array $apps) + { + return array_values(array_map(function ($app) { + unset($app['visible_to']); + if (!empty($app['menu_items']) && is_array($app['menu_items'])) { + $app['menu_items'] = array_values(array_map(function ($menu) { + $menu['keep_alive'] = isset($menu['keep_alive']) ? (bool)$menu['keep_alive'] : true; + $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); + if (isset($menu['visible_to'])) { + unset($menu['visible_to']); + } + return $menu; + }, $app['menu_items'])); + } + return $app; + }, $apps)); + } + + /** + * 规范自定义微应用 + * @param array $item + * @return array|null + */ + protected static function normalizeCustomMicroAppItem($item) + { + if (!is_array($item)) { + return null; + } + $id = trim($item['id'] ?? ''); + if ($id === '') { + return null; + } + $name = Base::newTrim($item['name'] ?? ''); + $version = Base::newTrim($item['version'] ?? '') ?: 'custom'; + $menuItems = []; + if (isset($item['menu_items']) && is_array($item['menu_items'])) { + $menuItems = $item['menu_items']; + } elseif (isset($item['menu']) && is_array($item['menu'])) { + $menuItems = [$item['menu']]; + } + if (empty($menuItems)) { + return null; + } + $normalizedMenus = []; + foreach ($menuItems as $menu) { + $formattedMenu = self::normalizeCustomMicroMenuItem($menu, $name ?: $id); + if ($formattedMenu) { + $normalizedMenus[] = $formattedMenu; + } + } + if (empty($normalizedMenus)) { + return null; + } + return Base::newTrim([ + 'id' => $id, + 'name' => $name, + 'version' => $version, + 'menu_items' => $normalizedMenus, + 'visible_to' => self::normalizeCustomMicroVisible($item['visible_to'] ?? 'admin'), + ]); + } + + /** + * 规范自定义微应用菜单项 + * @param array $menu + * @param string $fallbackLabel + * @return array|null + */ + protected static function normalizeCustomMicroMenuItem($menu, $fallbackLabel = '') + { + if (!is_array($menu)) { + return null; + } + $url = trim($menu['url'] ?? ''); + if ($url === '') { + return null; + } + $location = trim($menu['location'] ?? 'application'); + $label = trim($menu['label'] ?? $fallbackLabel); + $urlType = strtolower(trim($menu['url_type'] ?? 'iframe')); + $payload = [ + 'location' => $location, + 'label' => $label, + 'icon' => Base::newTrim($menu['icon'] ?? ''), + 'url' => $url, + 'url_type' => $urlType, + 'keep_alive' => isset($menu['keep_alive']) ? (bool)$menu['keep_alive'] : true, + 'disable_scope_css' => (bool)($menu['disable_scope_css'] ?? false), + 'auto_dark_theme' => isset($menu['auto_dark_theme']) ? (bool)$menu['auto_dark_theme'] : true, + 'transparent' => (bool)($menu['transparent'] ?? false), + ]; + if (!empty($menu['background'])) { + $payload['background'] = Base::newTrim($menu['background']); + } + if (!empty($menu['capsule']) && is_array($menu['capsule'])) { + $payload['capsule'] = Base::newTrim($menu['capsule']); + } + return $payload; + } + + /** + * 规范自定义微应用可见范围 + * @param mixed $value + * @return array + */ + protected static function normalizeCustomMicroVisible($value) + { + if (is_array($value)) { + $list = array_filter(array_map('trim', $value)); + } else { + $list = array_filter(array_map('trim', explode(',', (string)$value))); + } + if (empty($list)) { + return ['admin']; + } + if (in_array('all', $list)) { + return ['all']; + } + return array_values($list); + } + + /** + * 判断自定义微应用是否可见 + * @param array $visible + * @param bool $isAdmin + * @param int $userId + * @return bool + */ + protected static function isCustomMicroVisibleTo(array $visible, bool $isAdmin, int $userId) + { + if (in_array('all', $visible)) { + return true; + } + if ($isAdmin && in_array('admin', $visible)) { + return true; + } + if ($userId > 0 && in_array((string)$userId, $visible, true)) { + return true; + } + return false; + } + /** * 验证邮箱地址(过滤忽略地址) * @param $array diff --git a/app/Module/Base.php b/app/Module/Base.php index ab0baeccb..172de773a 100755 --- a/app/Module/Base.php +++ b/app/Module/Base.php @@ -1301,7 +1301,7 @@ class Base /** * 获取或设置 * @param $setname // 配置名称 - * @param bool $array // 保存内容 + * @param bool|array $array // 保存内容 * @param bool $isUpdate // 保存内容为更新模式,默认否 * @return array */ diff --git a/resources/assets/js/pages/manage/application.vue b/resources/assets/js/pages/manage/application.vue index 5622a6311..dc4b2d090 100644 --- a/resources/assets/js/pages/manage/application.vue +++ b/resources/assets/js/pages/manage/application.vue @@ -15,6 +15,7 @@ {{ $L('调整排序') }} {{ $L('退出排序') }} + {{ $L('自定义应用菜单') }} @@ -111,6 +112,114 @@ + + + + {{ $L('仅管理员可配置,保存后会在应用列表中生成对应菜单。') }} + + + + + + + {{ $L('暂无自定义菜单,请点击下方按钮新增。') }} + + + + + + {{ item.id || $L('未命名应用') }} + + + {{ $L('复制') }} + {{ $L('删除') }} + + + + + + + + + + + + + + + + + + + + + + + + {{ $L('应用中心 - 常用') }} + {{ $L('应用中心 - 管理') }} + {{ $L('主导航') }} + + + + + + + {{ $L('仅管理员') }} + {{ $L('所有成员') }} + + + + + + + + + + + + + + + iframe + iframe_blank + inline + inline_blank + external + + + + + + + + + + + {{ $L('保持激活状态 (keep_alive)') }} + {{ $L('禁用作用域样式') }} + {{ $L('透明背景') }} + {{ $L('自动暗黑模式') }} + + + + + + + {{ $L('新增菜单') }} + + + + {{ $L('关闭') }} + {{ $L('保存') }} + + + @@ -323,6 +432,20 @@ import ImgUpload from "../../components/ImgUpload.vue"; import {webhookEventOptions} from "../../utils/webhook"; import Draggable from "vuedraggable"; +const createCustomMicroMenu = () => ({ + uid: `custom_${Math.random().toString(36).slice(2, 10)}`, + id: '', + name: '', + version: 'custom', + menu: { + location: 'application', + url_type: 'iframe', + visible_to: 'admin', + keep_alive: true, + auto_dark_theme: true, + } +}); + export default { components: { Draggable, @@ -383,6 +506,12 @@ export default { // sendData: [], sendType: '', + // + customMicroModalVisible: false, + customMicroMenus: [], + customMicroLoading: false, + customMicroSaving: false, + customMicroCollapsed: '', } }, created() { @@ -494,8 +623,123 @@ export default { this.enterSortMode(); } else if (action === 'cancelSort') { this.exitSortMode(); + } else if (action === 'customMicro') { + this.openCustomMicroModal(); } }, + openCustomMicroModal() { + if (!this.userIsAdmin) { + return; + } + this.customMicroModalVisible = true; + this.loadCustomMicroMenus(); + }, + loadCustomMicroMenus() { + this.customMicroLoading = true; + this.$store.dispatch("call", { + url: 'system/microapp_menu?type=get', + method: 'post', + }).then(({data}) => { + this.customMicroMenus = this.normalizeCustomMenus(data); + this.customMicroCollapsed = this.customMicroMenus.length > 0 ? this.customMicroMenus[0].uid : ''; + }).catch(({msg}) => { + if (msg) { + $A.modalError(msg); + } + }).finally(() => { + this.customMicroLoading = false; + }); + }, + normalizeCustomMenus(list = []) { + if (!$A.isArray(list)) { + return []; + } + return list.map(app => { + const draft = createCustomMicroMenu(); + return Object.assign({}, draft, app, { + menu: Object.assign({}, draft.menu, $A.isArray(app.menu_items) && app.menu_items.length > 0 ? app.menu_items[0] : {}), + }); + }); + }, + pickCustomMenuLabel(label, fallback = '') { + if (typeof label === 'string') { + return label || fallback; + } + if ($A.isJson(label)) { + return label.zh || label.en || fallback; + } + return fallback; + }, + addCustomMenu() { + const draft = createCustomMicroMenu(); + this.customMicroMenus.push(draft); + this.customMicroCollapsed = draft.uid; + }, + duplicateCustomMenu(index) { + const target = this.customMicroMenus[index]; + if (!target) { + return; + } + const copy = $A.cloneJSON(target); + copy.uid = createCustomMicroMenu().uid; + copy.id = copy.id ? `${copy.id}_copy` : ''; + copy.name = copy.name ? `${copy.name} copy` : ''; + copy.menu.label = copy.menu.label ? `${copy.menu.label} copy` : ''; + this.customMicroMenus.splice(index + 1, 0, copy); + this.customMicroCollapsed = copy.uid; + }, + removeCustomMenu(index) { + this.customMicroMenus.splice(index, 1); + }, + saveCustomMenus() { + if (this.customMicroSaving) { + return; + } + const payload = []; + for (const item of this.customMicroMenus) { + const formatted = this.formatCustomMenuForSave(item); + if (!formatted) { + $A.modalWarning({ + title: '提示', + content: '请为每个菜单填写应用ID、菜单标题和有效的 URL。', + }); + return; + } + payload.push(formatted); + } + this.customMicroSaving = true; + this.$store.dispatch("call", { + url: 'system/microapp_menu?type=save', + method: 'post', + data: { + list: payload + }, + }).then(_ => { + $A.messageSuccess('保存成功'); + this.loadCustomMicroMenus(); + this.$store.dispatch("updateMicroAppsStatus"); + }).catch(({msg}) => { + if (msg) { + $A.modalError(msg); + } + }).finally(() => { + this.customMicroSaving = false; + }); + }, + formatCustomMenuForSave(item) { + const id = (item.id || '').trim(); + const url = (item.menu.url || '').trim(); + const label = (item.menu.label || item.name || item.id || '').trim(); + if (!id || !url || !label) { + return null; + } + return { + id, + name: (item.name || '').trim(), + version: item.version || 'custom', + menu_items: [Object.assign({}, item.menu, { url, label })], + }; + }, currentCards(type) { return this.sortingMode ? (this.sortLists[type] || []) : this.getDisplayItems(type); }, @@ -682,9 +926,9 @@ export default { }).then(({data, msg}) => { this.appSorts = this.normalizeSortPayload(data?.sorts || payload); this.exitSortMode(); - $A.messageSuccess(msg || this.$L('保存成功')); + $A.messageSuccess(msg || '保存成功'); }).catch(({msg}) => { - $A.modalError(msg || this.$L('保存失败')); + $A.modalError(msg || '保存失败'); }).finally(() => { this.appSortSaving = false; }); @@ -876,7 +1120,7 @@ export default { // 与我的机器人聊天 chatMybot(userid) { this.$store.dispatch("openDialogUserid", userid).catch(({msg}) => { - $A.modalError(msg || this.$L('打开会话失败')) + $A.modalError(msg || '打开会话失败') }); }, // 添加修改我的机器人 @@ -985,7 +1229,7 @@ export default { } else { // 其他文本 $A.modalInfo({ - title: this.$L('扫描结果'), + title: '扫描结果', content: text, width: 400, }); diff --git a/resources/assets/js/store/actions.js b/resources/assets/js/store/actions.js index c742a6aea..b1a04f6df 100644 --- a/resources/assets/js/store/actions.js +++ b/resources/assets/js/store/actions.js @@ -5144,7 +5144,7 @@ export default { * @param commit * @param dispatch */ - async updateMicroAppsStatus({commit, state}) { + async updateMicroAppsStatus({commit, state, dispatch}) { const {data: {code, data}} = await axios.get($A.mainUrl('appstore/api/v1/internal/installed'), { headers: { Token: state.userToken, @@ -5152,7 +5152,23 @@ export default { } }) if (code === 200) { - commit("microApps/data", data|| []) + let apps = Array.isArray(data) ? data : []; + try { + const {data: customData} = await dispatch('call', { + url: 'system/microapp_menu?type=get', + }); + if ($A.isArray(customData) && customData.length > 0) { + customData.forEach(item => { + item.menu_items.forEach(menu => { + menu.icon = menu.icon || $A.mainUrl("images/application/appstore-default.svg"); + }); + }); + apps = apps.concat(customData); + } + } catch (e) { + // 忽略自定义菜单加载失败 + } + commit("microApps/data", apps || []) } }, diff --git a/resources/assets/js/store/mutations.js b/resources/assets/js/store/mutations.js index decdd2347..e0b516655 100644 --- a/resources/assets/js/store/mutations.js +++ b/resources/assets/js/store/mutations.js @@ -420,8 +420,8 @@ export default { // 更新菜单 const menus = []; data.forEach((item) => { - if (item.menu_items) { - menus.push(...item.menu_items.map(m => Object.assign(m, {id: item.id}))); + if (Array.isArray(item.menu_items) && item.menu_items.length > 0) { + menus.push(...item.menu_items.map(menu => Object.assign({}, menu, {id: item.id}))); } }) menus.forEach(item => { diff --git a/resources/assets/sass/pages/page-apply.scss b/resources/assets/sass/pages/page-apply.scss index b6f41a48a..01d7491c0 100644 --- a/resources/assets/sass/pages/page-apply.scss +++ b/resources/assets/sass/pages/page-apply.scss @@ -582,6 +582,86 @@ } } +.custom-micro-alert { + margin-bottom: 16px; +} + +.custom-micro-loading { + min-height: 160px; + display: flex; + align-items: center; + justify-content: center; +} + +.custom-micro-body { + margin: 0 -24px; + padding: 0 24px; + display: flex; + flex-direction: column; + gap: 16px; + + .ivu-collapse { + > .ivu-collapse-item { + > .ivu-collapse-header { + display: flex; + align-items: center; + height: 60px; + padding-left: 0 + } + } + } +} + +.custom-micro-empty { + text-align: center; + color: #909399; + padding: 40px 0; +} + +.custom-micro-card { + border: 1px solid #e5e6eb; + border-radius: 8px; + padding: 16px; + background-color: #fff; +} + +.custom-micro-card__header { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + + .custom-micro-card__title { + font-weight: 600; + font-size: 15px; + color: #333; + } + + .custom-micro-card__actions { + display: flex; + gap: 8px; + + .ivu-btn { + font-size: 13px; + padding: 0 10px; + height: 28px; + } + } +} + +.custom-micro-checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 12px 24px; + margin-top: 8px; +} + +.custom-micro-add-btn { + flex-shrink: 0; + height: 38px; +} + body.window-portrait { .page-apply { .apply-wrapper {