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:
2026-08-06 15:59:44 +08:00
parent ef2a921765
commit 607689b9e6
4 changed files with 380 additions and 32 deletions
@@ -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>