task-258(admin.html观感对齐): 最低价ASIN页对齐(价格区间筛选/配置抽屉ASIN+最低价/导入删除导入/导出)

This commit is contained in:
2026-09-05 21:34:55 +08:00
parent 4c6884223d
commit 0248c8737d
2 changed files with 452 additions and 0 deletions
@@ -0,0 +1,395 @@
<script setup lang="ts">
/** 最低价 ASIN(跳过跟价):对齐 admin panel-skip-price-asin —— 价格范围筛选、行内「配置」抽屉
* (逐国 ASIN+最低价,留空=删除该站点、低价必带 ASIN)、导入添加/删除导入/导出。 */
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchSkipPriceList } from './skip-price-api.ts'
import type { SkipPriceItem } from './skip-price-model.ts'
import { asinCountryLabel } from './asin-country.ts'
import { createSkipPriceFilterState, toSkipPriceParams, type SkipPriceFilterState } from './skip-price-filter.ts'
import { deleteSkipPriceCountry, updateSkipPriceCountry } from './skip-price-detail-api.ts'
import { fetchSkipPriceDeleteImportProgress, fetchSkipPriceImportProgress, startSkipPriceDeleteImport, startSkipPriceImport } from './skip-price-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<SkipPriceItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 15
const groups = ref<ShopGroupOption[]>([])
const filter = reactive<SkipPriceFilterState>(createSkipPriceFilterState())
const COUNTRIES = ['DE', 'UK', 'FR', 'IT', 'ES'] as const
const ASIN_MAP: Record<string, keyof SkipPriceItem> = { DE: 'asinDe', UK: 'asinUk', FR: 'asinFr', IT: 'asinIt', ES: 'asinEs' }
const PRICE_MAP: Record<string, keyof SkipPriceItem> = { DE: 'minimumPriceDe', UK: 'minimumPriceUk', FR: 'minimumPriceFr', IT: 'minimumPriceIt', ES: 'minimumPriceEs' }
function asinOf(row: SkipPriceItem, code: string): string {
return (row[ASIN_MAP[code]] as string) || ''
}
function priceOf(row: SkipPriceItem, code: string): number | null {
const value = row[PRICE_MAP[code]] as number | null
return typeof value === 'number' ? value : null
}
// ---- 行内配置抽屉 ----
const drawerVisible = ref(false)
const drawerItem = ref<SkipPriceItem | null>(null)
const drawerSaving = ref(false)
const draftAsin = reactive<Record<string, string>>({ DE: '', UK: '', FR: '', IT: '', ES: '' })
const draftPrice = reactive<Record<string, string>>({ DE: '', UK: '', FR: '', IT: '', ES: '' })
function openConfig(row: SkipPriceItem): void {
drawerItem.value = row
for (const code of COUNTRIES) {
draftAsin[code] = asinOf(row, code)
const price = priceOf(row, code)
draftPrice[code] = price == null ? '' : String(price)
}
drawerVisible.value = true
}
function normalizeAsin(code: string): void {
draftAsin[code] = draftAsin[code].trim().toUpperCase()
}
function parsePriceText(raw: string): number | null {
const value = (raw || '').trim()
if (!value) return null
if (!/^\d+(\.\d{1,2})?$/.test(value)) return undefined as unknown as number | null
const num = Number(value)
return Number.isFinite(num) ? num : null
}
async function saveConfig(): Promise<void> {
const item = drawerItem.value
if (!item) return
drawerSaving.value = true
const errors: string[] = []
try {
for (const code of COUNTRIES) {
const asin = draftAsin[code].trim().toUpperCase()
const price = parsePriceText(draftPrice[code])
if (price === undefined) {
errors.push(`${asinCountryLabel(code)}:最低价格式不正确`)
continue
}
if (draftPrice[code].trim() !== '' && !asin) {
errors.push(`${asinCountryLabel(code)}:填写最低价时必须填写 ASIN`)
continue
}
const oldAsin = asinOf(item, code)
const oldPrice = priceOf(item, code)
if (asin === oldAsin && price === oldPrice) continue
try {
if (!asin) {
if (oldAsin) await deleteSkipPriceCountry(item.id, code)
} else {
await updateSkipPriceCountry(item.id, code, asin, price)
}
} catch (error) {
errors.push(`${asinCountryLabel(code)}${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
}
}
// ---- 导入添加 / 删除导入(group_id 必填) ----
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> {
if (!importGroupId.value) {
ElMessage.warning('请先选择分组后再导入')
return
}
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批量删除该店铺跳过跟价 ASIN 吗?', '删除导入', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
} catch {
return
}
}
const mode = importMode.value
const groupId = importGroupId.value as number
importRunning.value = true
importProgress.value = '正在上传并解析文件…'
try {
const started = mode === 'add' ? await startSkipPriceImport(file, groupId) : await startSkipPriceDeleteImport(file, groupId)
const poll = mode === 'add' ? () => fetchSkipPriceImportProgress(started) : () => fetchSkipPriceDeleteImportProgress(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.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/skip-price-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 fetchSkipPriceList(toSkipPriceParams(filter, page.value, pageSize))
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, createSkipPriceFilterState())
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 COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
</el-select>
</div>
<div class="f-item">
<label>最低价 </label>
<el-input v-model="filter.minimumPriceFrom" placeholder="数值" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>最低价 </label>
<el-input v-model="filter.minimumPriceTo" placeholder="数值" clearable @keyup.enter="apply" />
</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="110" />
<template v-for="code in COUNTRIES" :key="code">
<el-table-column :label="`${asinCountryLabel(code)} ASIN`" min-width="140">
<template #default="{ row }">
<span v-if="asinOf(row as SkipPriceItem, code)" class="asin-cell">{{ asinOf(row as SkipPriceItem, code) }}</span>
<span v-else class="dim"></span>
</template>
</el-table-column>
<el-table-column :label="`${asinCountryLabel(code)} 最低价`" min-width="110" align="right">
<template #default="{ row }">{{ priceOf(row as SkipPriceItem, code) ?? '—' }}</template>
</el-table-column>
</template>
<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 SkipPriceItem)">配置</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(560px, 94%)">
<p class="drawer-tip">留空表示删除该站点已有的 ASIN 与最低价。</p>
<el-form label-position="top">
<el-form-item v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)">
<div class="country-cell">
<el-input
v-model="draftAsin[code]"
placeholder="ASIN留空删除该站点"
@input="normalizeAsin(code)"
/>
<el-input v-model="draftPrice[code]" placeholder="最低价填最低价须填 ASIN" />
</div>
</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="分组必选">
<el-select v-model="importGroupId" placeholder="请选择分组" 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' ? '与最低价' : '' }}批量{{ 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: 190px; }
.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; }
.country-cell { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; width: 100%; }
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
</style>
+57
View File
@@ -0,0 +1,57 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
// module 13 task 258:最低价ASIN页对齐 admin panel-skip-price-asin —— 价格范围筛选生效、
// 行内「配置」抽屉(逐国 ASIN+最低价,留空=删除、低价必带 ASIN 校验)、导入添加/删除导入/导出。
test('test_task_258_skipprice_normal_primary_path', () => {
const page = readSource('src/pages/asin/SkipPricePage.vue')
assert.match(page, /updateSkipPriceCountry/, '需接线逐国 PUT(ASIN+最低价)')
assert.match(page, /deleteSkipPriceCountry/, '需接线逐国 DELETE')
assert.match(page, /配置/, '行内需配置入口')
assert.match(page, /留空表示删除该站点/, '抽屉需留空删除语义')
})
test('test_task_258_skipprice_normal_variant_input', () => {
const page = readSource('src/pages/asin/SkipPricePage.vue')
assert.match(page, /填写最低价时必须填写 ASIN/, '低价必带 ASIN 校验文案')
assert.match(page, /最低价格式不正确/, '低价格式校验文案')
assert.match(page, /导入添加/, '页头导入添加')
assert.match(page, /导入删除/, '页头删除导入')
assert.match(page, /导出/, '页头导出')
})
test('test_task_258_skipprice_normal_repeated_operation_is_idempotent', () => {
const page = readSource('src/pages/asin/SkipPricePage.vue')
assert.ok(page.includes('配置'))
assert.ok(page.includes('最低价'))
})
test('test_task_258_skipprice_boundary_empty_input', () => {
const page = readSource('src/pages/asin/SkipPricePage.vue')
assert.match(page, /startSkipPriceImport|fetchSkipPriceImportProgress/, '需接线导入添加')
assert.match(page, /toUpperCase/, 'ASIN 需自动大写')
assert.match(page, /minimumPriceFrom/, '价格范围筛选需保留')
})
test('test_task_258_skipprice_boundary_single_item', () => {
const page = readSource('src/pages/asin/SkipPricePage.vue')
assert.match(page, /请选择分组|分组后再导入/, '导入需分组必选(后端必填)')
})
test('test_task_258_skipprice_boundary_limit_or_missing_field', () => {
const api = readSource('src/pages/asin/skip-price-detail-api.ts')
assert.match(api, /minimumPrice/, '详情 API 需含最低价')
})
test('test_task_258_skipprice_invalid_input_rejected', () => {
const page = readSource('src/pages/asin/SkipPricePage.vue')
assert.match(page, /批量删除|确定按 Excel/, '删除导入需前置确认')
assert.match(page, /skip-price-asins\/export|导出/, '需导出能力')
})
test('test_task_258_skipprice_dependency_failure_returns_actionable_message', () => {
const page = readSource('src/pages/asin/SkipPricePage.vue')
assert.match(page, /toSkipPriceParams/, '筛选需含最低价区间归一')
})