feat(任务判死): 心跳正常但 180 分钟无结果上报的二次判死线(13 模块)+ 同期待发改动
判死线(治 28131 型「主线程卡死、心跳线程照发」): - 判据改看 biz_task_scope_state.last_chunk_at(HTTP 心跳不刷新它);从未上报跳过不判 - 中央线覆盖 DELETE_BRAND/PRODUCT_RISK_RESOLVE/PRICE_TRACK/SHOP_MATCH/PATROL_DELETE/QUERY_ASIN/WITHDRAW - 自带线接入 COLLECT_DATA/SIMILAR_ASIN/APPEARANCE_PATENT/SHOP_DATA_CRAWL/PUBLISH/BRAND - 客户端心跳带处理位置 progressText,判死文案含最后位置;no-result-upload-timeout-minutes 默认 180(0 关闭) 同期带上另一工作流的待发改动:跟价换 IP 重试、品牌检测重试上限与 LLM 并发下调、 教程包后台管理页与 V126 迁移、admin-vue 教程记录页。
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 教程管理页:工具台首页「立即下载教程」的包体管理(上传新包 / 列表 / 下载 / 删除)。
|
||||
* 上传走浏览器直传 MinIO(presign → PUT 带进度 → confirm 落库),与软件版本管理页同一套链路;
|
||||
* 工具台始终下载"最新上传"的包(列表第一条即当前生效)。 */
|
||||
* 上传时可填版本号(仅展示与排序用);工具台始终下载"最新上传"的包(不随列表排序变化)。
|
||||
* 列表默认按上传时间降序,「版本号」「上传时间」表头可点击切换升降序。 */
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -22,12 +23,64 @@ const filteredItems = computed(() => {
|
||||
/** 全站分页统一:客户端分页(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))
|
||||
|
||||
/** 排序:默认按上传时间降序(最新上传在最前,与后端返回顺序一致);点表头切换升降序。 */
|
||||
type SortKey = 'version' | 'createdAt'
|
||||
const sortKey = ref<SortKey>('createdAt')
|
||||
const sortAsc = ref(false)
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey.value === key) {
|
||||
sortAsc.value = !sortAsc.value
|
||||
} else {
|
||||
sortKey.value = key
|
||||
sortAsc.value = false
|
||||
}
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
/** 排序标记:未激活 ▲▼,激活时只留方向箭头(比 ⇅/↕ 字形支持好,避免 Windows 缺字形显示方框)。 */
|
||||
function sortMark(key: SortKey): string {
|
||||
if (sortKey.value !== key) return '▲▼'
|
||||
return sortAsc.value ? '▲' : '▼'
|
||||
}
|
||||
|
||||
function compareText(a: string, b: string): number {
|
||||
return a.localeCompare(b, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' })
|
||||
}
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
const rows = [...filteredItems.value]
|
||||
rows.sort((a, b) => {
|
||||
// 版本号为空的历史行始终排在末尾,避免切换排序时"无版本"占满首页。
|
||||
if (sortKey.value === 'version') {
|
||||
const av = a.version
|
||||
const bv = b.version
|
||||
if (!av || !bv) {
|
||||
if (!av && !bv) return 0
|
||||
return av ? -1 : 1
|
||||
}
|
||||
const diff = compareText(av, bv)
|
||||
return sortAsc.value ? diff : -diff
|
||||
}
|
||||
const at = a.createdAt
|
||||
const bt = b.createdAt
|
||||
if (!at || !bt) {
|
||||
if (!at && !bt) return 0
|
||||
return at ? -1 : 1
|
||||
}
|
||||
const diff = compareText(at, bt)
|
||||
return sortAsc.value ? diff : -diff
|
||||
})
|
||||
return rows
|
||||
})
|
||||
|
||||
const pagedItems = computed(() => sortedItems.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)))
|
||||
// 搜索/排序导致数据收缩时回钳页码,避免停在空页。
|
||||
watch(sortedItems, () => {
|
||||
page.value = Math.min(page.value, Math.max(1, Math.ceil(sortedItems.value.length / pageSize.value)))
|
||||
})
|
||||
|
||||
/** 当前生效包 = 列表第一条(工具台下发的就是它)。 */
|
||||
@@ -80,6 +133,7 @@ async function removeOne(row: TutorialPackageItem) {
|
||||
const uploadVisible = ref(false)
|
||||
const uploading = ref(false)
|
||||
const uploadPercent = ref(0)
|
||||
const newVersion = ref('')
|
||||
const pickedFile = ref<File | null>(null)
|
||||
/** 弹窗内成功/失败文案留驻(对齐软件版本页交互)。 */
|
||||
const uploadMsg = ref('')
|
||||
@@ -103,6 +157,7 @@ function onFileChange(file: File) {
|
||||
function openUpload() {
|
||||
uploadMsg.value = ''
|
||||
uploadMsgOk.value = false
|
||||
newVersion.value = ''
|
||||
pickedFile.value = null
|
||||
uploadVisible.value = true
|
||||
}
|
||||
@@ -126,12 +181,13 @@ async function submitUpload() {
|
||||
uploading.value = true
|
||||
try {
|
||||
// 浏览器直传 MinIO:presign → PUT(进度条)→ confirm 落库。
|
||||
await uploadTutorialPackage(pickedFile.value, pickedFile.value.name, (p) => {
|
||||
await uploadTutorialPackage(pickedFile.value, pickedFile.value.name, newVersion.value.trim(), (p) => {
|
||||
uploadPercent.value = p
|
||||
uploadMsg.value = p >= 100 ? '上传完成,正在登记教程包...' : `正在上传:${p}%`
|
||||
})
|
||||
uploadMsg.value = '上传成功,工具台「立即下载教程」已切换为该包'
|
||||
uploadMsgOk.value = true
|
||||
newVersion.value = ''
|
||||
pickedFile.value = null
|
||||
uploadPercent.value = 0
|
||||
load()
|
||||
@@ -165,8 +221,13 @@ onMounted(load)
|
||||
<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">
|
||||
<button class="sort-th sort-version" type="button" @click="toggleSort('version')">版本号<span class="sort-mark">{{ sortMark('version') }}</span></button>
|
||||
</th>
|
||||
<th style="width: 110px">大小</th>
|
||||
<th style="width: 150px">上传时间</th>
|
||||
<th style="width: 150px">
|
||||
<button class="sort-th sort-time" type="button" @click="toggleSort('createdAt')">上传时间<span class="sort-mark">{{ sortMark('createdAt') }}</span></button>
|
||||
</th>
|
||||
<th>下载链接</th>
|
||||
<th style="width: 170px">操作</th>
|
||||
</tr>
|
||||
@@ -181,6 +242,10 @@ onMounted(load)
|
||||
<span class="file-name" :title="row.fileName">{{ row.fileName }}</span>
|
||||
<span v-if="row.id === activeId" class="tag-active">当前生效</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="row.version" class="version-cell" :title="row.version">{{ row.version }}</span>
|
||||
<span v-else class="dim">—</span>
|
||||
</td>
|
||||
<td>{{ formatFileSize(row.fileSize) }}</td>
|
||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||
<td>
|
||||
@@ -195,10 +260,10 @@ onMounted(load)
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="6" class="empty-tip">加载中...</td>
|
||||
<td colspan="7" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="6" class="empty-tip">{{ keyword ? '暂无匹配教程包' : '暂无教程包记录' }}</td>
|
||||
<td colspan="7" class="empty-tip">{{ keyword ? '暂无匹配教程包' : '暂无教程包记录' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -209,6 +274,10 @@ onMounted(load)
|
||||
<el-dialog v-model="uploadVisible" title="上传教程包" width="520px">
|
||||
<p class="upload-desc">上传教程 ZIP 包后,工具台首页「立即下载教程」将以下载该包为准(以最新上传的为主)。</p>
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="版本号">
|
||||
<el-input v-model="newVersion" placeholder="例如:v2026.09 或留空" maxlength="64" />
|
||||
<p class="zip-hint">仅作展示与排序用,可留空;不影响工具台按最新上传下载</p>
|
||||
</el-form-item>
|
||||
<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>
|
||||
@@ -368,6 +437,46 @@ h3 {
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* 表头排序:按钮铺满单元格,仅 hover 时加深字色,保持旧版表头观感。 */
|
||||
.sort-th {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sort-th:hover {
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.sort-mark {
|
||||
color: #8293a5;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sort-th:hover .sort-mark {
|
||||
color: #5f85ad;
|
||||
}
|
||||
.sort-version {
|
||||
width: 110px;
|
||||
}
|
||||
.sort-time {
|
||||
width: 150px;
|
||||
}
|
||||
.version-cell {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tag-active {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
|
||||
@@ -22,11 +22,12 @@ export interface TutorialUploadTarget {
|
||||
objectKey: string
|
||||
uploadUrl: string
|
||||
fileUrl: string
|
||||
version: 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 } })
|
||||
export async function requestTutorialPresign(fileName: string, version = ''): Promise<TutorialUploadTarget> {
|
||||
const { data } = await http.post<unknown>(TUTORIAL_PRESIGN_ENDPOINT, null, { params: { file_name: fileName, version } })
|
||||
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 : ''
|
||||
@@ -37,13 +38,14 @@ export async function requestTutorialPresign(fileName: string): Promise<Tutorial
|
||||
objectKey,
|
||||
uploadUrl,
|
||||
fileUrl: typeof core.file_url === 'string' ? core.file_url : '',
|
||||
version: typeof core.version === 'string' ? core.version : version.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
/** 第三步:直传完成后通知后端校验对象并写入记录(data.item 为新记录行)。 */
|
||||
export async function confirmTutorialPackage(objectKey: string, fileName: string): Promise<TutorialPackageItem | null> {
|
||||
export async function confirmTutorialPackage(objectKey: string, fileName: string, version = ''): Promise<TutorialPackageItem | null> {
|
||||
const { data } = await http.post<unknown>(TUTORIAL_CONFIRM_ENDPOINT, null, {
|
||||
params: { object_key: objectKey, file_name: fileName },
|
||||
params: { object_key: objectKey, file_name: fileName, version },
|
||||
})
|
||||
return parseTutorialPackageUpload(data)
|
||||
}
|
||||
@@ -63,9 +65,10 @@ export async function deleteTutorialPackages(ids: number[]): Promise<number> {
|
||||
export async function uploadTutorialPackage(
|
||||
file: Blob,
|
||||
fileName: string,
|
||||
version = '',
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<TutorialPackageItem | null> {
|
||||
const target = await requestTutorialPresign(fileName)
|
||||
const target = await requestTutorialPresign(fileName, version)
|
||||
await directPut.put(target.uploadUrl, file, {
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
onUploadProgress: (event) => {
|
||||
@@ -74,5 +77,5 @@ export async function uploadTutorialPackage(
|
||||
onProgress(total > 0 ? Math.min(Math.round((event.loaded / total) * 100), 100) : 0)
|
||||
},
|
||||
})
|
||||
return confirmTutorialPackage(target.objectKey, fileName)
|
||||
return confirmTutorialPackage(target.objectKey, fileName, target.version)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
export interface TutorialPackageItem {
|
||||
id: number
|
||||
fileName: string
|
||||
/** 版本号(上传时填写,V126 之前的历史行为空串) */
|
||||
version: string
|
||||
objectKey: string
|
||||
fileSize: number
|
||||
fileUrl: string
|
||||
|
||||
@@ -19,6 +19,7 @@ export function toTutorialPackageItem(raw: unknown): TutorialPackageItem | null
|
||||
return {
|
||||
id,
|
||||
fileName: text(r.file_name ?? r.fileName),
|
||||
version: text(r.version),
|
||||
objectKey: text(r.object_key ?? r.objectKey),
|
||||
fileSize: numberOrNull(r.file_size ?? r.fileSize) ?? 0,
|
||||
fileUrl: text(r.file_url ?? r.fileUrl),
|
||||
|
||||
Reference in New Issue
Block a user