feat(app): 应用中心公司级管理+配置页+身份徽标
- AppMarket 已安装应用改'管理':公司级安装/停用弹窗(diff 批量增删公司) - 新增 AppConfigPage 独立配置页(AppConfig 键值表,/API/App/Config) - 修复 Config 接口参数嵌套(params[AppUUID]→AppUUID) - 侧边栏新增'强制刷新菜单'按钮 - 顶部导航新增当前身份徽标(角色+公司,管理员高亮,点击切换) - 新增前端应用注册表 src/apps/index.ts(AppCode→路由/类型/旧页面映射,预留 MSYNC)
This commit is contained in:
1 parent
91e2c186c7
commit
5b221c07b7
7 files changed
+677
-28
No files matched your search
+30
-6
@@ -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 }),
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 + 内容体纵向排列
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user