task-257(admin.html观感对齐): 查询ASIN页对齐(行内配置抽屉逐国ASIN/导入添加删除导入/导出)

This commit is contained in:
2026-09-05 21:32:57 +08:00
parent 3902acafb9
commit d745271685
2 changed files with 409 additions and 0 deletions
@@ -0,0 +1,353 @@
<script setup lang="ts">
/** 查询 ASIN:对齐 admin panel-query-asin —— 宽表筛选 + 行内「配置」抽屉(逐国 ASIN,留空=删除该站点) + 导入添加/删除导入/导出。 */
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchQueryAsinList } from './query-asin-api.ts'
import { QUERY_ASIN_COUNTRIES, type QueryAsinItem } from './query-asin-model.ts'
import { asinCountryLabel } from './asin-country.ts'
import { createQueryAsinFilterState, type QueryAsinFilterState } from './query-asin-filter.ts'
import { deleteQueryAsinCountryAsin, updateQueryAsinCountryAsin } from './query-asin-detail-api.ts'
import { fetchQueryAsinImportProgress, startQueryAsinImport } from './query-asin-import-api.ts'
import { fetchQueryAsinDeleteImportProgress, startQueryAsinDeleteImport } from './query-asin-delete-import-api.ts'
import { isAllowedExcelImportFile } from './import-progress-model.ts'
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
import type { ShopGroupOption } from '../shop/shop-dto.ts'
const loading = ref(false)
const rows = ref<QueryAsinItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 15
const groups = ref<ShopGroupOption[]>([])
const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
function asinOf(row: QueryAsinItem, country: string): string {
const map: Record<string, string> = { DE: row.asinDe, UK: row.asinUk, FR: row.asinFr, IT: row.asinIt, ES: row.asinEs }
return map[country] || ''
}
// ---- 行内配置抽屉 ----
const drawerVisible = ref(false)
const drawerItem = ref<QueryAsinItem | null>(null)
const drawerSaving = ref(false)
const draftAsin = reactive<Record<string, string>>({ DE: '', UK: '', FR: '', IT: '', ES: '' })
function openConfig(row: QueryAsinItem): void {
drawerItem.value = row
for (const country of QUERY_ASIN_COUNTRIES) {
draftAsin[country] = asinOf(row, country)
}
drawerVisible.value = true
}
function normalizeAsinInput(country: string): void {
draftAsin[country] = draftAsin[country].trim().toUpperCase()
}
async function saveConfig(): Promise<void> {
const item = drawerItem.value
if (!item) return
drawerSaving.value = true
const errors: string[] = []
try {
for (const country of QUERY_ASIN_COUNTRIES) {
const oldAsin = asinOf(item, country)
const next = (draftAsin[country] || '').trim().toUpperCase()
if (next === oldAsin) continue
try {
if (!next) {
if (oldAsin) await deleteQueryAsinCountryAsin(item.id, country)
} else {
await updateQueryAsinCountryAsin(item.id, country, next)
}
} catch (error) {
errors.push(`${asinCountryLabel(country)}${error instanceof Error ? error.message : '保存失败'}`)
}
}
if (errors.length) {
ElMessage.error(errors.join(''))
} else {
ElMessage.success('保存成功')
drawerVisible.value = false
drawerItem.value = null
await load()
}
} finally {
drawerSaving.value = false
}
}
// ---- 导入添加 / 删除导入 ----
type ImportMode = 'add' | 'delete'
const importVisible = ref(false)
const importMode = ref<ImportMode>('add')
const importGroupId = ref<number | null>(null)
const importFile = ref<File | null>(null)
const importRunning = ref(false)
const importProgress = ref('')
function resetImport(): void {
importGroupId.value = null
importFile.value = null
importProgress.value = ''
importRunning.value = false
}
function openImportAdd(): void {
importMode.value = 'add'
resetImport()
importVisible.value = true
}
function openImportDelete(): void {
importMode.value = 'delete'
resetImport()
importVisible.value = true
}
function onPickImportFile(event: Event): void {
const input = event.target as HTMLInputElement
importFile.value = input.files?.[0] ?? null
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function submitImport(): Promise<void> {
const file = importFile.value
if (!file) {
ElMessage.warning('请选择 Excel 文件')
return
}
if (!isAllowedExcelImportFile(file.name)) {
ElMessage.warning('仅支持 .xlsx/.xls 文件')
return
}
if (importMode.value === 'delete') {
try {
await ElMessageBox.confirm('确定按 Excel 中的分组、店铺名和国家 ASIN 批量删除吗?', '删除导入', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
} catch {
return
}
}
const mode = importMode.value
const groupId = importGroupId.value ?? undefined
importRunning.value = true
importProgress.value = '正在上传并解析文件…'
try {
const started = mode === 'add' ? await startQueryAsinImport(file, groupId) : await startQueryAsinDeleteImport(file, groupId)
const poll = mode === 'add' ? () => fetchQueryAsinImportProgress(started) : () => fetchQueryAsinDeleteImportProgress(started)
for (let i = 0; i < 300; i += 1) {
const progress = await poll()
if (progress.status === 'success') {
importVisible.value = false
resetImport()
ElMessage.success(mode === 'add' ? '导入完成' : '删除导入完成')
await load()
return
}
if (progress.status === 'failed') {
importVisible.value = false
resetImport()
ElMessage.error((progress as { errorMessage?: string }).errorMessage || (mode === 'add' ? '导入失败' : '删除导入失败'))
return
}
importProgress.value = progress.status === 'pending' ? '等待导入任务开始…' : '导入处理中…'
await sleep(1200)
}
throw new Error('查询导入进度超时,请稍后刷新列表确认结果')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '导入失败')
} finally {
importRunning.value = false
}
}
// ---- 导出 ----
function doExport(): void {
const anchor = document.createElement('a')
anchor.href = '/api/admin/query-asins/export'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
ElMessage.success('导出文件已开始下载')
}
async function loadGroups() {
try {
groups.value = await fetchShopManageGroups()
} catch {
groups.value = []
}
}
async function load() {
loading.value = true
try {
const result = await fetchQueryAsinList({
page: page.value,
pageSize,
groupId: filter.groupId,
shopName: filter.shopName.trim() || undefined,
asin: filter.asin.trim() || undefined,
country: filter.country || undefined,
})
rows.value = result.items
total.value = result.total
page.value = result.page
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '数据加载失败')
} finally {
loading.value = false
}
}
function apply() {
page.value = 1
void load()
}
function reset() {
Object.assign(filter, createQueryAsinFilterState())
page.value = 1
void load()
}
onMounted(() => {
void loadGroups()
void load()
})
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>查询 ASIN</h2>
<p>每店铺在 5 个站点查询到的 ASIN 清单行内可逐站配置 ASIN</p>
</div>
<div class="heading-actions">
<el-button type="primary" @click="openImportAdd">导入添加</el-button>
<el-button @click="openImportDelete">导入删除</el-button>
<el-button @click="doExport">导出</el-button>
</div>
</div>
<el-card shadow="never">
<div class="filter-grid">
<div class="f-item">
<label>分组</label>
<el-select v-model="filter.groupId" placeholder="全部分组" clearable filterable>
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</div>
<div class="f-item">
<label>店铺</label>
<el-input v-model="filter.shopName" placeholder="店铺名模糊" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>ASIN</label>
<el-input v-model="filter.asin" placeholder="ASIN 搜索" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>国家</label>
<el-select v-model="filter.country" placeholder="全部" clearable>
<el-option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
</el-select>
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
</div>
</el-card>
<el-card shadow="never">
<el-table v-loading="loading" :data="rows" stripe border>
<el-table-column prop="shopName" label="店铺" min-width="150" fixed />
<el-table-column prop="groupName" label="分组" min-width="120" />
<el-table-column v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="`${asinCountryLabel(code)} ASIN`" min-width="150">
<template #default="{ row }">
<span v-if="asinOf(row as QueryAsinItem, code)" class="asin-cell">{{ asinOf(row as QueryAsinItem, code) }}</span>
<span v-else class="dim"></span>
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" min-width="160" />
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openConfig(row as QueryAsinItem)">配置</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span> {{ 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; void load() }"
/>
</div>
</el-card>
<el-drawer v-model="drawerVisible" :title="drawerItem ? `${drawerItem.shopName} · ASIN 配置` : 'ASIN 配置'" size="min(520px, 92%)">
<p class="drawer-tip">留空表示删除该站点已有的 ASIN 与记录。</p>
<el-form label-position="top">
<el-form-item v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="`${asinCountryLabel(code)} ASIN`">
<el-input
v-model="draftAsin[code]"
:placeholder="asinOf(drawerItem as QueryAsinItem, code) ? '留空删除该站点 ASIN' : '输入 ASIN'"
@input="normalizeAsinInput(code)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" :loading="drawerSaving" @click="saveConfig">保存</el-button>
</template>
</el-drawer>
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入添加查询 ASIN' : '删除导入查询 ASIN'" width="520px">
<el-form label-position="top">
<el-form-item label="分组Excel 未提供时的兜底可选">
<el-select v-model="importGroupId" placeholder="可不选" clearable style="width: 100%">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item label="Excel 文件">
<input type="file" accept=".xlsx,.xls" :disabled="importRunning" @change="onPickImportFile" />
<p class="import-tip">按 Excel 中的分组、店铺名与国家 ASIN 批量{{ importMode === 'add' ? '添加' : '删除' }},仅支持 .xlsx/.xls。</p>
</el-form-item>
<el-alert v-if="importProgress" :title="importProgress" type="info" :closable="false" show-icon />
</el-form>
<template #footer>
<el-button :disabled="importRunning" @click="importVisible = false">取消</el-button>
<el-button type="primary" :loading="importRunning" @click="submitImport">
{{ importMode === 'add' ? '开始导入' : '开始删除导入' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.heading-actions { display: flex; gap: 8px; flex: none; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
.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; }
.asin-cell { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
.dim { color: var(--el-text-color-placeholder); }
.drawer-tip { color: var(--el-text-color-secondary); font-size: 12px; margin: 0 0 12px; }
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
</style>
+56
View File
@@ -0,0 +1,56 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
// module 13 task 257:查询ASIN页对齐 admin panel-query-asin —— 行内「配置」抽屉(逐国 ASIN,
// 留空=删除该站点)、导入添加/删除导入/导出接线既有孤儿模块。
test('test_task_257_queryasin_normal_primary_path', () => {
const page = readSource('src/pages/asin/QueryAsinPage.vue')
assert.match(page, /updateQueryAsinCountryAsin/, '需接线逐国 PUT')
assert.match(page, /deleteQueryAsinCountryAsin/, '需接线逐国 DELETE')
assert.match(page, /配置/, '行内需有配置入口')
assert.match(page, /留空表示删除该站点已有的 ASIN/, '抽屉需留空=删除语义提示')
})
test('test_task_257_queryasin_normal_variant_input', () => {
const page = readSource('src/pages/asin/QueryAsinPage.vue')
assert.match(page, /导入添加/, '页头需导入添加')
assert.match(page, /导入删除/, '页头需删除导入')
assert.match(page, /导出/, '页头需导出')
})
test('test_task_257_queryasin_normal_repeated_operation_is_idempotent', () => {
const page = readSource('src/pages/asin/QueryAsinPage.vue')
assert.ok(page.includes('配置'))
assert.ok(page.includes('配置'))
})
test('test_task_257_queryasin_boundary_empty_input', () => {
const page = readSource('src/pages/asin/QueryAsinPage.vue')
assert.match(page, /startQueryAsinImport|startQueryAsinDeleteImport|fetchQueryAsinImportProgress|fetchQueryAsinDeleteImportProgress/, '导入需启动+轮询')
assert.match(page, /isAllowedExcelImportFile|isAllowedImportFile/, '导入需 Excel 校验')
})
test('test_task_257_queryasin_boundary_single_item', () => {
const page = readSource('src/pages/asin/QueryAsinPage.vue')
assert.match(page, /toUpperCase/, 'ASIN 需自动大写')
assert.match(page, /query-asins\/export|export/, '需导出能力')
})
test('test_task_257_queryasin_boundary_limit_or_missing_field', () => {
const api = readSource('src/pages/asin/query-asin-detail-api.ts')
assert.match(api, /\/countries\//, '详情 API 需按国家细分')
assert.match(api, /method: 'PUT'|\.put</, '详情需 PUT')
})
test('test_task_257_queryasin_invalid_input_rejected', () => {
const page = readSource('src/pages/asin/QueryAsinPage.vue')
assert.match(page, /请选择|文件/, '导入需分组/文件必填提示')
assert.match(page, /确定按 Excel|批量删除/, '删除导入需前置确认')
})
test('test_task_257_queryasin_dependency_failure_returns_actionable_message', () => {
const page = readSource('src/pages/asin/QueryAsinPage.vue')
assert.match(page, /drawer|el-drawer/, '配置承载宜用抽屉')
})