feat(gerp): 应用可注入自己的配置页/配置区块,配置范围只列已安装公司

- apps/index.ts:注册表新增 ConfigPage(替换通用「配置项」表单)与 ConfigPanels(注入自定义区块),新增 getAppConfigPage/getAppConfigPanels,并登记 subscription 应用
- AppConfigPage.vue:渲染应用注入的配置页/区块(下发 AppUUID/公司/范围上下文);右上角范围下拉只列「该应用已安装 且 有管理权」的公司,全租户安装视为全部公司已装
- subscription:注入「订阅关系概览」区块(按公司展示作为提供者/消费者参与的产品)
This commit is contained in:
TermiNexus committed 2026-10-02 22:35:04 +08:00
1 parent 8146b319fb
commit 3010a116c2
3 files changed
+310 -34

No files matched your search

+59
View File
@@ -16,8 +16,35 @@
* MSYNC 扩展:未来子节点上报应用清单时,通过 registerRemoteApp() 动态登记,
* 前端可据此动态 addRoute(路由由后端菜单驱动,此处登记即可)。
*/
import type { Component } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
/** 注入到应用配置页的自定义区块(由应用自己声明与提供) */
export interface AppConfigPanel {
/** 区块唯一键(v-for 渲染用) */
Key: string
/** 区块标题 */
Title: string
/** 区块副标题(可选) */
Sub?: string
/** 是否仅在选中「具体公司」时展示(租户默认层无公司上下文时隐藏) */
CompanyOnly?: boolean
/** 区块组件(异步加载) */
Component: () => Promise<{ default: Component }>
}
/** 配置页注入上下文:传给应用自定义配置页 / 自定义区块 */
export interface AppConfigContext {
AppUUID: string
AppName: string
/** '' = 租户默认(全租户共享);否则为公司 UUID */
CompanyUUID: string
/** 配置管理范围:all(跨租户)/ tenant(租户内)/ company(指定公司) */
Scope: string
/** 当前范围展示名(用于区块内文案) */
ScopeLabel: string
}
export interface AppFrontendModule {
AppCode: string
Title: string
@@ -27,6 +54,10 @@ export interface AppFrontendModule {
LegacySrc: string
/** 远端注入(MSYNC):提供方服务名(打开远端 legacy 页面时签发用户令牌用) */
ServiceName?: string
/** 应用自定义配置页:存在时替换配置页的通用「配置项」表单 */
ConfigPage?: () => Promise<{ default: Component }>
/** 应用注入配置页的自定义区块:渲染在「配置项」区块之后 */
ConfigPanels?: AppConfigPanel[]
}
/** 打开应用的跳转目标(vue 走前端路由;legacy 走对应 LegacyPage 路由) */
@@ -130,6 +161,24 @@ const builtin: Record<string, AppFrontendModule> = {
FrontendType: 'vue',
LegacySrc: '',
},
subscription: {
AppCode: 'subscription',
Title: '订阅式软件提供',
Icon: 'Connection',
FrontendRoute: '/subscription/group',
FrontendType: 'vue',
LegacySrc: '',
// 注入到「应用配置页」的自定义区块:展示当前范围作为提供者/消费者参与的产品
ConfigPanels: [
{
Key: 'subscription-relation-overview',
Title: '订阅关系概览',
Sub: '当前范围作为提供者 / 消费者参与的产品(按公司隔离)',
CompanyOnly: true,
Component: () => import('@/views/subscription/ConfigPanel.vue'),
},
],
},
}
/** 远端注册表(MSYNC 上报的应用,本地内置同名优先) */
@@ -148,3 +197,13 @@ export function listAppModules(): AppFrontendModule[] {
const merged = { ...remote, ...builtin }
return Object.values(merged)
}
/** 应用自定义配置页(未声明返回 undefined → 配置页保留通用「配置项」表单) */
export function getAppConfigPage(AppCode: string): (() => Promise<{ default: Component }>) | undefined {
return getAppModule(AppCode)?.ConfigPage
}
/** 应用注入到配置页的自定义区块(未声明返回空数组) */
export function getAppConfigPanels(AppCode: string): AppConfigPanel[] {
return getAppModule(AppCode)?.ConfigPanels || []
}
+110 -34
View File
@@ -9,12 +9,13 @@
* 读取回退链:公司级配置 → 租户默认配置 → 模板默认值。
* 接口:GET/POST /API/App/Config、POST /API/App/Config/Copy
*/
import { ref, computed, watch, onMounted } from 'vue'
import { ref, computed, watch, onMounted, shallowRef, defineAsyncComponent, type Component } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { appApi, type AppConfigItem, type CompanyScopeItem, type VirtualMappingItem, type PositionTemplateDiffItem, type PositionTemplateRemoveItem } from '@/api/app'
import type { EntityPickerConfig } from '@/types/api'
import { setPageTitle } from '@/utils/serverScope'
import { getAppConfigPage, getAppConfigPanels, type AppConfigContext } from '@/apps'
const route = useRoute()
const AppUUID = computed(() => (route.query.AppUUID as string) || '')
@@ -29,6 +30,50 @@ const saving = ref(false)
const copying = ref(false)
const configs = ref<AppConfigItem[]>([])
// ==================== 应用注入:自定义配置页 / 自定义区块 ====================
// 由前端应用注册表(src/apps/index.ts)声明:应用可提供自己的配置页替换通用「配置项」表单,
// 也可注入若干自定义区块渲染在「配置项」之后;未声明的应用保持原有通用表单。
/** 配置页注入上下文(以 props 传给应用自定义组件) */
const configCtx = computed<AppConfigContext>(() => ({
AppUUID: AppUUID.value,
AppName: AppName.value,
CompanyUUID: currentCompany.value,
Scope: scope.value,
ScopeLabel: scopeLabel.value,
}))
/** 应用自定义配置页(存在时替换通用「配置项」区块) */
const configPageComp = shallowRef<Component | null>(null)
/** 已解析的自定义区块(异步组件) */
interface ResolvedConfigPanel {
Key: string
Title: string
Sub?: string
CompanyOnly?: boolean
Comp: Component
}
const configPanels = ref<ResolvedConfigPanel[]>([])
/** 解析当前应用注入的配置页与区块(AppUUID 变化时重算) */
function resolveInjection(): void {
const pageLoader = getAppConfigPage(AppUUID.value)
configPageComp.value = pageLoader ? (defineAsyncComponent(pageLoader) as Component) : null
configPanels.value = getAppConfigPanels(AppUUID.value).map((p) => ({
Key: p.Key,
Title: p.Title,
Sub: p.Sub,
CompanyOnly: p.CompanyOnly,
Comp: defineAsyncComponent(p.Component) as Component,
}))
}
/** 实际展示的区块:CompanyOnly 的区块需选中具体公司(租户默认层无公司上下文) */
const visibleConfigPanels = computed(() =>
configPanels.value.filter((p) => !p.CompanyOnly || !!currentCompany.value),
)
/** 可管理公司列表(权限范围决定) */
const companies = ref<CompanyScopeItem[]>([])
/** 配置管理范围:all/tenant(可管全部+租户默认)/ company(仅本公司) */
@@ -59,10 +104,20 @@ const MASK = '******'
/** 初始化范围:拉取可管理公司,确定默认编辑范围 */
async function initScope(): Promise<void> {
if (!AppUUID.value) return
try {
const r = await appApi.manageCompanies()
companies.value = r.Companies || []
// 传 AppUUID 取各公司安装状态;同时取应用市场状态以识别「全租户安装」
const [r, market] = await Promise.all([
appApi.manageCompanies(AppUUID.value),
appApi.market(),
])
scope.value = r.Scope || ''
// 范围下拉只列「该应用已安装 且 当前管理员有管理权」的公司;
// 全租户安装(TenantApp)对租户内所有公司都视为已安装(此时 CompanyApp 可能为空)
const tenantInstalled = (market.list || []).some(
(a) => a.AppUUID === AppUUID.value && a.TenantInstalled === 1,
)
companies.value = (r.Companies || []).filter((c) => tenantInstalled || c.Installed === 1)
// URL 指定公司(公司详情页进入):在可管理范围内则直接定位到该公司
const target = QueryCompanyUUID.value
if (target && companies.value.some((c) => c.CompanyUUID === target)) {
@@ -392,6 +447,8 @@ async function onRemoveTemplates(): Promise<void> {
}
onMounted(async () => {
// 先解析应用注入的配置页/区块,再初始化范围(区块按范围显隐)
resolveInjection()
await initScope()
await loadConfig()
// 虚拟职务绑定按公司隔离:仅在已默认选中本公司(公司管理员)时加载
@@ -459,44 +516,63 @@ onMounted(async () => {
</div>
</div>
<!-- ==================== 区块 A:配置项 ==================== -->
<!-- ==================== 区块 A:应用配置(通用表单 或 应用注入的自定义配置页) ==================== -->
<div class="section">
<div class="section-head">
<span class="section-title">配置项</span>
<span class="section-title">{{ configPageComp ? '应用配置' : '配置项' }}</span>
<span class="section-sub">
{{ currentCompany ? '公司级保存 · 未保存键回退:租户默认 → 模板默认' : '全租户共享的默认配置 · 各公司可独立覆盖' }}
<template v-if="configPageComp">由「{{ AppName || '应用' }}」自带的配置界面提供 · 范围:{{ scopeLabel }}</template>
<template v-else>{{ currentCompany ? '公司级保存 · 未保存键回退:租户默认 → 模板默认' : '全租户共享的默认配置 · 各公司可独立覆盖' }}</template>
</span>
</div>
<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">已保存({{ currentCompany ? '公司级' : '租户默认' }})</div>
<div v-else-if="c.Default" class="cfg-meta">默认:{{ c.Default }}</div>
</el-form-item>
</el-form>
</div>
<div class="cfg-footer">
<el-button v-if="isMultiCompany" :loading="copying" :disabled="!configs.length" @click="openCopyDialog">
复制到公司…
</el-button>
<el-button :loading="saving" :disabled="!configs.length" type="primary" @click="onSave">
保存配置
</el-button>
</div>
<!-- 应用注入的自定义配置页:替换通用表单(上下文以 props 下发) -->
<component v-if="configPageComp" :is="configPageComp" v-bind="configCtx" />
<template v-else>
<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">已保存({{ currentCompany ? '公司级' : '租户默认' }})</div>
<div v-else-if="c.Default" class="cfg-meta">默认:{{ c.Default }}</div>
</el-form-item>
</el-form>
</div>
<div class="cfg-footer">
<el-button v-if="isMultiCompany" :loading="copying" :disabled="!configs.length" @click="openCopyDialog">
复制到公司…
</el-button>
<el-button :loading="saving" :disabled="!configs.length" type="primary" @click="onSave">
保存配置
</el-button>
</div>
</template>
</div>
<!-- ==================== 区块 A2:应用注入的自定义区块 ==================== -->
<template v-for="p in visibleConfigPanels" :key="p.Key">
<el-divider class="main-divider" />
<div class="section">
<div class="section-head">
<span class="section-title">{{ p.Title }}</span>
<span v-if="p.Sub" class="section-sub">{{ p.Sub }}</span>
</div>
<component :is="p.Comp" v-bind="configCtx" />
</div>
</template>
<el-divider class="main-divider" />
<!-- ==================== 区块 B:虚拟职务绑定 ==================== -->
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
/**
* 订阅式软件提供 · 注入到「应用配置页」的自定义区块
*
* 由前端应用注册表(src/apps/index.ts 的 subscription.ConfigPanels)挂载,
* 配置页把当前范围上下文以 props 传入,本组件只读展示当前公司的订阅关系。
*/
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { subscriptionApi, type SubscriptionRelationItem } from '@/api/subscription'
const props = defineProps<{
/** 应用 UUID */
AppUUID: string
/** 应用名 */
AppName: string
/** '' = 租户默认;否则为公司 UUID */
CompanyUUID: string
/** 配置管理范围 */
Scope: string
/** 当前范围展示名 */
ScopeLabel: string
}>()
const router = useRouter()
const loading = ref(false)
const relation = ref<SubscriptionRelationItem | null>(null)
const hasRelation = computed(
() => !!relation.value && (relation.value.ProviderGroups.length > 0 || relation.value.ConsumerGroups.length > 0),
)
async function load(): Promise<void> {
if (!props.CompanyUUID) {
relation.value = null
return
}
loading.value = true
try {
const r = await subscriptionApi.relations()
relation.value = (r.list || []).find((x) => x.CompanyUUID === props.CompanyUUID) || null
} catch {
/* 拦截器已提示 */
} finally {
loading.value = false
}
}
watch(() => props.CompanyUUID, load, { immediate: true })
</script>
<template>
<div v-loading="loading" class="sub-cfg-panel">
<div class="sub-cfg-tip">
当前范围:<b>{{ ScopeLabel }}</b>。订阅关系按<b>产品(订阅组)</b>登记,下述为该范围作为「提供者 / 消费者」参与的产品。
</div>
<el-empty
v-if="!loading && !hasRelation"
description="该范围尚未参与任何订阅组(可在「订阅组管理」里新建产品并指定提供者/消费者)"
:image-size="60"
/>
<div v-else-if="relation" class="sub-cfg-body">
<div class="sub-cfg-row">
<span class="sub-cfg-label">作为提供者</span>
<div class="sub-cfg-tags">
<el-tag
v-for="g in relation.ProviderGroups"
:key="g.SubscriptionGroupUUID"
size="small"
effect="plain"
:type="g.GroupStatus ? 'primary' : 'info'"
>{{ g.GroupName }}</el-tag>
<span v-if="!relation.ProviderGroups.length" class="sub-cfg-empty">—</span>
</div>
</div>
<div class="sub-cfg-row">
<span class="sub-cfg-label">作为消费者</span>
<div class="sub-cfg-tags">
<el-tag
v-for="g in relation.ConsumerGroups"
:key="g.SubscriptionGroupUUID"
size="small"
type="success"
effect="plain"
>{{ g.GroupName }}</el-tag>
<span v-if="!relation.ConsumerGroups.length" class="sub-cfg-empty">—</span>
</div>
</div>
</div>
<div class="sub-cfg-actions">
<el-button size="small" @click="router.push('/subscription/group')">订阅组管理</el-button>
<el-button size="small" type="primary" plain @click="router.push('/subscription/relation')">订阅关系</el-button>
</div>
</div>
</template>
<style scoped lang="scss">
.sub-cfg-panel {
min-height: 60px;
}
.sub-cfg-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 10px;
line-height: 1.6;
}
.sub-cfg-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.sub-cfg-row {
display: flex;
align-items: flex-start;
gap: 10px;
}
.sub-cfg-label {
flex-shrink: 0;
width: 76px;
font-size: 13px;
color: var(--el-text-color-regular);
line-height: 24px;
}
.sub-cfg-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.sub-cfg-empty {
font-size: 12px;
color: var(--el-text-color-placeholder);
line-height: 24px;
}
.sub-cfg-actions {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--el-border-color-lighter);
display: flex;
gap: 8px;
}
</style>