feat: 添加自定义微应用菜单功能,支持管理员配置和保存菜单项

This commit is contained in:
kuaifan 2025-11-19 07:54:47 +00:00
parent f65da118d7
commit 4983fe8feb
7 changed files with 597 additions and 9 deletions

View File

@ -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 创建项目模板
*

View File

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

View File

@ -1301,7 +1301,7 @@ class Base
/**
* 获取或设置
* @param $setname // 配置名称
* @param bool $array // 保存内容
* @param bool|array $array // 保存内容
* @param bool $isUpdate // 保存内容为更新模式,默认否
* @return array
*/

View File

@ -15,6 +15,7 @@
<DropdownMenu slot="list">
<DropdownItem v-if="!sortingMode" name="sort">{{ $L('调整排序') }}</DropdownItem>
<DropdownItem v-else name="cancelSort">{{ $L('退出排序') }}</DropdownItem>
<DropdownItem v-if="userIsAdmin" divided name="customMicro">{{ $L('自定义应用菜单') }}</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
@ -111,6 +112,114 @@
</div>
</div>
<!--自定义应用菜单-->
<Modal
v-if="userIsAdmin"
v-model="customMicroModalVisible"
:title="$L('自定义应用菜单')"
:mask-closable="false"
width="760">
<Alert type="info" show-icon class="custom-micro-alert">
{{ $L('仅管理员可配置,保存后会在应用列表中生成对应菜单。') }}
</Alert>
<div v-if="customMicroLoading" class="custom-micro-loading">
<Loading/>
</div>
<div v-else class="custom-micro-body">
<div v-if="!customMicroMenus.length" class="custom-micro-empty">
{{ $L('暂无自定义菜单,请点击下方按钮新增。') }}
</div>
<Collapse v-else v-model="customMicroCollapsed" accordion simple>
<Panel v-for="(item, index) in customMicroMenus" :key="item.uid" :name="item.uid">
<div class="custom-micro-card__header">
<div class="custom-micro-card__title">
{{ item.id || $L('未命名应用') }}
</div>
<div class="custom-micro-card__actions">
<Button @click.stop="duplicateCustomMenu(index)">{{ $L('复制') }}</Button>
<Button type="error" @click.stop="removeCustomMenu(index)">{{ $L('删除') }}</Button>
</div>
</div>
<div slot="content">
<Form label-position="top">
<Row :gutter="16">
<Col :sm="12" :xs="24">
<FormItem :label="$L('应用 ID')" required>
<Input v-model.trim="item.id" placeholder="custom-okr"/>
</FormItem>
</Col>
<Col :sm="12" :xs="24">
<FormItem :label="$L('应用名称')">
<Input v-model.trim="item.name" placeholder="OKR 开发"/>
</FormItem>
</Col>
</Row>
<FormItem :label="$L('菜单标题')" required>
<Input v-model.trim="item.menu.label" placeholder="OKR 开发入口"/>
</FormItem>
<Row :gutter="16">
<Col :sm="12" :xs="24">
<FormItem :label="$L('菜单位置')">
<Select v-model="item.menu.location" transfer>
<Option value="application">{{ $L('应用中心 - 常用') }}</Option>
<Option value="application/admin">{{ $L('应用中心 - 管理') }}</Option>
<Option value="main/menu">{{ $L('主导航') }}</Option>
</Select>
</FormItem>
</Col>
<Col :sm="12" :xs="24">
<FormItem :label="$L('可见范围')">
<Select v-model="item.menu.visible_to" transfer>
<Option value="admin">{{ $L('仅管理员') }}</Option>
<Option value="all">{{ $L('所有成员') }}</Option>
</Select>
</FormItem>
</Col>
</Row>
<FormItem :label="$L('图标地址')">
<Input v-model.trim="item.menu.icon" placeholder="https://example.com/icon.png"/>
</FormItem>
<FormItem :label="$L('菜单 URL')" required>
<Input v-model.trim="item.menu.url" placeholder="https://example.com/app?token={user_token}"/>
</FormItem>
<Row :gutter="16">
<Col :sm="12" :xs="24">
<FormItem :label="$L('URL 类型')">
<Select v-model="item.menu.url_type" transfer>
<Option value="iframe">iframe</Option>
<Option value="iframe_blank">iframe_blank</Option>
<Option value="inline">inline</Option>
<Option value="inline_blank">inline_blank</Option>
<Option value="external">external</Option>
</Select>
</FormItem>
</Col>
<Col :sm="12" :xs="24">
<FormItem :label="$L('背景颜色')">
<Input v-model.trim="item.menu.background" placeholder="#FFFFFF 或 #FFFFFF|#000000"/>
</FormItem>
</Col>
</Row>
<div class="custom-micro-checkbox-group">
<Checkbox v-model="item.menu.keep_alive">{{ $L('保持激活状态 (keep_alive)') }}</Checkbox>
<Checkbox v-model="item.menu.disable_scope_css">{{ $L('禁用作用域样式') }}</Checkbox>
<Checkbox v-model="item.menu.transparent">{{ $L('透明背景') }}</Checkbox>
<Checkbox v-model="item.menu.auto_dark_theme">{{ $L('自动暗黑模式') }}</Checkbox>
</div>
</Form>
</div>
</Panel>
</Collapse>
<Button class="custom-micro-add-btn" type="dashed" long icon="md-add" @click="addCustomMenu">
{{ $L('新增菜单') }}
</Button>
</div>
<div slot="footer" class="adaption">
<Button @click="customMicroModalVisible=false">{{ $L('关闭') }}</Button>
<Button type="primary" :loading="customMicroSaving" @click="saveCustomMenus">{{ $L('保存') }}</Button>
</div>
</Modal>
<!--MY BOT-->
<DrawerOverlay v-model="mybotShow" placement="right" :size="720">
<template v-if="mybotShow" #title>
@ -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,
});

View File

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

View File

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

View File

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