feat(订阅式软件提供): GVSDServiceServer 新增 Venom 包管理器与远端页面子工程

- apps/venom: VenomPackage/VenomPackageRelease 模型 + 0001 迁移,提供包列表/新建(包名全局唯一)/详情/配置保存/发布历史接口,并放行跨源 CORS

- venom_ui: Vite+Vue SFC 子工程,单文件 ES module 产物,支持 mount/unmount 微应用契约,页面含包列表、新建、配置页、发布历史

- sub_server: VenomRemoteEntryView 改由 venom_ui 构建产物托管;remote_pages 占位模块改为 mount/unmount
This commit is contained in:
TermiNexus committed 2026-10-05 17:45:33 +08:00
1 parent b078f8e9fe
commit c50f3f0a55
19 files changed
+2087 -45

No files matched your search

+4
View File
@@ -2,3 +2,7 @@ __pycache__/
*.pyc
# 本地开发库(本服务无本地模型,仅占位)
db.sqlite3
# venom_ui 前端子工程:依赖与构建产物不入库(构建见 venom_ui/README 或 vite build)
venom_ui/node_modules/
venom_ui/dist/
@@ -27,11 +27,14 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
'rest_framework',
'apps.sub_server',
'apps.venom',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.middleware.common.CommonMiddleware',
# Venom 远端页面在 GERP 宿主内渲染,需跨源调用 /api/venom/* 与加载 /venom/*
'apps.venom.middleware.VenomCorsMiddleware',
]
ROOT_URLCONF = 'GVSDServiceServer.urls'
+3 -1
View File
@@ -7,7 +7,9 @@ from apps.sub_server import views
urlpatterns = [
path('', views.HomeView.as_view(), name='home'),
path('health/', views.HealthView.as_view(), name='health'),
# 产品定制页的远端组件入口(宿主 GERP 运行时 import 的 ES module)
# 产品定制页的远端组件入口(宿主 GERP 运行时 import 的 ES module,构建产物托管)
path('venom/remote-entry.js', views.VenomRemoteEntryView.as_view(), name='venom_remote_entry'),
path('api/sub/', include('apps.sub_server.urls')),
# Venom 包管理器业务接口(远端页面跨源调用)
path('api/venom/', include('apps.venom.urls')),
]
@@ -1,51 +1,29 @@
"""模块:产品定制页的远端组件(ES module)
"""模块:Venom 定制页远端入口的**占位实现**(仅在 UI 未构建时使用)
宿主 GERP 前端通过 `import(<RemoteEntry>)` 加载本模块,并用其中的
`createComponent(Host)` 工厂取得 Vue 组件,然后**原生渲染**(非 iframe)。
真实产物由 `venom_ui`(Vite + Vue SFC)构建得到 `venom_ui/dist/venom-entry.js`,
由 `apps.sub_server.views.VenomRemoteEntryView` 直接托管。
契约(与 GERP `src/apps/remoteLoader.ts` 一致):
export function createComponent(Host) -> Vue 组件
Host = { Vue, VueRouter, Pinia, ElementPlus }(宿主注入 → 共享同一份运行时)
真实项目里这份产物可由 Vite 打包 Vue SFC 得到;此处用渲染函数写成等价实现,
以便子服务器无需 Node 构建链即可交付。
远端模块契约(与 GERP `src/apps/remoteLoader.ts` 一致,二者满足其一即可):
1) 微应用(推荐,页面自带运行时):
export function mount(Element, Context) -> void
export function unmount(Element) -> void
2) 组件工厂(宿主渲染):
export function createComponent(Host) -> Vue 组件
"""
VENOM_REMOTE_ENTRY_JS = r"""/**
* Venom 定制页 —— 远端组件入口(由 GVSDServiceServer 托管)
* 契约:export function createComponent(Host) -> Vue 组件
* 占位模块:Venom 定制页 UI 尚未构建
* 构建方式:cd venom_ui && npm install && npm run build
*/
export function createComponent(Host) {
const { h, ref } = Host.Vue
const { ElCard, ElButton, ElTag, ElMessage } = Host.ElementPlus
export function mount(Element) {
Element.innerHTML =
'<div style="padding:24px;font-size:14px;color:#909399;line-height:1.9">' +
'<b>Venom 定制页 UI 尚未构建</b><br/>' +
'请在 GVSDServiceServer/venom_ui 下执行 <code>npm install</code> 与 <code>npm run build</code> 后刷新本页。' +
'</div>'
}
return {
name: 'VenomCustomPage',
setup() {
const count = ref(0)
return () =>
h('div', { style: 'padding: 4px' }, [
h(ElCard, { shadow: 'never' }, {
header: () =>
h('div', { style: 'display: flex; align-items: center; gap: 8px' }, [
h('b', '这是Venom'),
h(ElTag, { type: 'danger', effect: 'plain', size: 'small' }, () => 'Venom 定制页 · 由 GVSDServiceServer 托管'),
]),
default: () =>
h('div', [
h('div', { style: 'font-size: 20px; font-weight: 700; padding: 8px 0' }, '这是Venom'),
h('div', { style: 'font-size: 13px; line-height: 2; color: var(--el-text-color-secondary)' }, [
h('div', '该页面组件由提供者子服务器 GVSDServiceServer 托管,GERP 宿主运行时加载后原生渲染(非 iframe)。'),
h('div', ['点击次数:', h('b', String(count.value))]),
]),
h('div', { style: 'margin-top: 12px; display: flex; gap: 8px' }, [
h(ElButton, { type: 'primary', onClick: () => { count.value += 1 } }, () => '点我 +1'),
h(ElButton, { onClick: () => { ElMessage.success('来自 Venom 定制页') } }, () => '弹提示'),
]),
]),
}),
])
},
}
export function unmount(Element) {
Element.innerHTML = ''
}
"""
+11 -2
View File
@@ -4,6 +4,7 @@
"""
import logging
from pathlib import Path
from django.conf import settings
from django.http import HttpResponse
@@ -75,14 +76,22 @@ class MSyncStatusView(APIView):
class VenomRemoteEntryView(APIView):
"""Venom 定制页的远端组件入口(ES module,宿主 GERP 运行时 import 后原生渲染)
"""Venom 定制页的远端入口(ES module,宿主 GERP 运行时 import 后原生渲染)
内容来自 venom_ui 的 Vite 构建产物(venom_ui/dist/venom-entry.js);
未构建时回退到占位提示模块,避免 404/500。
宿主页面多为 https 源、本服务在 http://127.0.0.1:25090,故 ES module 加载属**跨源**,
必须放行 CORS(`Access-Control-Allow-Origin`),否则浏览器会拒绝该模块。
"""
def get(self, request):
response = HttpResponse(VENOM_REMOTE_ENTRY_JS, content_type='text/javascript; charset=utf-8')
dist_file = Path(settings.BASE_DIR) / 'venom_ui' / 'dist' / 'venom-entry.js'
if dist_file.is_file():
content = dist_file.read_text(encoding='utf-8')
else:
content = VENOM_REMOTE_ENTRY_JS
response = HttpResponse(content, content_type='text/javascript; charset=utf-8')
response['Access-Control-Allow-Origin'] = '*'
response['Cache-Control'] = 'no-store'
return response
+2
View File
@@ -0,0 +1,2 @@
"""Venom 包管理器(提供者子服务器的产品业务)
"""
+10
View File
@@ -0,0 +1,10 @@
"""模块:Venom 包管理器 AppConfig"""
from django.apps import AppConfig
class VenomConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.venom'
verbose_name = 'Venom 包管理器'
verbose_name_plural = 'Venom 包管理器'
@@ -0,0 +1,23 @@
"""模块:Venom 相关路径的 CORS 中间件
远端页面在 GERP 宿主内渲染(宿主多为 https 源),页面需跨源调用本服务的
`/api/venom/*` 与加载 `/venom/remote-entry.js`,故统一放行 CORS。
本服务为演示子服务器,未启用 CSRF/Session 鉴权。
"""
class VenomCorsMiddleware:
SERVICE_ORIGIN = '*'
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
path = request.path or ''
if path.startswith('/api/venom/') or path.startswith('/venom/'):
response['Access-Control-Allow-Origin'] = self.SERVICE_ORIGIN
response['Access-Control-Allow-Methods'] = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'
response['Access-Control-Allow-Headers'] = 'Content-Type, X-Operator'
response['Access-Control-Max-Age'] = '600'
return response
@@ -0,0 +1,51 @@
# Generated by Django 6.0.5 on 2026-10-05 17:41
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='VenomPackage',
fields=[
('PackageUUID', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='软件包UUID')),
('PackageName', models.CharField(max_length=128, unique=True, verbose_name='包名(全局唯一)')),
('PackageDesc', models.CharField(default='', max_length=512, verbose_name='包说明')),
('ConfigText', models.TextField(default='', verbose_name='包配置内容')),
('CreatorName', models.CharField(default='', max_length=128, verbose_name='创建人')),
('CreateTime', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('UpdateTime', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': 'Venom 软件包',
'verbose_name_plural': 'Venom 软件包',
'db_table': 'VenomPackage',
'ordering': ['-CreateTime'],
},
),
migrations.CreateModel(
name='VenomPackageRelease',
fields=[
('ReleaseUUID', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='发布UUID')),
('Version', models.CharField(max_length=64, verbose_name='版本号')),
('Notes', models.TextField(default='', verbose_name='发布说明')),
('PublisherName', models.CharField(default='', max_length=128, verbose_name='发布人')),
('CreateTime', models.DateTimeField(auto_now_add=True, verbose_name='发布时间')),
('Package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='releases', to='venom.venompackage', verbose_name='所属软件包')),
],
options={
'verbose_name': 'Venom 发布记录',
'verbose_name_plural': 'Venom 发布记录',
'db_table': 'VenomPackageRelease',
'ordering': ['-CreateTime'],
},
),
]
Whitespace-only changes.
+53
View File
@@ -0,0 +1,53 @@
"""模块:Venom 包管理器数据模型
VenomPackage —— 软件包(包名全局唯一)
VenomPackageRelease—— 包的发布历史(每次发布一条,含版本与说明)
"""
import uuid
from django.db import models
class VenomPackage(models.Model):
"""软件包——包名全局唯一"""
PackageUUID = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False,
verbose_name='软件包UUID')
PackageName = models.CharField(max_length=128, unique=True, verbose_name='包名(全局唯一)')
PackageDesc = models.CharField(max_length=512, default='', verbose_name='包说明')
ConfigText = models.TextField(default='', verbose_name='包配置内容')
CreatorName = models.CharField(max_length=128, default='', verbose_name='创建人')
CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间')
class Meta:
db_table = 'VenomPackage'
ordering = ['-CreateTime']
verbose_name = 'Venom 软件包'
verbose_name_plural = 'Venom 软件包'
def __str__(self):
return self.PackageName
class VenomPackageRelease(models.Model):
"""包的发布历史"""
ReleaseUUID = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False,
verbose_name='发布UUID')
Package = models.ForeignKey(VenomPackage, on_delete=models.CASCADE, related_name='releases',
verbose_name='所属软件包')
Version = models.CharField(max_length=64, verbose_name='版本号')
Notes = models.TextField(default='', verbose_name='发布说明')
PublisherName = models.CharField(max_length=128, default='', verbose_name='发布人')
CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='发布时间')
class Meta:
db_table = 'VenomPackageRelease'
ordering = ['-CreateTime']
verbose_name = 'Venom 发布记录'
verbose_name_plural = 'Venom 发布记录'
def __str__(self):
return f'{self.Package.PackageName}@{self.Version}'
+14
View File
@@ -0,0 +1,14 @@
"""Venom 包管理器 API 路由(挂载于 /api/venom/)"""
from django.urls import path
from . import views
app_name = 'venom'
urlpatterns = [
path('packages/', views.PackageListView.as_view(), name='package_list'),
path('packages/<uuid:package_uuid>/', views.PackageDetailView.as_view(), name='package_detail'),
path('packages/<uuid:package_uuid>/config/', views.PackageConfigView.as_view(), name='package_config'),
path('packages/<uuid:package_uuid>/releases/', views.PackageReleaseView.as_view(), name='package_releases'),
]
+146
View File
@@ -0,0 +1,146 @@
"""模块:Venom 包管理器 API
远端页面(由本服务托管、在 GERP 宿主内渲染)跨源调用以下接口:
GET /api/venom/packages/ 包列表
POST /api/venom/packages/ 新建包(包名全局唯一)
GET /api/venom/packages/<uuid>/ 包详情(配置页读取)
POST /api/venom/packages/<uuid>/config/ 保存包配置
GET /api/venom/packages/<uuid>/releases/ 发布历史
POST /api/venom/packages/<uuid>/releases/ 发布新版本(写历史)
"""
import logging
from django.db.models import Count
from django.utils.dateformat import format as date_format
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import VenomPackage, VenomPackageRelease
logger = logging.getLogger(__name__)
def _ok(data=None, msg='success'):
return Response({'code': 200, 'msg': msg, 'data': data})
def _err(msg, code=400, status=None):
return Response({'code': code, 'msg': msg, 'data': None}, status=status or 200)
def _Time(value):
return date_format(value, 'Y-m-d H:i:s') if value else ''
def _PackageRow(package):
return {
'PackageUUID': str(package.PackageUUID),
'PackageName': package.PackageName,
'PackageDesc': package.PackageDesc or '',
'CreatorName': package.CreatorName or '',
'CreateTime': _Time(package.CreateTime),
'UpdateTime': _Time(package.UpdateTime),
'ReleaseCount': getattr(package, 'release_count', 0),
}
def _ReleaseRow(release):
return {
'ReleaseUUID': str(release.ReleaseUUID),
'Version': release.Version,
'Notes': release.Notes or '',
'PublisherName': release.PublisherName or '',
'CreateTime': _Time(release.CreateTime),
}
def _GetPackage(PackageUUID):
return VenomPackage.objects.annotate(release_count=Count('releases')).filter(
PackageUUID=PackageUUID).first()
class PackageListView(APIView):
"""包列表 / 新建包"""
def get(self, request):
keyword = (request.query_params.get('Keyword') or '').strip()
qs = VenomPackage.objects.annotate(release_count=Count('releases'))
if keyword:
qs = qs.filter(PackageName__icontains=keyword)
rows = [_PackageRow(p) for p in qs]
return _ok({'list': rows, 'total': len(rows)})
def post(self, request):
name = (request.data.get('PackageName') or '').strip()
if not name:
return _err('请填写包名')
if len(name) > 128:
return _err('包名过长(最多 128 字)')
if VenomPackage.objects.filter(PackageName=name).exists():
return _err(f'包名「{name}」已存在(包名必须全局唯一)')
package = VenomPackage.objects.create(
PackageName=name,
PackageDesc=(request.data.get('PackageDesc') or '').strip()[:512],
ConfigText=request.data.get('ConfigText') or '',
CreatorName=(request.data.get('CreatorName') or '').strip()[:128],
)
package = _GetPackage(package.PackageUUID)
return _ok(_PackageRow(package), msg='软件包已创建')
class PackageDetailView(APIView):
"""包详情(配置页读取)"""
def get(self, request, package_uuid):
package = _GetPackage(package_uuid)
if package is None:
return _err('软件包不存在', 404)
row = _PackageRow(package)
row['ConfigText'] = package.ConfigText or ''
return _ok(row)
class PackageConfigView(APIView):
"""保存包配置"""
def post(self, request, package_uuid):
package = VenomPackage.objects.filter(PackageUUID=package_uuid).first()
if package is None:
return _err('软件包不存在', 404)
if 'PackageDesc' in request.data:
package.PackageDesc = (request.data.get('PackageDesc') or '').strip()[:512]
if 'ConfigText' in request.data:
package.ConfigText = request.data.get('ConfigText') or ''
package.save(update_fields=['PackageDesc', 'ConfigText', 'UpdateTime'])
row = _PackageRow(_GetPackage(package.PackageUUID))
row['ConfigText'] = package.ConfigText or ''
return _ok(row, msg='配置已保存')
class PackageReleaseView(APIView):
"""发布历史 / 发布新版本"""
def get(self, request, package_uuid):
package = VenomPackage.objects.filter(PackageUUID=package_uuid).first()
if package is None:
return _err('软件包不存在', 404)
rows = [_ReleaseRow(r) for r in package.releases.all()]
return _ok({'PackageName': package.PackageName, 'list': rows, 'total': len(rows)})
def post(self, request, package_uuid):
package = VenomPackage.objects.filter(PackageUUID=package_uuid).first()
if package is None:
return _err('软件包不存在', 404)
version = (request.data.get('Version') or '').strip()
if not version:
return _err('请填写版本号')
if len(version) > 64:
return _err('版本号过长(最多 64 字)')
release = VenomPackageRelease.objects.create(
Package=package,
Version=version,
Notes=request.data.get('Notes') or '',
PublisherName=(request.data.get('PublisherName') or '').strip()[:128],
)
return _ok(_ReleaseRow(release), msg='已发布')
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "venom-ui",
"private": true,
"version": "1.0.0",
"description": "Venom 包管理器定制页(远端微前端产物,由 GVSDServiceServer 托管)",
"type": "module",
"scripts": {
"build": "vite build"
},
"dependencies": {
"element-plus": "^2.14.3",
"vue": "^3.5.40"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.8",
"vite": "^8.2.0"
}
}
+322
View File
@@ -0,0 +1,322 @@
<script setup lang="ts">
/**
* Venom 包管理器(产品定制页)
*
* 列表视图:展示全部软件包(包名全局唯一),可新建、可打开包配置页
* 配置视图:编辑包说明/配置内容、发布新版本、查看发布历史
*
* 视图切换在组件内部完成(不占用宿主路由),符合「远端页面自理」的微前端约定。
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
venomApi,
type RemoteContext,
type VenomPackageItem,
type VenomReleaseItem,
} from './api'
const props = defineProps<{ Context?: RemoteContext }>()
const operatorName = computed(() => props.Context?.UserName || '')
const loading = ref(false)
const saving = ref(false)
const publishing = ref(false)
const keyword = ref('')
const packages = ref<VenomPackageItem[]>([])
/** '' = 列表视图;否则为当前打开配置页的包 UUID */
const openedUUID = ref('')
const detail = ref<VenomPackageItem | null>(null)
const releases = ref<VenomReleaseItem[]>([])
const dialogVisible = ref(false)
const createForm = reactive({ PackageName: '', PackageDesc: '', ConfigText: '' })
const configForm = reactive({ PackageDesc: '', ConfigText: '' })
const releaseForm = reactive({ Version: '', Notes: '' })
async function loadPackages(): Promise<void> {
loading.value = true
try {
const r = await venomApi.listPackages(keyword.value.trim())
packages.value = r.list || []
} catch (e) {
ElMessage.error((e as Error).message)
} finally {
loading.value = false
}
}
function openCreate(): void {
createForm.PackageName = ''
createForm.PackageDesc = ''
createForm.ConfigText = ''
dialogVisible.value = true
}
async function submitCreate(): Promise<void> {
if (!createForm.PackageName.trim()) {
ElMessage.warning('请填写包名')
return
}
saving.value = true
try {
await venomApi.createPackage({
PackageName: createForm.PackageName.trim(),
PackageDesc: createForm.PackageDesc,
ConfigText: createForm.ConfigText,
CreatorName: operatorName.value,
})
ElMessage.success('软件包已创建')
dialogVisible.value = false
await loadPackages()
} catch (e) {
ElMessage.error((e as Error).message)
} finally {
saving.value = false
}
}
/** 打开包的配置页 */
async function openConfig(row: VenomPackageItem): Promise<void> {
openedUUID.value = row.PackageUUID
loading.value = true
try {
const [d, r] = await Promise.all([
venomApi.getPackage(row.PackageUUID),
venomApi.listReleases(row.PackageUUID),
])
detail.value = d
configForm.PackageDesc = d.PackageDesc || ''
configForm.ConfigText = d.ConfigText || ''
releases.value = r.list || []
releaseForm.Version = ''
releaseForm.Notes = ''
} catch (e) {
ElMessage.error((e as Error).message)
openedUUID.value = ''
} finally {
loading.value = false
}
}
function backToList(): void {
openedUUID.value = ''
detail.value = null
releases.value = []
}
async function saveConfig(): Promise<void> {
if (!detail.value) return
saving.value = true
try {
detail.value = await venomApi.saveConfig(detail.value.PackageUUID, {
PackageDesc: configForm.PackageDesc,
ConfigText: configForm.ConfigText,
})
ElMessage.success('配置已保存')
await loadPackages()
} catch (e) {
ElMessage.error((e as Error).message)
} finally {
saving.value = false
}
}
async function publishRelease(): Promise<void> {
if (!detail.value) return
if (!releaseForm.Version.trim()) {
ElMessage.warning('请填写版本号')
return
}
publishing.value = true
try {
await venomApi.publishRelease(detail.value.PackageUUID, {
Version: releaseForm.Version.trim(),
Notes: releaseForm.Notes,
PublisherName: operatorName.value,
})
ElMessage.success('已发布')
releaseForm.Version = ''
releaseForm.Notes = ''
const r = await venomApi.listReleases(detail.value.PackageUUID)
releases.value = r.list || []
await loadPackages()
} catch (e) {
ElMessage.error((e as Error).message)
} finally {
publishing.value = false
}
}
onMounted(loadPackages)
</script>
<template>
<div class="venom-page">
<!-- ==================== 列表视图 ==================== -->
<template v-if="!openedUUID">
<div class="head">
<div class="brand">
<span class="brand-name">这是Venom</span>
<span class="brand-sub">Venom 包管理器 · 软件包列表</span>
</div>
<div class="spacer" />
<el-input
v-model="keyword"
placeholder="按包名搜索"
clearable
class="search"
@keyup.enter="loadPackages"
@clear="loadPackages"
/>
<el-button @click="loadPackages">刷新</el-button>
<el-button type="primary" @click="openCreate">新建软件包</el-button>
</div>
<el-table v-loading="loading" :data="packages" border size="default" class="table">
<el-table-column prop="PackageName" label="包名" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="pkg-name">{{ row.PackageName }}</span>
</template>
</el-table-column>
<el-table-column prop="PackageDesc" label="说明" min-width="220" show-overflow-tooltip />
<el-table-column prop="CreatorName" label="创建人" width="140" show-overflow-tooltip />
<el-table-column prop="CreateTime" label="创建时间" width="180" />
<el-table-column prop="ReleaseCount" label="发布数" width="90" align="center" />
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openConfig(row)">打开配置</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="还没有软件包,点右上角「新建软件包」开始" :image-size="80" />
</template>
</el-table>
</template>
<!-- ==================== 配置视图 ==================== -->
<template v-else>
<div class="head">
<el-button link @click="backToList">← 返回列表</el-button>
<div class="brand">
<span class="brand-name">{{ detail?.PackageName || '' }}</span>
<span class="brand-sub">包配置</span>
</div>
<div class="spacer" />
<el-tag size="small" effect="plain">发布 {{ detail?.ReleaseCount || 0 }} 次</el-tag>
</div>
<el-divider content-position="left">包配置</el-divider>
<el-form label-width="96px" class="form">
<el-form-item label="包名">
<el-input :model-value="detail?.PackageName || ''" disabled />
</el-form-item>
<el-form-item label="包说明">
<el-input v-model="configForm.PackageDesc" maxlength="512" placeholder="可选" />
</el-form-item>
<el-form-item label="配置内容">
<el-input
v-model="configForm.ConfigText"
type="textarea"
:rows="6"
placeholder="包配置内容(如依赖、构建参数等)"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="saveConfig">保存配置</el-button>
</el-form-item>
</el-form>
<el-divider content-position="left">发布新版本</el-divider>
<el-form label-width="96px" class="form">
<el-form-item label="版本号">
<el-input v-model="releaseForm.Version" maxlength="64" placeholder="如 1.0.0" class="version" />
</el-form-item>
<el-form-item label="发布说明">
<el-input v-model="releaseForm.Notes" type="textarea" :rows="3" placeholder="可选" />
</el-form-item>
<el-form-item>
<el-button type="success" :loading="publishing" @click="publishRelease">发布</el-button>
</el-form-item>
</el-form>
<el-divider content-position="left">发布历史</el-divider>
<el-table :data="releases" border size="small" class="table">
<el-table-column prop="Version" label="版本" width="140" />
<el-table-column prop="PublisherName" label="发布人" width="150" show-overflow-tooltip />
<el-table-column prop="CreateTime" label="发布时间" width="180" />
<el-table-column prop="Notes" label="发布说明" min-width="200" show-overflow-tooltip />
<template #empty>
<el-empty description="暂无发布记录" :image-size="60" />
</template>
</el-table>
</template>
<!-- ==================== 新建软件包 ==================== -->
<el-dialog v-model="dialogVisible" title="新建软件包" width="620px" :close-on-click-modal="false">
<el-form label-width="96px">
<el-form-item label="包名" required>
<el-input v-model="createForm.PackageName" maxlength="128" placeholder="包名必须全局唯一" />
</el-form-item>
<el-form-item label="包说明">
<el-input v-model="createForm.PackageDesc" maxlength="512" placeholder="可选" />
</el-form-item>
<el-form-item label="配置内容">
<el-input v-model="createForm.ConfigText" type="textarea" :rows="5" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitCreate">创建</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.venom-page {
padding: 4px;
box-sizing: border-box;
}
.head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.brand {
display: flex;
align-items: baseline;
gap: 8px;
}
.brand-name {
font-size: 16px;
font-weight: 700;
}
.brand-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.spacer {
flex: 1;
}
.search {
width: 220px;
}
.table {
width: 100%;
}
.form {
max-width: 720px;
}
.version {
width: 220px;
}
.pkg-name {
font-weight: 600;
}
</style>
+78
View File
@@ -0,0 +1,78 @@
/** Venom 包管理器 API 客户端
*
* 远端模块由 GVSDServiceServer 自身托管,故用 `import.meta.url` 推断服务地址,
* 无需宿主额外注入(跨源由子服务器的 CORS 中间件放行)。
*/
const API_BASE = (() => {
try {
return new URL('/api/venom', import.meta.url).toString()
} catch {
return '/api/venom'
}
})()
export interface VenomPackageItem {
PackageUUID: string
PackageName: string
PackageDesc: string
CreatorName: string
CreateTime: string
UpdateTime: string
ReleaseCount: number
ConfigText?: string
}
/** 宿主注入的上下文(当前登录人/公司,用于「创建人」「发布人」) */
export interface RemoteContext {
UserUUID?: string
UserName?: string
CompanyUUID?: string
CompanyName?: string
}
export interface VenomReleaseItem {
ReleaseUUID: string
Version: string
Notes: string
PublisherName: string
CreateTime: string
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
let payload: { code?: number; msg?: string; data?: unknown } | null = null
try {
payload = await response.json()
} catch {
throw new Error(`服务响应异常(HTTP ${response.status})`)
}
if (!payload || payload.code !== 200) {
throw new Error(payload?.msg || `请求失败(HTTP ${response.status})`)
}
return payload.data as T
}
export const venomApi = {
listPackages: (keyword = '') =>
request<{ list: VenomPackageItem[]; total: number }>(
'GET',
`/packages/${keyword ? `?Keyword=${encodeURIComponent(keyword)}` : ''}`,
),
createPackage: (data: { PackageName: string; PackageDesc: string; ConfigText: string; CreatorName: string }) =>
request<VenomPackageItem>('POST', '/packages/', data),
getPackage: (uuid: string) => request<VenomPackageItem>('GET', `/packages/${uuid}/`),
saveConfig: (uuid: string, data: { PackageDesc: string; ConfigText: string }) =>
request<VenomPackageItem>('POST', `/packages/${uuid}/config/`, data),
listReleases: (uuid: string) =>
request<{ PackageName: string; list: VenomReleaseItem[]; total: number }>(
'GET',
`/packages/${uuid}/releases/`,
),
publishRelease: (uuid: string, data: { Version: string; Notes: string; PublisherName: string }) =>
request<VenomReleaseItem>('POST', `/packages/${uuid}/releases/`, data),
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Venom 定制页远端入口(微应用契约)
*
* 宿主 GERP 通过 `import(<RemoteEntry>)` 加载本模块,调用:
* mount(Element, Context) —— 在给定容器内挂载本页面(自带运行时)
* unmount(Element) —— 卸载并清空容器
*
* 与宿主组件工厂契约 createComponent(Host) 并列,二者满足其一即可(见 GERP remoteLoader.ts)。
* 本页面自带 Vue/Element Plus,运行时可与会话完全自洽。
*/
import { createApp, type App as VueApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import App from './App.vue'
import type { RemoteContext } from './api'
let app: VueApp | null = null
export function mount(Element: HTMLElement, Context: RemoteContext = {}): void {
app = createApp(App, { Context: Context || {} })
app.use(ElementPlus)
app.mount(Element)
}
export function unmount(Element: HTMLElement): void {
if (app) {
app.unmount()
app = null
}
Element.innerHTML = ''
}
+45
View File
@@ -0,0 +1,45 @@
import { defineConfig, type Plugin } from 'vite'
import vue from '@vitejs/plugin-vue'
/**
* 把构建出的 CSS 内联进 JS(远端入口要求单文件:宿主只 import 这一个 ES module)。
* 产物在页面加载时自动注入 <style>,避免宿主再去加载额外的 .css。
*/
function InlineCssPlugin(): Plugin {
return {
name: 'venom-inline-css',
enforce: 'post',
generateBundle(_options, bundle) {
const cssAsset = Object.values(bundle).find(
(f) => f.type === 'asset' && String(f.fileName).endsWith('.css'),
)
if (!cssAsset || cssAsset.type !== 'asset') return
const css = String(cssAsset.source)
delete bundle[cssAsset.fileName]
const entry = Object.values(bundle).find((f) => f.type === 'chunk' && f.isEntry)
if (!entry || entry.type !== 'chunk') return
entry.code +=
'\n;(function(){try{var s=document.createElement("style");' +
's.setAttribute("data-venom-ui","");s.textContent=' + JSON.stringify(css) +
';document.head.appendChild(s)}catch(e){}})();\n'
},
}
}
export default defineConfig({
plugins: [vue(), InlineCssPlugin()],
build: {
outDir: 'dist',
emptyOutDir: true,
cssCodeSplit: false,
// 单文件 ES module 产物:venom-entry.js(由 GVSDServiceServer 托管)
lib: {
entry: 'src/main.ts',
formats: ['es'],
fileName: () => 'venom-entry.js',
},
rollupOptions: {
output: { inlineDynamicImports: true },
},
},
})