feat(教程管理): 后台可上传教程包,工具台按最新上传下载

- 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 切换→删除回落全链路。
This commit is contained in:
2026-09-13 23:48:24 +08:00
parent 14a723ab1c
commit 9ffd68bae5
13 changed files with 722 additions and 7 deletions
@@ -74,6 +74,10 @@ export const OPERATION_GUIDES: Record<string, OperationGuideData> = {
text: '上传版本后请核对版本号和下载链接,再通知用户更新。',
steps: ['上传压缩包', '检查版本记录', '维护历史版本'],
},
admin_tutorial: {
text: '上传新的教程压缩包后即刻生效:工具台首页「立即下载教程」以下载最新上传的包为准。确认旧包不再需要后再删除。',
steps: ['上传教程压缩包', '到工具台核对下载', '清理历史包'],
},
digital_human_version: {
text: '数字人版本需先上传草稿,再发布并标记最新版本。',
steps: ['上传草稿', '确认更新日志', '发布或设为最新'],
@@ -0,0 +1,445 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 教程管理页:工具台首页「立即下载教程」的包体管理(上传新包 / 列表 / 下载 / 删除)。
* 上传走浏览器直传 MinIOpresign → PUT 带进度 → confirm 落库),与软件版本管理页同一套链路;
* 工具台始终下载"最新上传"的包(列表第一条即当前生效)。 */
import { computed, onMounted, ref, watch } from 'vue'
import OldPagination from '@/components/OldPagination.vue'
import { ElMessage } from 'element-plus'
import { fetchTutorialPackages, uploadTutorialPackage, deleteTutorialPackages } from './tutorial-api.ts'
import { formatFileSize } from './tutorial-model.ts'
import type { TutorialPackageItem } from './tutorial-dto.ts'
const loading = ref(false)
const items = ref<TutorialPackageItem[]>([])
/** 文件名搜索(客户端过滤)。 */
const keyword = ref('')
const filteredItems = computed(() => {
const kw = (keyword.value || '').trim().toLowerCase()
if (!kw) return items.value
return items.value.filter((item) => item.fileName.toLowerCase().includes(kw))
})
/** 全站分页统一:客户端分页(10/20/50/100)。 */
const page = ref(1)
const pageSize = ref(10)
const pagedItems = computed(() => filteredItems.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
function changePage(p: number) { page.value = p }
function changeSize(size: number) { pageSize.value = size; page.value = 1 }
// 搜索导致数据收缩时回钳页码,避免停在空页。
watch(filteredItems, () => {
page.value = Math.min(page.value, Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)))
})
/** 当前生效包 = 列表第一条(工具台下发的就是它)。 */
const activeId = computed(() => items.value[0]?.id ?? null)
// 勾选删除:选中跨页累计;表头复选只作用于当前页,避免误选整表。
const selectedIds = ref<Set<number>>(new Set())
const selectedCount = computed(() => selectedIds.value.size)
function toggleSelect(id: number) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
function toggleSelectAllPage() {
const pageRows = pagedItems.value
const allOn = pageRows.length > 0 && pageRows.every((row) => selectedIds.value.has(row.id))
const next = new Set(selectedIds.value)
if (allOn) pageRows.forEach((row) => next.delete(row.id))
else pageRows.forEach((row) => next.add(row.id))
selectedIds.value = next
}
async function removeSelected() {
if (!selectedCount.value) return
const rows = items.value.filter((row) => selectedIds.value.has(row.id))
const sample = rows.slice(0, 3).map((row) => row.fileName).join('、')
const summary = rows.length > 3 ? `${sample}${rows.length}` : sample
if (!window.confirm(`确认删除选中的 ${rows.length} 个教程包(${summary})?此操作不可恢复。`)) return
try {
await deleteTutorialPackages(Array.from(selectedIds.value))
ElMessage.success(`已删除 ${rows.length} 个教程包`)
selectedIds.value = new Set()
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
async function removeOne(row: TutorialPackageItem) {
if (!window.confirm(`确认删除教程包 ${row.fileName}?此操作不可恢复。`)) return
try {
await deleteTutorialPackages([row.id])
ElMessage.success('删除成功')
selectedIds.value = new Set([...selectedIds.value].filter((id) => id !== row.id))
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
const uploadVisible = ref(false)
const uploading = ref(false)
const uploadPercent = ref(0)
const pickedFile = ref<File | null>(null)
/** 弹窗内成功/失败文案留驻(对齐软件版本页交互)。 */
const uploadMsg = ref('')
const uploadMsgOk = ref(false)
async function load() {
loading.value = true
try {
items.value = (await fetchTutorialPackages()).items
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '教程包列表加载失败')
} finally {
loading.value = false
}
}
function onFileChange(file: File) {
pickedFile.value = file
}
function openUpload() {
uploadMsg.value = ''
uploadMsgOk.value = false
pickedFile.value = null
uploadVisible.value = true
}
async function submitUpload() {
uploadMsg.value = ''
uploadMsgOk.value = false
uploadPercent.value = 0
if (!pickedFile.value) {
uploadMsg.value = '请选择 zip 压缩包'
return
}
if (!/\.zip$/i.test(pickedFile.value.name)) {
uploadMsg.value = '仅支持 .zip 格式'
return
}
if (pickedFile.value.size > 512 * 1024 * 1024) {
uploadMsg.value = '文件超过允许的大小限制'
return
}
uploading.value = true
try {
// 浏览器直传 MinIOpresign → PUT(进度条)→ confirm 落库。
await uploadTutorialPackage(pickedFile.value, pickedFile.value.name, (p) => {
uploadPercent.value = p
uploadMsg.value = p >= 100 ? '上传完成,正在登记教程包...' : `正在上传:${p}%`
})
uploadMsg.value = '上传成功,工具台「立即下载教程」已切换为该包'
uploadMsgOk.value = true
pickedFile.value = null
uploadPercent.value = 0
load()
} catch (error) {
uploadMsg.value = error instanceof Error ? error.message : '上传失败'
} finally {
uploading.value = false
}
}
onMounted(load)
</script>
<template>
<div class="tutorial-view">
<section class="panel-box">
<div class="tutorial-head">
<h3>教程包列表</h3>
<div class="tutorial-head-tools">
<input v-model="keyword" type="text" placeholder="搜索文件名" class="tutorial-keyword" />
<button class="btn btn-danger" type="button" :disabled="!selectedCount" @click="removeSelected">删除选中{{ selectedCount ? `(${selectedCount})` : '' }}</button>
<button class="btn" type="button" @click="openUpload">上传教程包</button>
</div>
</div>
<div class="table-scroll tutorial-table-scroll">
<table>
<thead>
<tr>
<th style="width: 40px" class="th-select">
<input type="checkbox" :checked="pagedItems.length > 0 && pagedItems.every((r) => selectedIds.has(r.id))" @change="toggleSelectAllPage" :disabled="!pagedItems.length" />
</th>
<th style="width: 260px">文件名</th>
<th style="width: 110px">大小</th>
<th style="width: 150px">上传时间</th>
<th>下载链接</th>
<th style="width: 170px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="filteredItems.length">
<tr v-for="row in pagedItems" :key="row.id" :class="{ 'row-selected': selectedIds.has(row.id) }">
<td class="td-select">
<input type="checkbox" :checked="selectedIds.has(row.id)" @change="toggleSelect(row.id)" />
</td>
<td>
<span class="file-name" :title="row.fileName">{{ row.fileName }}</span>
<span v-if="row.id === activeId" class="tag-active">当前生效</span>
</td>
<td>{{ formatFileSize(row.fileSize) }}</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>
<a v-if="row.fileUrl" class="link-cell" :href="row.fileUrl" target="_blank" rel="noopener" :title="row.fileUrl">{{ row.fileUrl }}</a>
<span v-else class="dim"></span>
</td>
<td class="ops-cell">
<a v-if="row.fileUrl" class="btn btn-sm dl-btn" :href="row.fileUrl" download>下载</a>
<span v-else class="dim"></span>
<button class="btn btn-sm btn-danger" type="button" @click="removeOne(row)">删除</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="6" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="6" class="empty-tip">{{ keyword ? '暂无匹配教程包' : '暂无教程包记录' }}</td>
</tr>
</tbody>
</table>
</div>
<OldPagination v-if="filteredItems.length > 0" :total="filteredItems.length" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<el-dialog v-model="uploadVisible" title="上传教程包" width="520px">
<p class="upload-desc">上传教程 ZIP 包后工具台首页立即下载教程将以下载该包为准以最新上传的为主</p>
<el-form label-width="110px">
<el-form-item label="ZIP 压缩包" required>
<el-upload :auto-upload="false" :limit="1" accept=".zip" :on-change="(file: any) => onFileChange(file.raw)" :on-remove="() => (pickedFile = null)">
<el-button>选择文件</el-button>
</el-upload>
<p class="zip-hint">仅支持 .zip 格式,最大 512MB</p>
<div v-if="pickedFile" class="dim">{{ pickedFile.name }}{{ formatFileSize(pickedFile.size) }}</div>
</el-form-item>
<el-progress v-if="uploading && uploadPercent > 0" :percentage="uploadPercent" :stroke-width="10" :status="uploadPercent >= 100 ? 'success' : undefined" class="upload-progress" />
<el-alert v-if="uploadMsg" :title="uploadMsg" :type="uploadMsgOk ? 'success' : 'error'" :closable="false" show-icon class="upload-msg" />
</el-form>
<template #footer>
<el-button @click="uploadVisible = false">取消</el-button>
<el-button type="primary" :loading="uploading" @click="submitUpload">上传教程包</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
/* 沿用软件版本管理页(旧版 admin.html panel-version)的样式语言。 */
.tutorial-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.tutorial-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.tutorial-head-tools {
display: flex;
align-items: center;
gap: 10px;
}
.tutorial-keyword {
width: 200px;
min-height: 42px;
padding: 6px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.tutorial-keyword:hover {
border-color: #9fb7cd;
}
.tutorial-keyword:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.tutorial-table-scroll > table {
min-width: 940px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.file-name {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
.tag-active {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border: 1px solid #4f9a72;
border-radius: 999px;
background: #eaf6ef;
color: #2f7a55;
font-size: 11.5px;
vertical-align: middle;
}
.link-cell {
display: block;
color: #2f5d8b;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
}
.link-cell:hover {
text-decoration: underline;
}
.dl-btn {
text-decoration: none;
}
.dim {
color: #8293a5;
font-size: 12px;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.upload-desc {
margin: 0 0 12px;
color: #5b6f83;
font-size: 12.5px;
line-height: 1.6;
}
.zip-hint {
margin: 4px 0 0;
color: #5b6f83;
font-size: 12px;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.row-selected td {
background: #fdf0ee;
}
.th-select,
.td-select {
text-align: center;
padding-left: 4px !important;
padding-right: 4px !important;
}
.th-select input,
.td-select input {
cursor: pointer;
accent-color: #b33a2e;
}
.upload-progress {
margin: 2px 0 10px;
}
.upload-msg {
margin-bottom: 8px;
}
.upload-msg :deep(.el-alert__title) {
word-break: break-all;
}
</style>
@@ -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<TutorialPackageList> {
const { data } = await http.get<unknown>(TUTORIALS_ENDPOINT)
return parseTutorialPackageList(data)
}
export interface TutorialUploadTarget {
objectKey: string
uploadUrl: string
fileUrl: string
}
/** 第一步:向后端申请直传 PUT 预签名地址(登录会话签发,3 分钟有效)。 */
export async function requestTutorialPresign(fileName: string): Promise<TutorialUploadTarget> {
const { data } = await http.post<unknown>(TUTORIAL_PRESIGN_ENDPOINT, null, { params: { file_name: fileName } })
const core = (unwrap(data) ?? {}) as Record<string, unknown>
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<TutorialPackageItem | null> {
const { data } = await http.post<unknown>(TUTORIAL_CONFIRM_ENDPOINT, null, {
params: { object_key: objectKey, file_name: fileName },
})
return parseTutorialPackageUpload(data)
}
/** 按 id 批量删除教程包(后端会顺带清理无引用对象的 MinIO 包体);返回实际删除行数。 */
export async function deleteTutorialPackages(ids: number[]): Promise<number> {
if (!ids.length) return 0
const { data } = await http.post<unknown>(TUTORIAL_DELETE_ENDPOINT, ids)
const core = (unwrap(data) ?? {}) as Record<string, unknown>
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<TutorialPackageItem | null> {
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)
}
@@ -0,0 +1,19 @@
/** 教程包 DTO(教程管理页):工具台下载入口对应的教程压缩包行类型;纯逻辑。 */
/** 教程包行(GET /api/admin/tutorials data.itemsJava 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: [] }
}
@@ -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<string, unknown>
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<unknown>(payload)
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : {}
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<unknown>(payload)
if (!data || typeof data !== 'object') return null
const item = (data as Record<string, unknown>).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]}`
}
+1
View File
@@ -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') },
@@ -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, /<a v-if="row\.fileUrl" class="btn btn-sm dl-btn"[^>]*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 文件大小')
})
@@ -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) {
+16 -3
View File
@@ -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<string> {
try {
const latest = await fetchLatestTutorialPackage()
return latest?.fileUrl || TUTORIAL_DOWNLOAD_URL
} catch {
return TUTORIAL_DOWNLOAD_URL
}
}
+3
View File
@@ -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 = {
+1
View File
@@ -0,0 +1 @@
export * from './types/modules/tutorial'
@@ -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<LatestTutorialPackage | null> {
const payload = await unwrapJavaResponse(
get<JavaApiResponse<Record<string, unknown>>>(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 : '',
}
}
+3
View File
@@ -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"
}
}, '常量表快照不应漂移')
})