align(最低价ASIN): 回退参考纵向 rowspan 布局(序号/分组/店铺/操作合并+逐国ASIN点击复制+最低价两位小数)、补新增ASIN弹窗(分组→店铺联动→国家→ASIN+最低价校验)、去更新时间列(对齐 admin.js renderSkipPriceAsinRows/btnCreateSkipPriceAsin)

This commit is contained in:
2026-09-05 23:13:36 +08:00
parent 1848a34a27
commit a8d08c4d39
4 changed files with 348 additions and 24 deletions
@@ -1,16 +1,18 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 最低价 ASIN(跳过跟价):对齐 admin panel-skip-price-asin —— 价格范围筛选、行内「配置」抽屉
* (逐国 ASIN+最低价,留空=删除该站点、低价必带 ASIN)、导入添加/删除导入/导出。 */
import { onMounted, reactive, ref } from 'vue'
/** 最低价 ASIN(跳过跟价):对齐 admin panel-skip-price-asin —— 纵向 rowspan 展示(序号/分组/店铺合并 + 逐国
* ASIN 可点击复制 + 最低价两位小数)、新增 ASIN 弹窗(分组→店铺联动→国家→ASIN+最低价)、价格范围筛选、
* 行内「配置」抽屉(逐国 ASIN+最低价,留空=删除该站点、低价必带 ASIN)、导入添加/删除导入/导出。 */
import { computed, 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 CopyText from '@/components/CopyText.vue'
import { createSkipPriceAsin, fetchSkipPriceList } from './skip-price-api.ts'
import { skipPriceDisplayRows, 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 { fetchShopNamesByGroup } from './query-asin-api.ts'
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
import type { ShopGroupOption } from '../shop/shop-dto.ts'
@@ -35,6 +37,107 @@ function priceOf(row: SkipPriceItem, code: string): number | null {
return typeof value === 'number' ? value : null
}
/** 纵向展示行:序号/分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderSkipPriceAsinRows)。 */
const displayRows = computed(() => skipPriceDisplayRows(rows.value, (page.value - 1) * pageSize + 1))
/** rowspan 合并:序号(0)/分组(1)/店铺(2)/操作(6) 仅首行占位,其余合并。 */
function spanMethod({ row, columnIndex }: { row: { isFirst: boolean; rowspan: number }; columnIndex: number }): [number, number] {
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 2 || columnIndex === 6) {
return row.isFirst ? [row.rowspan, 1] : [0, 0]
}
return [1, 1]
}
// ---- 新增 ASIN 弹窗(对齐 admin.js btnOpenCreateSkipPriceAsin/btnCreateSkipPriceAsin ----
const createVisible = ref(false)
const createGroupId = ref<number | null>(null)
const createShopName = ref('')
const createCountry = ref('')
const createAsin = ref('')
const createMinimumPrice = ref('')
const createMsg = ref('')
const createMsgOk = ref(false)
const shopNames = ref<string[]>([])
const shopNamesLoading = ref(false)
const creating = ref(false)
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
function openCreate(): void {
createGroupId.value = null
createShopName.value = ''
createCountry.value = ''
createAsin.value = ''
createMinimumPrice.value = ''
createMsg.value = ''
createMsgOk.value = false
shopNames.value = []
createVisible.value = true
}
async function onCreateGroupChange(): Promise<void> {
createShopName.value = ''
if (createGroupId.value == null) {
shopNames.value = []
return
}
shopNamesLoading.value = true
try {
shopNames.value = await fetchShopNamesByGroup(createGroupId.value)
} catch (error) {
shopNames.value = []
ElMessage.error(error instanceof Error ? error.message : '店铺列表加载失败')
} finally {
shopNamesLoading.value = false
}
}
function onCreateAsinInput(): void {
createAsin.value = createAsin.value.toUpperCase()
}
async function submitCreate(): Promise<void> {
createMsg.value = ''
createMsgOk.value = false
const groupId = createGroupId.value
const shopName = createShopName.value.trim()
const country = (createCountry.value || '').trim()
const asin = createAsin.value.trim().toUpperCase()
const minimumPrice = createMinimumPrice.value.trim()
if (!groupId || !shopName || !country || !asin) {
createMsg.value = '请完整填写分组、店铺名、国家和 ASIN'
return
}
if (minimumPrice) {
const minimumPriceNumber = Number(minimumPrice)
if (!Number.isFinite(minimumPriceNumber) || minimumPriceNumber < 0) {
createMsg.value = '最低价格式不正确'
return
}
}
creating.value = true
try {
const message = await createSkipPriceAsin({
groupId,
shopName,
countries: [country],
asin,
asinMappings: { [country]: asin },
minimumPriceMappings: minimumPrice ? { [country]: Number(minimumPrice) } : undefined,
})
// 对齐 admin:保留分组/店铺/国家,清空 ASIN/最低价方便连续录入,成功后刷新列表。
createMsg.value = message
createMsgOk.value = true
createAsin.value = ''
createMinimumPrice.value = ''
createAsinInputRef.value?.focus()
await load()
} catch (error) {
createMsg.value = error instanceof Error ? error.message : '保存失败'
} finally {
creating.value = false
}
}
// ---- 行内配置抽屉 ----
const drawerVisible = ref(false)
const drawerItem = ref<SkipPriceItem | null>(null)
@@ -258,7 +361,8 @@ onMounted(() => {
<p>每店铺在 5 个站点设定的最低价 ASIN 清单行内可逐站配置 ASIN 与最低价</p>
</div>
<div class="heading-actions">
<el-button type="primary" @click="openImportAdd">导入添加</el-button>
<el-button type="primary" @click="openCreate">新增 ASIN</el-button>
<el-button @click="openImportAdd">导入添加</el-button>
<el-button @click="openImportDelete">导入删除</el-button>
<el-button @click="doExport">导出</el-button>
</div>
@@ -302,26 +406,37 @@ onMounted(() => {
</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">
<template #default="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
<el-table v-loading="loading" :data="displayRows" :span-method="spanMethod" stripe border>
<el-table-column label="序号" width="80">
<template #default="{ row }">{{ row.rowNo }}</template>
</el-table-column>
<el-table-column label="分组" width="130">
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
</el-table-column>
<el-table-column label="店铺" min-width="150">
<template #default="{ row }">{{ row.item.shopName }}</template>
</el-table-column>
<el-table-column label="ASIN" min-width="150">
<template #default="{ row }">
<CopyText v-if="row.asin" :text="row.asin" class="asin-cell" />
<span v-else class="dim">-</span>
</template>
</el-table-column>
<el-table-column label="国家" width="110">
<template #default="{ row }">
<span v-if="row.country">{{ asinCountryLabel(row.country) }}</span>
<span v-else class="dim">-</span>
</template>
</el-table-column>
<el-table-column label="最低价" min-width="110" align="right">
<template #default="{ row }">
<span v-if="row.minimumPrice !== ''">{{ row.minimumPrice }}</span>
<span v-else class="dim">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openConfig(row as SkipPriceItem)">配置</el-button>
<el-button v-if="row.isFirst" link type="primary" @click="openConfig(row.item as SkipPriceItem)">配置</el-button>
</template>
</el-table-column>
</el-table>
@@ -338,6 +453,44 @@ onMounted(() => {
</div>
</el-card>
<el-dialog v-model="createVisible" title="新增 ASIN" width="560px" :close-on-click-modal="false">
<el-form label-position="top" @submit.prevent>
<el-form-item label="分组">
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" @change="onCreateGroupChange">
<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="店铺名">
<el-select
v-model="createShopName"
:disabled="createGroupId == null"
:loading="shopNamesLoading"
:placeholder="createGroupId == null ? '请先选择分组' : '请选择店铺'"
filterable
style="width: 100%"
>
<el-option v-for="name in shopNames" :key="name" :label="name" :value="name" />
</el-select>
</el-form-item>
<el-form-item label="国家">
<el-select v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%">
<el-option v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
</el-select>
</el-form-item>
<el-form-item label="ASIN">
<el-input ref="createAsinInputRef" v-model="createAsin" placeholder="请输入 ASIN" @input="onCreateAsinInput" />
</el-form-item>
<el-form-item label="最低价">
<el-input v-model="createMinimumPrice" type="number" min="0" step="0.01" placeholder="选填" />
</el-form-item>
<el-alert v-if="createMsg" :title="createMsg" :type="createMsgOk ? 'success' : 'error'" :closable="false" show-icon />
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="submitCreate">保存</el-button>
</template>
</el-dialog>
<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">
@@ -1,5 +1,6 @@
/** 最低价 ASIN 列表查询适配(任务 75):GET /api/admin/skip-price-asins + snake 分页/筛选序列化。 */
import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import { parseSkipPricePage, type SkipPricePageResult } from './skip-price-model'
import {
normalizeSkipPriceParams,
@@ -14,3 +15,21 @@ export async function fetchSkipPriceList(params: Partial<SkipPriceListParams> =
const { data } = await http.get<unknown>(SKIP_PRICE_ENDPOINT, { params: toSkipPriceQuery(normalized) })
return parseSkipPricePage(data)
}
/** 新增/覆盖最低价 ASIN 载荷(对齐 Java SkipPriceAsinCreateRequest camelCase)。 */
export interface SkipPriceCreatePayload {
groupId: number
shopName: string
countries: string[]
asin: string
asinMappings: Record<string, string>
minimumPriceMappings?: Record<string, number>
}
/** 新增或覆盖最低价 ASINPOST /api/admin/skip-price-asins,返回后端成功消息。 */
export async function createSkipPriceAsin(payload: SkipPriceCreatePayload): Promise<string> {
const { data } = await http.post<unknown>(SKIP_PRICE_ENDPOINT, payload)
const record = unwrap<unknown>(data) as Record<string, unknown> | null | undefined
const message = record && typeof record.message === 'string' ? record.message : ''
return message || '保存成功'
}
@@ -129,3 +129,65 @@ export function skipPriceMinimum(item: SkipPriceItem, country: string): number |
if (!ASIN_COUNTRY_CODES.includes(country as AsinCountryCode)) return null
return item[`minimumPrice${countrySuffix(country)}` as keyof SkipPriceItem] as number | null
}
/** 最低价展示文本(对齐 admin.js formatSkipPriceMinimumPrice:数字→两位小数,空→'',非法→原串)。 */
export function formatSkipPriceText(value: number | null | undefined): string {
if (value === null || value === undefined) return ''
const num = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(num)) return String(value)
return num.toFixed(2)
}
/** 纵向行条目(对齐 admin.js buildSkipPriceAsinEntries):有 ASIN 或最低价的国家一行;全空补一条空条目。 */
export interface SkipPriceRowEntry {
country: AsinCountryCode | null
asin: string
minimumPrice: string
}
export function skipPriceRowEntries(item: SkipPriceItem): SkipPriceRowEntry[] {
const entries: SkipPriceRowEntry[] = []
for (const code of ASIN_COUNTRY_CODES) {
const asin = skipPriceAsin(item, code)
const minimumPrice = skipPriceMinimum(item, code)
if (asin || minimumPrice != null) {
entries.push({ country: code, asin, minimumPrice: formatSkipPriceText(minimumPrice) })
}
}
if (!entries.length) entries.push({ country: null, asin: '', minimumPrice: '' })
return entries
}
/** 纵向展示行(对齐 admin.js renderSkipPriceAsinRows):序号/分组/店铺/操作 按 rowspan 合并,逐国一行。 */
export interface SkipPriceDisplayRow {
key: string
rowNo: number
rowspan: number
isFirst: boolean
country: AsinCountryCode | null
asin: string
minimumPrice: string
item: SkipPriceItem
}
export function skipPriceDisplayRows(items: SkipPriceItem[], startRowNo: number): SkipPriceDisplayRow[] {
const out: SkipPriceDisplayRow[] = []
let rowNo = Math.max(Math.floor(startRowNo || 1), 1)
for (const item of items) {
const entries = skipPriceRowEntries(item)
entries.forEach((entry, index) => {
out.push({
key: `${item.id}:${index}`,
rowNo,
rowspan: entries.length,
isFirst: index === 0,
country: entry.country,
asin: entry.asin,
minimumPrice: entry.minimumPrice,
item,
})
})
rowNo += 1
}
return out
}
@@ -0,0 +1,90 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
formatSkipPriceText,
skipPriceDisplayRows,
skipPriceRowEntries,
type SkipPriceItem,
} from '../src/pages/asin/skip-price-model.ts'
/** 对齐 admin.js:4839-4890 buildSkipPriceAsinEntries/renderSkipPriceAsinRows:纵向 rowspan + 最低价两位小数。 */
function item(id: number, overrides: Partial<SkipPriceItem> = {}): SkipPriceItem {
return {
id,
groupId: 1,
groupName: 'A组',
shopName: `店铺${id}`,
asinDe: '',
minimumPriceDe: null,
asinUk: '',
minimumPriceUk: null,
asinFr: '',
minimumPriceFr: null,
asinIt: '',
minimumPriceIt: null,
asinEs: '',
minimumPriceEs: null,
...overrides,
}
}
test('align_skip_price_row_entries_collects_asin_or_price_rows', () => {
const entries = skipPriceRowEntries(item(1, { asinDe: 'B0DE', minimumPriceFr: 12.5 }))
assert.deepEqual(entries, [
{ country: 'DE', asin: 'B0DE', minimumPrice: '' },
{ country: 'FR', asin: '', minimumPrice: '12.50' },
])
})
test('align_skip_price_row_entries_empty_shop_yields_single_dash_row', () => {
assert.deepEqual(skipPriceRowEntries(item(2)), [{ country: null, asin: '', minimumPrice: '' }])
})
test('align_skip_price_price_text_two_decimals', () => {
// 对齐 formatSkipPriceMinimumPrice:数字→toFixed(2),空→'',非法→原串。
assert.equal(formatSkipPriceText(3), '3.00')
assert.equal(formatSkipPriceText(12.5), '12.50')
assert.equal(formatSkipPriceText(null), '')
assert.equal(formatSkipPriceText(undefined), '')
assert.equal(formatSkipPriceText('x' as unknown as number), 'x')
})
test('align_skip_price_display_rows_merge_and_numbering', () => {
const rows = skipPriceDisplayRows([item(1, { asinDe: 'B0DE', asinUk: 'B0UK' }), item(2)], 1)
assert.equal(rows.length, 3)
const [r0, r1, r2] = rows
assert.equal(r0.rowNo, 1)
assert.equal(r0.rowspan, 2)
assert.equal(r0.isFirst, true)
assert.equal(r0.asin, 'B0DE')
assert.equal(r1.isFirst, false)
assert.equal(r1.country, 'UK')
assert.equal(r2.rowNo, 2)
assert.equal(r2.country, null)
assert.equal(new Set(rows.map((r) => r.key)).size, rows.length)
})
test('align_skip_price_display_rows_empty_input', () => {
assert.deepEqual(skipPriceDisplayRows([], 1), [])
})
test('align_skip_price_create_api_wiring', () => {
const api = readSource('src/pages/asin/skip-price-api.ts')
assert.match(api, /createSkipPriceAsin/, '含新增适配函数')
assert.match(api, /skip-price-asins/, '端点正确')
assert.match(api, /minimumPriceMappings/, '载荷含逐国最低价映射')
})
test('align_skip_price_page_layout_wiring', () => {
const page = readSource('src/pages/asin/SkipPricePage.vue')
assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮')
assert.match(page, /span-method/, '表格用 span-method 做 rowspan 合并')
assert.match(page, /CopyText/, 'ASIN 单元格点击复制')
assert.doesNotMatch(page, /更新时间/, '去掉更新时间列(参考无此列)')
assert.match(page, /skipPriceDisplayRows/, '行展开走纯模型')
assert.match(page, /请完整填写分组、店铺名、国家和 ASIN/, '新增弹窗校验文案对齐')
assert.match(page, /最低价格式不正确/, '最低价格式校验对齐')
assert.match(page, /请先选择分组/, '店铺下拉未选分组时占位对齐')
})