feat(app): 应用中心公司级管理+配置页+身份徽标

- AppMarket 已安装应用改'管理':公司级安装/停用弹窗(diff 批量增删公司)
- 新增 AppConfigPage 独立配置页(AppConfig 键值表,/API/App/Config)
- 修复 Config 接口参数嵌套(params[AppUUID]→AppUUID)
- 侧边栏新增'强制刷新菜单'按钮
- 顶部导航新增当前身份徽标(角色+公司,管理员高亮,点击切换)
- 新增前端应用注册表 src/apps/index.ts(AppCode→路由/类型/旧页面映射,预留 MSYNC)
This commit is contained in:
TermiNexus committed 2026-08-13 07:46:59 +08:00
1 parent 91e2c186c7
commit 5b221c07b7
7 files changed
+677 -28

No files matched your search

+30 -6
View File
@@ -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<AppItem> =>
post<AppItem>('/API/App/Install', { AppUUID, CompanyUUIDs: CompanyUUIDs || [] }),
/** 管理员:停用企业功能(不传 CompanyUUIDs=停用全租户;传 CompanyUUIDs=停用指定公司) */
uninstall: (AppUUID: string, CompanyUUIDs?: string[]): Promise<void> =>
post<void>('/API/App/Uninstall', { AppUUID, CompanyUUIDs: CompanyUUIDs || [] }),
/** 管理员:停用企业功能(传 CompanyUUIDs=停用指定公司;TenantOnly=仅停用全租户;都不传=停全租户+当前公司) */
uninstall: (AppUUID: string, CompanyUUIDs?: string[], TenantOnly?: boolean): Promise<void> =>
post<void>('/API/App/Uninstall', { AppUUID, CompanyUUIDs: CompanyUUIDs || [], TenantOnly }),
/** 个人:启用应用 */
enable: (AppUUID: string): Promise<AppItem> => post<AppItem>('/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<string, string>): Promise<{ Updated: string[] }> =>
post<{ Updated: string[] }>('/API/App/Config', { AppUUID, Config }),
}
+101
View File
@@ -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<string, AppFrontendModule> = {
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<string, AppFrontendModule> = {}
/** 注册 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)
}
+130 -6
View File
@@ -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<void> {
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 => {
</div>
<div class="gerp-header-right">
<!-- 当前身份徽标(角色 + 公司,点击可切换角色) -->
<el-tooltip
:content="currentRoleCompany ? `${currentRoleName || '当前角色'} · ${currentRoleCompany}` : (currentRoleName || '当前角色')"
placement="bottom"
>
<div
class="gerp-role-badge"
:class="{ 'is-admin': userStore.isAdmin }"
title="点击切换角色"
@click="openRoleSwitcher"
>
<el-icon v-if="userStore.isAdmin" class="gerp-role-icon"><UserFilled /></el-icon>
<el-icon v-else class="gerp-role-icon"><User /></el-icon>
<span class="gerp-role-name">{{ navRoleText }}</span>
<span v-if="currentRoleCompany" class="gerp-role-company">{{ currentRoleCompany }}</span>
<el-icon class="gerp-role-arrow"><ArrowDown /></el-icon>
</div>
</el-tooltip>
<div class="gerp-header-actions">
<el-badge :value="unreadCount" :hidden="unreadCount === 0" :max="99" class="gerp-notify-badge">
<el-button text circle title="站内信" @click="goNotify">
@@ -320,12 +371,24 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
</template>
</el-menu>
</el-scrollbar>
<!-- 侧边栏底部:版本号 -->
<!-- 侧边栏底部:版本号 + 菜单刷新 + 设置 -->
<div class="gerp-sidebar-footer">
<span class="gerp-version">v1.0.0</span>
<el-button text circle size="small" title="设置" @click="router.push('/system/settings')">
<el-icon><Setting /></el-icon>
</el-button>
<div class="gerp-footer-actions">
<el-button
text
circle
size="small"
title="强制刷新菜单(应用安装/卸载后同步侧边栏)"
:loading="menuRefreshing"
@click="refreshMenus"
>
<el-icon><Refresh /></el-icon>
</el-button>
<el-button text circle size="small" title="设置" @click="router.push('/system/settings')">
<el-icon><Setting /></el-icon>
</el-button>
</div>
</div>
</aside>
@@ -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 + 内容体纵向排列
+6
View File
@@ -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',
+170
View File
@@ -0,0 +1,170 @@
<script setup lang="ts">
/**
* AppConfigPage —— 应用配置页(独立 tab)
*
* 读取应用 Manifest 声明的配置模板 + 当前租户已保存值,
* 有安装权限者(管理员)可编辑保存。
* 入口:应用市场/我的应用 → "配置"按钮(router.push 带 AppUUID/AppName query)
* 接口:GET/POST /API/App/Config
*/
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { appApi, type AppConfigItem } from '@/api/app'
import { setPageTitle } from '@/utils/serverScope'
const route = useRoute()
const AppUUID = computed(() => (route.query.AppUUID as string) || '')
const AppName = computed(() => (route.query.AppName as string) || '')
setPageTitle('应用配置')
const loading = ref(false)
const saving = ref(false)
const configs = ref<AppConfigItem[]>([])
/** 编辑副本:{Key: 用户输入值}(敏感键未改保持占位符) */
const edited = ref<Record<string, string>>({})
const MASK = '******'
async function loadConfig(): Promise<void> {
if (!AppUUID.value) return
loading.value = true
try {
const r = await appApi.config(AppUUID.value)
configs.value = r.Configs || []
edited.value = {}
for (const c of configs.value) {
edited.value[c.Key] = c.Secret && c.Value ? MASK : (c.Value ?? '')
}
} catch { /* 拦截器已提示 */ } finally {
loading.value = false
}
}
/** 是否存在实际修改(敏感键占位符未变视为未改) */
function hasEdits(): boolean {
for (const c of configs.value) {
const cur = edited.value[c.Key] ?? ''
if (c.Secret) {
if (cur !== MASK && cur !== (c.Value ?? '')) return true
} else if (cur !== (c.Value ?? '')) {
return true
}
}
return false
}
async function onSave(): Promise<void> {
if (!hasEdits()) {
ElMessage.info('没有需要保存的修改')
return
}
try {
await ElMessageBox.confirm(
'保存后将立即生效。\n请谨慎操作,错误的配置可能导致该应用不可用。',
'确认保存配置',
{ type: 'warning', confirmButtonText: '保存', cancelButtonText: '取消' },
)
} catch {
return
}
saving.value = true
try {
const payload: Record<string, string> = {}
for (const c of configs.value) {
const cur = edited.value[c.Key] ?? ''
// 敏感键提交占位符 = 未修改,跳过
if (c.Secret && cur === MASK) continue
payload[c.Key] = cur
}
const r = await appApi.saveConfig(AppUUID.value, payload)
ElMessage.success(`配置已保存(${(r.Updated || []).length} 项)`)
await loadConfig()
} catch { /* 拦截器已提示 */ } finally {
saving.value = false
}
}
onMounted(loadConfig)
</script>
<template>
<div class="app-config-page">
<el-card shadow="never" class="cfg-card">
<template #header>
<div class="cfg-header">
<span class="cfg-title">配置「{{ AppName || '应用' }}」</span>
<span class="cfg-sub">配置按租户隔离,有安装权限者(管理员)可编辑</span>
</div>
</template>
<div v-loading="loading" class="cfg-body">
<el-empty
v-if="!loading && !configs.length"
description="该应用暂未声明配置项"
:image-size="80"
/>
<el-form v-else label-position="top" class="cfg-form">
<el-form-item v-for="c in configs" :key="c.Key" :label="c.Label">
<el-input
v-model="edited[c.Key]"
:type="c.Secret ? 'password' : 'text'"
:show-password="!!c.Secret"
:placeholder="c.Placeholder || c.Default"
clearable
/>
<div v-if="c.Updated" class="cfg-meta">已保存</div>
<div v-else-if="c.Default" class="cfg-meta">默认:{{ c.Default }}</div>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="cfg-footer">
<el-button :loading="saving" :disabled="!configs.length" type="primary" @click="onSave">
保存配置
</el-button>
</div>
</template>
</el-card>
</div>
</template>
<style scoped lang="scss">
.app-config-page {
padding: 16px;
box-sizing: border-box;
}
.cfg-card {
border-radius: 8px;
}
.cfg-header {
display: flex;
align-items: center;
gap: 10px;
.cfg-title {
font-weight: 600;
font-size: 15px;
}
.cfg-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.cfg-body {
min-height: 120px;
}
.cfg-form {
max-width: 560px;
}
.cfg-meta {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.cfg-footer {
display: flex;
justify-content: flex-end;
}
</style>
+215 -16
View File
@@ -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<void> {
}
}
/** 租户管理员:安装/停用企业功能 */
/** 租户管理员:安装/停用企业功能。
* 已安装 → 打开管理弹窗(公司级开关);未安装 → 打开安装弹窗(全租户/指定公司) */
async function toggleInstall(a: AppItem): Promise<void> {
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<AppItem | null>(null)
/** 可管理公司列表(含各公司安装状态 Installed) */
const manageCompaniesList = ref<CompanyScopeItem[]>([])
/** 当前勾选(已装)的公司 UUID 列表 */
const manageSelected = ref<string[]>([])
/** 打开管理弹窗:拉取该应用在各可管理公司的安装状态 */
async function openManageDialog(a: AppItem): Promise<void> {
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<void> {
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<void> {
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<void> {
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)
</script>
@@ -178,11 +280,14 @@ onMounted(loadMarket)
<span v-else-if="a.ApplyStatus === 2" class="app-status ok">已授权</span>
<span v-else-if="a.ApplyStatus === 3" class="app-status err">已拒绝</span>
<div class="spacer" />
<!-- 企业功能:管理员安装/停用;普通用户提示 -->
<!-- 打开应用(已安装/已启用时可用) -->
<el-button v-if="isAppUsable(a)" size="small" type="primary" plain @click="openApp(a)">打开</el-button>
<!-- 企业功能:管理员安装/管理(公司级);普通用户提示 -->
<template v-if="a.AppType === 'enterprise'">
<el-button v-if="isAdmin" size="small" :type="a.TenantInstalled || a.CompanyInstalled ? 'danger' : 'primary'"
<el-button v-if="isAdmin && isAppUsable(a)" size="small" type="info" plain @click="openConfig(a)">配置</el-button>
<el-button v-if="isAdmin" size="small" type="primary"
:plain="!!(a.TenantInstalled || a.CompanyInstalled)" @click="toggleInstall(a)">
{{ a.TenantInstalled || a.CompanyInstalled ? '停用' : '安装' }}
{{ a.TenantInstalled || a.CompanyInstalled ? '管理' : '安装' }}
</el-button>
<el-tag v-else size="small" type="info" effect="plain">由企业管理员开通</el-tag>
</template>
@@ -197,12 +302,13 @@ onMounted(loadMarket)
</el-button>
<el-tag v-else size="small" type="info" effect="plain">{{ applyStatusLabel(a.ApplyStatus) }}</el-tag>
</template>
<!-- 混合:管理员可安装,个人可启用 -->
<!-- 混合:管理员可安装/管理,个人可启用 -->
<template v-else>
<template v-if="isAdmin">
<el-button size="small" :type="a.TenantInstalled || a.CompanyInstalled ? 'danger' : 'primary'"
<el-button v-if="isAppUsable(a)" size="small" type="info" plain @click="openConfig(a)">配置</el-button>
<el-button size="small" type="primary"
:plain="!!(a.TenantInstalled || a.CompanyInstalled)" @click="toggleInstall(a)">
{{ a.TenantInstalled || a.CompanyInstalled ? '停用' : '安装' }}
{{ a.TenantInstalled || a.CompanyInstalled ? '管理' : '安装' }}
</el-button>
</template>
<el-button v-else size="small" :type="a.UserEnabled ? 'danger' : 'primary'"
@@ -265,6 +371,64 @@ onMounted(loadMarket)
</el-button>
</template>
</el-dialog>
<!-- 管理弹窗:公司级安装/停用 -->
<el-dialog
v-model="manageDialogVisible"
:title="`管理「${manageTargetApp?.AppName || ''}」`"
width="560px"
:close-on-click-modal="false"
>
<div v-loading="manageDialogLoading">
<!-- 全租户安装状态 -->
<div class="gm-tenant-row">
<div class="gm-tenant-info">
<el-tag :type="manageTargetApp?.TenantInstalled ? 'success' : 'info'" size="small">
{{ manageTargetApp?.TenantInstalled ? '全租户已安装(租户全员可用)' : '未全租户安装' }}
</el-tag>
</div>
<el-button
v-if="manageTargetApp?.TenantInstalled"
size="small"
type="danger"
plain
:loading="manageDialogLoading"
@click="stopTenantInstall"
>
停用全租户
</el-button>
</div>
<!-- 公司级安装状态 -->
<div class="gm-company-title">公司级安装(勾选=该公司已安装)</div>
<el-empty
v-if="!manageCompaniesList.length && !manageDialogLoading"
description="您没有可管理的公司(或当前无公司权限)"
:image-size="60"
/>
<div v-else class="gm-company-list">
<el-checkbox-group v-model="manageSelected">
<el-checkbox
v-for="c in manageCompaniesList"
:key="c.CompanyUUID"
:value="c.CompanyUUID"
class="gm-company-item"
>
{{ c.CompanyName }}{{ c.CompanyShort ? `(${c.CompanyShort})` : '' }}
<el-tag v-if="c.Installed === 1" size="small" type="success" effect="plain" class="gm-installed-tag">已装</el-tag>
</el-checkbox>
</el-checkbox-group>
</div>
<p v-if="manageScope === 'company'" class="gs-scope-tip">您仅有指定公司的管理权限,只能为授权公司管理。</p>
<p v-else-if="manageScope === 'tenant'" class="gs-scope-tip">您可管理本租户下全部公司。</p>
<p v-else-if="manageScope === 'all'" class="gs-scope-tip">您是跨租户管理员,可管理全部公司。</p>
</div>
<template #footer>
<el-button @click="manageDialogVisible = false">关闭</el-button>
<el-button type="primary" :loading="manageDialogLoading" @click="saveCompanyManage">保存公司状态</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -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;
}
</style>
+25
View File
@@ -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<AppItem[]>([])
const loading = ref(false)
@@ -41,6 +46,24 @@ async function toggleDisable(a: AppItem): Promise<void> {
} 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)
</script>
@@ -71,6 +94,8 @@ onMounted(loadMyApps)
<div class="app-foot">
<span class="app-status ok">{{ a.TenantInstalled && a.AppType !== 'personal' ? '企业已开通' : '个人已启用' }}</span>
<div class="spacer" />
<el-button size="small" type="primary" plain @click="openApp(a)">打开</el-button>
<el-button v-if="userStore.isAdmin" size="small" type="info" plain @click="openConfig(a)">配置</el-button>
<el-button v-if="a.AppType !== 'enterprise'" size="small" type="danger" plain @click="toggleDisable(a)">停用</el-button>
<el-tag v-else size="small" type="success" effect="plain">企业功能</el-tag>
</div>