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:
2026-09-14 17:56:20 +08:00
parent b54f72d3f6
commit 52b55df7b2
36 changed files with 1538 additions and 57 deletions
@@ -2,7 +2,8 @@
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'
@@ -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 {
// 浏览器直传 MinIOpresign → 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),