task-266/267(admin.html观感对齐): 店铺数据任务改每店铺卡片(单文件下载/删除/批量/合计)+筛选权限对齐
This commit is contained in:
@@ -1,37 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 店铺数据任务/记录页:按店铺分组展示抓取结果文件,支持筛选、单文件下载、批量下载入口与数据范围授权。 */
|
||||
/** 店铺数据任务/记录页:每店铺卡片网格(分组/任务状态/站点/文件 下载删除)+ 筛选 + 批量下载 + 数据范围授权。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { asinCountryLabel } from '../asin/asin-country.ts'
|
||||
import { createShopDataFilter, type ShopDataFilter } from './shop-data-filter.ts'
|
||||
import type { ShopDataResultRow } from './shop-data-model.ts'
|
||||
import { fetchShopDataTaskList, requestShopDataZipDownload, fetchShopDataTaskPermissionUsers, saveShopDataTaskPermissions } from './shop-data-api.ts'
|
||||
import type { TaskStatus } from './task-model.ts'
|
||||
import type { TaskPermissionItem } from './task-permission.ts'
|
||||
import { grantedUserIds } from './task-permission.ts'
|
||||
import type { ShopDataResultRow, ShopDataTaskGroup } from './shop-data-model.ts'
|
||||
import {
|
||||
deleteShopDataResultHistory,
|
||||
fetchShopDataResultDownload,
|
||||
fetchShopDataTaskList,
|
||||
fetchShopDataTaskPermissionUsers,
|
||||
requestShopDataZipDownload,
|
||||
saveShopDataTaskPermissions,
|
||||
} from './shop-data-api.ts'
|
||||
import { isTerminalStatus } from './image-video-view.ts'
|
||||
import { grantedUserIds, type TaskPermissionItem } from './task-permission.ts'
|
||||
import {
|
||||
addShopDataSelection,
|
||||
countShopDataSelection,
|
||||
createShopDataSelection,
|
||||
toggleShopDataSelection,
|
||||
} from './shop-data-selection.ts'
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<ShopDataResultRow[]>([])
|
||||
const total = ref(0)
|
||||
const groups = ref<ShopDataTaskGroup[]>([])
|
||||
const totalShops = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const shopCount = ref(0)
|
||||
|
||||
const filter = reactive<ShopDataFilter & { dateRange: string[] }>({ ...createShopDataFilter(), dateRange: [] })
|
||||
const selection = ref<Set<string>>(new Set())
|
||||
|
||||
const permissionVisible = ref(false)
|
||||
const permissionItems = ref<TaskPermissionItem[]>([])
|
||||
const permissionInitial = ref<number[]>([])
|
||||
const permissionSaving = ref(false)
|
||||
const downloadingKey = ref('')
|
||||
|
||||
function statusLabel(status: TaskStatus): string {
|
||||
const busyKey = ref('')
|
||||
const deletingKey = ref('')
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const map: Record<string, string> = { PENDING: '排队中', RUNNING: '进行中', SUCCESS: '成功', FAILED: '失败', CANCELLED: '已取消', UNKNOWN: '未知' }
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
function statusType(status: TaskStatus): 'info' | 'warning' | 'success' | 'danger' {
|
||||
function statusType(status: string): 'info' | 'warning' | 'success' | 'danger' {
|
||||
if (status === 'SUCCESS') return 'success'
|
||||
if (status === 'RUNNING') return 'warning'
|
||||
if (status === 'FAILED') return 'danger'
|
||||
@@ -52,10 +65,8 @@ async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchShopDataTaskList(toFilter(), page.value, pageSize)
|
||||
const groups = result.items
|
||||
shopCount.value = groups.length
|
||||
rows.value = groups.flatMap((group) => group.results)
|
||||
total.value = result.total
|
||||
groups.value = result.items
|
||||
totalShops.value = result.total
|
||||
page.value = result.page
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '店铺数据任务加载失败')
|
||||
@@ -66,14 +77,41 @@ async function load() {
|
||||
|
||||
function apply() {
|
||||
page.value = 1
|
||||
load()
|
||||
void load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(filter, createShopDataFilter())
|
||||
filter.dateRange = []
|
||||
page.value = 1
|
||||
load()
|
||||
void load()
|
||||
}
|
||||
|
||||
const allResultRows = computed(() => groups.value.flatMap((group) => group.results))
|
||||
const selectionCount = computed(() => countShopDataSelection(selection.value))
|
||||
|
||||
function resultFileCount(): number {
|
||||
return groups.value.reduce((sum, group) => sum + group.results.length, 0)
|
||||
}
|
||||
|
||||
function selectableRows(): ShopDataResultRow[] {
|
||||
return allResultRows.value.filter((row) => row.fileReady && isTerminalStatus(row.status))
|
||||
}
|
||||
|
||||
function toggleRow(row: ShopDataResultRow) {
|
||||
selection.value = toggleShopDataSelection(selection.value, row.resultId)
|
||||
}
|
||||
|
||||
function toggleSelectAllReady() {
|
||||
const rows = selectableRows()
|
||||
const allIn = rows.length > 0 && rows.every((row) => selection.value.has(row.resultId))
|
||||
selection.value = allIn
|
||||
? createShopDataSelection()
|
||||
: addShopDataSelection(new Set(selection.value), rows.map((row) => row.resultId))
|
||||
}
|
||||
|
||||
function rowSelected(row: ShopDataResultRow): boolean {
|
||||
return selection.value.has(row.resultId)
|
||||
}
|
||||
|
||||
async function downloadRow(row: ShopDataResultRow) {
|
||||
@@ -81,16 +119,56 @@ async function downloadRow(row: ShopDataResultRow) {
|
||||
ElMessage.warning('文件未就绪,无法下载')
|
||||
return
|
||||
}
|
||||
downloadingKey.value = row.resultId
|
||||
busyKey.value = row.resultId
|
||||
try {
|
||||
const { blob, fileCount, errorCount } = await requestShopDataZipDownload([row.resultId])
|
||||
saveBlob(blob, row.filename || `店铺数据_${row.shopName}.zip`)
|
||||
const note = errorCount ? `,${errorCount} 个失败` : ''
|
||||
ElMessage.success(`已打包 ${fileCount ?? 1} 个文件${note}`)
|
||||
const { blob, filename } = await fetchShopDataResultDownload(row.resultId)
|
||||
saveBlob(blob, filename)
|
||||
ElMessage.success('下载已开始')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '下载失败')
|
||||
} finally {
|
||||
downloadingKey.value = ''
|
||||
busyKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBatch() {
|
||||
const resultIds = Array.from(selection.value)
|
||||
if (!resultIds.length) {
|
||||
ElMessage.warning('请先勾选要下载的结果文件')
|
||||
return
|
||||
}
|
||||
busyKey.value = 'batch'
|
||||
try {
|
||||
const zip = await requestShopDataZipDownload(resultIds)
|
||||
saveBlob(zip.blob, `店铺数据批量下载_${resultIds.length}.zip`)
|
||||
const note = zip.errorCount ? `,${zip.errorCount} 个失败(详见包内错误清单)` : ''
|
||||
ElMessage.success(`已下载 ${zip.fileCount ?? resultIds.length} 个文件${note}`)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '批量下载失败')
|
||||
} finally {
|
||||
busyKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRow(group: ShopDataTaskGroup, row: ShopDataResultRow) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除店铺“${group.shopName}”的任务 ${row.taskNo || row.resultId} 及结果文件吗?`, '删除前确认', {
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
deletingKey.value = row.resultId
|
||||
try {
|
||||
await deleteShopDataResultHistory(row.resultId)
|
||||
ElMessage.success('删除成功')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : `正在删除任务 ${row.taskNo || row.resultId}...`)
|
||||
} finally {
|
||||
deletingKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +184,6 @@ function saveBlob(blob: Blob, filename: string) {
|
||||
async function openPermission() {
|
||||
try {
|
||||
permissionItems.value = await fetchShopDataTaskPermissionUsers()
|
||||
permissionInitial.value = grantedUserIds(permissionItems.value)
|
||||
permissionVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '权限用户加载失败')
|
||||
@@ -122,8 +199,7 @@ async function savePermission() {
|
||||
try {
|
||||
const next = grantedUserIds(permissionItems.value)
|
||||
await saveShopDataTaskPermissions(next)
|
||||
permissionInitial.value = next
|
||||
ElMessage.success('权限已保存')
|
||||
ElMessage.success('保存成功')
|
||||
permissionVisible.value = false
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||
@@ -142,9 +218,19 @@ onMounted(load)
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>店铺数据任务/记录</h2>
|
||||
<p>查看各店铺数据抓取结果文件,下载结果,配置数据范围授权。</p>
|
||||
<p>按店铺查看数据抓取结果文件,支持筛选、下载、删除与数据范围授权。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-checkbox
|
||||
:model-value="selectableRows().length > 0 && selectableRows().every((row) => rowSelected(row))"
|
||||
:indeterminate="selectionCount > 0 && selectionCount < selectableRows().length"
|
||||
@change="toggleSelectAllReady"
|
||||
>
|
||||
全选可下载
|
||||
</el-checkbox>
|
||||
<el-button :disabled="selectionCount === 0" :loading="busyKey === 'batch'" @click="downloadBatch">
|
||||
批量下载{{ selectionCount ? `(${selectionCount})` : '' }}
|
||||
</el-button>
|
||||
<el-button @click="openPermission">权限配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,12 +238,12 @@ onMounted(load)
|
||||
<el-card shadow="never" style="margin-bottom: 14px">
|
||||
<div class="filter-grid">
|
||||
<div class="f-item">
|
||||
<label>店铺</label>
|
||||
<el-input v-model="filter.shopName" placeholder="店铺模糊" clearable @keyup.enter="apply" />
|
||||
<label>店铺(模糊搜索)</label>
|
||||
<el-input v-model="filter.shopName" placeholder="输入店铺关键字" clearable @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>分组</label>
|
||||
<el-input v-model="filter.groupName" placeholder="分组模糊" clearable @keyup.enter="apply" />
|
||||
<label>分组(模糊搜索)</label>
|
||||
<el-input v-model="filter.groupName" placeholder="输入分组关键字" clearable @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>国家</label>
|
||||
@@ -176,53 +262,82 @@ onMounted(load)
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="rows" stripe border>
|
||||
<el-table-column label="店铺" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ (row as ShopDataResultRow).shopName }}
|
||||
<el-tag v-if="(row as ShopDataResultRow).groupName" size="small" effect="plain" style="margin-left: 4px">{{ (row as ShopDataResultRow).groupName }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="用户" width="120" />
|
||||
<el-table-column prop="taskNo" label="任务号" min-width="140" />
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType((row as ShopDataResultRow).status)" size="small">{{ statusLabel((row as ShopDataResultRow).status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="站点" width="130">
|
||||
<template #default="{ row }">
|
||||
<span v-for="code in (row as ShopDataResultRow).countryCodes" :key="code" class="chip-gap">{{ asinCountryLabel(code) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="filename" label="文件名" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="行数" width="90" align="center">
|
||||
<template #default="{ row }">{{ (row as ShopDataResultRow).rowCount ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" min-width="160">
|
||||
<template #default="{ row }">{{ formatDateTime((row as ShopDataResultRow).createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="success" size="small" :loading="downloadingKey === (row as ShopDataResultRow).resultId" :disabled="!(row as ShopDataResultRow).fileReady" @click="downloadRow(row as ShopDataResultRow)">下载</el-button>
|
||||
<el-button v-if="(row as ShopDataResultRow).fileUrl" text type="primary" size="small">
|
||||
<el-link type="primary" :href="(row as ShopDataResultRow).fileUrl" target="_blank">查看</el-link>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 <b>{{ rows.length }}</b> 个结果文件(本页 {{ shopCount }} 家店铺 · 总计 {{ total.toLocaleString() }} 记录)</span>
|
||||
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="(p: number) => { page = p; load() }" />
|
||||
<div v-loading="loading" class="shop-summary-row">
|
||||
<span>共 {{ totalShops }} 家店铺 · 本页 {{ resultFileCount() }} 个结果文件</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, jumper"
|
||||
small
|
||||
:total="totalShops"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
@current-change="(p: number) => { page = p; selection = new Set(); load() }"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div v-if="groups.length" class="shop-card-grid">
|
||||
<section v-for="group in groups" :key="group.shopId || group.shopName" class="shop-card">
|
||||
<header class="shop-card-head">
|
||||
<span class="shop-name">{{ group.shopName }}</span>
|
||||
<el-tag v-if="group.groupName" size="small" effect="plain">{{ group.groupName }}</el-tag>
|
||||
<span v-if="group.latestCreatedAt" class="shop-latest">{{ formatDateTime(group.latestCreatedAt) }}</span>
|
||||
</header>
|
||||
<div class="file-list">
|
||||
<div v-for="row in group.results" :key="row.resultId" class="file-row">
|
||||
<el-checkbox
|
||||
:model-value="rowSelected(row)"
|
||||
:disabled="!(row.fileReady && isTerminalStatus(row.status))"
|
||||
@change="toggleRow(row)"
|
||||
/>
|
||||
<div class="file-main">
|
||||
<div class="file-top">
|
||||
<span class="file-task">任务 {{ row.taskNo || row.resultId }}</span>
|
||||
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
<span v-for="code in row.countryCodes" :key="code" class="country-chip">{{ asinCountryLabel(code) }}</span>
|
||||
</div>
|
||||
<div class="file-name" :title="row.filename">{{ row.filename || '未命名文件' }}</div>
|
||||
<div class="file-meta">
|
||||
<span>{{ row.username || '—' }}</span>
|
||||
<span v-if="row.rowCount != null">· {{ row.rowCount }} 行</span>
|
||||
<span v-if="row.fileSize != null">· {{ row.fileSize }} 字节</span>
|
||||
<span>· {{ formatDateTime(row.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<el-button
|
||||
text
|
||||
type="success"
|
||||
size="small"
|
||||
:loading="busyKey === row.resultId"
|
||||
:disabled="!row.fileReady"
|
||||
@click="downloadRow(row)"
|
||||
>
|
||||
下载
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isTerminalStatus(row.status)"
|
||||
text
|
||||
type="danger"
|
||||
size="small"
|
||||
:loading="deletingKey === row.resultId"
|
||||
@click="removeRow(group, row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!group.results.length" class="file-empty">暂无结果文件</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-empty v-else-if="!loading" description="暂无店铺数据任务" />
|
||||
|
||||
<el-dialog v-model="permissionVisible" title="店铺数据任务数据范围授权" width="620px">
|
||||
<p class="dim">勾选允许查看/操作店铺数据任务的用户。当前已授权 {{ grantedCount }} 人。</p>
|
||||
<p class="dim">勾选允许查看/操作店铺数据任务的用户(仅数据范围)。当前已授权 {{ grantedCount }} 人。</p>
|
||||
<el-table :data="permissionItems" border size="small" max-height="420">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="username" label="用户名" min-width="160" />
|
||||
<el-table-column prop="username" label="用户名" min-width="180" />
|
||||
<el-table-column label="授权" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-checkbox :model-value="(row as TaskPermissionItem).granted" @change="toggleGrant(row as TaskPermissionItem)" />
|
||||
@@ -239,15 +354,28 @@ onMounted(load)
|
||||
|
||||
<style scoped>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: center; }
|
||||
.actions { display: flex; align-items: center; gap: 10px; }
|
||||
.actions { display: flex; align-items: center; gap: 12px; }
|
||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 170px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 220px; }
|
||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.f-item.wide { width: 340px; }
|
||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.table-footer b { color: var(--el-text-color-primary); }
|
||||
.shop-summary-row { display: flex; justify-content: space-between; align-items: center; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.shop-card-grid { display: flex; flex-direction: column; gap: 14px; }
|
||||
.shop-card { border: 1px solid var(--el-border-color); border-radius: 12px; background: #fff; box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28); overflow: hidden; }
|
||||
.shop-card-head { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||
.shop-name { font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.shop-latest { margin-left: auto; color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.file-list { padding: 4px 16px; }
|
||||
.file-row { display: flex; align-items: flex-start; gap: 12px; padding: 12px 0; border-bottom: 1px dashed var(--el-border-color-lighter); }
|
||||
.file-row:last-child { border-bottom: 0; }
|
||||
.file-main { flex: 1; min-width: 0; }
|
||||
.file-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.file-task { font-weight: 600; font-size: 13px; color: var(--el-text-color-primary); }
|
||||
.country-chip { padding: 1px 8px; border-radius: 999px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); font-size: 12px; }
|
||||
.file-name { margin-top: 6px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--el-text-color-regular); font-size: 13px; }
|
||||
.file-meta { margin-top: 4px; color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.file-actions { display: flex; gap: 6px; }
|
||||
.file-empty { padding: 14px 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.chip-gap { margin-right: 4px; }
|
||||
</style>
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import { http } from '@/api/http'
|
||||
import { parseShopDataTaskPage, type ShopDataTaskPageResult } from './shop-data-model.ts'
|
||||
import { toShopDataQuery, type ShopDataFilter } from './shop-data-filter.ts'
|
||||
import { toShopDataZipRequest } from './shop-data-download.ts'
|
||||
import { shopDataFilenameFromDisposition, toShopDataZipRequest } from './shop-data-download.ts'
|
||||
import { downloadZipHeaderCounts } from './image-video-download.ts'
|
||||
import { parseTaskPermissionPayload, toPermissionUserIdsPayload, type TaskPermissionItem } from './task-permission.ts'
|
||||
|
||||
export const SHOP_DATA_CRAWL_TASKS_ENDPOINT = '/api/admin/shop-data-crawl-tasks'
|
||||
export const SHOP_DATA_TASK_PERMISSIONS_ENDPOINT = '/api/admin/shop-data-crawl-task-permissions'
|
||||
export const SHOP_DATA_SINGLE_ENDPOINT = '/api/admin/shop-data-crawl'
|
||||
|
||||
export async function fetchShopDataTaskList(
|
||||
filter: ShopDataFilter,
|
||||
@@ -35,6 +36,25 @@ export async function requestShopDataZipDownload(resultIds: readonly (number | s
|
||||
return { blob: data, fileCount: counts.fileCount, errorCount: counts.errorCount }
|
||||
}
|
||||
|
||||
export interface ShopDataResultDownload {
|
||||
blob: Blob
|
||||
filename: string
|
||||
}
|
||||
|
||||
/** 下载单个结果文件(服务端原文件名):GET /api/admin/shop-data-crawl/results/{resultId}/download。 */
|
||||
export async function fetchShopDataResultDownload(resultId: number | string): Promise<ShopDataResultDownload> {
|
||||
const { data, headers } = await http.get<Blob>(`${SHOP_DATA_SINGLE_ENDPOINT}/results/${resultId}/download`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
const disposition = (headers as Record<string, string> | undefined)?.['content-disposition']
|
||||
return { blob: data, filename: shopDataFilenameFromDisposition(disposition, `shop-data-task-${resultId}.xlsx`) }
|
||||
}
|
||||
|
||||
/** 删除单条结果历史(仅终态):DELETE /api/admin/shop-data-crawl/history/{resultId}。 */
|
||||
export async function deleteShopDataResultHistory(resultId: number | string): Promise<void> {
|
||||
await http.delete<unknown>(`${SHOP_DATA_SINGLE_ENDPOINT}/history/${resultId}`)
|
||||
}
|
||||
|
||||
/** 加载店铺数据任务数据范围授权用户:GET /api/admin/shop-data-crawl-task-permissions。 */
|
||||
export async function fetchShopDataTaskPermissionUsers(): Promise<TaskPermissionItem[]> {
|
||||
const { data } = await http.get<unknown>(SHOP_DATA_TASK_PERMISSIONS_ENDPOINT)
|
||||
|
||||
@@ -9,6 +9,22 @@ export interface ShopDataDownloadJob {
|
||||
errorCount?: number
|
||||
}
|
||||
|
||||
/** 从 Content-Disposition 解析单文件下载名(rfc5987 优先),失败回默认名。 */
|
||||
export function shopDataFilenameFromDisposition(disposition: string | undefined, fallback: string): string {
|
||||
const text = typeof disposition === 'string' ? disposition : ''
|
||||
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(text)
|
||||
if (encoded) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(encoded[1])
|
||||
if (decoded.trim()) return decoded.trim()
|
||||
} catch {
|
||||
// 回退普通 filename
|
||||
}
|
||||
}
|
||||
const plain = /filename="?([^";]+)"?/i.exec(text)
|
||||
return plain && plain[1] ? plain[1].trim() : fallback
|
||||
}
|
||||
|
||||
function finitePositive(value: unknown): number | null {
|
||||
const number = typeof value === 'number' && Number.isFinite(value)
|
||||
? value
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
import { shopDataFilenameFromDisposition } from '../src/pages/tasks/shop-data-download.ts'
|
||||
import { isTerminalStatus } from '../src/pages/tasks/image-video-view.ts'
|
||||
|
||||
// module 13 task 266:店铺数据任务视图改“每店铺卡片”,单文件下载/删除真实端点。
|
||||
|
||||
test('test_task_266_view_normal_primary_path', () => {
|
||||
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
||||
assert.match(page, /shop-card-grid/, '需每店铺卡片网格')
|
||||
assert.match(page, /group\.results/, '卡片内遍历该店结果文件')
|
||||
assert.match(page, /暂无结果文件/, '店空态')
|
||||
})
|
||||
|
||||
test('test_task_266_view_normal_variant_input', () => {
|
||||
const api = readSource('src/pages/tasks/shop-data-api.ts')
|
||||
assert.match(api, /fetchShopDataResultDownload/, '单文件下载适配')
|
||||
assert.match(api, /\/results\/\$\{resultId\}\/download/, '单文件下载走管理端真实端点')
|
||||
assert.match(api, /deleteShopDataResultHistory/, '删除适配')
|
||||
assert.match(api, /\/history\/\$\{resultId\}/, '删除走管理端真实端点')
|
||||
})
|
||||
|
||||
test('test_task_266_view_normal_repeated_operation_is_idempotent', () => {
|
||||
assert.equal(shopDataFilenameFromDisposition('attachment; filename="a.xlsx"', 'fallback.xlsx'), 'a.xlsx')
|
||||
assert.equal(shopDataFilenameFromDisposition(undefined, 'fallback.xlsx'), 'fallback.xlsx')
|
||||
})
|
||||
|
||||
test('test_task_266_view_boundary_empty_input', () => {
|
||||
assert.equal(shopDataFilenameFromDisposition('', 'shop-data-task-9.xlsx'), 'shop-data-task-9.xlsx', '缺 header 回默认文件名')
|
||||
assert.equal(shopDataFilenameFromDisposition('inline; filename="店铺.xlsx"', 'd.xlsx'), '店铺.xlsx')
|
||||
})
|
||||
|
||||
test('test_task_266_view_boundary_single_item', () => {
|
||||
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
||||
assert.match(page, /fileReady/, '未就绪禁下载')
|
||||
assert.match(page, /isTerminalStatus/, '终态门控下载/删除')
|
||||
assert.equal(isTerminalStatus('SUCCESS'), true)
|
||||
assert.equal(isTerminalStatus('RUNNING'), false)
|
||||
})
|
||||
|
||||
test('test_task_266_delete_normal_primary_path', () => {
|
||||
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
||||
assert.match(page, /确认删除店铺/, '删除确认含店铺')
|
||||
assert.match(page, /及结果文件/, '删除确认含结果文件')
|
||||
assert.match(page, /删除成功/, '删除成功提示')
|
||||
})
|
||||
|
||||
test('test_task_266_delete_boundary_limit_or_missing_field', () => {
|
||||
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
||||
assert.match(page, /正在删除任务/, '删除中有进行文案')
|
||||
})
|
||||
|
||||
test('test_task_266_dependency_failure_returns_actionable_message', () => {
|
||||
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
||||
assert.equal(/<el-table[^>]*:data="(rows|allResultRows)"/.test(page), false, '结果文件不再用整表渲染')
|
||||
assert.match(page, /暂无店铺数据任务/, '空态文案')
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
import { toShopDataQuery } from '../src/pages/tasks/shop-data-filter.ts'
|
||||
import { toShopDataZipRequest } from '../src/pages/tasks/shop-data-download.ts'
|
||||
|
||||
// module 13 task 267:店铺数据任务筛选/批量下载/权限/合计对齐。
|
||||
|
||||
test('test_task_267_filter_normal_primary_path', () => {
|
||||
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
||||
assert.match(page, /店铺(模糊搜索)/, '需店铺模糊筛选')
|
||||
assert.match(page, /分组(模糊搜索)/, '需分组模糊筛选')
|
||||
assert.match(page, /国家/, '需国家筛选')
|
||||
assert.match(page, /创建时间/, '需创建时间范围')
|
||||
assert.match(page, /查询/, '需查询')
|
||||
assert.match(page, /reset/, '需重置')
|
||||
})
|
||||
|
||||
test('test_task_267_filter_normal_variant_input', () => {
|
||||
const query = toShopDataQuery({ shopName: ' 店A ', groupName: ' 组1 ', country: 'DE', createdFrom: '', createdTo: '2026-09-05T09:30' }, 1, 20)
|
||||
assert.equal(query.shop_name, '店A')
|
||||
assert.equal(query.group_name, '组1')
|
||||
assert.equal(query.country, 'DE')
|
||||
assert.equal(query.created_to, '2026-09-05T09:30')
|
||||
assert.equal(query.created_from, undefined, '空日期不下发')
|
||||
})
|
||||
|
||||
test('test_task_267_batch_normal_primary_path', () => {
|
||||
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
||||
assert.match(page, /全选可下载/, '全选可下载文件')
|
||||
assert.match(page, /批量下载/, '批量下载')
|
||||
assert.match(page, /requestShopDataZipDownload/, '批量走打包端点')
|
||||
})
|
||||
|
||||
test('test_task_267_batch_boundary_empty_input', () => {
|
||||
assert.deepEqual(toShopDataZipRequest(['3', '3', 'abc', 4]), { result_ids: [3, 4] }, '过滤非法/去重')
|
||||
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
||||
assert.match(page, /请先勾选要下载的结果文件/, '未勾选提示')
|
||||
})
|
||||
|
||||
test('test_task_267_batch_boundary_limit_or_missing_field', () => {
|
||||
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
||||
assert.match(page, /包内错误清单/, '部分失败提示包内错误清单')
|
||||
})
|
||||
|
||||
test('test_task_267_total_normal_primary_path', () => {
|
||||
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
||||
assert.match(page, /家店铺/, '合计为店铺数')
|
||||
assert.match(page, /结果文件/, '展示结果文件数')
|
||||
})
|
||||
|
||||
test('test_task_267_permission_normal_primary_path', () => {
|
||||
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
||||
assert.match(page, /权限配置/, '权限入口')
|
||||
assert.match(page, /fetchShopDataTaskPermissionUsers/, '权限加载')
|
||||
assert.match(page, /saveShopDataTaskPermissions/, '权限保存')
|
||||
})
|
||||
|
||||
test('test_task_267_dependency_failure_returns_actionable_message', () => {
|
||||
const api = readSource('src/pages/tasks/shop-data-api.ts')
|
||||
assert.match(api, /\/download-zip/)
|
||||
assert.match(api, /\/api\/admin\/shop-data-crawl-task-permissions/)
|
||||
})
|
||||
Reference in New Issue
Block a user