Files
crawler-plugin/admin-frontend-vue/src/pages/asin/SkipPricePage.vue
T
huangzd1997 3f6ad0c6ad feat(后台分组): 分组筛选与分组列仅超管可见,非超管一律隐藏
管理员/普通用户数据已由后端裁剪到本人可访问分组,分组维度对其无意义;
账号归属分组是超管职责,故只有超管需要按分组筛选与区分。

- 隐藏分组筛选+分组列:品牌数据库、查询ASIN、最低价ASIN、去重总数据、
  店铺管理、用户密钥
- 仅隐藏分组展示:店铺数据记录(筛选框+卡片行)、图片视频任务(所属分组行)、
  撞款检测(明细抽屉分组列+卡片分组标签)
- 弹窗分组选择保留展示但锁定:非超管仅1个可访问分组时自动选中并置灰
  (查询/最低价ASIN 另联动加载该分组店铺),多分组不锁以免限制用户
- 空数据行 colspan 随列显隐动态化
- 新增 tests/align-group-visibility.test.ts 回归守卫(5 条)
2026-09-13 14:00:19 +08:00

869 lines
28 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-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>