From 5b221c07b7d03da8b7aa8192661a2c8eda5287a1 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Thu, 13 Aug 2026 07:46:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E5=BA=94=E7=94=A8=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E5=85=AC=E5=8F=B8=E7=BA=A7=E7=AE=A1=E7=90=86+?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=A1=B5+=E8=BA=AB=E4=BB=BD=E5=BE=BD?= =?UTF-8?q?=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppMarket 已安装应用改'管理':公司级安装/停用弹窗(diff 批量增删公司) - 新增 AppConfigPage 独立配置页(AppConfig 键值表,/API/App/Config) - 修复 Config 接口参数嵌套(params[AppUUID]→AppUUID) - 侧边栏新增'强制刷新菜单'按钮 - 顶部导航新增当前身份徽标(角色+公司,管理员高亮,点击切换) - 新增前端应用注册表 src/apps/index.ts(AppCode→路由/类型/旧页面映射,预留 MSYNC) --- src/api/app.ts | 36 ++++- src/apps/index.ts | 101 ++++++++++++++ src/layouts/DefaultLayout.vue | 136 ++++++++++++++++++- src/router/index.ts | 6 + src/views/app/AppConfigPage.vue | 170 +++++++++++++++++++++++ src/views/app/AppMarket.vue | 231 +++++++++++++++++++++++++++++--- src/views/app/AppMine.vue | 25 ++++ 7 files changed, 677 insertions(+), 28 deletions(-) create mode 100644 src/apps/index.ts create mode 100644 src/views/app/AppConfigPage.vue diff --git a/src/api/app.ts b/src/api/app.ts index c1a56a2..6450c5d 100644 --- a/src/api/app.ts +++ b/src/api/app.ts @@ -11,6 +11,10 @@ export interface AppItem { AuthRequired: number MenuRootUUID?: string AppSort?: number + /** 前端入口(真应用注册协议:安装后"打开应用"跳转目标) */ + FrontendRoute?: string + FrontendType?: string + LegacySrc?: string /** 市场页附加状态 */ TenantInstalled?: number TenantAuth?: number @@ -25,6 +29,8 @@ export interface CompanyScopeItem { CompanyUUID: string CompanyName: string CompanyShort: string + /** 指定应用在各公司的安装状态(ManageCompanies 传 AppUUID 时返回) */ + Installed?: number } /** 个人授权申请项(租户管理员审批台) */ @@ -38,6 +44,17 @@ export interface PendingItem { ApplyTime: string | null } +/** 应用配置项(AppConfig 键值表) */ +export interface AppConfigItem { + Key: string + Label: string + Value: string + Default: string + Secret: number + Placeholder: string + Updated: boolean +} + /** 应用/功能 API 客户端 */ export const appApi = { /** 应用市场列表(附租户/公司安装/个人启用/申请状态) */ @@ -46,17 +63,18 @@ export const appApi = { /** 我的应用(当前用户可用) */ myApps: (): Promise<{ list: AppItem[]; total: number }> => get<{ list: AppItem[]; total: number }>('/API/App/MyApps'), - /** 管理员可安装应用的公司列表(按权限范围) */ - manageCompanies: (): Promise<{ Scope: string; Companies: CompanyScopeItem[] }> => - get<{ Scope: string; Companies: CompanyScopeItem[] }>('/API/App/ManageCompanies'), + /** 管理员可安装应用的公司列表(按权限范围); + * 传 AppUUID 时附带各公司安装状态(Installed),供管理弹窗用 */ + manageCompanies: (AppUUID?: string): Promise<{ Scope: string; Companies: CompanyScopeItem[] }> => + get<{ Scope: string; Companies: CompanyScopeItem[] }>('/API/App/ManageCompanies', { params: AppUUID ? { AppUUID } : {} }), /** 管理员:安装企业功能(不传 CompanyUUIDs=全租户;传 CompanyUUIDs=指定公司) */ install: (AppUUID: string, CompanyUUIDs?: string[]): Promise => post('/API/App/Install', { AppUUID, CompanyUUIDs: CompanyUUIDs || [] }), - /** 管理员:停用企业功能(不传 CompanyUUIDs=停用全租户;传 CompanyUUIDs=停用指定公司) */ - uninstall: (AppUUID: string, CompanyUUIDs?: string[]): Promise => - post('/API/App/Uninstall', { AppUUID, CompanyUUIDs: CompanyUUIDs || [] }), + /** 管理员:停用企业功能(传 CompanyUUIDs=停用指定公司;TenantOnly=仅停用全租户;都不传=停全租户+当前公司) */ + uninstall: (AppUUID: string, CompanyUUIDs?: string[], TenantOnly?: boolean): Promise => + post('/API/App/Uninstall', { AppUUID, CompanyUUIDs: CompanyUUIDs || [], TenantOnly }), /** 个人:启用应用 */ enable: (AppUUID: string): Promise => post('/API/App/Enable', { AppUUID }), @@ -73,4 +91,10 @@ export const appApi = { /** 租户管理员:待审批申请列表 */ pending: (): Promise<{ list: PendingItem[]; total: number }> => get<{ list: PendingItem[]; total: number }>('/API/App/Pending'), + + /** 应用配置:读取(GET)/保存(POST) */ + config: (AppUUID: string): Promise<{ Configs: AppConfigItem[]; Total: number }> => + get<{ Configs: AppConfigItem[]; Total: number }>('/API/App/Config', { AppUUID }), + saveConfig: (AppUUID: string, Config: Record): Promise<{ Updated: string[] }> => + post<{ Updated: string[] }>('/API/App/Config', { AppUUID, Config }), } diff --git a/src/apps/index.ts b/src/apps/index.ts new file mode 100644 index 0000000..20ba658 --- /dev/null +++ b/src/apps/index.ts @@ -0,0 +1,101 @@ +/** + * 前端应用注册表(AppFrontendRegistry) + * + * 与后端应用清单(gvsds/apps/gerp/app_manifests.py)一一对应的前端侧注册: + * 每个应用声明其前端入口(路由/类型/旧页面路径),供"应用中心 · 我的应用" + * 打开应用、及未来动态路由注入使用。 + * + * 数据契约(对齐后端 App 表): + * AppCode 应用编码 + * Title 应用名 + * Icon 图标(Element Plus 图标名 / FontAwesome 类名) + * FrontendRoute 前端路由路径(打开应用跳转目标) + * FrontendType vue=前端组件 / legacy=旧页面 iframe 直载 + * LegacySrc legacy 页面原始路径(如 pages/private/CreateE/shop.html) + * + * MSYNC 扩展:未来子节点上报应用清单时,通过 registerRemoteApp() 动态登记, + * 前端可据此动态 addRoute(路由由后端菜单驱动,此处登记即可)。 + */ +import type { RouteLocationRaw } from 'vue-router' + +export interface AppFrontendModule { + AppCode: string + Title: string + Icon: string + FrontendRoute: string + FrontendType: 'vue' | 'legacy' + LegacySrc: string +} + +/** 打开应用的跳转目标(vue 走前端路由;legacy 走对应 LegacyPage 路由) */ +export function appOpenTarget(mod: AppFrontendModule): RouteLocationRaw { + return mod.FrontendRoute || (mod.LegacySrc ? `/${mod.LegacySrc}` : '/app/mine') +} + +const builtin: Record = { + mailbox: { + AppCode: 'mailbox', + Title: '企业邮箱', + Icon: 'Message', + FrontendRoute: '/mail/mailbox', + FrontendType: 'vue', + LegacySrc: '', + }, + gitea: { + AppCode: 'gitea', + Title: '代码托管 Gitea', + Icon: 'Connection', + FrontendRoute: '/gitea/mine', + FrontendType: 'vue', + LegacySrc: '', + }, + shop: { + AppCode: 'shop', + Title: '枢机协律-商店', + Icon: 'ShoppingCart', + FrontendRoute: '/private/CreateE/shop', + FrontendType: 'legacy', + LegacySrc: 'pages/private/CreateE/shop.html', + }, + 'store-value': { + AppCode: 'store-value', + Title: '枢机协律-储值', + Icon: 'Wallet', + FrontendRoute: '/private/CreateE/StoreValue', + FrontendType: 'legacy', + LegacySrc: 'pages/private/CreateE/StoreValue.html', + }, + wallet: { + AppCode: 'wallet', + Title: '枢机协律-支付系统', + Icon: 'Wallet', + FrontendRoute: '/private/CreateE/wallet', + FrontendType: 'legacy', + LegacySrc: 'pages/private/CreateE/wallet.html', + }, + enterprise: { + AppCode: 'enterprise', + Title: '枢机协律-模拟企业', + Icon: 'OfficeBuilding', + FrontendRoute: '/private/CreateE/enterprise', + FrontendType: 'legacy', + LegacySrc: 'pages/private/CreateE/enterprise.html', + }, +} + +/** 远端注册表(MSYNC 上报的应用,本地内置同名优先) */ +const remote: Record = {} + +/** 注册 MSYNC 远端应用模块(AppCode 冲突时远端生效,覆盖内置) */ +export function registerRemoteApp(mod: AppFrontendModule): void { + remote[mod.AppCode] = mod +} + +export function getAppModule(AppCode: string): AppFrontendModule | undefined { + return remote[AppCode] || builtin[AppCode] +} + +export function listAppModules(): AppFrontendModule[] { + const merged = { ...remote, ...builtin } + return Object.values(merged) +} diff --git a/src/layouts/DefaultLayout.vue b/src/layouts/DefaultLayout.vue index 5dd289e..5431307 100644 --- a/src/layouts/DefaultLayout.vue +++ b/src/layouts/DefaultLayout.vue @@ -15,6 +15,7 @@ import { useUserStore } from '@/stores/user' import { useTabsStore } from '@/stores/tabs' import { getAvatarUrl } from '@/api/auth' import request from '@/api/request' +import { ElMessage } from 'element-plus' import type { AxiosResponse } from 'axios' import TabBar from '@/components/TabBar.vue' import RouteProgress from '@/components/RouteProgress.vue' @@ -38,9 +39,25 @@ const avatarUrl = getAvatarUrl() // 头像下拉菜单用户信息 const userInfo = computed(() => userStore.userInfo) +/** 当前角色(从 roles 中找 active,兼容 userInfo.CurrentRole) */ +const currentRoleName = computed(() => { + const active = userStore.roles.find((r) => r.active) + if (active?.RoleName) return active.RoleName + return userStore.currentRole || '' +}) +/** 当前角色所在公司(提醒身份用) */ +const currentRoleCompany = computed(() => { + const active = userStore.roles.find((r) => r.active) + return active?.CompanyName || active?.CompanyShort || '' +}) const roleLabel = computed(() => { if (userStore.isAdmin) return '管理员' - return userStore.currentRole || '普通用户' + return currentRoleName.value || '普通用户' +}) +/** 右上角身份徽标文案:角色名(管理员则加前缀) */ +const navRoleText = computed(() => { + if (userStore.isAdmin) return '管理员' + return currentRoleName.value || '普通用户' }) // === 身份切换 === @@ -111,6 +128,21 @@ function openRoleSwitcher(): void { roleSwitcherVisible.value = true } +/** 强制刷新菜单(应用安装/卸载/菜单调整后手动同步侧边栏) */ +const menuRefreshing = ref(false) +async function refreshMenus(): Promise { + if (menuRefreshing.value) return + menuRefreshing.value = true + try { + await userStore.fetchMenus() + ElMessage.success('菜单已刷新') + } catch { + ElMessage.error('菜单刷新失败,请重试') + } finally { + menuRefreshing.value = false + } +} + function handleMenuSelect(index: string): void { router.push(index) } @@ -227,6 +259,25 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
+ + +
+ + + {{ navRoleText }} + {{ currentRoleCompany }} + +
+
+
@@ -320,12 +371,24 @@ const isFaIcon = (icon: string | undefined | null): boolean => { - + @@ -439,6 +502,61 @@ const isFaIcon = (icon: string | undefined | null): boolean => { gap: 4px; } +/* 右上角当前身份徽标(角色 + 公司,管理员高亮) */ +.gerp-role-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: 14px; + border: 1px solid var(--el-border-color-light); + background: var(--el-fill-color-light); + color: var(--el-text-color-regular); + cursor: pointer; + transition: all 0.2s; + font-size: 12px; + user-select: none; + + &:hover { + border-color: var(--el-color-primary-light-5); + background: var(--el-color-primary-light-9); + } + + &.is-admin { + border-color: var(--el-color-danger-light-5); + background: var(--el-color-danger-light-9); + color: var(--el-color-danger); + + .gerp-role-name { + font-weight: 600; + } + } + + .gerp-role-icon { + font-size: 13px; + } + + .gerp-role-name { + white-space: nowrap; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + } + + .gerp-role-company { + color: var(--el-text-color-secondary); + max-width: 90px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .gerp-role-arrow { + font-size: 11px; + color: var(--el-text-color-placeholder); + } +} + .gerp-user-info { display: flex; align-items: center; @@ -635,7 +753,7 @@ const isFaIcon = (icon: string | undefined | null): boolean => { } } -// 侧边栏底部(版本号 + 设置按钮) +// 侧边栏底部(版本号 + 菜单刷新 + 设置) .gerp-sidebar-footer { padding: 8px 16px; border-top: 1px solid var(--el-border-color-light); @@ -650,6 +768,12 @@ const isFaIcon = (icon: string | undefined | null): boolean => { font-size: 11px; color: var(--el-text-color-disabled); } + + .gerp-footer-actions { + display: flex; + align-items: center; + gap: 2px; + } } // 主内容区:TabBar + 内容体纵向排列 diff --git a/src/router/index.ts b/src/router/index.ts index 1bb0f7d..a4d1fa1 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -248,6 +248,12 @@ export const staticRoutes: RouteRecordRaw[] = [ component: () => import('@/views/app/AppPending.vue'), meta: { title: '授权审批', icon: 'Stamp', requiresAuth: true, menuGuarded: true }, }, + { + path: 'app/config', + name: 'AppConfigPage', + component: () => import('@/views/app/AppConfigPage.vue'), + meta: { title: '应用配置', requiresAuth: true }, + }, // === 阶段 6:独立工具 === { path: 'doc/editor', diff --git a/src/views/app/AppConfigPage.vue b/src/views/app/AppConfigPage.vue new file mode 100644 index 0000000..abff5e7 --- /dev/null +++ b/src/views/app/AppConfigPage.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/src/views/app/AppMarket.vue b/src/views/app/AppMarket.vue index f39d7f8..8327e03 100644 --- a/src/views/app/AppMarket.vue +++ b/src/views/app/AppMarket.vue @@ -7,14 +7,17 @@ * 接口:GET/POST /API/App/* */ import { ref, computed, onMounted } from 'vue' +import { useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' import { Grid } from '@element-plus/icons-vue' import { appApi, type AppItem, type CompanyScopeItem } from '@/api/app' +import { getAppModule, appOpenTarget } from '@/apps' import { useUserStore } from '@/stores/user' import { setPageTitle } from '@/utils/serverScope' setPageTitle('应用市场') +const router = useRouter() const userStore = useUserStore() const isAdmin = computed(() => userStore.isAdmin) @@ -95,25 +98,98 @@ async function onConfirmInstall(): Promise { } } -/** 租户管理员:安装/停用企业功能 */ +/** 租户管理员:安装/停用企业功能。 + * 已安装 → 打开管理弹窗(公司级开关);未安装 → 打开安装弹窗(全租户/指定公司) */ async function toggleInstall(a: AppItem): Promise { try { - // 已安装(租户或当前公司)→ 直接停用;未安装 → 打开安装弹窗 if (a.TenantInstalled || a.CompanyInstalled) { - await ElMessageBox.confirm( - `确定停用「${a.AppName}」吗?\n${a.TenantInstalled ? '将停用全租户安装' : '将停用当前公司安装'}`, - '停用功能', - { type: 'warning', confirmButtonText: '停用', cancelButtonText: '取消' }, - ) - await appApi.uninstall(a.AppUUID, a.TenantInstalled ? undefined : []) - ElMessage.success(`已停用「${a.AppName}」`) - await loadMarket() + await openManageDialog(a) return } await openInstallDialog(a) } catch { /* 拦截器已提示或取消 */ } } +// ==================== 管理弹窗(公司级安装/停用) ==================== +const manageDialogVisible = ref(false) +const manageDialogLoading = ref(false) +const manageTargetApp = ref(null) +/** 可管理公司列表(含各公司安装状态 Installed) */ +const manageCompaniesList = ref([]) +/** 当前勾选(已装)的公司 UUID 列表 */ +const manageSelected = ref([]) + +/** 打开管理弹窗:拉取该应用在各可管理公司的安装状态 */ +async function openManageDialog(a: AppItem): Promise { + manageTargetApp.value = a + manageSelected.value = [] + manageCompaniesList.value = [] + manageDialogVisible.value = true + manageDialogLoading.value = true + try { + const r = await appApi.manageCompanies(a.AppUUID) + manageCompaniesList.value = r.Companies || [] + manageSelected.value = (r.Companies || []) + .filter((c) => c.Installed === 1) + .map((c) => c.CompanyUUID) + manageScope.value = r.Scope || '' + } catch { /* 拦截器已提示 */ } finally { + manageDialogLoading.value = false + } +} + +/** 停用全租户安装(保留公司级记录不动) */ +async function stopTenantInstall(): Promise { + const a = manageTargetApp.value + if (!a) return + try { + await ElMessageBox.confirm( + `确定停用「${a.AppName}」的全租户安装吗?\n公司级安装不受影响。`, + '停用全租户', + { type: 'warning', confirmButtonText: '停用', cancelButtonText: '取消' }, + ) + } catch { + return + } + manageDialogLoading.value = true + try { + await appApi.uninstall(a.AppUUID, [], true) // TenantOnly=仅停用全租户(公司级记录不动) + ElMessage.success('已停用全租户安装') + manageDialogVisible.value = false + await loadMarket() + } catch { /* 拦截器已提示 */ } finally { + manageDialogLoading.value = false + } +} + +/** 保存公司级安装状态:对比原勾选与现勾选,分别安装新增公司 / 停用移除公司 */ +async function saveCompanyManage(): Promise { + const a = manageTargetApp.value + if (!a) return + const orig = new Set( + manageCompaniesList.value + .filter((c) => c.Installed === 1) + .map((c) => c.CompanyUUID), + ) + const now = new Set(manageSelected.value) + const toInstall = [...now].filter((x) => !orig.has(x)) + const toUninstall = [...orig].filter((x) => !now.has(x)) + if (!toInstall.length && !toUninstall.length) { + ElMessage.info('公司安装状态未变化') + return + } + manageDialogLoading.value = true + try { + if (toInstall.length) await appApi.install(a.AppUUID, toInstall) + if (toUninstall.length) await appApi.uninstall(a.AppUUID, toUninstall) + ElMessage.success(`已更新 ${toInstall.length + toUninstall.length} 家公司的安装状态`) + manageDialogVisible.value = false + await loadMarket() + } catch { /* 拦截器已提示 */ } finally { + manageDialogLoading.value = false + } +} + /** 个人:启用/停用应用 */ async function toggleEnable(a: AppItem): Promise { try { @@ -146,6 +222,32 @@ function marketActionDesc(a: AppItem): string { return '' } +/** 应用是否当前可用(已装/已启用 → 可打开) */ +function isAppUsable(a: AppItem): boolean { + if (a.TenantInstalled) return true + if (a.CompanyInstalled) return true + if (a.UserEnabled) return true + return false +} + +/** 打开应用(真应用注册:按前端注册表跳转入口) */ +function openApp(a: AppItem): void { + const mod = getAppModule(a.AppCode) + router.push(appOpenTarget(mod || { + AppCode: a.AppCode, + Title: a.AppName, + Icon: a.AppIcon, + FrontendRoute: a.FrontendRoute || '', + FrontendType: (a.FrontendType as 'vue' | 'legacy') || 'vue', + LegacySrc: a.LegacySrc || '', + })) +} + +// ==================== 应用配置(独立 tab 页面) ==================== +function openConfig(a: AppItem): void { + router.push({ path: '/app/config', query: { AppUUID: a.AppUUID, AppName: a.AppName } }) +} + onMounted(loadMarket) @@ -178,11 +280,14 @@ onMounted(loadMarket) 已授权 已拒绝
- + + 打开 + @@ -197,12 +302,13 @@ onMounted(loadMarket) {{ applyStatusLabel(a.ApplyStatus) }} - + + + + +
+ +
+
+ + {{ manageTargetApp?.TenantInstalled ? '全租户已安装(租户全员可用)' : '未全租户安装' }} + +
+ + 停用全租户 + +
+ + +
公司级安装(勾选=该公司已安装)
+ +
+ + + {{ c.CompanyName }}{{ c.CompanyShort ? `(${c.CompanyShort})` : '' }} + 已装 + + +
+

您仅有指定公司的管理权限,只能为授权公司管理。

+

您可管理本租户下全部公司。

+

您是跨租户管理员,可管理全部公司。

+
+ + +
@@ -395,4 +559,39 @@ onMounted(loadMarket) color: var(--el-text-color-secondary); margin: 10px 0 0; } +/* 管理弹窗 */ +.gm-tenant-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 16px; + padding-bottom: 14px; + border-bottom: 1px solid var(--el-border-color-lighter); +} +.gm-company-title { + font-size: 13px; + font-weight: 600; + color: var(--el-text-color-primary); + margin-bottom: 10px; +} +.gm-company-list { + max-height: 260px; + overflow-y: auto; + border: 1px solid var(--el-border-color-lighter); + border-radius: 8px; + padding: 10px 14px; +} +.gm-company-item { + display: flex; + width: 100%; + margin-right: 0; + margin-bottom: 8px; + &:last-child { + margin-bottom: 0; + } +} +.gm-installed-tag { + margin-left: 6px; +} diff --git a/src/views/app/AppMine.vue b/src/views/app/AppMine.vue index 9c37fee..f992e55 100644 --- a/src/views/app/AppMine.vue +++ b/src/views/app/AppMine.vue @@ -6,13 +6,18 @@ * 接口:GET/POST /API/App/* */ import { ref, onMounted } from 'vue' +import { useRouter } from 'vue-router' import { ElMessage } from 'element-plus' import { Grid } from '@element-plus/icons-vue' import { appApi, type AppItem } from '@/api/app' +import { getAppModule, appOpenTarget } from '@/apps' +import { useUserStore } from '@/stores/user' import { setPageTitle } from '@/utils/serverScope' setPageTitle('我的应用') +const router = useRouter() +const userStore = useUserStore() const myApps = ref([]) const loading = ref(false) @@ -41,6 +46,24 @@ async function toggleDisable(a: AppItem): Promise { } catch { /* 拦截器已提示 */ } } +/** 打开应用(真应用注册:按前端注册表跳转入口) */ +function openApp(a: AppItem): void { + const mod = getAppModule(a.AppCode) + router.push(appOpenTarget(mod || { + AppCode: a.AppCode, + Title: a.AppName, + Icon: a.AppIcon, + FrontendRoute: a.FrontendRoute || '', + FrontendType: (a.FrontendType as 'vue' | 'legacy') || 'vue', + LegacySrc: a.LegacySrc || '', + })) +} + +// ==================== 应用配置(独立 tab 页面) ==================== +function openConfig(a: AppItem): void { + router.push({ path: '/app/config', query: { AppUUID: a.AppUUID, AppName: a.AppName } }) +} + onMounted(loadMyApps) @@ -71,6 +94,8 @@ onMounted(loadMyApps)
{{ a.TenantInstalled && a.AppType !== 'personal' ? '企业已开通' : '个人已启用' }}
+ 打开 + 配置 停用 企业功能