3f6ad0c6ad
管理员/普通用户数据已由后端裁剪到本人可访问分组,分组维度对其无意义; 账号归属分组是超管职责,故只有超管需要按分组筛选与区分。 - 隐藏分组筛选+分组列:品牌数据库、查询ASIN、最低价ASIN、去重总数据、 店铺管理、用户密钥 - 仅隐藏分组展示:店铺数据记录(筛选框+卡片行)、图片视频任务(所属分组行)、 撞款检测(明细抽屉分组列+卡片分组标签) - 弹窗分组选择保留展示但锁定:非超管仅1个可访问分组时自动选中并置灰 (查询/最低价ASIN 另联动加载该分组店铺),多分组不锁以免限制用户 - 空数据行 colspan 随列显隐动态化 - 新增 tests/align-group-visibility.test.ts 回归守卫(5 条)
869 lines
28 KiB
Vue
869 lines
28 KiB
Vue
<script setup lang="ts">
|
||
/** 最低价 ASIN(跳过跟价)· 像素复刻旧版 admin.html panel-skip-price-asin(自绘:面板头操作组、筛选(分组/店铺/国家/ASIN/最低价≥≤)+导出 XLSX、原生 rowspan 表格、旧式分页)。
|
||
* script 逻辑沿用现有 Vue 实现(新增级联+最低价、配置抽屉双列、导入轮询、导出)。 */
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
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 { deleteSkipPriceAsin, 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'
|
||
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<SkipPriceItem[]>([])
|
||
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<SkipPriceFilterState>(createSkipPriceFilterState())
|
||
const jumpPage = ref('')
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||
|
||
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
|
||
}
|
||
|
||
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderSkipPriceAsinRows)。 */
|
||
const displayRows = computed(() => skipPriceDisplayRows(rows.value, (page.value - 1) * pageSize.value + 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 = lockedGroupId.value
|
||
createShopName.value = ''
|
||
createCountry.value = ''
|
||
createAsin.value = ''
|
||
createMinimumPrice.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()
|
||
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)
|
||
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
|
||
}
|
||
}
|
||
|
||
// ---- 整条删除 ----
|
||
async function removeRow(item: SkipPriceItem): Promise<void> {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确定删除「${item.shopName}」${item.groupName ? `(${item.groupName})` : ''}全部站点的最低价 ASIN 配置吗?`,
|
||
'删除确认',
|
||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||
)
|
||
} catch {
|
||
return
|
||
}
|
||
try {
|
||
await deleteSkipPriceAsin(item.id)
|
||
ElMessage.success('删除成功')
|
||
// 删除后当前页若已空且非首页,回退一页避免展示空页。
|
||
if (rows.value.length === 1 && page.value > 1) {
|
||
page.value -= 1
|
||
}
|
||
await load()
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||
}
|
||
}
|
||
|
||
// ---- 导入添加 / 删除导入(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 = 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> {
|
||
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') {
|
||
if (!window.confirm('确定按 Excel 中的删除ASIN批量删除该店铺跳过跟价 ASIN 吗?')) 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.value))
|
||
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="skip-view">
|
||
<section class="panel-box">
|
||
<div class="skip-head">
|
||
<h3>店铺列表</h3>
|
||
<div class="skip-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 skip-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 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>
|
||
<div class="form-group price-range">
|
||
<label>最低价范围</label>
|
||
<div class="price-range-row">
|
||
<input v-model="filter.minimumPriceFrom" type="number" min="0" step="0.01" placeholder="最低价 ≥" @keyup.enter="apply" />
|
||
<span class="range-sep">-</span>
|
||
<input v-model="filter.minimumPriceTo" type="number" min="0" step="0.01" placeholder="最高价 ≤" @keyup.enter="apply" />
|
||
</div>
|
||
</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 skip-price-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: 10%">最低价</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>
|
||
<td class="price-cell">{{ row.minimumPrice !== '' ? row.minimumPrice : '-' }}</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 SkipPriceItem)">配置</button>
|
||
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row.item as SkipPriceItem)">删除</button>
|
||
</td>
|
||
</template>
|
||
</tr>
|
||
</template>
|
||
<tr v-else-if="loading">
|
||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">加载中...</td>
|
||
</tr>
|
||
<tr v-else>
|
||
<td :colspan="isSuperAdmin ? 7 : 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%" :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 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">
|
||
<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 filterable v-model="importGroupId" placeholder="请选择分组" 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' ? '与最低价' : '' }}批量{{ 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-skip-price-asin(蓝白末层)。 */
|
||
.skip-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;
|
||
}
|
||
.skip-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.skip-head-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
.form-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: flex-end;
|
||
gap: 14px 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.skip-filter-row > .form-group {
|
||
flex: 1 1 130px;
|
||
min-width: 0;
|
||
}
|
||
.skip-filter-row .form-group.price-range {
|
||
flex: 1 1 220px;
|
||
}
|
||
.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);
|
||
}
|
||
.price-range-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
min-width: 0;
|
||
}
|
||
.price-range-row input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.price-range-row .range-sep {
|
||
color: #8293a5;
|
||
flex: none;
|
||
}
|
||
.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;
|
||
}
|
||
.skip-price-asin-table-scroll > table {
|
||
min-width: 900px;
|
||
}
|
||
.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;
|
||
}
|
||
.price-cell {
|
||
text-align: right;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.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;
|
||
}
|
||
.country-cell {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 10px;
|
||
width: 100%;
|
||
}
|
||
.import-tip {
|
||
margin: 6px 0 0;
|
||
color: #5b6f83;
|
||
font-size: 12px;
|
||
}
|
||
</style>
|