align(查询ASIN): 回退参考纵向 rowspan 布局(序号/分组/店铺/操作合并+逐国ASIN行点击复制)、补新增ASIN弹窗(分组→店铺联动100/页聚合→国家→ASIN)、去更新时间列(对齐 admin.js renderQueryAsinRows/btnCreateQueryAsin/loadAsinShopNameOptions)
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 查询 ASIN:对齐 admin panel-query-asin —— 宽表筛选 + 行内「配置」抽屉(逐国 ASIN,留空=删除该站点) + 导入添加/删除导入/导出。 */
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
/** 查询 ASIN:对齐 admin panel-query-asin —— 纵向 rowspan 展示(序号/分组/店铺合并 + 逐国 ASIN 行可点击复制) +
|
||||
* 新增 ASIN 弹窗(分组→店铺联动→国家→ASIN) + 行内「配置」抽屉 + 导入添加/删除导入/导出。 */
|
||||
import { computed, 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 CopyText from '@/components/CopyText.vue'
|
||||
import { createQueryAsin, fetchQueryAsinList, fetchShopNamesByGroup } from './query-asin-api.ts'
|
||||
import { QUERY_ASIN_COUNTRIES, queryAsinDisplayRows, 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'
|
||||
@@ -22,6 +23,17 @@ const pageSize = 15
|
||||
const groups = ref<ShopGroupOption[]>([])
|
||||
const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
|
||||
|
||||
/** 纵向展示行:序号/分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderQueryAsinRows)。 */
|
||||
const displayRows = computed(() => queryAsinDisplayRows(rows.value, (page.value - 1) * pageSize + 1))
|
||||
|
||||
/** rowspan 合并:序号(0)/分组(1)/店铺(2)/操作(5) 仅首行占位,其余合并。 */
|
||||
function spanMethod({ row, columnIndex }: { row: { isFirst: boolean; rowspan: number }; columnIndex: number }): [number, number] {
|
||||
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 2 || columnIndex === 5) {
|
||||
return row.isFirst ? [row.rowspan, 1] : [0, 0]
|
||||
}
|
||||
return [1, 1]
|
||||
}
|
||||
|
||||
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] || ''
|
||||
@@ -78,6 +90,84 @@ async function saveConfig(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 新增 ASIN 弹窗(对齐 admin.js btnOpenCreateQueryAsin/btnCreateQueryAsin) ----
|
||||
const createVisible = ref(false)
|
||||
const createGroupId = ref<number | null>(null)
|
||||
const createShopName = ref('')
|
||||
const createCountry = ref('')
|
||||
const createAsin = 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 = ''
|
||||
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()
|
||||
if (!groupId || !shopName || !country || !asin) {
|
||||
createMsg.value = '请完整填写分组、店铺名、国家和 ASIN'
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const message = await createQueryAsin({
|
||||
groupId,
|
||||
shopName,
|
||||
countries: [country],
|
||||
asin,
|
||||
asinMappings: { [country]: asin },
|
||||
})
|
||||
// 对齐 admin:保留分组/店铺/国家,清空 ASIN 方便连续录入,成功后刷新列表。
|
||||
createMsg.value = message
|
||||
createMsgOk.value = true
|
||||
createAsin.value = ''
|
||||
createAsinInputRef.value?.focus()
|
||||
await load()
|
||||
} catch (error) {
|
||||
createMsg.value = error instanceof Error ? error.message : '保存失败'
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 导入添加 / 删除导入 ----
|
||||
type ImportMode = 'add' | 'delete'
|
||||
const importVisible = ref(false)
|
||||
@@ -233,7 +323,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>
|
||||
@@ -248,16 +339,16 @@ onMounted(() => {
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>店铺</label>
|
||||
<el-input v-model="filter.shopName" placeholder="店铺名模糊" clearable @keyup.enter="apply" />
|
||||
<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" />
|
||||
<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-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>
|
||||
@@ -269,21 +360,31 @@ 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="120" />
|
||||
<el-table-column v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="`${asinCountryLabel(code)} ASIN`" min-width="150">
|
||||
<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 }">
|
||||
<span v-if="asinOf(row as QueryAsinItem, code)" class="asin-cell">{{ asinOf(row as QueryAsinItem, code) }}</span>
|
||||
<span v-else class="dim">—</span>
|
||||
<CopyText v-if="row.asin" :text="row.asin" class="asin-cell" />
|
||||
<span v-else class="dim">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updatedAt" label="更新时间" min-width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
|
||||
<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="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openConfig(row as QueryAsinItem)">配置</el-button>
|
||||
<el-button v-if="row.isFirst" link type="primary" @click="openConfig(row.item as QueryAsinItem)">配置</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -300,6 +401,41 @@ 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 QUERY_ASIN_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-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(520px, 92%)">
|
||||
<p class="drawer-tip">留空表示删除该站点已有的 ASIN 与记录。</p>
|
||||
<el-form label-position="top">
|
||||
@@ -342,7 +478,7 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
||||
.heading-actions { display: flex; gap: 8px; flex: none; }
|
||||
.heading-actions { display: flex; gap: 8px; flex: none; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.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; }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** 查询 ASIN 列表查询适配(任务 71):GET /api/admin/query-asins + 分页/筛选归一与 camel 序列化。 */
|
||||
import { http } from '@/api/http'
|
||||
import { unwrap } from '@/api/envelope'
|
||||
import { fetchShopManageList } from '../shop/shop-manage-api.ts'
|
||||
import { parseQueryAsinPage, type QueryAsinPageResult } from './query-asin-model'
|
||||
import {
|
||||
normalizeQueryAsinParams,
|
||||
@@ -14,3 +16,38 @@ export async function fetchQueryAsinList(params: Partial<QueryAsinListParams> =
|
||||
const { data } = await http.get<unknown>(QUERY_ASIN_ENDPOINT, { params: toQueryAsinQuery(normalized) })
|
||||
return parseQueryAsinPage(data)
|
||||
}
|
||||
|
||||
/** 新增/覆盖查询 ASIN 载荷(对齐 Java QueryAsinCreateRequest camelCase)。 */
|
||||
export interface QueryAsinCreatePayload {
|
||||
groupId: number
|
||||
shopName: string
|
||||
countries: string[]
|
||||
asin: string
|
||||
asinMappings: Record<string, string>
|
||||
}
|
||||
|
||||
/** 新增或覆盖查询 ASIN:POST /api/admin/query-asins,返回后端成功消息。 */
|
||||
export async function createQueryAsin(payload: QueryAsinCreatePayload): Promise<string> {
|
||||
const { data } = await http.post<unknown>(QUERY_ASIN_ENDPOINT, payload)
|
||||
const record = unwrap<unknown>(data) as Record<string, unknown> | null | undefined
|
||||
const message = record && typeof record.message === 'string' ? record.message : ''
|
||||
return message || '保存成功'
|
||||
}
|
||||
|
||||
/** 按分组加载店铺名列表(对齐 admin.js loadAsinShopNameOptions:100/页翻页聚合去重,上限 20 页)。 */
|
||||
export async function fetchShopNamesByGroup(groupId: number): Promise<string[]> {
|
||||
const names: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (let page = 1; page <= 20; page += 1) {
|
||||
const result = await fetchShopManageList({ page, pageSize: 100, groupId })
|
||||
for (const shop of result.items) {
|
||||
const name = (shop.shopName || '').trim()
|
||||
if (name && !seen.has(name)) {
|
||||
seen.add(name)
|
||||
names.push(name)
|
||||
}
|
||||
}
|
||||
if (page * result.pageSize >= result.total) break
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -98,3 +98,51 @@ export function asinForCountry(item: QueryAsinItem, country: string): string {
|
||||
export function queryAsinItemHasAnyAsin(item: QueryAsinItem): boolean {
|
||||
return QUERY_ASIN_COUNTRIES.some((code) => asinForCountry(item, code) !== '')
|
||||
}
|
||||
|
||||
/** 纵向行条目(对齐 admin.js renderQueryAsinEntries):按 DE/UK/FR/IT/ES 顺序收集有值国家;全空补一条空条目。 */
|
||||
export interface QueryAsinRowEntry {
|
||||
country: QueryAsinCountry | null
|
||||
asin: string
|
||||
}
|
||||
|
||||
export function queryAsinRowEntries(item: QueryAsinItem): QueryAsinRowEntry[] {
|
||||
const entries: QueryAsinRowEntry[] = []
|
||||
for (const code of QUERY_ASIN_COUNTRIES) {
|
||||
const asin = asinForCountry(item, code)
|
||||
if (asin) entries.push({ country: code, asin })
|
||||
}
|
||||
if (!entries.length) entries.push({ country: null, asin: '' })
|
||||
return entries
|
||||
}
|
||||
|
||||
/** 纵向展示行(对齐 admin.js renderQueryAsinRows):序号/分组/店铺/操作 按 rowspan 合并,逐国一行。 */
|
||||
export interface QueryAsinDisplayRow {
|
||||
key: string
|
||||
rowNo: number
|
||||
rowspan: number
|
||||
isFirst: boolean
|
||||
country: QueryAsinCountry | null
|
||||
asin: string
|
||||
item: QueryAsinItem
|
||||
}
|
||||
|
||||
export function queryAsinDisplayRows(items: QueryAsinItem[], startRowNo: number): QueryAsinDisplayRow[] {
|
||||
const out: QueryAsinDisplayRow[] = []
|
||||
let rowNo = Math.max(Math.floor(startRowNo || 1), 1)
|
||||
for (const item of items) {
|
||||
const entries = queryAsinRowEntries(item)
|
||||
entries.forEach((entry, index) => {
|
||||
out.push({
|
||||
key: `${item.id}:${index}`,
|
||||
rowNo,
|
||||
rowspan: entries.length,
|
||||
isFirst: index === 0,
|
||||
country: entry.country,
|
||||
asin: entry.asin,
|
||||
item,
|
||||
})
|
||||
})
|
||||
rowNo += 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
import {
|
||||
asinForCountry,
|
||||
queryAsinDisplayRows,
|
||||
queryAsinRowEntries,
|
||||
type QueryAsinItem,
|
||||
} from '../src/pages/asin/query-asin-model.ts'
|
||||
|
||||
/** 对齐 admin.js:5525-5559 renderQueryAsinEntries/renderQueryAsinRows:纵向 rowspan 展示 + 逐国行。 */
|
||||
|
||||
function item(id: number, overrides: Partial<QueryAsinItem> = {}): QueryAsinItem {
|
||||
return {
|
||||
id,
|
||||
groupId: 1,
|
||||
groupName: 'A组',
|
||||
shopName: `店铺${id}`,
|
||||
asinDe: '',
|
||||
asinUk: '',
|
||||
asinFr: '',
|
||||
asinIt: '',
|
||||
asinEs: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test('align_query_asin_row_entries_collects_valued_countries_in_order', () => {
|
||||
const entries = queryAsinRowEntries(item(1, { asinDe: 'B0DE', asinFr: 'B0FR' }))
|
||||
assert.deepEqual(entries, [
|
||||
{ country: 'DE', asin: 'B0DE' },
|
||||
{ country: 'FR', asin: 'B0FR' },
|
||||
])
|
||||
})
|
||||
|
||||
test('align_query_asin_row_entries_empty_shop_yields_single_dash_row', () => {
|
||||
const entries = queryAsinRowEntries(item(2))
|
||||
assert.deepEqual(entries, [{ country: null, asin: '' }])
|
||||
})
|
||||
|
||||
test('align_query_asin_display_rows_merge_rowspan_and_numbering', () => {
|
||||
// 第 2 页(每页 15)第一条 rowNo=16:店铺A 占 2 行合并,店铺B 1 行。
|
||||
const rows = queryAsinDisplayRows(
|
||||
[item(1, { asinDe: 'B0DE', asinUk: 'B0UK' }), item(2)],
|
||||
16,
|
||||
)
|
||||
assert.equal(rows.length, 3)
|
||||
const [r0, r1, r2] = rows
|
||||
assert.equal(r0.rowNo, 16)
|
||||
assert.equal(r0.rowspan, 2)
|
||||
assert.equal(r0.isFirst, true)
|
||||
assert.equal(r0.country, 'DE')
|
||||
assert.equal(r0.asin, 'B0DE')
|
||||
assert.equal(r1.rowNo, 16)
|
||||
assert.equal(r1.isFirst, false)
|
||||
assert.equal(r1.country, 'UK')
|
||||
assert.equal(r2.rowNo, 17)
|
||||
assert.equal(r2.rowspan, 1)
|
||||
assert.equal(r2.isFirst, true)
|
||||
assert.equal(r2.country, null)
|
||||
assert.equal(r2.asin, '')
|
||||
// key 唯一,供 v-for 使用。
|
||||
assert.equal(new Set(rows.map((r) => r.key)).size, rows.length)
|
||||
})
|
||||
|
||||
test('align_query_asin_display_rows_empty_input', () => {
|
||||
assert.deepEqual(queryAsinDisplayRows([], 1), [])
|
||||
})
|
||||
|
||||
test('align_query_asin_create_api_wiring', () => {
|
||||
// 新增适配:POST /api/admin/query-asins,载荷含 groupId/shopName/countries/asinMappings(对齐 Java QueryAsinCreateRequest)。
|
||||
const api = readSource('src/pages/asin/query-asin-api.ts')
|
||||
assert.match(api, /createQueryAsin/, '含新增适配函数')
|
||||
assert.match(api, /query-asins/, '端点正确')
|
||||
assert.match(api, /asinMappings/, '载荷含逐国映射')
|
||||
assert.match(api, /fetchShopManageList/, '店铺名联动复用店铺列表适配')
|
||||
assert.match(api, /page_size|pageSize:\s*100/, '店铺名加载按 100/页分页拉取')
|
||||
})
|
||||
|
||||
test('align_query_asin_page_layout_wiring', () => {
|
||||
const page = readSource('src/pages/asin/QueryAsinPage.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, /queryAsinDisplayRows/, '行展开走纯模型')
|
||||
assert.match(page, /请完整填写分组、店铺名、国家和 ASIN/, '新增弹窗校验文案对齐')
|
||||
assert.match(page, /请先选择分组/, '店铺下拉未选分组时占位对齐')
|
||||
})
|
||||
Reference in New Issue
Block a user