feat(#183): 前端接入注册表 API 模型管理/版本发布改走服务端 localStorage 降级(E2)
对接 E1(#182)注册表 HTTP API,模型管理与版本发布从 localStorage 改走服务端: - 新增 src/api/iaop/registry.ts:封装 E1 四接口(listModels/registerModel/ promoteModel/rollbackModel),复用 FBA access token 注入(useAccessStore), 走 iAOP 后端 /api/v1/registry/ 独立路径,原生 fetch + 超时 + 错误信息统一; 导出 isRegistryUnavailable 供调用方降级判断。 - admin/models.vue:数据源切换为注册表 API(list/promote/rollback 全落服务端, 两个浏览器互见);API 不可达时降级 localStorage 现有实现,UI 标注「离线演示模式」; 检测到旧 iaop.admin.models.v1 数据且服务端空时提示一次性导入服务端。 - studio/version.vue:发布 = 注册新版本到注册表(registerModel),列表优先读服务端、 失败降级静态演示;离线发布仅前端可见并标注。 - api/iaop/index.ts:导出 registry。 验收:TypeScript 全部文件转译通过(babel preset-typescript);pnpm build 受限于 本机无 node/pnpm 环境,待 CI/部署机执行(代码严格对齐 infer.ts/templates.ts 既有可构建模式)。fixes #183
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* iAOP API 客户端出口(Epic #163 / issue #166 [A3])
|
* iAOP API 客户端出口(Epic #163 / issue #166 [A3])
|
||||||
*
|
*
|
||||||
* - infer.ts —— LLM 网关(/v1/*:health / models / chat/completions)
|
* - infer.ts —— LLM 网关(/v1/*:health / models / chat/completions)
|
||||||
* - templates.ts —— 驾驶舱布局与告警面板模板 JSON(public/iaop/plans)
|
* - templates.ts —— 驾驶舱布局与告警面板模板 JSON(public/iaop/plans)
|
||||||
|
* - registry.ts —— 模型注册表 HTTP API(E1 #182 / E2 #183:list/register/promote/rollback)
|
||||||
*/
|
*/
|
||||||
export * from './infer';
|
export * from './infer';
|
||||||
|
export * from './registry';
|
||||||
export * from './templates';
|
export * from './templates';
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* iAOP 模型注册表 API 客户端(issue #183 [E2] / PRD 5.3 ③)
|
||||||
|
*
|
||||||
|
* 对接 E1 后端 ``core/model-framework/registry_api.py``(nginx 反代
|
||||||
|
* ``/api/v1/registry/`` → :8090),四接口:
|
||||||
|
*
|
||||||
|
* GET /api/v1/registry/models 列表(可按 stage 过滤)
|
||||||
|
* POST /api/v1/registry/models 注册新版本(服务端校验)
|
||||||
|
* POST /api/v1/registry/models/{name}/{ver}/promote dev→staging→prod
|
||||||
|
* POST /api/v1/registry/models/{name}/rollback?version=&stage= 回滚
|
||||||
|
*
|
||||||
|
* 鉴权:读接口接 FBA JWT,写接口要求权限码 iaop:admin/iaop:studio。
|
||||||
|
* 复用 FBA requestClient 的 token 注入(``useAccessStore().accessToken``),
|
||||||
|
* 但注册表走 iAOP 后端独立路径 ``/api/v1/registry/``(非 /fba),故用原生
|
||||||
|
* fetch 封装,与 infer.ts 同款风格(超时 / 错误信息 / 结果结构统一)。
|
||||||
|
*
|
||||||
|
* 调用方应在 catch 中降级到 localStorage 演示模式(见 views/iaop/admin/models.vue)。
|
||||||
|
*/
|
||||||
|
import { useAccessStore } from '@vben/stores';
|
||||||
|
|
||||||
|
const BASE_URL = '/api/v1/registry';
|
||||||
|
const TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
/** 注册表模型视图(对齐后端 registry_api._tpl_view) */
|
||||||
|
export interface RegistryModel {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
backbone: string;
|
||||||
|
stage: 'dev' | 'staging' | 'prod';
|
||||||
|
hyperparams?: Record<string, unknown>;
|
||||||
|
feature_columns?: string[];
|
||||||
|
target_column?: string;
|
||||||
|
metrics?: Record<string, number>;
|
||||||
|
registered_at: number;
|
||||||
|
description?: string;
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
|
/** 种子/迁移字段(extra.author 提到顶层,便于前端展示) */
|
||||||
|
author?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表响应 */
|
||||||
|
interface ListResponse {
|
||||||
|
models: RegistryModel[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否处于「服务不可达」错误(调用方据此降级 localStorage) */
|
||||||
|
export function isRegistryUnavailable(err: unknown): boolean {
|
||||||
|
if (!(err instanceof Error)) return false;
|
||||||
|
return /Failed to fetch|NetworkError|超时|HTTP 5\d\d|HTTP 0/i.test(err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册新版本的请求体 */
|
||||||
|
export interface RegisterModelParams {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
backbone?: string;
|
||||||
|
stage?: 'dev' | 'staging' | 'prod';
|
||||||
|
hyperparams?: Record<string, unknown>;
|
||||||
|
feature_columns?: string[];
|
||||||
|
target_column?: string;
|
||||||
|
metrics?: Record<string, number>;
|
||||||
|
description?: string;
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取当前 FBA access token(与 requestClient 同源注入) */
|
||||||
|
function authHeader(): Record<string, string> {
|
||||||
|
try {
|
||||||
|
const token = useAccessStore().accessToken;
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
} catch {
|
||||||
|
// store 未就绪(如独立运行 / 单测)时降级无鉴权
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 带超时的 fetch + FBA token 注入 */
|
||||||
|
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...authHeader(),
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
// 401/403/404 等业务错误:尝试读后端 {error} 字段,否则给 HTTP 码
|
||||||
|
let detail = `HTTP ${resp.status}`;
|
||||||
|
try {
|
||||||
|
const body = await resp.json();
|
||||||
|
if (body?.error) detail = body.error;
|
||||||
|
} catch { /* 非 JSON 错误体忽略 */ }
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
const text = await resp.text();
|
||||||
|
return (text ? JSON.parse(text) : {}) as T;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
throw new Error(`注册表服务请求超时(>${TIMEOUT_MS / 1000}s)`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表(可按 stage 过滤)。失败抛错,调用方降级。 */
|
||||||
|
export async function listModels(stage?: 'dev' | 'staging' | 'prod'): Promise<RegistryModel[]> {
|
||||||
|
const qs = stage ? `?stage=${encodeURIComponent(stage)}` : '';
|
||||||
|
const data = await fetchJson<ListResponse>(`/models${qs}`);
|
||||||
|
return data.models || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册新版本。冲突(同 name+version 已存在)抛 409 错误。 */
|
||||||
|
export async function registerModel(params: RegisterModelParams): Promise<RegistryModel> {
|
||||||
|
return fetchJson<RegistryModel>('/models', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提升 dev→staging→prod(逐级)。已在 prod 抛错。 */
|
||||||
|
export async function promoteModel(name: string, version: string): Promise<RegistryModel> {
|
||||||
|
return fetchJson<RegistryModel>(
|
||||||
|
`/models/${encodeURIComponent(name)}/${encodeURIComponent(version)}/promote`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回滚某 stage 指针到指定版本。 */
|
||||||
|
export async function rollbackModel(
|
||||||
|
name: string,
|
||||||
|
version: string,
|
||||||
|
stage: 'dev' | 'staging' | 'prod' = 'prod',
|
||||||
|
): Promise<RegistryModel> {
|
||||||
|
const qs = `?version=${encodeURIComponent(version)}&stage=${encodeURIComponent(stage)}`;
|
||||||
|
return fetchJson<RegistryModel>(
|
||||||
|
`/models/${encodeURIComponent(name)}/rollback${qs}`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 模型管理(issue #172 [B6] 重构;#D2 样式对齐;2026-08-06 数据回退修复)
|
* 模型管理(issue #172 [B6] 重构;#D2 样式对齐;#183 [E2] 接入注册表 API)
|
||||||
*
|
*
|
||||||
* 数据语义对齐旧版 web/admin/admin.js:
|
* 数据来源:E1 模型注册表 HTTP API(/api/v1/registry/,服务端持久化),
|
||||||
* - 模型注册表持久化在 localStorage(key 与旧版相同:iaop.admin.models.v1),
|
* list / promote / rollback 全部走服务端,两个浏览器互见。
|
||||||
* 同源(39.101.182.167:8090)下旧版页面积累的数据自动迁移可见;
|
* - API 不可达(服务未部署 / 离线)时降级 localStorage 演示模式
|
||||||
* - 首次无数据时用旧版种子数据(含 author / updated_at);
|
* (旧版 iaop.admin.models.v1 数据保留可见,UI 标注「离线演示模式」);
|
||||||
* - 提升/回滚写回 localStorage(旧版行为),而非仅改内存。
|
* - 检测到 localStorage 旧数据且服务端列表为空时,提示一次性导入。
|
||||||
*/
|
*/
|
||||||
import { ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { Page, VbenButton } from '@vben/common-ui';
|
import { Page, VbenButton } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isRegistryUnavailable,
|
||||||
|
listModels,
|
||||||
|
promoteModel,
|
||||||
|
registerModel,
|
||||||
|
rollbackModel,
|
||||||
|
} from '#/api/iaop/registry';
|
||||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
@@ -34,7 +41,7 @@ function seedModels(): ModelItem[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadModels(): ModelItem[] {
|
function loadLocalModels(): ModelItem[] | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(MODELS_KEY);
|
const raw = localStorage.getItem(MODELS_KEY);
|
||||||
if (raw) {
|
if (raw) {
|
||||||
@@ -42,16 +49,75 @@ function loadModels(): ModelItem[] {
|
|||||||
if (Array.isArray(arr) && arr.length > 0) return arr;
|
if (Array.isArray(arr) && arr.length > 0) return arr;
|
||||||
}
|
}
|
||||||
} catch { /* 隐私模式等忽略 */ }
|
} catch { /* 隐私模式等忽略 */ }
|
||||||
const seed = seedModels();
|
return null;
|
||||||
saveModels(seed);
|
|
||||||
return seed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveModels(list: ModelItem[]) {
|
function saveLocalModels(list: ModelItem[]) {
|
||||||
try { localStorage.setItem(MODELS_KEY, JSON.stringify(list)); } catch { /* ignore */ }
|
try { localStorage.setItem(MODELS_KEY, JSON.stringify(list)); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = ref<ModelItem[]>(loadModels());
|
/** 把注册表 RegistryModel 适配为表格 ModelItem(updated_at 由 epoch 转 ISO) */
|
||||||
|
function fromRegistry(m: { name: string; version: string; stage: string; author?: string; registered_at?: number }): ModelItem {
|
||||||
|
const ts = typeof m.registered_at === 'number' && m.registered_at > 0
|
||||||
|
? new Date(m.registered_at * 1000).toISOString()
|
||||||
|
: '';
|
||||||
|
return { name: m.name, version: m.version, stage: m.stage, author: m.author || '', updated_at: ts };
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = ref<ModelItem[]>(loadLocalModels() ?? seedModels());
|
||||||
|
/** 在线=走注册表 API;离线=降级 localStorage 演示模式 */
|
||||||
|
const online = ref(false);
|
||||||
|
/** 导入提示:检测到 localStorage 旧数据且服务端可读时显示 */
|
||||||
|
const showImportHint = ref(false);
|
||||||
|
const busy = ref(false);
|
||||||
|
const errorMsg = ref('');
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
busy.value = true;
|
||||||
|
errorMsg.value = '';
|
||||||
|
try {
|
||||||
|
const remote = await listModels();
|
||||||
|
online.value = true;
|
||||||
|
models.value = remote.map(fromRegistry);
|
||||||
|
// 检测旧 localStorage 数据,提示一次性导入(仅服务端列表为空时)
|
||||||
|
const localOld = loadLocalModels();
|
||||||
|
showImportHint.value = !!localOld && localOld.length > 0 && models.value.length === 0;
|
||||||
|
} catch (err) {
|
||||||
|
online.value = false;
|
||||||
|
if (!isRegistryUnavailable(err)) {
|
||||||
|
errorMsg.value = (err as Error)?.message || '加载失败';
|
||||||
|
}
|
||||||
|
// 降级 localStorage(无则种子)
|
||||||
|
models.value = loadLocalModels() ?? seedModels();
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
gridApi.query();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importLocalToServer() {
|
||||||
|
const localOld = loadLocalModels();
|
||||||
|
if (!localOld) return;
|
||||||
|
busy.value = true;
|
||||||
|
errorMsg.value = '';
|
||||||
|
let ok = 0;
|
||||||
|
for (const m of localOld) {
|
||||||
|
try {
|
||||||
|
await registerModel({
|
||||||
|
name: m.name,
|
||||||
|
version: m.version,
|
||||||
|
stage: m.stage as 'dev' | 'staging' | 'prod',
|
||||||
|
backbone: 'generic',
|
||||||
|
extra: { author: m.author },
|
||||||
|
});
|
||||||
|
ok++;
|
||||||
|
} catch { /* 冲突(已存在)跳过 */ }
|
||||||
|
}
|
||||||
|
showImportHint.value = false;
|
||||||
|
await refresh();
|
||||||
|
busy.value = false;
|
||||||
|
errorMsg.value = ok > 0 ? `已导入 ${ok} 条到服务端` : '无可导入数据(服务端已存在)';
|
||||||
|
}
|
||||||
|
|
||||||
const gridOptions: VxeTableGridOptions = {
|
const gridOptions: VxeTableGridOptions = {
|
||||||
rowConfig: { keyField: 'name' },
|
rowConfig: { keyField: 'name' },
|
||||||
@@ -81,26 +147,87 @@ const gridOptions: VxeTableGridOptions = {
|
|||||||
|
|
||||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||||
|
|
||||||
function touch(m: ModelItem) {
|
function touchLocal(m: ModelItem) {
|
||||||
m.updated_at = new Date().toISOString();
|
m.updated_at = new Date().toISOString();
|
||||||
saveModels(models.value);
|
saveLocalModels(models.value);
|
||||||
gridApi.query();
|
gridApi.query();
|
||||||
}
|
}
|
||||||
|
|
||||||
function promote(m: ModelItem) {
|
async function promote(m: ModelItem) {
|
||||||
|
if (online.value) {
|
||||||
|
busy.value = true;
|
||||||
|
errorMsg.value = '';
|
||||||
|
try {
|
||||||
|
const updated = await promoteModel(m.name, m.version);
|
||||||
|
Object.assign(m, fromRegistry(updated));
|
||||||
|
m.updated_at = new Date().toISOString();
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg.value = (err as Error)?.message || '提升失败';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
gridApi.query();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 离线降级
|
||||||
if (m.stage === 'dev') m.stage = 'staging';
|
if (m.stage === 'dev') m.stage = 'staging';
|
||||||
else if (m.stage === 'staging') m.stage = 'prod';
|
else if (m.stage === 'staging') m.stage = 'prod';
|
||||||
touch(m);
|
touchLocal(m);
|
||||||
}
|
}
|
||||||
function rollback(m: ModelItem) {
|
|
||||||
|
async function rollback(m: ModelItem) {
|
||||||
|
if (online.value) {
|
||||||
|
busy.value = true;
|
||||||
|
errorMsg.value = '';
|
||||||
|
try {
|
||||||
|
// 回滚到上一阶段指针:prod→staging、staging→dev
|
||||||
|
const prev = m.stage === 'prod' ? 'staging' : 'dev';
|
||||||
|
const updated = await rollbackModel(m.name, m.version, prev as 'dev' | 'staging' | 'prod');
|
||||||
|
Object.assign(m, fromRegistry(updated));
|
||||||
|
m.updated_at = new Date().toISOString();
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg.value = (err as Error)?.message || '回滚失败';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
gridApi.query();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 离线降级
|
||||||
if (m.stage === 'prod') m.stage = 'staging';
|
if (m.stage === 'prod') m.stage = 'staging';
|
||||||
else if (m.stage === 'staging') m.stage = 'dev';
|
else if (m.stage === 'staging') m.stage = 'dev';
|
||||||
touch(m);
|
touchLocal(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(refresh);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page title="模型管理" description="PRD 5.3 · 模板注册表 / 版本阶段流转">
|
<Page title="模型管理" description="PRD 5.3 · 模板注册表 / 版本阶段流转">
|
||||||
|
<template #extra>
|
||||||
|
<a-tag v-if="!online" color="orange">离线演示模式(注册表服务不可达,降级 localStorage)</a-tag>
|
||||||
|
<a-tag v-else color="green">在线(服务端注册表)</a-tag>
|
||||||
|
<VbenButton size="small" variant="outline" :disabled="busy" @click="refresh">刷新</VbenButton>
|
||||||
|
</template>
|
||||||
|
<a-alert
|
||||||
|
v-if="showImportHint"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
message="检测到本地演示数据,是否一次性导入服务端注册表?"
|
||||||
|
description="导入后模型管理将完全落服务端,多浏览器互见。已存在的同名同版本会跳过。"
|
||||||
|
style="margin-bottom: 12px"
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<VbenButton size="small" :disabled="busy" @click="importLocalToServer">导入</VbenButton>
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
|
<a-alert
|
||||||
|
v-if="errorMsg"
|
||||||
|
:type="online ? 'error' : 'warning'"
|
||||||
|
show-icon
|
||||||
|
:message="errorMsg"
|
||||||
|
style="margin-bottom: 12px"
|
||||||
|
/>
|
||||||
<Grid>
|
<Grid>
|
||||||
<template #stage="{ row }">
|
<template #stage="{ row }">
|
||||||
<a-tag :color="row.stage === 'prod' ? 'green' : row.stage === 'staging' ? 'orange' : 'blue'">{{ row.stage }}</a-tag>
|
<a-tag :color="row.stage === 'prod' ? 'green' : row.stage === 'staging' ? 'orange' : 'blue'">{{ row.stage }}</a-tag>
|
||||||
@@ -109,8 +236,8 @@ function rollback(m: ModelItem) {
|
|||||||
{{ (row.updated_at || '').slice(0, 10) }}
|
{{ (row.updated_at || '').slice(0, 10) }}
|
||||||
</template>
|
</template>
|
||||||
<template #action="{ row }">
|
<template #action="{ row }">
|
||||||
<VbenButton size="small" :disabled="row.stage === 'prod'" @click="promote(row)">提升</VbenButton>
|
<VbenButton size="small" :disabled="row.stage === 'prod' || busy" @click="promote(row)">提升</VbenButton>
|
||||||
<VbenButton size="small" variant="outline" :disabled="row.stage === 'dev'" @click="rollback(row)">回滚</VbenButton>
|
<VbenButton size="small" variant="outline" :disabled="row.stage === 'dev' || busy" @click="rollback(row)">回滚</VbenButton>
|
||||||
</template>
|
</template>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -1,17 +1,63 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
/**
|
||||||
|
* 模板版本发布(issue #183 [E2]:发布 = 注册新版本到注册表)
|
||||||
|
*
|
||||||
|
* - 列表优先读注册表 API(/api/v1/registry/models),失败降级静态演示数据;
|
||||||
|
* - 「发布新版本」= 调 registerModel 注册到服务端注册表;注册表不可达时
|
||||||
|
* 降级为本地新增一条(仅前端可见,提示离线)。
|
||||||
|
*/
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { Page, VbenButton } from '@vben/common-ui';
|
import { Page, VbenButton } from '@vben/common-ui';
|
||||||
import { MaterialSymbolsAdd } from '@vben/icons';
|
import { MaterialSymbolsAdd } from '@vben/icons';
|
||||||
|
|
||||||
|
import { isRegistryUnavailable, listModels, registerModel } from '#/api/iaop/registry';
|
||||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
const rows = ref([
|
interface VersionRow {
|
||||||
|
version: string;
|
||||||
|
stage: string;
|
||||||
|
time: string;
|
||||||
|
author: string;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATIC_ROWS: VersionRow[] = [
|
||||||
{ version: 'v1.0.0', stage: 'prod', time: '2026-08-05 10:00', author: 'bot_dev1', note: 'Ti 一期发布' },
|
{ version: 'v1.0.0', stage: 'prod', time: '2026-08-05 10:00', author: 'bot_dev1', note: 'Ti 一期发布' },
|
||||||
{ version: 'v0.9.0', stage: 'staging', time: '2026-08-04 16:30', author: 'bot_dev1', note: '验收候选' },
|
{ version: 'v0.9.0', stage: 'staging', time: '2026-08-04 16:30', author: 'bot_dev1', note: '验收候选' },
|
||||||
{ version: 'v0.8.0', stage: 'dev', time: '2026-08-03 09:00', author: 'bot_dev1', note: '布局资产初版' },
|
{ version: 'v0.8.0', stage: 'dev', time: '2026-08-03 09:00', author: 'bot_dev1', note: '布局资产初版' },
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
const rows = ref<VersionRow[]>([...STATIC_ROWS]);
|
||||||
|
const online = ref(false);
|
||||||
|
const busy = ref(false);
|
||||||
|
const errorMsg = ref('');
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
busy.value = true;
|
||||||
|
errorMsg.value = '';
|
||||||
|
try {
|
||||||
|
const remote = await listModels();
|
||||||
|
online.value = true;
|
||||||
|
rows.value = remote.map((m) => ({
|
||||||
|
version: m.version,
|
||||||
|
stage: m.stage,
|
||||||
|
time: typeof m.registered_at === 'number' && m.registered_at > 0
|
||||||
|
? new Date(m.registered_at * 1000).toISOString().slice(0, 16).replace('T', ' ')
|
||||||
|
: '',
|
||||||
|
author: m.author || '',
|
||||||
|
note: m.description || '',
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
online.value = false;
|
||||||
|
rows.value = [...STATIC_ROWS];
|
||||||
|
if (!isRegistryUnavailable(err)) errorMsg.value = (err as Error)?.message || '加载失败';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
gridApi.query();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const gridOptions: VxeTableGridOptions = {
|
const gridOptions: VxeTableGridOptions = {
|
||||||
rowConfig: { keyField: 'version' },
|
rowConfig: { keyField: 'version' },
|
||||||
@@ -39,20 +85,46 @@ const gridOptions: VxeTableGridOptions = {
|
|||||||
|
|
||||||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
|
||||||
|
|
||||||
function publish() {
|
async function publish() {
|
||||||
rows.value.unshift({
|
const version = `v1.0.${rows.value.length + 1}`;
|
||||||
version: 'v1.0.1', stage: 'staging', time: new Date().toISOString().slice(0, 16).replace('T', ' '),
|
const time = new Date().toISOString().slice(0, 16).replace('T', ' ');
|
||||||
author: 'admin', note: '手动发布',
|
if (online.value) {
|
||||||
});
|
busy.value = true;
|
||||||
|
errorMsg.value = '';
|
||||||
|
try {
|
||||||
|
// 发布 = 注册新版本到注册表(模板布局资产,backbone=generic)
|
||||||
|
await registerModel({
|
||||||
|
name: 'ti_layout',
|
||||||
|
version: version.replace(/^v/, ''),
|
||||||
|
stage: 'dev',
|
||||||
|
backbone: 'generic',
|
||||||
|
description: '模板布局资产发布',
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
errorMsg.value = `已发布 ${version} 到注册表`;
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg.value = (err as Error)?.message || '发布失败';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 离线降级:仅前端新增
|
||||||
|
rows.value.unshift({ version, stage: 'staging', time, author: 'admin', note: '手动发布(离线)' });
|
||||||
gridApi.query();
|
gridApi.query();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(refresh);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page title="模板版本发布" description="PRD 5.7 · 版本管理 / 阶段流转">
|
<Page title="模板版本发布" description="PRD 5.7 · 版本管理 / 阶段流转">
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<VbenButton variant="solid" @click="publish"><MaterialSymbolsAdd class="size-4" />发布新版本</VbenButton>
|
<a-tag v-if="!online" color="orange">离线演示</a-tag>
|
||||||
|
<a-tag v-else color="green">在线(服务端注册表)</a-tag>
|
||||||
|
<VbenButton variant="solid" :disabled="busy" @click="publish"><MaterialSymbolsAdd class="size-4" />发布新版本</VbenButton>
|
||||||
</template>
|
</template>
|
||||||
|
<a-alert v-if="errorMsg" :type="online ? 'info' : 'warning'" show-icon :message="errorMsg" style="margin-bottom: 12px" />
|
||||||
<Grid>
|
<Grid>
|
||||||
<template #stage="{ row }">
|
<template #stage="{ row }">
|
||||||
<a-tag :color="row.stage === 'prod' ? 'green' : row.stage === 'staging' ? 'orange' : 'blue'">{{ row.stage }}</a-tag>
|
<a-tag :color="row.stage === 'prod' ? 'green' : row.stage === 'staging' ? 'orange' : 'blue'">{{ row.stage }}</a-tag>
|
||||||
|
|||||||
Reference in New Issue
Block a user