feat(订阅式软件提供): 新增「我的订阅」页并动态加载产品定制页

新增 MySubscriptions 页(列出当前用户已订阅产品、提供者与订阅部门);对已声明 RemoteEntry 的产品经 RegisterProductRemoteApp 登记为远端应用,点「打开定制页」走 /remote-app/:code 由 RemoteAppHost 原生渲染;新增 GET /API/Subscription/MySubscriptions 客户端与类型;路由新增 subscription/my。
This commit is contained in:
TermiNexus committed 2026-10-05 17:02:20 +08:00
1 parent bcd4aca403
commit 5ffadd3f8a
4 files changed
+230

No files matched your search

+20
View File
@@ -103,6 +103,20 @@ export interface SubscriptionMemberSaveResult {
total: number
}
/** 我的订阅条目(被订阅者视角;含提供者定制的远端页面入口) */
export interface MySubscriptionItem {
SubscriptionGroupUUID: string
GroupName: string
GroupDesc: string
GroupStatus: number
Providers: SubscriptionCompanyItem[]
/** 提供者声明的定制页远端入口(空=提供者尚未提供定制页) */
RemoteEntry: string
RemoteModule: string
/** 我在该产品下订阅的部门名(展示用) */
MyDeptNames: string[]
}
/** 订阅式软件提供 API 客户端 */
export const subscriptionApi = {
/** 订阅组(产品)列表(含提供者/消费者公司) */
@@ -166,4 +180,10 @@ export const subscriptionApi = {
/** 删除一条订阅成员 */
deleteMember: (SubscriptionGroupUUID: string, SubscriptionMemberUUID: string): Promise<void> =>
post<void>('/API/Subscription/Member/Delete', { SubscriptionGroupUUID, SubscriptionMemberUUID }),
// ==================== 我的订阅(被订阅者视角) ====================
/** 当前登录用户已订阅的产品(含提供者定制页远端入口) */
mySubscriptions: (): Promise<{ list: MySubscriptionItem[]; total: number }> =>
get<{ list: MySubscriptionItem[]; total: number }>('/API/Subscription/MySubscriptions'),
}
+27
View File
@@ -40,3 +40,30 @@ export function InstallRemoteAppRoutes(router: Router): void {
meta: { title: '远端应用', requiresAuth: true },
})
}
/**
* 为「产品定制页」登记远端应用模块,返回可直接用于 `/remote-app/:code` 的 AppCode
*
* 订阅式软件提供场景:提供者子服务器把定制页入口(RemoteEntry)声明到主服务器,
* 消费者在「我的订阅」里登记后点开,即由 RemoteAppHost 运行时加载并原生渲染。
*/
export function RegisterProductRemoteApp(
GroupUUID: string,
Title: string,
RemoteEntry: string,
RemoteModule = 'default',
): string {
const Code = `sub-${GroupUUID}`
registerRemoteApp({
AppCode: Code,
Title: Title || '定制页',
Icon: 'Star',
FrontendRoute: `/remote-app/${Code}`,
FrontendType: 'remote',
LegacySrc: '',
RemoteEntry,
RemoteModule: RemoteModule || 'default',
ServiceName: 'gvsd-service-server',
})
return Code
}
+6
View File
@@ -469,6 +469,12 @@ export const staticRoutes: RouteRecordRaw[] = [
component: () => import('@/views/subscription/Relation.vue'),
meta: { title: '订阅关系', icon: 'Share', requiresAuth: true, menuGuarded: true },
},
{
path: 'subscription/my',
name: 'SubscriptionMy',
component: () => import('@/views/subscription/MySubscriptions.vue'),
meta: { title: '我的订阅', icon: 'Star', requiresAuth: true, menuGuarded: true },
},
// === OA 办公自动化流程(审批/请假/出差/会议/公告/汇报) ===
{
path: 'oa/approval',
+177
View File
@@ -0,0 +1,177 @@
<script setup lang="ts">
/**
* 我的订阅(被订阅者视角)
*
* 列出当前登录用户作为订阅成员(部门-员工键)已订阅的产品;
* 提供者若已声明定制页远端入口(RemoteEntry),点「打开定制页」即由宿主
* 远端微前端运行时加载并**原生渲染**(非 iframe)。
*/
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { setPageTitle } from '@/utils/serverScope'
import { subscriptionApi, type MySubscriptionItem } from '@/api/subscription'
import { RegisterProductRemoteApp } from '@/apps/remoteRoutes'
setPageTitle('我的订阅')
const router = useRouter()
const loading = ref(false)
const rows = ref<MySubscriptionItem[]>([])
async function load(): Promise<void> {
loading.value = true
try {
const r = await subscriptionApi.mySubscriptions()
rows.value = r.list || []
// 已有定制页入口的产品先登记为远端应用(点开时可即时渲染)
for (const it of rows.value) {
if (it.RemoteEntry) {
RegisterProductRemoteApp(it.SubscriptionGroupUUID, it.GroupName, it.RemoteEntry, it.RemoteModule)
}
}
} catch {
/* 拦截器已提示 */
} finally {
loading.value = false
}
}
/** 打开产品定制页(登记远端应用 → 跳远端宿主路由原生渲染) */
function openCustomPage(row: MySubscriptionItem): void {
if (!row.RemoteEntry) {
ElMessage.info('该产品暂未提供定制页')
return
}
const code = RegisterProductRemoteApp(
row.SubscriptionGroupUUID, row.GroupName, row.RemoteEntry, row.RemoteModule,
)
router.push({ path: `/remote-app/${code}` })
}
onMounted(load)
</script>
<template>
<div class="my-sub-page">
<el-card shadow="never">
<template #header>
<div class="page-head">
<el-icon><Star /></el-icon>
<span class="page-title">我的订阅</span>
<span class="page-sub">你是以下产品的订阅成员;提供者若已提供定制页,可直接打开</span>
<div class="spacer" />
<el-button @click="load"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
</template>
<div v-loading="loading" class="sub-list">
<el-empty
v-if="!loading && !rows.length"
description="你还没有订阅任何产品"
:image-size="90"
/>
<div v-for="r in rows" :key="r.SubscriptionGroupUUID" class="sub-card">
<div class="sub-head">
<span class="sub-name">{{ r.GroupName }}</span>
<el-tag :type="r.GroupStatus ? 'success' : 'info'" size="small" effect="plain">
{{ r.GroupStatus ? '启用' : '停用' }}
</el-tag>
<div class="spacer" />
<el-button type="primary" :disabled="!r.RemoteEntry" @click="openCustomPage(r)">
<el-icon><Link /></el-icon>{{ r.RemoteEntry ? '打开定制页' : '暂无定制页' }}
</el-button>
</div>
<div v-if="r.GroupDesc" class="sub-desc">{{ r.GroupDesc }}</div>
<div class="sub-meta">
<span class="meta-label">提供者:</span>
<el-tag
v-for="c in r.Providers"
:key="c.CompanyUUID"
size="small"
effect="plain"
>{{ c.CompanyName }}</el-tag>
<span v-if="!r.Providers.length" class="empty-tip">未指定</span>
</div>
<div class="sub-meta">
<span class="meta-label">我的订阅部门:</span>
<el-tag
v-for="d in r.MyDeptNames"
:key="d"
size="small"
type="info"
effect="plain"
>{{ d }}</el-tag>
<span v-if="!r.MyDeptNames.length" class="empty-tip">—</span>
</div>
</div>
</div>
</el-card>
</div>
</template>
<style scoped lang="scss">
.my-sub-page {
padding: 16px;
box-sizing: border-box;
}
.page-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.page-title {
font-weight: 600;
font-size: 15px;
flex-shrink: 0;
}
.page-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
min-width: 0;
}
}
.spacer {
flex: 1;
}
.sub-list {
min-height: 120px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sub-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 12px 14px;
}
.sub-head {
display: flex;
align-items: center;
gap: 8px;
}
.sub-name {
font-size: 14px;
font-weight: 600;
}
.sub-desc {
margin-top: 6px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.sub-meta {
margin-top: 8px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 12px;
}
.meta-label {
color: var(--el-text-color-regular);
}
.empty-tip {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
</style>