Files
crawler-plugin/admin-frontend-vue/src/pages/asin/QueryAsinPage.vue
T

747 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/** 查询 ASIN · 像素复刻旧版 admin.html panel-query-asin(自绘:面板头操作组、筛选+导出 XLSX、原生 rowspan 表格+可复制 ASIN、旧式分页)。
* script 逻辑沿用现有 Vue 实现(新增级联/配置抽屉/导入轮询/导出)。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
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'
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 = ref(15)
const groups = ref<ShopGroupOption[]>([])
const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderQueryAsinRows)。 */
const displayRows = computed(() => queryAsinDisplayRows(rows.value, (page.value - 1) * pageSize.value + 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] || ''
}
// ---- 行内配置抽屉 ----
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
}
}
// ---- 新增 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)
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') {
if (!window.confirm('确定按 Excel 中的分组、店铺名和国家 ASIN 批量删除吗?')) 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: pageSize.value,
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 changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
void load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(Math.min(Math.max(n, 1), totalPages.value))
}
onMounted(() => {
void loadGroups()
void load()
})
</script>
<template>
<div class="query-view">
<section class="panel-box">
<div class="query-head">
<h3>店铺列表</h3>
<div class="query-head-actions">
<button class="btn" type="button" @click="openCreate">新增 ASIN</button>
<button class="btn btn-secondary" type="button" @click="openImportAdd">导入添加</button>
<button class="btn" type="button" @click="openImportDelete">导入删除</button>
</div>
</div>
<div class="form-row query-filter-row">
<div class="form-group">
<label>分组</label>
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</div>
<div class="form-group">
<label>店铺名</label>
<input v-model="filter.shopName" type="text" placeholder="请输入店铺名" @keyup.enter="apply" />
</div>
<div class="form-group">
<label>国家</label>
<select v-model="filter.country">
<option value="">全部国家</option>
<option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
</select>
</div>
<div class="form-group">
<label>ASIN</label>
<input v-model="filter.asin" type="text" placeholder="请输入 ASIN" @keyup.enter="apply" />
</div>
<button class="btn" type="button" @click="apply">查询</button>
<button class="btn btn-secondary" type="button" @click="doExport">导出 XLSX</button>
</div>
<div class="table-scroll query-asin-table-scroll">
<table>
<thead>
<tr>
<th style="width: 6%">序号</th>
<th style="width: 13%">分组</th>
<th style="width: 15%">店铺名</th>
<th style="width: 24%">ASIN</th>
<th style="width: 12%">国家</th>
<th style="width: 9%">操作</th>
</tr>
</thead>
<tbody>
<template v-if="displayRows.length">
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
<template v-if="row.isFirst">
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
<td :rowspan="row.rowspan">{{ row.item.groupName || '—' }}</td>
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
</template>
<td>
<template v-if="row.asin">
<CopyText :text="row.asin" class="asin-cell" />
</template>
<span v-else class="asin-empty">-</span>
</td>
<td>{{ asinCountryLabel(row.country || '') }}</td>
<template v-if="row.isFirst">
<td :rowspan="row.rowspan" class="asin-col-actions">
<button class="btn btn-sm" type="button" @click="openConfig(row.item as QueryAsinItem)">配置</button>
</td>
</template>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="6" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="6" class="empty-tip">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<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 filterable 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">
<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 filterable 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>
/* 像素复刻旧版 admin.html panel-query-asin(蓝白末层)。 */
.query-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.query-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.query-head-actions {
display: flex;
gap: 10px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 12px;
margin-bottom: 16px;
}
.query-filter-row > .form-group {
flex: 1 1 140px;
min-width: 0;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.query-asin-table-scroll > table {
min-width: 860px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.table-scroll td {
vertical-align: top;
}
.asin-cell {
display: block;
max-width: 100%;
margin: -3px 0 -3px -7px;
padding: 3px 7px;
border: 1px solid transparent;
border-radius: 9px;
background: none;
color: inherit;
font-size: 13.5px;
font-variant-numeric: tabular-nums;
line-height: 1.5;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.asin-cell:hover {
border-color: #c7d7e5;
background: #eef4fa;
color: #2f5d8b;
}
.asin-empty {
color: #8b9aaa;
}
.asin-col-actions {
text-align: right;
background: #ffffff;
box-shadow: -9px 0 12px -10px rgba(31, 48, 66, 0.45);
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.drawer-tip {
color: #5b6f83;
font-size: 12px;
margin: 0 0 12px;
}
.import-tip {
margin: 6px 0 0;
color: #5b6f83;
font-size: 12px;
}
</style>