5b8105ec2b
用户管理、菜单管理、不符合ASIN、数据去重总数据、查询ASIN、最低价ASIN、 商品类目、密钥管理共 8 个实体管理列表补齐两列。分组管理/店铺密钥/店铺管理 此前已带创建+修改时间,任务列表与统计报表(撞款监控、密钥用量、日志、 记录与版本)不含实体更新语义,均未改动。 关键点——时间列必须由数据库维护,否则新列是假的: 这些表的更新走 selectById → 改字段 → updateById,实体带着读出的旧 updated_at 一起写回。MySQL 规则是「UPDATE 显式给某列赋值时不触发该列的 ON UPDATE 自动更新」,不禁写就会把旧值写回去,更新时间永远冻结在首次写入 时刻。按 V125(biz_file_result)既有样板,给 7 个实体标注 @TableField(insertStrategy=NEVER, updateStrategy=NEVER)。 - V131:users / columns 补 updated_at(幂等 ADD COLUMN,仿 V125 写法)。 存量行被回填为迁移执行时刻,非真实历史变更时间(历史上无记录,无法还原) - 实体/VO:AdminUserEntity、PermissionMenuEntity、InvalidAsinDataEntity、 DedupeTotalDataEntity、ProductCategoryEntity、QueryAsinEntity、 SkipPriceAsinEntity 加/改写 updatedAt;AdminUserItemVo、PermissionMenuItemVo、 InvalidAsinDataItemVo、DedupeTotalDataItemVo 补 updatedAt; AdminUserSecretRowVo 补 createdAt(行级首次配置时间 = 三模块最早) - 查询ASIN/最低价ASIN 后端 VO 与前端 model 本就有两字段,仅补渲染 - 前端 8 页表格加列,同步修正空态/加载行的 colspan(手写表格,不同步会错位) - 测试:align-query-asin / align-skip-price 原断言「不允许有更新时间列」 (像素复刻旧版),按新需求改为断言两列存在;新增 e2e list-time-columns 覆盖 8 页表头与真实时间值渲染
787 lines
25 KiB
Vue
787 lines
25 KiB
Vue
<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 { formatDateTime } from '@/utils/datetime'
|
||
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 { toQueryAsinExportUrl } from './query-asin-export.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'
|
||
import OldPagination from '@/components/OldPagination.vue'
|
||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||
|
||
const session = useAdminSessionStore()
|
||
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||
|
||
const loading = ref(false)
|
||
const rows = ref<QueryAsinItem[]>([])
|
||
const total = ref(0)
|
||
const page = ref(1)
|
||
const pageSize = ref(10)
|
||
const groups = ref<ShopGroupOption[]>([])
|
||
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
|
||
const lockedGroupId = computed<number | null>(() => {
|
||
if (isSuperAdmin.value) return null
|
||
return groups.value.length === 1 ? groups.value[0].id : null
|
||
})
|
||
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 removeShopAsin(item: QueryAsinItem) {
|
||
// 删除该店铺全部站点的 ASIN 配置(逐国调用现有端点)。
|
||
if (!window.confirm(`确定删除店铺“${item.shopName}”的全部站点 ASIN 配置吗?`)) return
|
||
try {
|
||
for (const country of QUERY_ASIN_COUNTRIES) {
|
||
if (asinOf(item, country)) await deleteQueryAsinCountryAsin(item.id, country)
|
||
}
|
||
ElMessage.success('已删除')
|
||
await load()
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||
}
|
||
}
|
||
|
||
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 = lockedGroupId.value
|
||
createShopName.value = ''
|
||
createCountry.value = ''
|
||
createAsin.value = ''
|
||
createMsg.value = ''
|
||
createMsgOk.value = false
|
||
shopNames.value = []
|
||
createVisible.value = true
|
||
// 非超管分组已锁定:直接联动加载该分组店铺,省去一次无意义的选择。
|
||
if (createGroupId.value != null) void onCreateGroupChange()
|
||
}
|
||
|
||
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 = lockedGroupId.value
|
||
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 {
|
||
// 带当前筛选(分组/店铺名/ASIN/国家)导出,范围与列表筛选一致(此前不带参数导致导出恒为全量)。
|
||
const anchor = document.createElement('a')
|
||
anchor.href = toQueryAsinExportUrl(filter)
|
||
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 v-if="isSuperAdmin" 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 v-if="isSuperAdmin" style="width: 13%">分组</th>
|
||
<th style="width: 15%">店铺名</th>
|
||
<th style="width: 24%">ASIN</th>
|
||
<th style="width: 12%">国家</th>
|
||
<th style="width: 170px">创建时间</th>
|
||
<th style="width: 170px">更新时间</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 v-if="isSuperAdmin" :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">{{ formatDateTime(row.item.createdAt) }}</td>
|
||
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.updatedAt) }}</td>
|
||
<td :rowspan="row.rowspan" class="asin-col-actions">
|
||
<button class="btn btn-sm" type="button" @click="openConfig(row.item as QueryAsinItem)">配置</button>
|
||
<button class="btn btn-sm btn-danger" type="button" @click="removeShopAsin(row.item as QueryAsinItem)">删除</button>
|
||
</td>
|
||
</template>
|
||
</tr>
|
||
</template>
|
||
<tr v-else-if="loading">
|
||
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
|
||
</tr>
|
||
<tr v-else>
|
||
<td :colspan="isSuperAdmin ? 8 : 7" 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%" :disabled="lockedGroupId != null" @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%" :disabled="lockedGroupId != null">
|
||
<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 {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
white-space: nowrap;
|
||
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>
|