task-257(admin.html观感对齐): 查询ASIN页对齐(行内配置抽屉逐国ASIN/导入添加删除导入/导出)
This commit is contained in:
@@ -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>
|
||||||
@@ -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/, '配置承载宜用抽屉')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user