From 9ffd68bae579a37ea6ec241f81457c7568919c8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sun, 13 Sep 2026 23:48:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=95=99=E7=A8=8B=E7=AE=A1=E7=90=86):=20?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=8F=AF=E4=B8=8A=E4=BC=A0=E6=95=99=E7=A8=8B?= =?UTF-8?q?=E5=8C=85=EF=BC=8C=E5=B7=A5=E5=85=B7=E5=8F=B0=E6=8C=89=E6=9C=80?= =?UTF-8?q?=E6=96=B0=E4=B8=8A=E4=BC=A0=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin-vue 新增「记录与版本 → 教程管理」页(menuKey admin_tutorial): zip 浏览器直传 MinIO(presign → PUT 带进度 → confirm 落库)+ 列表/下载/删除, 最新一条标「当前生效」;补操作指引与 align-tutorial 守卫测试。 - frontend-vue 工具台「立即下载教程」进页面取 /api/tutorial/latest(最新上传优先), 接口异常或无记录回退历史固定直链,按钮不失效。 - Java 侧 tutorial 模块与 V119 迁移已在 dc6e8924 提交并上线(2026-09-13)。 vue-tsc + 两端单测(工具台 695 / 后台 1617)通过;生产已实测 presign→PUT→confirm→latest 切换→删除回落全链路。 --- .../src/layout/operation-guides.ts | 4 + .../src/pages/records/RecordsTutorialPage.vue | 445 ++++++++++++++++++ .../src/pages/records/tutorial-api.ts | 78 +++ .../src/pages/records/tutorial-dto.ts | 19 + .../src/pages/records/tutorial-model.ts | 59 +++ admin-frontend-vue/src/router/routes.ts | 1 + .../tests/align-tutorial.test.ts | 49 ++ .../src/pages/amazon/AmazonConsolePage.vue | 17 +- frontend-vue/src/pages/amazon/download-btn.ts | 19 +- frontend-vue/src/shared/api/endpoints.ts | 3 + frontend-vue/src/shared/api/tutorial.ts | 1 + .../src/shared/api/types/modules/tutorial.ts | 31 ++ frontend-vue/tests/endpoints.test.ts | 3 + 13 files changed, 722 insertions(+), 7 deletions(-) create mode 100644 admin-frontend-vue/src/pages/records/RecordsTutorialPage.vue create mode 100644 admin-frontend-vue/src/pages/records/tutorial-api.ts create mode 100644 admin-frontend-vue/src/pages/records/tutorial-dto.ts create mode 100644 admin-frontend-vue/src/pages/records/tutorial-model.ts create mode 100644 admin-frontend-vue/tests/align-tutorial.test.ts create mode 100644 frontend-vue/src/shared/api/tutorial.ts create mode 100644 frontend-vue/src/shared/api/types/modules/tutorial.ts diff --git a/admin-frontend-vue/src/layout/operation-guides.ts b/admin-frontend-vue/src/layout/operation-guides.ts index a580303f..b2e0ce14 100644 --- a/admin-frontend-vue/src/layout/operation-guides.ts +++ b/admin-frontend-vue/src/layout/operation-guides.ts @@ -74,6 +74,10 @@ export const OPERATION_GUIDES: Record = { text: '上传版本后请核对版本号和下载链接,再通知用户更新。', steps: ['上传压缩包', '检查版本记录', '维护历史版本'], }, + admin_tutorial: { + text: '上传新的教程压缩包后即刻生效:工具台首页「立即下载教程」以下载最新上传的包为准。确认旧包不再需要后再删除。', + steps: ['上传教程压缩包', '到工具台核对下载', '清理历史包'], + }, digital_human_version: { text: '数字人版本需先上传草稿,再发布并标记最新版本。', steps: ['上传草稿', '确认更新日志', '发布或设为最新'], diff --git a/admin-frontend-vue/src/pages/records/RecordsTutorialPage.vue b/admin-frontend-vue/src/pages/records/RecordsTutorialPage.vue new file mode 100644 index 00000000..75f52c5d --- /dev/null +++ b/admin-frontend-vue/src/pages/records/RecordsTutorialPage.vue @@ -0,0 +1,445 @@ + + + + + diff --git a/admin-frontend-vue/src/pages/records/tutorial-api.ts b/admin-frontend-vue/src/pages/records/tutorial-api.ts new file mode 100644 index 00000000..52daa660 --- /dev/null +++ b/admin-frontend-vue/src/pages/records/tutorial-api.ts @@ -0,0 +1,78 @@ +/** 教程包接口适配(教程管理页):列表 / 直传预签名 / 确认 / 删除。 */ +import axios from 'axios' +import { http, unwrap } from '@/api/http' +import { parseTutorialPackageList, parseTutorialPackageUpload } from './tutorial-model.ts' +import type { TutorialPackageItem, TutorialPackageList } from './tutorial-dto.ts' + +export const TUTORIALS_ENDPOINT = '/api/admin/tutorials' +export const TUTORIAL_UPLOAD_ENDPOINT = '/api/admin/tutorial' +export const TUTORIAL_PRESIGN_ENDPOINT = `${TUTORIAL_UPLOAD_ENDPOINT}/presign` +export const TUTORIAL_CONFIRM_ENDPOINT = `${TUTORIAL_UPLOAD_ENDPOINT}/confirm` +export const TUTORIAL_DELETE_ENDPOINT = `${TUTORIAL_UPLOAD_ENDPOINT}/delete` + +/** MinIO 直传专用实例:不带 cookie、不挂 401 跳登录拦截器(预签名过期/签名失败不能误判会话过期);大 zip 给足超时。 */ +const directPut = axios.create({ timeout: 600_000, withCredentials: false }) + +export async function fetchTutorialPackages(): Promise { + const { data } = await http.get(TUTORIALS_ENDPOINT) + return parseTutorialPackageList(data) +} + +export interface TutorialUploadTarget { + objectKey: string + uploadUrl: string + fileUrl: string +} + +/** 第一步:向后端申请直传 PUT 预签名地址(登录会话签发,3 分钟有效)。 */ +export async function requestTutorialPresign(fileName: string): Promise { + const { data } = await http.post(TUTORIAL_PRESIGN_ENDPOINT, null, { params: { file_name: fileName } }) + const core = (unwrap(data) ?? {}) as Record + const uploadUrl = typeof core.upload_url === 'string' ? core.upload_url : '' + const objectKey = typeof core.object_key === 'string' ? core.object_key : '' + if (!uploadUrl || !objectKey) { + throw new Error('后端未返回直传地址,请重试') + } + return { + objectKey, + uploadUrl, + fileUrl: typeof core.file_url === 'string' ? core.file_url : '', + } +} + +/** 第三步:直传完成后通知后端校验对象并写入记录(data.item 为新记录行)。 */ +export async function confirmTutorialPackage(objectKey: string, fileName: string): Promise { + const { data } = await http.post(TUTORIAL_CONFIRM_ENDPOINT, null, { + params: { object_key: objectKey, file_name: fileName }, + }) + return parseTutorialPackageUpload(data) +} + +/** 按 id 批量删除教程包(后端会顺带清理无引用对象的 MinIO 包体);返回实际删除行数。 */ +export async function deleteTutorialPackages(ids: number[]): Promise { + if (!ids.length) return 0 + const { data } = await http.post(TUTORIAL_DELETE_ENDPOINT, ids) + const core = (unwrap(data) ?? {}) as Record + return typeof core.deleted === 'number' ? core.deleted : ids.length +} + +/** + * 上传教程包(浏览器直传):presign 申请 → 直接 PUT 到 MinIO client 桶(不走 Java 中转) + * → confirm 校验落库。onProgress 回传 0-100 上传进度。 + */ +export async function uploadTutorialPackage( + file: Blob, + fileName: string, + onProgress?: (percent: number) => void, +): Promise { + const target = await requestTutorialPresign(fileName) + await directPut.put(target.uploadUrl, file, { + headers: { 'Content-Type': 'application/octet-stream' }, + onUploadProgress: (event) => { + if (!onProgress) return + const total = event.total || 0 + onProgress(total > 0 ? Math.min(Math.round((event.loaded / total) * 100), 100) : 0) + }, + }) + return confirmTutorialPackage(target.objectKey, fileName) +} diff --git a/admin-frontend-vue/src/pages/records/tutorial-dto.ts b/admin-frontend-vue/src/pages/records/tutorial-dto.ts new file mode 100644 index 00000000..53d039ec --- /dev/null +++ b/admin-frontend-vue/src/pages/records/tutorial-dto.ts @@ -0,0 +1,19 @@ +/** 教程包 DTO(教程管理页):工具台下载入口对应的教程压缩包行类型;纯逻辑。 */ + +/** 教程包行(GET /api/admin/tutorials data.items,Java TutorialPackageService snake)。 */ +export interface TutorialPackageItem { + id: number + fileName: string + objectKey: string + fileSize: number + fileUrl: string + createdAt: string +} + +export interface TutorialPackageList { + items: TutorialPackageItem[] +} + +export function emptyTutorialPackageList(): TutorialPackageList { + return { items: [] } +} diff --git a/admin-frontend-vue/src/pages/records/tutorial-model.ts b/admin-frontend-vue/src/pages/records/tutorial-model.ts new file mode 100644 index 00000000..3b7b42de --- /dev/null +++ b/admin-frontend-vue/src/pages/records/tutorial-model.ts @@ -0,0 +1,59 @@ +/** 教程包列表加载模型:解析 /api/admin/tutorials data.items(snake);纯逻辑。 */ +import { unwrap } from '../../api/envelope.ts' +import { type TutorialPackageItem, type TutorialPackageList } from './tutorial-dto.ts' + +function text(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function numberOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null +} + +/** 解析单条教程包行;缺 id 视为无效。 */ +export function toTutorialPackageItem(raw: unknown): TutorialPackageItem | null { + if (!raw || typeof raw !== 'object') return null + const r = raw as Record + const id = numberOrNull(r.id) + if (id === null) return null + return { + id, + fileName: text(r.file_name ?? r.fileName), + objectKey: text(r.object_key ?? r.objectKey), + fileSize: numberOrNull(r.file_size ?? r.fileSize) ?? 0, + fileUrl: text(r.file_url ?? r.fileUrl), + createdAt: text(r.created_at ?? r.createdAt), + } +} + +/** 归一化教程包列表负载为前端结果;缺省回空列表。 */ +export function parseTutorialPackageList(payload: unknown): TutorialPackageList { + const data = unwrap(payload) + const record = data && typeof data === 'object' ? (data as Record) : {} + const items = Array.isArray(record.items) + ? record.items.map((raw) => toTutorialPackageItem(raw)).filter((item): item is TutorialPackageItem => item !== null) + : [] + return { items } +} + +/** 上传成功后返回的新记录行(data.item);无 item 回 null。 */ +export function parseTutorialPackageUpload(payload: unknown): TutorialPackageItem | null { + const data = unwrap(payload) + if (!data || typeof data !== 'object') return null + const item = (data as Record).item + return item ? toTutorialPackageItem(item) : null +} + +/** 字节数展示:0=未知展示为 -,其余按 KB/MB/GB 人性化。 */ +export function formatFileSize(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '—' + if (bytes < 1024) return `${bytes} B` + const units = ['KB', 'MB', 'GB'] + let value = bytes / 1024 + let unitIndex = 0 + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024 + unitIndex += 1 + } + return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}` +} diff --git a/admin-frontend-vue/src/router/routes.ts b/admin-frontend-vue/src/router/routes.ts index 65532d53..d7b70302 100644 --- a/admin-frontend-vue/src/router/routes.ts +++ b/admin-frontend-vue/src/router/routes.ts @@ -24,6 +24,7 @@ export const adminPages: AdminPageDef[] = [ { path: 'shop-center/shops', menuKey: 'admin_shop_manage', title: '店铺管理', load: () => import('@/pages/shop/ShopManagePage.vue') }, { path: 'records/history', menuKey: 'admin_history', title: '生成记录', load: () => import('@/pages/records/RecordsHistoryPage.vue') }, { path: 'records/software-version', menuKey: 'admin_version', title: '软件版本管理', load: () => import('@/pages/records/RecordsSoftwareVersionPage.vue') }, + { path: 'records/tutorial', menuKey: 'admin_tutorial', title: '教程管理', load: () => import('@/pages/records/RecordsTutorialPage.vue') }, { path: 'records/digital-human-version', menuKey: 'digital_human_version', title: '数字人版本管理', load: () => import('@/pages/records/RecordsDigitalHumanVersionPage.vue') }, { path: 'records/image-video-tasks', menuKey: 'admin_image_video_tasks', title: '视频任务记录', load: () => import('@/pages/tasks/ImageVideoTasksPage.vue') }, { path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') }, diff --git a/admin-frontend-vue/tests/align-tutorial.test.ts b/admin-frontend-vue/tests/align-tutorial.test.ts new file mode 100644 index 00000000..9da740aa --- /dev/null +++ b/admin-frontend-vue/tests/align-tutorial.test.ts @@ -0,0 +1,49 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { adminPages } from '../src/router/routes.ts' + +/** 教程管理页(工具台「立即下载教程」的后台入口):上传走 presign 直传、列表取最新、下载/删除留驻反馈。 */ + +test('align_tutorial_page_registered', () => { + const page = adminPages.find((p) => p.path === 'records/tutorial') + assert.ok(page, '路由注册表应包含 records/tutorial') + assert.equal(page.menuKey, 'admin_tutorial') + assert.equal(page.title, '教程管理') +}) + +test('align_tutorial_page_wiring', () => { + const page = readSource('src/pages/records/RecordsTutorialPage.vue') + // 上传入口:选择 zip → 直传(进度条)→ 成功提示留驻弹窗。 + assert.match(page, /上传教程包/, '存在上传入口按钮') + assert.match(page, /uploadTutorialPackage/, '上传走教程包直传链路') + assert.match(page, /请选择 zip 压缩包/, '未选文件时提示') + assert.match(page, /仅支持 \.zip 格式/, '格式校验提示') + assert.match(page, /上传成功,工具台「立即下载教程」已切换为该包/, '成功提示说明生效位置') + assert.match(page, /上传完成,正在登记教程包/, '进度文案') + // 列表:当前生效标记 + 下载 + 删除 + 空态。 + assert.match(page, /当前生效/, '最新上传的包标记当前生效') + assert.match(page, /formatFileSize/, '展示包体大小') + assert.match(page, /下载/, '操作列提供下载') + assert.match(page, /]*download/, '下载链接带 download 强制下载') + assert.match(page, /确认删除选中的/, '批量删除二次确认') + assert.match(page, /暂无教程包记录/, '空态文案') +}) + +test('align_tutorial_api_contract', () => { + const api = readSource('src/pages/records/tutorial-api.ts') + assert.match(api, /\/api\/admin\/tutorials/, '列表端点') + assert.match(api, /TUTORIAL_UPLOAD_ENDPOINT = '\/api\/admin\/tutorial'/, '上传端点前缀') + assert.match(api, /\$\{TUTORIAL_UPLOAD_ENDPOINT\}\/presign/, '直传预签名端点') + assert.match(api, /\$\{TUTORIAL_UPLOAD_ENDPOINT\}\/confirm/, '确认端点') + assert.match(api, /\$\{TUTORIAL_UPLOAD_ENDPOINT\}\/delete/, '删除端点') + assert.match(api, /params: \{ object_key: objectKey, file_name: fileName \}/, '确认回传对象 key 与文件名') + assert.match(api, /withCredentials: false/, '直传实例不挂会话拦截器') +}) + +test('align_tutorial_model_parsers', () => { + const model = readSource('src/pages/records/tutorial-model.ts') + assert.match(model, /r\.file_name \?\? r\.fileName/, '兼容 snake/camel 文件名') + assert.match(model, /r\.file_url \?\? r\.fileUrl/, '兼容 snake/camel 下载链接') + assert.match(model, /r\.file_size \?\? r\.fileSize/, '兼容 snake/camel 文件大小') +}) diff --git a/frontend-vue/src/pages/amazon/AmazonConsolePage.vue b/frontend-vue/src/pages/amazon/AmazonConsolePage.vue index 40785a55..4b427b84 100644 --- a/frontend-vue/src/pages/amazon/AmazonConsolePage.vue +++ b/frontend-vue/src/pages/amazon/AmazonConsolePage.vue @@ -126,7 +126,7 @@ import { useRouter } from 'vue-router' import AmazonTopBar from '@/pages/amazon/components/AmazonTopBar.vue' import AmazonFooterBar from '@/pages/amazon/components/AmazonFooterBar.vue' import ToolIcon from '@/pages/amazon/components/ToolIcon.vue' -import { TUTORIAL_DOWNLOAD_URL } from '@/pages/amazon/download-btn' +import { TUTORIAL_DOWNLOAD_URL, resolveTutorialDownloadUrl } from '@/pages/amazon/download-btn' import { filterGroupsByPermission, filterWorkflowByPermission, TOOL_GROUPS, TOOL_MAP } from '@/pages/amazon/tool-catalog' import type { ToolGroup, ToolInfo, VisibleGroup, WorkflowStep } from '@/pages/amazon/tool-catalog' import { resolvePageHref } from '@/shared/page-prefix' @@ -217,13 +217,20 @@ function toast(msg: string, type: 'normal' | 'error' = 'normal') { }, 2500) } +/** 教程包直链:进页面时解析一次(后台「教程管理」最新上传优先),失败自动回退固定直链 */ +const tutorialUrl = ref(TUTORIAL_DOWNLOAD_URL) + +async function refreshTutorialUrl() { + tutorialUrl.value = await resolveTutorialDownloadUrl() +} + async function onDownloadClick() { - // 教程客户端压缩包取自 MinIO 公开直链(client/tutorial/,见 download-btn.ts) + // 教程客户端压缩包:最新上传的包优先(/api/tutorial/latest),兜底走固定直链(见 download-btn.ts) const api = getPywebviewApi() if (api?.open_external_url) { // 桌面端:交系统默认浏览器下载,可看到下载进度 try { - const res = await api.open_external_url(TUTORIAL_DOWNLOAD_URL) + const res = await api.open_external_url(tutorialUrl.value) if (!res?.success) toast(`下载失败:${res?.error ?? '请重试'}`, 'error') } catch (_error) { toast('下载失败,请重试', 'error') @@ -231,7 +238,7 @@ async function onDownloadClick() { return } // 网页端:新窗口打开直链下载 - window.open(TUTORIAL_DOWNLOAD_URL, '_blank', 'noopener') + window.open(tutorialUrl.value, '_blank', 'noopener') } function onCatChange(key: 'front' | 'ops' | 'logi') { @@ -246,6 +253,8 @@ function parseHashGroup() { } onMounted(async () => { + // 教程包直链异步解析(不阻塞页面渲染;失败自动回退固定直链) + void refreshTutorialUrl() try { allowedKeys.value = await getCurrentUserAppColumnKeys() } catch (_error) { diff --git a/frontend-vue/src/pages/amazon/download-btn.ts b/frontend-vue/src/pages/amazon/download-btn.ts index 87f0e49f..34f55298 100644 --- a/frontend-vue/src/pages/amazon/download-btn.ts +++ b/frontend-vue/src/pages/amazon/download-btn.ts @@ -1,7 +1,20 @@ /** - * 工具台首页「立即下载教程」:数富AI 教学客户端压缩包公开直链。 + * 工具台首页「立即下载教程」:优先取后台「教程管理」最新上传的包(GET /api/tutorial/latest, + * 见 shared/api/tutorial.ts);接口异常或无记录时回退到历史固定直链。 * - * 对象位于 MinIO 公开 client 桶(host A minio 容器,openresty 反代 oss.aishufu.top), - * 由 scripts/upload_tutorial_zip.py 上传(同 key 可覆盖更新)。 + * 固定直链对象位于 MinIO 公开 client 桶(host A minio 容器,openresty 反代 oss.aishufu.top), + * 由 scripts/upload_tutorial_zip.py 上传(同 key 覆盖更新),保留作为兜底。 */ +import { fetchLatestTutorialPackage } from '@/shared/api/tutorial' + export const TUTORIAL_DOWNLOAD_URL = 'https://oss.aishufu.top/client/tutorial/数富AI-教学客户端.zip' + +/** 解析当前生效的教程包直链:最新上传优先;接口异常/无记录时静默回退固定直链,保证按钮始终可下载。 */ +export async function resolveTutorialDownloadUrl(): Promise { + try { + const latest = await fetchLatestTutorialPackage() + return latest?.fileUrl || TUTORIAL_DOWNLOAD_URL + } catch { + return TUTORIAL_DOWNLOAD_URL + } +} diff --git a/frontend-vue/src/shared/api/endpoints.ts b/frontend-vue/src/shared/api/endpoints.ts index fd31d2e6..4493e451 100644 --- a/frontend-vue/src/shared/api/endpoints.ts +++ b/frontend-vue/src/shared/api/endpoints.ts @@ -262,6 +262,9 @@ export const API_ENDPOINTS = { delete: '/api/digital-human/versions/{version}', downloadUrl: '/api/digital-human/versions/{version}/download-url', }, + tutorial: { + latest: '/api/tutorial/latest', + }, } as const export type ApiEndpointGroup = { diff --git a/frontend-vue/src/shared/api/tutorial.ts b/frontend-vue/src/shared/api/tutorial.ts new file mode 100644 index 00000000..c76f6e52 --- /dev/null +++ b/frontend-vue/src/shared/api/tutorial.ts @@ -0,0 +1 @@ +export * from './types/modules/tutorial' diff --git a/frontend-vue/src/shared/api/types/modules/tutorial.ts b/frontend-vue/src/shared/api/types/modules/tutorial.ts new file mode 100644 index 00000000..fb441b40 --- /dev/null +++ b/frontend-vue/src/shared/api/types/modules/tutorial.ts @@ -0,0 +1,31 @@ +import { get, type JavaApiResponse, unwrapJavaResponse } from '../../http.ts' +import { buildJavaUrl } from '../../url.ts' +import { API_ENDPOINTS } from '../../endpoints.ts' + +/** 工具台首页「立即下载教程」的最新教程包(后台「教程管理」上传,以最新上传为主)。 */ +export interface LatestTutorialPackage { + fileName: string + fileUrl: string + fileSize: number + createdAt: string +} + +/** 读取最新教程包;后端无记录时返回 null(调用方回退到历史固定直链)。 */ +export async function fetchLatestTutorialPackage(): Promise { + const payload = await unwrapJavaResponse( + get>>(buildJavaUrl(API_ENDPOINTS.tutorial.latest)), + ) + if (!payload || typeof payload !== 'object') { + return null + } + const fileUrl = typeof payload.file_url === 'string' ? payload.file_url.trim() : '' + if (!fileUrl) { + return null + } + return { + fileName: typeof payload.file_name === 'string' ? payload.file_name.trim() : '', + fileUrl, + fileSize: typeof payload.file_size === 'number' ? payload.file_size : 0, + createdAt: typeof payload.created_at === 'string' ? payload.created_at : '', + } +} diff --git a/frontend-vue/tests/endpoints.test.ts b/frontend-vue/tests/endpoints.test.ts index 885e5dc5..cc411e68 100644 --- a/frontend-vue/tests/endpoints.test.ts +++ b/frontend-vue/tests/endpoints.test.ts @@ -369,6 +369,9 @@ test('test_endpoints_frozen_snapshot', () => { "setLatest": "/api/digital-human/versions/{version}/set-latest", "delete": "/api/digital-human/versions/{version}", "downloadUrl": "/api/digital-human/versions/{version}/download-url" + }, + "tutorial": { + "latest": "/api/tutorial/latest" } }, '常量表快照不应漂移') })