task-258(admin.html观感对齐): 最低价ASIN页对齐(价格区间筛选/配置抽屉ASIN+最低价/导入删除导入/导出)
This commit is contained in:
@@ -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>
|
||||
@@ -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/, '筛选需含最低价区间归一')
|
||||
})
|
||||
Reference in New Issue
Block a user