feat(web): 内联展示商品详情抽屉 (#186)
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="detailDrawerOpen"
|
||||
title="PDD 商品详情"
|
||||
size="960px"
|
||||
append-to-body
|
||||
:before-close="beforeCloseDetail"
|
||||
>
|
||||
<div v-loading="detail.loading" class="drawer-body">
|
||||
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="notice" role="alert">
|
||||
<template #default><el-button link type="primary" @click="reload">重新加载</el-button></template>
|
||||
</el-alert>
|
||||
<template v-if="detail.product">
|
||||
<div class="drawer-actions">
|
||||
<el-tag :type="statusMeta(detail.product.status).type">{{ statusMeta(detail.product.status).label }}</el-tag>
|
||||
<div><el-button type="primary" @click="openEdit">编辑商品</el-button><el-button v-if="canCreateStockPurchase" type="primary" @click="openStockPurchase">创建备货采购</el-button></div>
|
||||
</div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="goods_id">{{ detail.product.goodsId }}</el-descriptions-item><el-descriptions-item label="店铺">{{ detail.product.shopName || '未填写' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="标题" :span="2">{{ detail.product.title || '未填写' }}</el-descriptions-item><el-descriptions-item label="销量">{{ countText(detail.product.salesCount) }}</el-descriptions-item><el-descriptions-item label="评价数量">{{ countText(detail.product.reviewCount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品链接" :span="2"><a class="link" :href="detail.product.url" target="_blank" rel="noopener noreferrer">{{ detail.product.url }}</a></el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h3 class="section-title">商品规格</h3><el-empty v-if="!detail.product.specs.length" description="尚未维护规格" />
|
||||
<div v-for="dimension in detail.product.specs" :key="dimension.name" class="spec-block">
|
||||
<div class="spec-title">{{ dimension.name }} <el-tag size="small" effect="plain">{{ roleLabel(dimension.role) }}</el-tag></div>
|
||||
<el-table :data="dimension.values" border size="small">
|
||||
<el-table-column v-if="dimension.role === 'color'" label="颜色图片" width="96" align="center"><template #default="{ row }"><el-image v-if="colorImage(row.name)" class="color-thumb" :src="colorImage(row.name).imagePath" :preview-src-list="[colorImage(row.name).imagePath]" :alt="`${row.name}颜色商品图`" fit="cover" lazy preview-teleported /><span v-else class="color-image-placeholder" role="img" :aria-label="`${row.name}暂无图片`">暂无图片</span></template></el-table-column>
|
||||
<el-table-column label="规格值" prop="name" /><el-table-column label="是否可选择" width="120"><template #default="{ row }">{{ row.selectable ? '可选择' : '不可选择' }}</template></el-table-column><el-table-column v-if="dimension.role === 'color'" label="价格" width="130"><template #default="{ row }">{{ priceText(row.priceCent) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<p v-if="dimension.role === 'color'" class="color-image-note">颜色图片为采集到的可选数据,缺失不影响规格与价格结果。</p>
|
||||
</div>
|
||||
<template v-if="canPurchase">
|
||||
<div class="section-heading related-heading"><div><h3>关联商品</h3><p class="muted">为已有 SYB 订单继续采购;与顶部无订单关联的“创建备货采购”不同。</p></div><el-button type="primary" :disabled="relatedPurchaseCandidates.length === 0" @click="openRelatedPurchase">继续订单采购({{ relatedPurchaseCandidates.length }})</el-button></div>
|
||||
<el-empty v-if="!detail.relatedShopee.length" description="尚未关联虾皮商品,请先在虾皮商品页完成 PDD 关联" />
|
||||
<div v-else class="related-summary-list"><el-card v-for="item in detail.relatedShopee" :key="item.shopeeProductId" shadow="never" class="related-summary" @click="filterRelatedShopee(item.shopeeProductId)"><div class="primary">{{ item.shopeeItemId }} · {{ item.title || '未填写标题' }}</div><div class="muted">{{ specMappingText(item) }};待处理订单 {{ item.actionableOrderCount }}</div></el-card></div>
|
||||
<div v-if="detail.relatedShopee.length" class="related-toolbar"><el-select v-model="related.query.shopeeProductId" clearable placeholder="全部虾皮商品" @change="reloadRelated"><el-option v-for="item in detail.relatedShopee" :key="item.shopeeProductId" :label="`${item.shopeeItemId} · ${item.title || '未填写标题'}`" :value="item.shopeeProductId" /></el-select><el-radio-group v-model="related.query.scope" @change="reloadRelated"><el-radio-button value="actionable">待处理订单</el-radio-button><el-radio-button value="all">查看全部</el-radio-button></el-radio-group><span class="muted">已选择 {{ related.selected.length }} 条</span></div>
|
||||
<el-alert v-if="related.error" :title="related.error" type="error" :closable="false" show-icon role="alert"><template #default><el-button link type="primary" @click="loadRelated">重试</el-button></template></el-alert>
|
||||
<el-table v-if="detail.relatedShopee.length" ref="relatedTable" v-loading="related.loading" :data="related.items" row-key="sybProductId" border size="small" empty-text="当前范围没有关联订单" @selection-change="rows => related.selected = rows"><el-table-column type="selection" width="42" :selectable="isRelatedPurchaseCandidate" /><el-table-column label="订单号" prop="orderCode" min-width="130" /><el-table-column label="虾皮商品" min-width="180"><template #default="{ row }"><div>{{ row.shopeeItemId }}</div><div class="muted">{{ row.productTitle }}</div></template></el-table-column><el-table-column label="目标规格 / 数量" min-width="150"><template #default="{ row }">{{ row.targetColor || '—' }} / {{ row.targetSize || '—' }} ×{{ row.quantity }}</template></el-table-column><el-table-column label="处理阶段" min-width="190"><template #default="{ row }"><el-tag :type="row.processStage === 'purchase_ready' ? 'success' : 'warning'">{{ row.processStageLabel }}</el-tag><div class="muted">{{ row.processStageReason }}</div></template></el-table-column></el-table>
|
||||
<pagination v-if="related.total > 0" v-model:current-page="related.query.page" v-model:page-size="related.query.pageSize" :total="related.total" @pagination="loadRelated" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="stock.open" title="创建备货采购任务" width="680px" append-to-body :close-on-click-modal="false" :close-on-press-escape="!stock.saving" :show-close="!stock.saving" @closed="resetStockPurchase">
|
||||
<el-alert title="所选规格来自该商品最新采集结果,不支持自由输入。" type="info" :closable="false" show-icon class="notice" /><el-alert v-if="stock.error" :title="stock.error" type="error" :closable="false" show-icon class="notice" role="alert" />
|
||||
<el-form ref="stockForm" :model="stockData" :rules="stockRules" label-position="top"><div class="form-grid"><el-form-item label="颜色" prop="color"><el-select v-model="stockData.color" style="width:100%" placeholder="请选择颜色" @change="onStockColorChange"><el-option v-for="item in stockColorOptions" :key="item.name" :label="`${item.name} · ${priceText(item.priceCent)}`" :value="item.name" /></el-select></el-form-item><el-form-item v-if="stockRequiresSize" label="尺码" prop="size"><el-select v-model="stockData.size" style="width:100%" placeholder="请选择尺码"><el-option v-for="item in stockSizeOptions" :key="item.name" :label="item.name" :value="item.name" /></el-select></el-form-item><el-form-item label="数量" prop="quantity"><el-input-number v-model="stockData.quantity" :min="1" :precision="0" style="width:100%" /></el-form-item><el-form-item label="执行模式" prop="executionMode"><el-radio-group v-model="stockData.executionMode"><el-radio value="live">正式执行</el-radio><el-radio value="rehearsal">演练</el-radio></el-radio-group></el-form-item><el-form-item label="允许单价下限(元)" prop="minPriceYuan"><el-input-number v-model="stockData.minPriceYuan" :min="0" :precision="2" :step="0.01" style="width:100%" /></el-form-item><el-form-item label="允许单价上限(元)" prop="maxPriceYuan"><el-input-number v-model="stockData.maxPriceYuan" :min="0" :precision="2" :step="0.01" style="width:100%" /></el-form-item></div></el-form>
|
||||
<p class="stock-help">颜色切换后,上下限会更新为该颜色的已采集价格;提交前仍可修改。</p><el-alert title="备货采购不参与库存扣减与订单关联。" type="warning" :closable="false" show-icon class="stock-boundary" />
|
||||
<template #footer><el-button :disabled="stock.saving" @click="stock.open=false">取消</el-button><el-button type="primary" :loading="stock.saving" @click="submitStockPurchase">创建任务</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="relatedPurchase.open" title="关联订单继续采购" width="900px" append-to-body :close-on-click-modal="false">
|
||||
<div v-loading="relatedPurchase.loading"><el-alert title="仅为选中的已有 SYB 订单创建采购任务;系统永不支付。" type="warning" :closable="false" show-icon class="notice" /><div class="batch-summary"><el-tag effect="plain">已选择 {{ related.selected.length }} 条,其中 {{ relatedPurchase.items.length }} 条适用于采购</el-tag></div><el-form label-width="110px"><el-form-item label="Android 设备"><el-select v-model="relatedPurchase.deviceId" clearable placeholder="不指定,由空闲设备领取" style="width:360px" @change="refreshRelatedPurchase"><el-option v-for="device in relatedPurchase.devices" :key="device.id" :label="`${device.name} · ${device.model}`" :value="device.id" /></el-select></el-form-item></el-form><el-table :data="relatedPurchase.items" border size="small" max-height="360"><el-table-column label="订单号" prop="orderCode" /><el-table-column label="目标规格 / 数量"><template #default="{ row }">{{ row.targetColor || '—' }} / {{ row.targetSize || '—' }} ×{{ row.quantity }}</template></el-table-column><el-table-column label="预检结果"><template #default="{ row }"><el-tag :type="row.eligible ? 'success' : 'warning'">{{ row.eligible ? '可以创建' : '不可创建' }}</el-tag><div class="muted">{{ row.reason }}</div></template></el-table-column></el-table></div>
|
||||
<template #footer><el-button @click="relatedPurchase.open=false">取消</el-button><el-button type="primary" :loading="relatedPurchase.saving" :disabled="relatedPurchase.eligibleCount === 0" @click="submitRelatedPurchase">创建 {{ relatedPurchase.eligibleCount }} 个采购任务</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="edit.open" title="编辑 PDD 商品" size="820px" append-to-body :close-on-click-modal="false" @closed="onEditClosed">
|
||||
<div v-loading="edit.loading" class="drawer-body"><el-alert title="保存会完整覆盖商品资料;以后采集成功时也会更新这里的最新资料。" type="warning" :closable="false" show-icon class="notice" /><el-form ref="editForm" :model="editData" :rules="urlRules" label-position="top"><el-form-item label="PDD 商品链接" prop="url"><el-input v-model="editData.url" type="textarea" :rows="2" /></el-form-item><div class="form-grid"><el-form-item label="商品标题"><el-input v-model="editData.title" maxlength="500" /></el-form-item><el-form-item label="店铺名"><el-input v-model="editData.shopName" maxlength="255" /></el-form-item><el-form-item label="销量"><el-input-number v-model="editData.salesCount" :min="0" :precision="0" /></el-form-item><el-form-item label="评价数量"><el-input-number v-model="editData.reviewCount" :min="0" :precision="0" /></el-form-item><el-form-item label="商品状态"><el-radio-group v-model="editData.status"><el-radio value="active">正常</el-radio><el-radio value="disabled">已停用</el-radio></el-radio-group></el-form-item></div><div class="section-heading"><h3>规格维度</h3><el-button :icon="Plus" @click="addDimension">添加规格维度</el-button></div><el-empty v-if="!editData.specs.length" description="尚未添加规格,可直接保存基础资料" /><el-card v-for="(dimension, di) in editData.specs" :key="dimension.localId" shadow="never" class="dimension-card"><div class="dimension-header"><el-input v-model="dimension.name" placeholder="例如:颜色分类、尺码" maxlength="120" /><el-select v-model="dimension.role" style="width:130px"><el-option label="颜色" value="color" /><el-option label="尺码" value="size" /><el-option label="其他" value="other" /></el-select><el-button :disabled="di === 0" @click="move(editData.specs, di, -1)">上移</el-button><el-button :disabled="di === editData.specs.length - 1" @click="move(editData.specs, di, 1)">下移</el-button><el-button type="danger" plain @click="editData.specs.splice(di, 1)">删除</el-button></div><el-table :data="dimension.values" border size="small" empty-text="请添加规格值"><el-table-column label="规格值" min-width="180"><template #default="{ row }"><el-input v-model="row.name" maxlength="255" placeholder="规格值名称" /></template></el-table-column><el-table-column label="可选择" width="100" align="center"><template #default="{ row }"><el-switch v-model="row.selectable" /></template></el-table-column><el-table-column v-if="dimension.role === 'color'" label="价格(元)" width="170"><template #default="{ row }"><el-input-number v-model="row.priceYuan" :min="0" :precision="2" :step="0.01" /></template></el-table-column><el-table-column label="排序" width="135"><template #default="{ $index }"><el-button link :disabled="$index === 0" @click="move(dimension.values, $index, -1)">上移</el-button><el-button link :disabled="$index === dimension.values.length - 1" @click="move(dimension.values, $index, 1)">下移</el-button></template></el-table-column><el-table-column label="操作" width="75"><template #default="{ $index }"><el-button type="danger" link @click="dimension.values.splice($index, 1)">删除</el-button></template></el-table-column></el-table><el-button class="add-value" :icon="Plus" @click="addValue(dimension)">添加规格值</el-button></el-card></el-form></div>
|
||||
<template #footer><el-button @click="edit.open=false">取消</el-button><el-button type="primary" :loading="edit.saving" @click="saveEdit">保存修改</el-button></template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { getPddProduct, listRelatedSybProducts, updatePddProduct } from '@/api/goauto/pdd-products'
|
||||
import { listDevices } from '@/api/goauto/devices'
|
||||
import { createPurchaseTasksBatch, createStockPurchaseTask, previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
|
||||
let uid = 0
|
||||
export default {
|
||||
name: 'PddProductDetailDrawer',
|
||||
props: {
|
||||
modelValue: { type: Boolean, default: false },
|
||||
productId: { type: Number, default: null },
|
||||
action: { type: String, default: '' }
|
||||
},
|
||||
emits: ['update:modelValue', 'product-updated', 'purchase-created'],
|
||||
setup() { return { Plus } },
|
||||
data() {
|
||||
return {
|
||||
detailDrawerOpen: false,
|
||||
detail: { loading: false, product: null, relatedShopee: [] },
|
||||
loadError: '', requestGeneration: 0, loadedProductId: null, actionAppliedFor: null,
|
||||
related: this.emptyRelated(), relatedPurchase: this.emptyRelatedPurchase(),
|
||||
stock: this.emptyStock(), stockData: this.emptyStockData(),
|
||||
stockRules: { color: [{ required: true, message: '请选择颜色', trigger: 'change' }], size: [{ required: true, message: '请选择尺码', trigger: 'change' }], quantity: [{ required: true, message: '请输入数量', trigger: 'change' }], executionMode: [{ required: true, message: '请选择执行模式', trigger: 'change' }], minPriceYuan: [{ required: true, message: '请填写单价下限', trigger: 'change' }], maxPriceYuan: [{ required: true, message: '请填写单价上限', trigger: 'change' }] },
|
||||
edit: { open: false, loading: false, saving: false, productId: null, saved: false },
|
||||
editData: this.emptyEdit(), urlRules: { url: [{ required: true, message: '请输入 PDD 商品链接', trigger: 'blur' }] }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
|
||||
relatedPurchaseCandidates() { return this.related.selected.filter(row => this.isRelatedPurchaseCandidate(row)) },
|
||||
stockColorOptions() { return this.stockSpecValues('color').filter(item => item.priceCent !== null && item.priceCent !== undefined && item.priceCent >= 0) },
|
||||
stockSizeOptions() { return this.stockSpecValues('size') },
|
||||
stockRequiresSize() { return (this.detail.product?.specs || []).some(item => item.role === 'size') },
|
||||
canCreateStockPurchase() { return this.canPurchase && this.detail.product?.status === 'active' && this.stockColorOptions.length > 0 && (!this.stockRequiresSize || this.stockSizeOptions.length > 0) }
|
||||
},
|
||||
watch: {
|
||||
modelValue: { immediate: true, handler(open) { if (open) this.requestOpen(this.productId); else this.closeInternals() } },
|
||||
productId(next, previous) { if (this.modelValue && next !== previous) this.requestOpen(next) }
|
||||
},
|
||||
methods: {
|
||||
emptyRelated() { return { loading: false, error: '', items: [], selected: [], total: 0, query: { page: 1, pageSize: 20, scope: 'actionable', shopeeProductId: null }} },
|
||||
emptyRelatedPurchase() { return { open: false, loading: false, saving: false, ids: [], deviceId: null, devices: [], items: [], eligibleCount: 0 } },
|
||||
emptyStock() { return { open: false, saving: false, error: '' } },
|
||||
emptyStockData() { return { color: '', size: '', quantity: 1, minPriceYuan: null, maxPriceYuan: null, executionMode: 'live' } },
|
||||
emptyEdit() { return { url: '', title: '', shopName: '', salesCount: null, reviewCount: null, status: 'active', specs: [] } },
|
||||
validProductId(id) { return Number.isInteger(Number(id)) && Number(id) > 0 },
|
||||
isCurrent(generation, id) { return this.modelValue && generation === this.requestGeneration && Number(this.productId) === Number(id) },
|
||||
async requestOpen(id) {
|
||||
if (!this.validProductId(id)) { this.loadError = 'PDD 商品编号无效'; this.detailDrawerOpen = true; return }
|
||||
if (this.loadedProductId === Number(id) && this.detail.product) { this.detailDrawerOpen = true; await this.applyInitialAction(id); return }
|
||||
await this.loadProduct(Number(id))
|
||||
},
|
||||
async loadProduct(id) {
|
||||
const generation = ++this.requestGeneration
|
||||
this.loadedProductId = null; this.detailDrawerOpen = true; this.detail = { loading: true, product: null, relatedShopee: [] }; this.related = this.emptyRelated(); this.loadError = ''
|
||||
try {
|
||||
const response = await getPddProduct(id)
|
||||
if (!this.isCurrent(generation, id)) return
|
||||
this.detail.product = response.data.product; this.detail.relatedShopee = response.data.relatedShopeeProducts || []; this.loadedProductId = id
|
||||
await this.loadRelated(generation, id)
|
||||
if (this.isCurrent(generation, id)) await this.applyInitialAction(id)
|
||||
} catch (error) {
|
||||
if (!this.isCurrent(generation, id)) return
|
||||
this.loadError = error?.response?.data?.message || error?.response?.data?.msg || 'PDD 商品详情加载失败,请稍后重试'
|
||||
} finally {
|
||||
if (this.isCurrent(generation, id)) this.detail.loading = false
|
||||
}
|
||||
},
|
||||
reload() { return this.loadProduct(Number(this.productId)) },
|
||||
applyInitialAction(id) { if (this.action !== 'edit' || this.actionAppliedFor === Number(id)) return; this.actionAppliedFor = Number(id); return this.openEdit() },
|
||||
closeInternals() { this.requestGeneration++; this.actionAppliedFor = null; this.detailDrawerOpen = false; this.stock.open = false; this.relatedPurchase.open = false; this.edit.open = false },
|
||||
closeWorkflow() { this.requestGeneration++; this.detailDrawerOpen = false; this.$emit('update:modelValue', false) },
|
||||
beforeCloseDetail(done) { done(); this.closeWorkflow() },
|
||||
isRelatedPurchaseCandidate(row) { return row.eligible === true && row.processStage === 'purchase_ready' },
|
||||
specMappingText(item) { return item.specMappingStatus === 'not_required' ? '无需规格映射' : item.specMappingReason },
|
||||
filterRelatedShopee(id) { this.related.query.shopeeProductId = id; this.reloadRelated() },
|
||||
reloadRelated() { this.related.query.page = 1; this.loadRelated(this.requestGeneration, this.loadedProductId) },
|
||||
async loadRelated(generation = this.requestGeneration, id = this.loadedProductId) { if (!this.detail.product || !this.canPurchase) return; this.related.loading = true; this.related.error = ''; this.related.selected = []; this.$refs.relatedTable?.clearSelection(); try { const r = await listRelatedSybProducts(id, this.related.query); if (!this.isCurrent(generation, id)) return; this.related.items = r.data.items; this.related.total = r.data.total } catch (error) { if (!this.isCurrent(generation, id)) return; this.related.items = []; this.related.total = 0; this.related.error = error?.response?.data?.message || '关联订单加载失败' } finally { if (this.isCurrent(generation, id)) this.related.loading = false } },
|
||||
stockSpecValues(role) { return (this.detail.product?.specs || []).filter(item => item.role === role).flatMap(item => item.values || []).filter(item => item.selectable && String(item.name || '').trim()).map(item => ({ ...item, name: String(item.name).trim() })) },
|
||||
statusMeta(status) { return { pending: { label: '待完善', type: 'warning' }, active: { label: '正常', type: 'success' }, disabled: { label: '已停用', type: 'info' }}[status] || { label: status || '-', type: 'info' } },
|
||||
countText(value) { return value === null || value === undefined ? '-' : Number(value).toLocaleString('zh-CN') },
|
||||
roleLabel(role) { return { color: '颜色', size: '尺码', other: '其他' }[role] || role },
|
||||
priceText(value) { return value === null || value === undefined ? '未填写' : `¥${(value / 100).toFixed(2)}` },
|
||||
colorImage(color) { return (this.detail.product?.colorImages || []).find(item => item.color === color) || null },
|
||||
async openRelatedPurchase() { const candidates = this.related.selected.filter(row => this.isRelatedPurchaseCandidate(row)); if (!candidates.length) return; const ids = candidates.map(row => row.sybProductId); this.relatedPurchase = { ...this.emptyRelatedPurchase(), open: true, loading: true, ids }; try { const [preview, devices] = await Promise.all([previewPurchaseTasks({ sybProductIds: ids }), listDevices({ page: 1, pageSize: 100, status: 'online' })]); this.relatedPurchase.items = preview.data.items; this.relatedPurchase.eligibleCount = preview.data.eligibleCount; this.relatedPurchase.devices = devices.data.items.filter(item => item.selectable) } finally { this.relatedPurchase.loading = false } },
|
||||
async refreshRelatedPurchase() { this.relatedPurchase.loading = true; try { const r = await previewPurchaseTasks({ sybProductIds: this.relatedPurchase.ids, deviceId: this.relatedPurchase.deviceId || undefined }); this.relatedPurchase.items = r.data.items; this.relatedPurchase.eligibleCount = r.data.eligibleCount } finally { this.relatedPurchase.loading = false } },
|
||||
async submitRelatedPurchase() { this.relatedPurchase.saving = true; try { const r = await createPurchaseTasksBatch({ requestId: createRequestId(), sybProductIds: this.relatedPurchase.ids, deviceId: this.relatedPurchase.deviceId || undefined }); const failed = r.data.failedCount || 0; if (failed) ElMessage.warning(`已创建 ${r.data.createdCount} 个,${failed} 个未创建`); else ElMessage.success(`已创建 ${r.data.createdCount} 个采购任务`); this.relatedPurchase.open = false; this.$emit('purchase-created', r.data); await this.loadRelated() } finally { this.relatedPurchase.saving = false } },
|
||||
openStockPurchase() { if (!this.canCreateStockPurchase) return; const color = this.stockColorOptions[0]; const price = color.priceCent / 100; this.stock = { open: true, saving: false, error: '' }; this.stockData = { color: color.name, size: this.stockSizeOptions[0]?.name || '', quantity: 1, minPriceYuan: price, maxPriceYuan: price, executionMode: 'live' }; this.$nextTick(() => this.$refs.stockForm?.clearValidate()) },
|
||||
resetStockPurchase() { this.stock = this.emptyStock(); this.stockData = this.emptyStockData() },
|
||||
onStockColorChange(name) { const color = this.stockColorOptions.find(item => item.name === name); if (!color) return; const price = color.priceCent / 100; this.stockData.minPriceYuan = price; this.stockData.maxPriceYuan = price; this.stock.error = '' },
|
||||
async submitStockPurchase() { if (!await this.$refs.stockForm.validate().catch(() => false)) return; const minUnitPriceCent = Math.round(this.stockData.minPriceYuan * 100); const maxUnitPriceCent = Math.round(this.stockData.maxPriceYuan * 100); if (minUnitPriceCent < 0 || maxUnitPriceCent < minUnitPriceCent) { this.stock.error = '允许单价上限不能低于下限'; return } this.stock.saving = true; this.stock.error = ''; try { const response = await createStockPurchaseTask({ requestId: createRequestId(), executionMode: this.stockData.executionMode, pddProductId: this.detail.product.id, color: this.stockData.color, size: this.stockData.size || undefined, quantity: this.stockData.quantity, minUnitPriceCent, maxUnitPriceCent }, { suppressErrorMessage: true }); ElMessage.success('备货采购任务已创建'); this.stock.open = false; this.$emit('purchase-created', response.data); this.closeWorkflow(); await this.$router.push({ path: '/purchase-tasks/index', query: { taskId: response.data.id }}) } catch (error) { this.stock.error = error?.response?.data?.message || error?.response?.data?.msg || error?.message || '创建失败,请稍后重试' } finally { this.stock.saving = false } },
|
||||
async openEdit() { const id = this.detail.product.id; this.detailDrawerOpen = false; this.edit = { open: true, loading: true, saving: false, productId: id, saved: false }; try { const r = await getPddProduct(id); if (!this.modelValue || this.edit.productId !== id) return; const p = r.data.product; this.editData = { url: p.url, title: p.title || '', shopName: p.shopName || '', salesCount: p.salesCount, reviewCount: p.reviewCount, status: p.status === 'disabled' ? 'disabled' : 'active', specs: (p.specs || []).map(d => ({ ...d, localId: ++uid, values: d.values.map(v => ({ ...v, localId: ++uid, priceYuan: v.priceCent === null || v.priceCent === undefined ? null : v.priceCent / 100 })) })) } } finally { this.edit.loading = false } },
|
||||
onEditClosed() { if (this.edit.saved) { this.edit.saved = false; return } if (this.modelValue && !this.detailDrawerOpen) this.closeWorkflow() },
|
||||
addDimension() { this.editData.specs.push({ localId: ++uid, name: '', role: 'other', values: [] }) },
|
||||
addValue(dimension) { dimension.values.push({ localId: ++uid, name: '', selectable: true, priceYuan: null }) },
|
||||
move(items, index, offset) { const target = index + offset; if (target < 0 || target >= items.length) return; const [item] = items.splice(index, 1); items.splice(target, 0, item) },
|
||||
specError() { const dimensions = new Set(); for (const d of this.editData.specs) { const name = d.name.trim(); if (!name) return '请填写规格维度名称'; if (dimensions.has(name)) return `规格维度“${name}”重复`; dimensions.add(name); if (!d.values.length) return `请为“${name}”添加规格值`; const values = new Set(); for (const v of d.values) { const n = v.name.trim(); if (!n) return `请填写“${name}”中的规格值名称`; if (values.has(n)) return `“${name}”中存在重复规格值“${n}”`; values.add(n) } } return '' },
|
||||
async saveEdit() { if (!await this.$refs.editForm.validate().catch(() => false)) return; const error = this.specError(); if (error) { ElMessage.warning(error); return } this.edit.saving = true; const id = this.edit.productId; try { const data = this.editData; await updatePddProduct(id, { requestId: createRequestId(), url: data.url.trim(), title: data.title.trim(), shopName: data.shopName.trim(), salesCount: data.salesCount, reviewCount: data.reviewCount, status: data.status, specs: data.specs.map(d => ({ name: d.name.trim(), role: d.role, values: d.values.map(v => ({ name: v.name.trim(), selectable: v.selectable, ...(d.role === 'color' && v.priceYuan !== null ? { priceCent: Math.round(v.priceYuan * 100) } : {}) })) })) }); ElMessage.success('PDD 商品资料已保存'); this.edit.saved = true; this.edit.open = false; this.loadedProductId = null; await this.loadProduct(id); this.$emit('product-updated', { id, product: this.detail.product }) } finally { this.edit.saving = false } }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drawer-body{padding:0 4px 24px}.drawer-actions,.section-heading,.dimension-header{display:flex;align-items:center;gap:12px}.drawer-actions,.section-heading{justify-content:space-between}.drawer-actions>div{display:flex;gap:8px}.section-title{margin:28px 0 14px}.spec-block{margin-bottom:20px}.spec-title{display:flex;align-items:center;gap:8px;margin-bottom:8px;font-weight:600}.color-thumb,.color-image-placeholder{display:inline-flex;width:64px;height:64px;border-radius:6px}.color-thumb{cursor:zoom-in}.color-image-placeholder{align-items:center;justify-content:center;box-sizing:border-box;border:1px dashed #dcdfe6;background:#f5f7fa;color:#909399;font-size:12px}.color-image-note{margin:8px 0 0;color:#909399;font-size:12px;line-height:1.5}.link{color:#1677ff;overflow-wrap:anywhere}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.stock-help{margin:0 0 14px;color:#606266;font-size:13px;line-height:1.6}.stock-boundary{margin-bottom:0}.section-heading{margin:12px 0}.section-heading h3{margin:0}.dimension-card{margin-bottom:16px}.dimension-header{margin-bottom:14px}.dimension-header .el-input{flex:1}.add-value{margin-top:12px}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.notice{margin-bottom:20px}.batch-summary{display:flex;align-items:center;gap:12px;margin-bottom:16px;color:#606266}.related-summary-list{display:grid;gap:8px}.related-summary{cursor:pointer}.related-toolbar{display:flex;align-items:center;gap:12px;margin:12px 0}@media(max-width:768px){.dimension-header{align-items:stretch;flex-direction:column}.batch-summary,.related-toolbar{align-items:flex-start;flex-direction:column}.form-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
@@ -1,284 +1,47 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
|
||||
<el-form-item><el-button type="primary" :icon="Plus" @click="openCreate">添加</el-button></el-form-item>
|
||||
<el-form-item><el-button type="primary" :disabled="selectedProducts.length === 0" @click="openBatch">创建采集{{ selectedProducts.length ? ` (${selectedProducts.length})` : '' }}</el-button></el-form-item>
|
||||
<el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="goods_id、标题或店铺" clearable @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width: 150px"><el-option label="待完善" value="pending" /><el-option label="正常" value="active" /><el-option label="已停用" value="disabled" /></el-select></el-form-item>
|
||||
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="location.active && location.goodsId" :title="`当前定位:PDD-${location.productId} / goods_id ${location.goodsId}`" type="info" :closable="false" show-icon class="location-notice" />
|
||||
<p class="muted">已选择 {{ selectedProducts.length }} 条;仅选择当前页,每个商品创建一个独立采集任务,不合并、不拆单。</p>
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe :empty-text="location.error || '暂无 PDD 商品,请先添加商品链接'" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="48" :selectable="isProductSelectable" />
|
||||
<el-table-column label="goods_id" prop="goodsId" min-width="150" />
|
||||
<el-table-column label="商品 / 店铺" min-width="240"><template #default="{ row }"><div class="primary">{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '尚未填写店铺' }}</div></template></el-table-column>
|
||||
<el-table-column label="状态 / 采集任务" min-width="190"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag><div :class="row.collectionSelectable ? 'collection-ready' : 'muted'">{{ collectionStateText(row) }}</div></template></el-table-column>
|
||||
<el-table-column label="颜色图" width="100" align="center"><template #default="{ row }"><el-image v-if="row.colorImages?.[0]?.imagePath" class="list-color-thumb" :src="row.colorImages[0].imagePath" :preview-src-list="[row.colorImages[0].imagePath]" :alt="`${row.colorImages[0].color || ''}颜色图,单击或双击查看大图`" fit="cover" lazy preview-teleported><template #error><span class="list-color-placeholder">暂无图片</span></template></el-image><span v-else class="list-color-placeholder">暂无图片</span></template></el-table-column>
|
||||
<el-table-column label="规格" min-width="180"><template #default="{ row }">{{ specsSummary(row.specs) }}</template></el-table-column>
|
||||
<el-table-column label="颜色价格" min-width="130"><template #default="{ row }">{{ priceSummary(row.specs) }}</template></el-table-column>
|
||||
<el-table-column label="更新时间" width="170"><template #default="{ row }">{{ parseTime(row.updatedAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">查看</el-button><el-button type="primary" link @click="openEdit(row.id)">编辑</el-button><el-button type="primary" link tag="a" :href="row.url" target="_blank" rel="noopener noreferrer">打开链接</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="createDialog.open" title="添加 PDD 商品" width="640px" :close-on-click-modal="false">
|
||||
<el-alert title="只需填写商品链接。系统会提取 goods_id,新商品先显示为“待完善”。" type="info" :closable="false" show-icon class="notice" />
|
||||
<el-form ref="createForm" :model="createData" :rules="urlRules" label-position="top"><el-form-item label="PDD 商品链接" prop="url"><el-input v-model="createData.url" type="textarea" :rows="4" placeholder="https://mobile.yangkeduo.com/goods.html?goods_id=..." /></el-form-item></el-form>
|
||||
<template #footer><el-button @click="createDialog.open = false">取消</el-button><el-button type="primary" :loading="createDialog.saving" @click="createProduct">添加商品</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="batch.open" :title="batch.step === 'confirm' ? '批量创建采集任务' : '批量创建结果'" width="780px" :close-on-click-modal="false" @closed="resetBatch">
|
||||
<template v-if="batch.step === 'confirm'">
|
||||
<el-alert title="每个 PDD 商品会创建一个独立采集任务,不会创建采购任务或订单。" type="info" :closable="false" show-icon class="notice" />
|
||||
<div class="batch-summary"><el-tag effect="plain">已选择 {{ batch.products.length }} 个商品</el-tag><span>提交前服务端会再次检查,部分商品失败不会影响其他商品。</span></div>
|
||||
<el-form ref="batchForm" :model="batchData" :rules="batchRules" label-position="top" class="batch-form">
|
||||
<el-form-item label="采集规则" prop="ruleId"><el-select v-model="batchData.ruleId" filterable style="width:100%" placeholder="请选择采集规则"><el-option v-for="item in batch.options.rules" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item label="Android 设备"><el-select v-model="batchData.deviceId" clearable filterable style="width:100%" placeholder="不指定,由空闲设备领取"><el-option v-for="item in batch.options.devices" :key="item.id" :disabled="!item.selectable" :label="`${item.name} · ${item.selectable ? '在线空闲' : '不可用'}`" :value="item.id" /></el-select></el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="batch.products" border size="small" max-height="300" empty-text="没有可创建的商品">
|
||||
<el-table-column label="goods_id" prop="goodsId" width="160" />
|
||||
<el-table-column label="商品"><template #default="{ row }">{{ row.title || '资料待完善' }}</template></el-table-column>
|
||||
<el-table-column label="检查结果" width="130"><template #default><el-tag type="success" effect="plain">可创建</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-alert :title="`创建完成:成功 ${batch.successCount} 个,失败 ${batch.failureCount} 个`" :type="batch.failureCount ? 'warning' : 'success'" :closable="false" show-icon class="notice" />
|
||||
<el-table :data="batch.results" border size="small" max-height="360">
|
||||
<el-table-column label="goods_id" prop="goodsId" width="160" />
|
||||
<el-table-column label="商品"><template #default="{ row }">{{ row.title || '资料待完善' }}</template></el-table-column>
|
||||
<el-table-column label="结果" width="110"><template #default="{ row }"><el-tag :type="row.success ? 'success' : 'danger'">{{ row.success ? '已创建' : '未创建' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="任务 / 原因" min-width="230"><template #default="{ row }">{{ row.success ? `采集任务 #${row.taskId}` : row.message }}</template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<template #footer>
|
||||
<template v-if="batch.step === 'confirm'"><el-button @click="batch.open = false">取消</el-button><el-button type="primary" :loading="batch.saving" :disabled="batch.products.length === 0" @click="submitBatch">创建 {{ batch.products.length }} 个采集任务</el-button></template>
|
||||
<el-button v-else type="primary" @click="batch.open = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="detail.open" title="PDD 商品详情" size="960px">
|
||||
<div v-loading="detail.loading" class="drawer-body">
|
||||
<template v-if="detail.product">
|
||||
<div class="drawer-actions"><el-tag :type="statusMeta(detail.product.status).type">{{ statusMeta(detail.product.status).label }}</el-tag><div><el-button type="primary" @click="openEdit(detail.product.id)">编辑商品</el-button><el-button v-if="canCreateStockPurchase" type="primary" @click="openStockPurchase">创建备货采购</el-button></div></div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="goods_id">{{ detail.product.goodsId }}</el-descriptions-item><el-descriptions-item label="店铺">{{ detail.product.shopName || '未填写' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="标题" :span="2">{{ detail.product.title || '未填写' }}</el-descriptions-item><el-descriptions-item label="销量">{{ countText(detail.product.salesCount) }}</el-descriptions-item><el-descriptions-item label="评价数量">{{ countText(detail.product.reviewCount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品链接" :span="2"><a class="link" :href="detail.product.url" target="_blank" rel="noopener noreferrer">{{ detail.product.url }}</a></el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h3 class="section-title">商品规格</h3><el-empty v-if="!detail.product.specs.length" description="尚未维护规格" />
|
||||
<div v-for="dimension in detail.product.specs" :key="dimension.name" class="spec-block"><div class="spec-title">{{ dimension.name }} <el-tag size="small" effect="plain">{{ roleLabel(dimension.role) }}</el-tag></div><el-table :data="dimension.values" border size="small"><el-table-column v-if="dimension.role === 'color'" label="颜色图片" width="96" align="center"><template #default="{ row }"><el-image v-if="colorImage(row.name)" class="color-thumb" :src="colorImage(row.name).imagePath" :preview-src-list="[colorImage(row.name).imagePath]" :alt="`${row.name}颜色商品图`" fit="cover" lazy preview-teleported /><span v-else class="color-image-placeholder" role="img" :aria-label="`${row.name}暂无图片`">暂无图片</span></template></el-table-column><el-table-column label="规格值" prop="name" /><el-table-column label="是否可选择" width="120"><template #default="{ row }">{{ row.selectable ? '可选择' : '不可选择' }}</template></el-table-column><el-table-column v-if="dimension.role === 'color'" label="价格" width="130"><template #default="{ row }">{{ priceText(row.priceCent) }}</template></el-table-column></el-table><p v-if="dimension.role === 'color'" class="color-image-note">颜色图片为采集到的可选数据,缺失不影响规格与价格结果。</p></div>
|
||||
<template v-if="canPurchase">
|
||||
<div class="section-heading related-heading"><div><h3>关联商品</h3><p class="muted">为已有 SYB 订单继续采购;与顶部无订单关联的“创建备货采购”不同。</p></div><el-button type="primary" :disabled="relatedPurchaseCandidates.length === 0" @click="openRelatedPurchase">继续订单采购({{ relatedPurchaseCandidates.length }})</el-button></div>
|
||||
<el-empty v-if="!detail.relatedShopee.length" description="尚未关联虾皮商品,请先在虾皮商品页完成 PDD 关联" />
|
||||
<div v-else class="related-summary-list"><el-card v-for="item in detail.relatedShopee" :key="item.shopeeProductId" shadow="never" class="related-summary" @click="filterRelatedShopee(item.shopeeProductId)"><div class="primary">{{ item.shopeeItemId }} · {{ item.title || '未填写标题' }}</div><div class="muted">{{ specMappingText(item) }};待处理订单 {{ item.actionableOrderCount }}</div></el-card></div>
|
||||
<div v-if="detail.relatedShopee.length" class="related-toolbar"><el-select v-model="related.query.shopeeProductId" clearable placeholder="全部虾皮商品" @change="reloadRelated"><el-option v-for="item in detail.relatedShopee" :key="item.shopeeProductId" :label="`${item.shopeeItemId} · ${item.title || '未填写标题'}`" :value="item.shopeeProductId" /></el-select><el-radio-group v-model="related.query.scope" @change="reloadRelated"><el-radio-button value="actionable">待处理订单</el-radio-button><el-radio-button value="all">查看全部</el-radio-button></el-radio-group><span class="muted">已选择 {{ related.selected.length }} 条</span></div>
|
||||
<el-alert v-if="related.error" :title="related.error" type="error" :closable="false" show-icon><template #default><el-button link type="primary" @click="loadRelated">重试</el-button></template></el-alert>
|
||||
<el-table v-if="detail.relatedShopee.length" ref="relatedTable" v-loading="related.loading" :data="related.items" row-key="sybProductId" border size="small" empty-text="当前范围没有关联订单" @selection-change="rows => related.selected = rows"><el-table-column type="selection" width="42" :selectable="isRelatedPurchaseCandidate" /><el-table-column label="订单号" prop="orderCode" min-width="130" /><el-table-column label="虾皮商品" min-width="180"><template #default="{ row }"><div>{{ row.shopeeItemId }}</div><div class="muted">{{ row.productTitle }}</div></template></el-table-column><el-table-column label="目标规格 / 数量" min-width="150"><template #default="{ row }">{{ row.targetColor || '—' }} / {{ row.targetSize || '—' }} ×{{ row.quantity }}</template></el-table-column><el-table-column label="处理阶段" min-width="190"><template #default="{ row }"><el-tag :type="row.processStage === 'purchase_ready' ? 'success' : 'warning'">{{ row.processStageLabel }}</el-tag><div class="muted">{{ row.processStageReason }}</div></template></el-table-column></el-table>
|
||||
<pagination v-if="related.total > 0" v-model:current-page="related.query.page" v-model:page-size="related.query.pageSize" :total="related.total" @pagination="loadRelated" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="stock.open" title="创建备货采购任务" width="680px" :close-on-click-modal="false" :close-on-press-escape="!stock.saving" :show-close="!stock.saving" @closed="resetStockPurchase">
|
||||
<el-alert title="所选规格来自该商品最新采集结果,不支持自由输入。" type="info" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-if="stock.error" :title="stock.error" type="error" :closable="false" show-icon class="notice" />
|
||||
<el-form ref="stockForm" :model="stockData" :rules="stockRules" label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="颜色" prop="color"><el-select v-model="stockData.color" style="width:100%" placeholder="请选择颜色" @change="onStockColorChange"><el-option v-for="item in stockColorOptions" :key="item.name" :label="`${item.name} · ${priceText(item.priceCent)}`" :value="item.name" /></el-select></el-form-item>
|
||||
<el-form-item v-if="stockRequiresSize" label="尺码" prop="size"><el-select v-model="stockData.size" style="width:100%" placeholder="请选择尺码"><el-option v-for="item in stockSizeOptions" :key="item.name" :label="item.name" :value="item.name" /></el-select></el-form-item>
|
||||
<el-form-item label="数量" prop="quantity"><el-input-number v-model="stockData.quantity" :min="1" :precision="0" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="执行模式" prop="executionMode"><el-radio-group v-model="stockData.executionMode"><el-radio value="live">正式执行</el-radio><el-radio value="rehearsal">演练</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item label="允许单价下限(元)" prop="minPriceYuan"><el-input-number v-model="stockData.minPriceYuan" :min="0" :precision="2" :step="0.01" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="允许单价上限(元)" prop="maxPriceYuan"><el-input-number v-model="stockData.maxPriceYuan" :min="0" :precision="2" :step="0.01" style="width:100%" /></el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<p class="stock-help">颜色切换后,上下限会更新为该颜色的已采集价格;提交前仍可修改。</p>
|
||||
<el-alert title="备货采购不参与库存扣减与订单关联。" type="warning" :closable="false" show-icon class="stock-boundary" />
|
||||
<template #footer><el-button :disabled="stock.saving" @click="stock.open=false">取消</el-button><el-button type="primary" :loading="stock.saving" @click="submitStockPurchase">创建任务</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="relatedPurchase.open" title="关联订单继续采购" width="900px" :close-on-click-modal="false">
|
||||
<div v-loading="relatedPurchase.loading"><el-alert title="仅为选中的已有 SYB 订单创建采购任务;系统永不支付。" type="warning" :closable="false" show-icon class="notice" /><div class="batch-summary"><el-tag effect="plain">已选择 {{ related.selected.length }} 条,其中 {{ relatedPurchase.items.length }} 条适用于采购</el-tag></div><el-form label-width="110px"><el-form-item label="Android 设备"><el-select v-model="relatedPurchase.deviceId" clearable placeholder="不指定,由空闲设备领取" style="width:360px" @change="refreshRelatedPurchase"><el-option v-for="device in relatedPurchase.devices" :key="device.id" :label="`${device.name} · ${device.model}`" :value="device.id" /></el-select></el-form-item></el-form><el-table :data="relatedPurchase.items" border size="small" max-height="360"><el-table-column label="订单号" prop="orderCode" /><el-table-column label="目标规格 / 数量"><template #default="{ row }">{{ row.targetColor || '—' }} / {{ row.targetSize || '—' }} ×{{ row.quantity }}</template></el-table-column><el-table-column label="预检结果"><template #default="{ row }"><el-tag :type="row.eligible ? 'success' : 'warning'">{{ row.eligible ? '可以创建' : '不可创建' }}</el-tag><div class="muted">{{ row.reason }}</div></template></el-table-column></el-table></div><template #footer><el-button @click="relatedPurchase.open=false">取消</el-button><el-button type="primary" :loading="relatedPurchase.saving" :disabled="relatedPurchase.eligibleCount === 0" @click="submitRelatedPurchase">创建 {{ relatedPurchase.eligibleCount }} 个采购任务</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="edit.open" title="编辑 PDD 商品" size="820px" :close-on-click-modal="false">
|
||||
<div v-loading="edit.loading" class="drawer-body">
|
||||
<el-alert title="保存会完整覆盖商品资料;以后采集成功时也会更新这里的最新资料。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-form ref="editForm" :model="editData" :rules="urlRules" label-position="top">
|
||||
<el-form-item label="PDD 商品链接" prop="url"><el-input v-model="editData.url" type="textarea" :rows="2" /></el-form-item>
|
||||
<div class="form-grid"><el-form-item label="商品标题"><el-input v-model="editData.title" maxlength="500" /></el-form-item><el-form-item label="店铺名"><el-input v-model="editData.shopName" maxlength="255" /></el-form-item><el-form-item label="销量"><el-input-number v-model="editData.salesCount" :min="0" :precision="0" /></el-form-item><el-form-item label="评价数量"><el-input-number v-model="editData.reviewCount" :min="0" :precision="0" /></el-form-item><el-form-item label="商品状态"><el-radio-group v-model="editData.status"><el-radio value="active">正常</el-radio><el-radio value="disabled">已停用</el-radio></el-radio-group></el-form-item></div>
|
||||
<div class="section-heading"><h3>规格维度</h3><el-button :icon="Plus" @click="addDimension">添加规格维度</el-button></div><el-empty v-if="!editData.specs.length" description="尚未添加规格,可直接保存基础资料" />
|
||||
<el-card v-for="(dimension, di) in editData.specs" :key="dimension.localId" shadow="never" class="dimension-card">
|
||||
<div class="dimension-header"><el-input v-model="dimension.name" placeholder="例如:颜色分类、尺码" maxlength="120" /><el-select v-model="dimension.role" style="width: 130px"><el-option label="颜色" value="color" /><el-option label="尺码" value="size" /><el-option label="其他" value="other" /></el-select><el-button :disabled="di === 0" @click="move(editData.specs, di, -1)">上移</el-button><el-button :disabled="di === editData.specs.length - 1" @click="move(editData.specs, di, 1)">下移</el-button><el-button type="danger" plain @click="editData.specs.splice(di, 1)">删除</el-button></div>
|
||||
<el-table :data="dimension.values" border size="small" empty-text="请添加规格值"><el-table-column label="规格值" min-width="180"><template #default="{ row }"><el-input v-model="row.name" maxlength="255" placeholder="规格值名称" /></template></el-table-column><el-table-column label="可选择" width="100" align="center"><template #default="{ row }"><el-switch v-model="row.selectable" /></template></el-table-column><el-table-column v-if="dimension.role === 'color'" label="价格(元)" width="170"><template #default="{ row }"><el-input-number v-model="row.priceYuan" :min="0" :precision="2" :step="0.01" /></template></el-table-column><el-table-column label="排序" width="135"><template #default="{ $index }"><el-button link :disabled="$index === 0" @click="move(dimension.values, $index, -1)">上移</el-button><el-button link :disabled="$index === dimension.values.length - 1" @click="move(dimension.values, $index, 1)">下移</el-button></template></el-table-column><el-table-column label="操作" width="75"><template #default="{ $index }"><el-button type="danger" link @click="dimension.values.splice($index, 1)">删除</el-button></template></el-table-column></el-table>
|
||||
<el-button class="add-value" :icon="Plus" @click="addValue(dimension)">添加规格值</el-button>
|
||||
</el-card>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer><el-button @click="edit.open = false">取消</el-button><el-button type="primary" :loading="edit.saving" @click="saveEdit">保存修改</el-button></template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
<BasicLayout><template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
|
||||
<el-form-item><el-button type="primary" :icon="Plus" @click="openCreate">添加</el-button></el-form-item><el-form-item><el-button type="primary" :disabled="selectedProducts.length === 0" @click="openBatch">创建采集{{ selectedProducts.length ? ` (${selectedProducts.length})` : '' }}</el-button></el-form-item><el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="goods_id、标题或店铺" clearable @keyup.enter="search" /></el-form-item><el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:150px"><el-option label="待完善" value="pending" /><el-option label="正常" value="active" /><el-option label="已停用" value="disabled" /></el-select></el-form-item><el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="location.active && location.goodsId" :title="`当前定位:PDD-${location.productId} / goods_id ${location.goodsId}`" type="info" :closable="false" show-icon class="location-notice" /><p class="muted">已选择 {{ selectedProducts.length }} 条;仅选择当前页,每个商品创建一个独立采集任务,不合并、不拆单。</p>
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe :empty-text="location.error || '暂无 PDD 商品,请先添加商品链接'" @selection-change="handleSelectionChange"><el-table-column type="selection" width="48" :selectable="isProductSelectable" /><el-table-column label="goods_id" prop="goodsId" min-width="150" /><el-table-column label="商品 / 店铺" min-width="240"><template #default="{ row }"><div class="primary">{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '尚未填写店铺' }}</div></template></el-table-column><el-table-column label="状态 / 采集任务" min-width="190"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag><div :class="row.collectionSelectable ? 'collection-ready' : 'muted'">{{ collectionStateText(row) }}</div></template></el-table-column><el-table-column label="颜色图" width="100" align="center"><template #default="{ row }"><el-image v-if="row.colorImages?.[0]?.imagePath" class="list-color-thumb" :src="row.colorImages[0].imagePath" :preview-src-list="[row.colorImages[0].imagePath]" :alt="`${row.colorImages[0].color || ''}颜色图,单击或双击查看大图`" fit="cover" lazy preview-teleported><template #error><span class="list-color-placeholder">暂无图片</span></template></el-image><span v-else class="list-color-placeholder">暂无图片</span></template></el-table-column><el-table-column label="规格" min-width="180"><template #default="{ row }">{{ specsSummary(row.specs) }}</template></el-table-column><el-table-column label="颜色价格" min-width="130"><template #default="{ row }">{{ priceSummary(row.specs) }}</template></el-table-column><el-table-column label="更新时间" width="170"><template #default="{ row }">{{ parseTime(row.updatedAt) }}</template></el-table-column><el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">查看</el-button><el-button type="primary" link @click="openDetail(row.id, 'edit')">编辑</el-button><el-button type="primary" link tag="a" :href="row.url" target="_blank" rel="noopener noreferrer">打开链接</el-button></template></el-table-column></el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
<el-dialog v-model="createDialog.open" title="添加 PDD 商品" width="640px" :close-on-click-modal="false"><el-alert title="只需填写商品链接。系统会提取 goods_id,新商品先显示为“待完善”。" type="info" :closable="false" show-icon class="notice" /><el-form ref="createForm" :model="createData" :rules="urlRules" label-position="top"><el-form-item label="PDD 商品链接" prop="url"><el-input v-model="createData.url" type="textarea" :rows="4" placeholder="https://mobile.yangkeduo.com/goods.html?goods_id=..." /></el-form-item></el-form><template #footer><el-button @click="createDialog.open=false">取消</el-button><el-button type="primary" :loading="createDialog.saving" @click="createProduct">添加商品</el-button></template></el-dialog>
|
||||
<el-dialog v-model="batch.open" :title="batch.step === 'confirm' ? '批量创建采集任务' : '批量创建结果'" width="780px" :close-on-click-modal="false" @closed="resetBatch"><template v-if="batch.step === 'confirm'"><el-alert title="每个 PDD 商品会创建一个独立采集任务,不会创建采购任务或订单。" type="info" :closable="false" show-icon class="notice" /><div class="batch-summary"><el-tag effect="plain">已选择 {{ batch.products.length }} 个商品</el-tag><span>提交前服务端会再次检查,部分商品失败不会影响其他商品。</span></div><el-form ref="batchForm" :model="batchData" :rules="batchRules" label-position="top" class="batch-form"><el-form-item label="采集规则" prop="ruleId"><el-select v-model="batchData.ruleId" filterable style="width:100%" placeholder="请选择采集规则"><el-option v-for="item in batch.options.rules" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="Android 设备"><el-select v-model="batchData.deviceId" clearable filterable style="width:100%" placeholder="不指定,由空闲设备领取"><el-option v-for="item in batch.options.devices" :key="item.id" :disabled="!item.selectable" :label="`${item.name} · ${item.selectable ? '在线空闲' : '不可用'}`" :value="item.id" /></el-select></el-form-item></el-form><el-table :data="batch.products" border size="small" max-height="300"><el-table-column label="goods_id" prop="goodsId" width="160" /><el-table-column label="商品"><template #default="{ row }">{{ row.title || '资料待完善' }}</template></el-table-column><el-table-column label="检查结果" width="130"><template #default><el-tag type="success" effect="plain">可创建</el-tag></template></el-table-column></el-table></template><template v-else><el-alert :title="`创建完成:成功 ${batch.successCount} 个,失败 ${batch.failureCount} 个`" :type="batch.failureCount ? 'warning' : 'success'" :closable="false" show-icon class="notice" /><el-table :data="batch.results" border size="small" max-height="360"><el-table-column label="goods_id" prop="goodsId" width="160" /><el-table-column label="商品"><template #default="{ row }">{{ row.title || '资料待完善' }}</template></el-table-column><el-table-column label="结果" width="110"><template #default="{ row }"><el-tag :type="row.success ? 'success' : 'danger'">{{ row.success ? '已创建' : '未创建' }}</el-tag></template></el-table-column><el-table-column label="任务 / 原因" min-width="230"><template #default="{ row }">{{ row.success ? `采集任务 #${row.taskId}` : row.message }}</template></el-table-column></el-table></template><template #footer><template v-if="batch.step === 'confirm'"><el-button @click="batch.open=false">取消</el-button><el-button type="primary" :loading="batch.saving" :disabled="!batch.products.length" @click="submitBatch">创建 {{ batch.products.length }} 个采集任务</el-button></template><el-button v-else type="primary" @click="batch.open=false">关闭</el-button></template></el-dialog>
|
||||
<PddProductDetailDrawer v-model="detail.open" :product-id="detail.productId" :action="detail.action" @product-updated="onProductUpdated" />
|
||||
</template></BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { createPddProduct, getPddProduct, listPddProducts, listRelatedSybProducts, updatePddProduct } from '@/api/goauto/pdd-products'
|
||||
import { batchCreateCollectionTasks } from '@/api/goauto/collection-tasks'
|
||||
import { listPddProducts, createPddProduct, getPddProduct } from '@/api/goauto/pdd-products'
|
||||
import { listCollectionRules } from '@/api/goauto/collection-rules'
|
||||
import { listDevices } from '@/api/goauto/devices'
|
||||
import { createPurchaseTasksBatch, createStockPurchaseTask, previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
|
||||
import { batchCreateCollectionTasks } from '@/api/goauto/collection-tasks'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
import PddProductDetailDrawer from './PddProductDetailDrawer.vue'
|
||||
|
||||
let uid = 0
|
||||
export default {
|
||||
name: 'GoAutoPddProducts', setup() { return { Plus, RefreshLeft, Search } },
|
||||
data() { return { loading: false, products: [], selectedProducts: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '' }, location: this.emptyLocation(), createDialog: { open: false, saving: false }, createData: { url: '' }, batch: this.emptyBatch(), batchData: { ruleId: null, deviceId: null }, batchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] }, detail: { open: false, loading: false, product: null, relatedShopee: [] }, related: this.emptyRelated(), relatedPurchase: this.emptyRelatedPurchase(), stock: this.emptyStock(), stockData: this.emptyStockData(), stockRules: { color: [{ required: true, message: '请选择颜色', trigger: 'change' }], size: [{ required: true, message: '请选择尺码', trigger: 'change' }], quantity: [{ required: true, message: '请输入数量', trigger: 'change' }], executionMode: [{ required: true, message: '请选择执行模式', trigger: 'change' }], minPriceYuan: [{ required: true, message: '请填写单价下限', trigger: 'change' }], maxPriceYuan: [{ required: true, message: '请填写单价上限', trigger: 'change' }] }, edit: { open: false, loading: false, saving: false, productId: null }, editData: this.emptyEdit(), urlRules: { url: [{ required: true, message: '请输入 PDD 商品链接', trigger: 'blur' }] }} },
|
||||
computed: {
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
|
||||
relatedPurchaseCandidates() { return this.related.selected.filter(row => this.isRelatedPurchaseCandidate(row)) },
|
||||
stockColorOptions() { return this.stockSpecValues('color').filter(item => item.priceCent !== null && item.priceCent !== undefined && item.priceCent >= 0) },
|
||||
stockSizeOptions() { return this.stockSpecValues('size') },
|
||||
stockRequiresSize() { return (this.detail.product?.specs || []).some(item => item.role === 'size') },
|
||||
canCreateStockPurchase() { return this.canPurchase && this.detail.product?.status === 'active' && this.stockColorOptions.length > 0 && (!this.stockRequiresSize || this.stockSizeOptions.length > 0) }
|
||||
},
|
||||
created() {
|
||||
const productId = Number(this.$route.query.productId)
|
||||
if (Number.isInteger(productId) && productId > 0) this.loadLocatedProduct(productId)
|
||||
else if (this.$route.query.productId !== undefined) this.showInvalidLocation(this.$route.query.productId)
|
||||
else this.load()
|
||||
},
|
||||
name: 'GoAutoPddProducts', components: { PddProductDetailDrawer }, setup() { return { Plus, RefreshLeft, Search } },
|
||||
data() { return { loading: false, products: [], selectedProducts: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '' }, location: this.emptyLocation(), createDialog: { open: false, saving: false }, createData: { url: '' }, urlRules: { url: [{ required: true, message: '请输入 PDD 商品链接', trigger: 'blur' }] }, batch: this.emptyBatch(), batchData: { ruleId: null, deviceId: null }, batchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] }, detail: { open: false, productId: null, action: '' }} },
|
||||
watch: { '$route.query.productId': { handler(value) { this.applyRouteProduct(value) } }}, created() { this.applyRouteProduct(this.$route.query.productId) }, activated() { this.applyRouteProduct(this.$route.query.productId) },
|
||||
methods: {
|
||||
emptyLocation() { return { active: false, productId: null, goodsId: '', error: '' } },
|
||||
emptyEdit() { return { url: '', title: '', shopName: '', salesCount: null, reviewCount: null, status: 'active', specs: [] } },
|
||||
emptyBatch() { return { open: false, saving: false, step: 'confirm', products: [], options: { rules: [], devices: [] }, results: [], successCount: 0, failureCount: 0 } },
|
||||
emptyRelated() { return { loading: false, error: '', items: [], selected: [], total: 0, query: { page: 1, pageSize: 20, scope: 'actionable', shopeeProductId: null }} },
|
||||
emptyRelatedPurchase() { return { open: false, loading: false, saving: false, ids: [], deviceId: null, devices: [], items: [], eligibleCount: 0 } },
|
||||
isRelatedPurchaseCandidate(row) { return row.eligible === true && row.processStage === 'purchase_ready' },
|
||||
specMappingText(item) { return item.specMappingStatus === 'not_required' ? '无需规格映射' : item.specMappingReason },
|
||||
filterRelatedShopee(id) { this.related.query.shopeeProductId = id; this.reloadRelated() },
|
||||
reloadRelated() { this.related.query.page = 1; this.loadRelated() },
|
||||
async loadRelated() { if (!this.detail.product || !this.canPurchase) return; this.related.loading = true; this.related.error = ''; this.related.selected = []; this.$refs.relatedTable?.clearSelection(); try { const r = await listRelatedSybProducts(this.detail.product.id, this.related.query); this.related.items = r.data.items; this.related.total = r.data.total } catch (error) { this.related.items = []; this.related.total = 0; this.related.error = error?.response?.data?.message || '关联订单加载失败' } finally { this.related.loading = false } },
|
||||
emptyStock() { return { open: false, saving: false, error: '' } },
|
||||
emptyStockData() { return { color: '', size: '', quantity: 1, minPriceYuan: null, maxPriceYuan: null, executionMode: 'live' } },
|
||||
stockSpecValues(role) { return (this.detail.product?.specs || []).filter(item => item.role === role).flatMap(item => item.values || []).filter(item => item.selectable && String(item.name || '').trim()).map(item => ({ ...item, name: String(item.name).trim() })) },
|
||||
emptyLocation() { return { active: false, productId: null, goodsId: '', error: '' } }, emptyBatch() { return { open: false, saving: false, step: 'confirm', products: [], options: { rules: [], devices: [] }, results: [], successCount: 0, failureCount: 0 } },
|
||||
async applyRouteProduct(value) { const id = Number(value); if (value === undefined) { if (!this.location.active && this.products.length) return; this.location = this.emptyLocation(); this.detail.open = false; await this.load(); return } if (!Number.isInteger(id) || id <= 0) { this.location = { active: true, productId: String(value || ''), goodsId: '', error: 'PDD 商品编号无效' }; this.products = []; this.total = 0; this.detail.open = false; return } await this.loadLocatedProduct(id) },
|
||||
async load() { this.loading = true; this.selectedProducts = []; this.$refs.productTable?.clearSelection(); try { const r = await listPddProducts(this.query); this.products = r.data.items; this.total = r.data.total } finally { this.loading = false } },
|
||||
async clearLocation() {
|
||||
if (!this.location.active && !this.$route.query.productId) return
|
||||
const routeQuery = { ...this.$route.query }
|
||||
delete routeQuery.productId
|
||||
this.location = this.emptyLocation()
|
||||
await this.$router.replace({ path: this.$route.path, query: routeQuery })
|
||||
},
|
||||
async loadLocatedProduct(productId) {
|
||||
this.loading = true
|
||||
this.selectedProducts = []
|
||||
this.location = { active: true, productId, goodsId: '', error: '' }
|
||||
this.detail = { open: true, loading: true, product: null, relatedShopee: [] }
|
||||
try {
|
||||
const r = await getPddProduct(productId)
|
||||
const product = r.data.product
|
||||
this.location.goodsId = product.goodsId
|
||||
this.products = [product]
|
||||
this.total = 1
|
||||
this.detail.product = product
|
||||
this.detail.relatedShopee = r.data.relatedShopeeProducts || []
|
||||
await this.loadRelated()
|
||||
} catch {
|
||||
this.products = []
|
||||
this.total = 0
|
||||
this.detail.open = false
|
||||
this.location.error = '该 PDD 商品不存在或已删除'
|
||||
} finally {
|
||||
this.loading = false
|
||||
this.detail.loading = false
|
||||
}
|
||||
},
|
||||
showInvalidLocation(productId) {
|
||||
this.location = { active: true, productId: String(productId || ''), goodsId: '', error: 'PDD 商品编号无效' }
|
||||
this.products = []
|
||||
this.total = 0
|
||||
},
|
||||
async search() { this.query.page = 1; await this.clearLocation(); await this.load() },
|
||||
async reset() { this.query = { page: 1, pageSize: 20, keyword: '', status: '' }; await this.clearLocation(); await this.load() },
|
||||
statusMeta(status) { return { pending: { label: '待完善', type: 'warning' }, active: { label: '正常', type: 'success' }, disabled: { label: '已停用', type: 'info' }}[status] || { label: status || '-', type: 'info' } },
|
||||
countText(value) { return value === null || value === undefined ? '-' : Number(value).toLocaleString('zh-CN') }, roleLabel(role) { return { color: '颜色', size: '尺码', other: '其他' }[role] || role }, priceText(value) { return value === null || value === undefined ? '未填写' : `¥${(value / 100).toFixed(2)}` }, colorImage(color) { return (this.detail.product?.colorImages || []).find(item => item.color === color) || null },
|
||||
specsSummary(specs = []) { return specs.length ? specs.map(x => `${x.name} ${x.values.length}项`).join(';') : '待完善' },
|
||||
priceSummary(specs = []) { const p = specs.filter(x => x.role === 'color').flatMap(x => x.values.map(v => v.priceCent).filter(v => v !== null && v !== undefined)); if (!p.length) return '-'; const min = Math.min(...p); const max = Math.max(...p); return min === max ? this.priceText(min) : `${this.priceText(min)}~${this.priceText(max)}` },
|
||||
isProductSelectable(row) { return row.collectionSelectable === true },
|
||||
collectionStateText(row) { return row.collectionSelectable ? '可创建采集任务' : (row.collectionDisabledReason || '当前不可创建采集任务') },
|
||||
handleSelectionChange(rows) { this.selectedProducts = rows },
|
||||
async openBatch() {
|
||||
if (!this.selectedProducts.length) return
|
||||
this.batch = { ...this.emptyBatch(), open: true, products: this.selectedProducts.map(item => ({ ...item })) }
|
||||
this.batchData = { ruleId: null, deviceId: null }
|
||||
try {
|
||||
const [rules, devices] = await Promise.all([listCollectionRules({ page: 1, pageSize: 100 }), listDevices({ page: 1, pageSize: 100 })])
|
||||
this.batch.options = { rules: rules.data.items, devices: devices.data.items }
|
||||
if (!this.batch.options.rules.length) ElMessage.warning('没有可用采集规则,请先创建采集规则')
|
||||
} catch (error) {
|
||||
this.batch.open = false
|
||||
throw error
|
||||
}
|
||||
this.$nextTick(() => this.$refs.batchForm?.clearValidate())
|
||||
},
|
||||
resetBatch() { this.batch = this.emptyBatch(); this.batchData = { ruleId: null, deviceId: null } },
|
||||
async submitBatch() {
|
||||
if (!await this.$refs.batchForm.validate().catch(() => false)) return
|
||||
this.batch.saving = true
|
||||
try {
|
||||
const response = await batchCreateCollectionTasks({ requestId: createRequestId(), pddProductIds: this.batch.products.map(item => item.id), ruleId: this.batchData.ruleId, deviceId: this.batchData.deviceId || null })
|
||||
const byID = new Map(this.batch.products.map(item => [item.id, item]))
|
||||
this.batch.results = response.data.items.map(item => ({ ...byID.get(item.pddProductId), ...item }))
|
||||
this.batch.successCount = response.data.successCount
|
||||
this.batch.failureCount = response.data.failureCount
|
||||
this.batch.step = 'result'
|
||||
if (this.batch.failureCount) {
|
||||
ElMessage.warning(`已创建 ${this.batch.successCount} 个,${this.batch.failureCount} 个未创建`)
|
||||
} else {
|
||||
ElMessage.success(`已创建 ${this.batch.successCount} 个采集任务`)
|
||||
}
|
||||
await this.load()
|
||||
} finally {
|
||||
this.batch.saving = false
|
||||
}
|
||||
},
|
||||
openCreate() { this.createDialog = { open: true, saving: false }; this.createData = { url: '' }; this.$nextTick(() => this.$refs.createForm?.clearValidate()) },
|
||||
async createProduct() { if (!await this.$refs.createForm.validate().catch(() => false)) return; this.createDialog.saving = true; try { const r = await createPddProduct({ requestId: createRequestId(), url: this.createData.url.trim() }); ElMessage.success('PDD 商品已添加'); this.createDialog.open = false; await this.load(); await this.openDetail(r.data.product.id) } finally { this.createDialog.saving = false } },
|
||||
async openDetail(id) { this.detail = { open: true, loading: true, product: null, relatedShopee: [] }; this.related = this.emptyRelated(); try { const r = await getPddProduct(id); this.detail.product = r.data.product; this.detail.relatedShopee = r.data.relatedShopeeProducts || []; await this.loadRelated() } finally { this.detail.loading = false } },
|
||||
async openRelatedPurchase() { const candidates = this.related.selected.filter(row => this.isRelatedPurchaseCandidate(row)); if (!candidates.length) return; const ids = candidates.map(row => row.sybProductId); this.relatedPurchase = { ...this.emptyRelatedPurchase(), open: true, loading: true, ids }; try { const [preview, devices] = await Promise.all([previewPurchaseTasks({ sybProductIds: ids }), listDevices({ page: 1, pageSize: 100, status: 'online' })]); this.relatedPurchase.items = preview.data.items; this.relatedPurchase.eligibleCount = preview.data.eligibleCount; this.relatedPurchase.devices = devices.data.items.filter(item => item.selectable) } finally { this.relatedPurchase.loading = false } },
|
||||
async refreshRelatedPurchase() { this.relatedPurchase.loading = true; try { const r = await previewPurchaseTasks({ sybProductIds: this.relatedPurchase.ids, deviceId: this.relatedPurchase.deviceId || undefined }); this.relatedPurchase.items = r.data.items; this.relatedPurchase.eligibleCount = r.data.eligibleCount } finally { this.relatedPurchase.loading = false } },
|
||||
async submitRelatedPurchase() { this.relatedPurchase.saving = true; try { const r = await createPurchaseTasksBatch({ requestId: createRequestId(), sybProductIds: this.relatedPurchase.ids, deviceId: this.relatedPurchase.deviceId || undefined }); const failed = r.data.failedCount || 0; if (failed) ElMessage.warning(`已创建 ${r.data.createdCount} 个,${failed} 个未创建`); else ElMessage.success(`已创建 ${r.data.createdCount} 个采购任务`); this.relatedPurchase.open = false; await this.loadRelated() } finally { this.relatedPurchase.saving = false } },
|
||||
openStockPurchase() { if (!this.canCreateStockPurchase) return; const color = this.stockColorOptions[0]; const price = color.priceCent / 100; this.stock = { open: true, saving: false, error: '' }; this.stockData = { color: color.name, size: this.stockSizeOptions[0]?.name || '', quantity: 1, minPriceYuan: price, maxPriceYuan: price, executionMode: 'live' }; this.$nextTick(() => this.$refs.stockForm?.clearValidate()) },
|
||||
resetStockPurchase() { this.stock = this.emptyStock(); this.stockData = this.emptyStockData() },
|
||||
onStockColorChange(name) { const color = this.stockColorOptions.find(item => item.name === name); if (!color) return; const price = color.priceCent / 100; this.stockData.minPriceYuan = price; this.stockData.maxPriceYuan = price; this.stock.error = '' },
|
||||
async submitStockPurchase() {
|
||||
if (!await this.$refs.stockForm.validate().catch(() => false)) return
|
||||
const minUnitPriceCent = Math.round(this.stockData.minPriceYuan * 100); const maxUnitPriceCent = Math.round(this.stockData.maxPriceYuan * 100)
|
||||
if (minUnitPriceCent < 0 || maxUnitPriceCent < minUnitPriceCent) { this.stock.error = '允许单价上限不能低于下限'; return }
|
||||
this.stock.saving = true; this.stock.error = ''
|
||||
try {
|
||||
const response = await createStockPurchaseTask({ requestId: createRequestId(), executionMode: this.stockData.executionMode, pddProductId: this.detail.product.id, color: this.stockData.color, size: this.stockData.size || undefined, quantity: this.stockData.quantity, minUnitPriceCent, maxUnitPriceCent }, { suppressErrorMessage: true })
|
||||
ElMessage.success('备货采购任务已创建'); this.stock.open = false; this.detail.open = false
|
||||
await this.$router.push({ path: '/purchase-tasks/index', query: { taskId: response.data.id }})
|
||||
} catch (error) { this.stock.error = error?.response?.data?.message || error?.response?.data?.msg || error?.message || '创建失败,请稍后重试' } finally { this.stock.saving = false }
|
||||
},
|
||||
async openEdit(id) { this.detail.open = false; this.edit = { open: true, loading: true, saving: false, productId: id }; try { const r = await getPddProduct(id); const p = r.data.product; this.editData = { url: p.url, title: p.title || '', shopName: p.shopName || '', salesCount: p.salesCount, reviewCount: p.reviewCount, status: p.status === 'disabled' ? 'disabled' : 'active', specs: (p.specs || []).map(d => ({ ...d, localId: ++uid, values: d.values.map(v => ({ ...v, localId: ++uid, priceYuan: v.priceCent === null || v.priceCent === undefined ? null : v.priceCent / 100 })) })) } } finally { this.edit.loading = false } },
|
||||
addDimension() { this.editData.specs.push({ localId: ++uid, name: '', role: 'other', values: [] }) }, addValue(d) { d.values.push({ localId: ++uid, name: '', selectable: true, priceYuan: null }) },
|
||||
move(items, index, offset) { const target = index + offset; if (target < 0 || target >= items.length) return; const [item] = items.splice(index, 1); items.splice(target, 0, item) },
|
||||
specError() { const dimensions = new Set(); for (const d of this.editData.specs) { const name = d.name.trim(); if (!name) return '请填写规格维度名称'; if (dimensions.has(name)) return `规格维度“${name}”重复`; dimensions.add(name); if (!d.values.length) return `请为“${name}”添加规格值`; const values = new Set(); for (const v of d.values) { const n = v.name.trim(); if (!n) return `请填写“${name}”中的规格值名称`; if (values.has(n)) return `“${name}”中存在重复规格值“${n}”`; values.add(n) } } return '' },
|
||||
async saveEdit() { if (!await this.$refs.editForm.validate().catch(() => false)) return; const error = this.specError(); if (error) { ElMessage.warning(error); return } this.edit.saving = true; const id = this.edit.productId; try { const data = this.editData; await updatePddProduct(id, { requestId: createRequestId(), url: data.url.trim(), title: data.title.trim(), shopName: data.shopName.trim(), salesCount: data.salesCount, reviewCount: data.reviewCount, status: data.status, specs: data.specs.map(d => ({ name: d.name.trim(), role: d.role, values: d.values.map(v => ({ name: v.name.trim(), selectable: v.selectable, ...(d.role === 'color' && v.priceYuan !== null ? { priceCent: Math.round(v.priceYuan * 100) } : {}) })) })) }); ElMessage.success('PDD 商品资料已保存'); this.edit.open = false; await this.load(); await this.openDetail(id) } finally { this.edit.saving = false } }
|
||||
async loadLocatedProduct(id) { this.loading = true; this.location = { active: true, productId: id, goodsId: '', error: '' }; try { const r = await getPddProduct(id); const product = r.data.product; this.location.goodsId = product.goodsId; this.products = [product]; this.total = 1; this.detail = { open: true, productId: id } } catch { this.products = []; this.total = 0; this.detail.open = false; this.location.error = '该 PDD 商品不存在或已删除' } finally { this.loading = false } },
|
||||
async clearLocation() { if (!this.location.active && this.$route.query.productId === undefined) return; const query = { ...this.$route.query }; delete query.productId; this.location = this.emptyLocation(); await this.$router.replace({ path: this.$route.path, query }) }, async search() { this.query.page = 1; await this.clearLocation(); await this.load() }, async reset() { this.query = { page: 1, pageSize: 20, keyword: '', status: '' }; await this.clearLocation(); await this.load() },
|
||||
statusMeta(status) { return { pending: { label: '待完善', type: 'warning' }, active: { label: '正常', type: 'success' }, disabled: { label: '已停用', type: 'info' }}[status] || { label: status || '-', type: 'info' } }, priceText(value) { return value === null || value === undefined ? '未填写' : `¥${(value / 100).toFixed(2)}` }, specsSummary(specs = []) { return specs.length ? specs.map(x => `${x.name} ${x.values.length}项`).join(';') : '待完善' }, priceSummary(specs = []) { const p = specs.filter(x => x.role === 'color').flatMap(x => x.values.map(v => v.priceCent).filter(v => v !== null && v !== undefined)); if (!p.length) return '-'; const min = Math.min(...p); const max = Math.max(...p); return min === max ? this.priceText(min) : `${this.priceText(min)}~${this.priceText(max)}` },
|
||||
isProductSelectable(row) { return row.collectionSelectable === true }, collectionStateText(row) { return row.collectionSelectable ? '可创建采集任务' : (row.collectionDisabledReason || '当前不可创建采集任务') }, handleSelectionChange(rows) { this.selectedProducts = rows }, openDetail(id, action = '') { this.detail = { open: true, productId: Number(id), action } }, async onProductUpdated() { if (this.location.active) await this.loadLocatedProduct(this.location.productId); else await this.load() },
|
||||
openCreate() { this.createDialog = { open: true, saving: false }; this.createData = { url: '' }; this.$nextTick(() => this.$refs.createForm?.clearValidate()) }, async createProduct() { if (!await this.$refs.createForm.validate().catch(() => false)) return; this.createDialog.saving = true; try { const r = await createPddProduct({ requestId: createRequestId(), url: this.createData.url.trim() }); ElMessage.success('PDD 商品已添加'); this.createDialog.open = false; await this.load(); this.openDetail(r.data.product.id) } finally { this.createDialog.saving = false } },
|
||||
async openBatch() { if (!this.selectedProducts.length) return; this.batch = { ...this.emptyBatch(), open: true, products: this.selectedProducts.map(item => ({ ...item })) }; this.batchData = { ruleId: null, deviceId: null }; try { const [rules, devices] = await Promise.all([listCollectionRules({ page: 1, pageSize: 100 }), listDevices({ page: 1, pageSize: 100 })]); this.batch.options = { rules: rules.data.items, devices: devices.data.items }; if (!rules.data.items.length) ElMessage.warning('没有可用采集规则,请先创建采集规则') } catch (error) { this.batch.open = false; throw error } this.$nextTick(() => this.$refs.batchForm?.clearValidate()) }, resetBatch() { this.batch = this.emptyBatch(); this.batchData = { ruleId: null, deviceId: null } },
|
||||
async submitBatch() { if (!await this.$refs.batchForm.validate().catch(() => false)) return; this.batch.saving = true; try { const response = await batchCreateCollectionTasks({ requestId: createRequestId(), pddProductIds: this.batch.products.map(item => item.id), ruleId: this.batchData.ruleId, deviceId: this.batchData.deviceId || null }); const byID = new Map(this.batch.products.map(item => [item.id, item])); this.batch.results = response.data.items.map(item => ({ ...byID.get(item.pddProductId), ...item })); this.batch.successCount = response.data.successCount; this.batch.failureCount = response.data.failureCount; this.batch.step = 'result'; response.data.failureCount ? ElMessage.warning(`已创建 ${response.data.successCount} 个,${response.data.failureCount} 个未创建`) : ElMessage.success(`已创建 ${response.data.successCount} 个采集任务`); await this.load() } finally { this.batch.saving = false } }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-card { min-height: calc(100vh - 124px); }.page-heading { display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:24px;h1{margin:0 0 6px;font-size:24px;color:#1f2937}p{margin:0;color:#606266;line-height:1.5}}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.location-notice{margin-bottom:12px}.batch-toolbar{display:flex;align-items:center;gap:12px;min-height:48px;padding:8px 12px;margin-bottom:12px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.batch-toolbar strong{font-variant-numeric:tabular-nums;color:#1e40af}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.collection-ready{margin-top:4px;font-size:12px;color:#1677ff}.notice{margin-bottom:20px}.batch-summary{display:flex;align-items:center;gap:12px;margin-bottom:16px;color:#606266}.batch-form{display:grid;grid-template-columns:1fr 1fr;gap:20px}.drawer-body{padding:0 4px 24px}.drawer-actions,.section-heading,.dimension-header{display:flex;align-items:center;gap:12px}.drawer-actions,.section-heading{justify-content:space-between}.drawer-actions>div{display:flex;gap:8px}.section-title{margin:28px 0 14px}.spec-block{margin-bottom:20px}.spec-title{display:flex;align-items:center;gap:8px;margin-bottom:8px;font-weight:600}.color-thumb,.color-image-placeholder{display:inline-flex;width:64px;height:64px;border-radius:6px}.color-thumb{cursor:zoom-in}.color-image-placeholder{align-items:center;justify-content:center;box-sizing:border-box;border:1px dashed #dcdfe6;background:#f5f7fa;color:#909399;font-size:12px}.color-image-note{margin:8px 0 0;color:#909399;font-size:12px;line-height:1.5}.link{color:#1677ff;overflow-wrap:anywhere}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.stock-help{margin:0 0 14px;color:#606266;font-size:13px;line-height:1.6}.stock-boundary{margin-bottom:0}.section-heading{margin:12px 0;h3{margin:0}}.dimension-card{margin-bottom:16px}.dimension-header{margin-bottom:14px}.dimension-header .el-input{flex:1}.add-value{margin-top:12px}@media(max-width:768px){.page-heading,.dimension-header{align-items:stretch;flex-direction:column}.batch-toolbar,.batch-summary{align-items:flex-start;flex-direction:column}.batch-form,.form-grid{grid-template-columns:1fr}}
|
||||
.list-color-thumb{display:block;width:56px;height:56px;margin:auto;border-radius:4px}.list-color-placeholder{display:flex;align-items:center;justify-content:center;width:56px;height:56px;margin:auto;border-radius:4px;background:#f1f5f9;color:#909399;font-size:11px}
|
||||
.page-card{min-height:calc(100vh - 124px)}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.location-notice{margin-bottom:12px}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.collection-ready{margin-top:4px;font-size:12px;color:#1677ff}.notice{margin-bottom:20px}.batch-summary{display:flex;align-items:center;gap:12px;margin-bottom:16px;color:#606266}.batch-form{display:grid;grid-template-columns:1fr 1fr;gap:20px}.list-color-thumb{display:block;width:56px;height:56px;margin:auto;border-radius:4px}.list-color-placeholder{display:flex;align-items:center;justify-content:center;width:56px;height:56px;margin:auto;border-radius:4px;background:#f1f5f9;color:#909399;font-size:11px}@media(max-width:768px){.batch-summary{align-items:flex-start;flex-direction:column}.batch-form{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="detailDrawerOpen"
|
||||
title="虾皮商品详情"
|
||||
size="1080px"
|
||||
append-to-body
|
||||
:close-on-click-modal="!pddDetail.open"
|
||||
:close-on-press-escape="!pddDetail.open"
|
||||
:before-close="beforeCloseDetail"
|
||||
>
|
||||
<div v-loading="detail.loading" class="drawer-body">
|
||||
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="notice" role="alert">
|
||||
<template #default><el-button link type="primary" @click="reload">重新加载</el-button></template>
|
||||
</el-alert>
|
||||
<template v-if="detail.product">
|
||||
<div class="drawer-actions"><el-tag :type="mappingMeta(detail.product).type">{{ mappingMeta(detail.product).label }}</el-tag><el-button type="primary" @click="openEdit">编辑档案</el-button></div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="虾皮商品ID">{{ detail.product.shopeeItemId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="店铺">{{ detail.product.shopName || '未填写' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="标题" :span="2">{{ detail.product.title || '未填写' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="售价">{{ priceText(detail.product) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="关联 PDD 商品"><a v-if="detail.product.pddProductId" class="link" href="javascript:void(0)" @click="openPddDetail(detail.product.pddProductId)">PDD-{{ detail.product.pddProductId }} ↗</a><span v-else class="muted">未关联</span> <el-button link type="primary" size="small" @click="openPddPicker">{{ detail.product.pddProductId ? '更换' : '关联' }}</el-button></el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p v-if="detail.product.pddProductId && detail.product.sharedByPddCount > 1" class="muted shared-note">该 PDD 商品当前被 {{ detail.product.sharedByPddCount }} 个虾皮商品共用,允许共用。</p>
|
||||
|
||||
<div class="section-heading"><h3>规格匹配</h3><span class="mapping-progress">{{ mappingProgressText }}</span></div>
|
||||
<el-alert title="只保存虾皮规格与 PDD 规格的对应关系,不会修改两端的原始颜色和尺码。" type="info" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-if="!detail.product.pddProductId" title="请先关联 PDD 商品,再进行规格匹配。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-else-if="detail.pddLoaded && !pddSpecValues('color').length && !pddSpecValues('size').length" title="关联的 PDD 商品暂无可用规格,请先完成商品采集。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-tabs v-model="detail.activeSpecTab" class="mapping-tabs">
|
||||
<el-tab-pane label="颜色匹配" name="color">
|
||||
<div class="tab-actions"><span class="muted">AI 只生成未保存的建议,置信度不足不会预填,均需人工确认后保存。</span><el-button type="primary" plain :loading="detail.aiSuggesting.color" :disabled="!canAIMatchColor" @click="previewAISuggestions('color')">AI 匹配</el-button></div>
|
||||
<el-empty v-if="!specRows('color').length" description="虾皮商品暂无颜色规格" />
|
||||
<el-table v-else :data="specRows('color')" :row-class-name="mappingRowClass" border size="small">
|
||||
<el-table-column label="虾皮颜色" min-width="180"><template #default="{ row }"><div class="primary">{{ row.name }}</div><div class="muted">{{ row.source === 'manual' ? '人工添加' : 'SYB 导入' }}</div></template></el-table-column>
|
||||
<el-table-column label="PDD 颜色" min-width="360"><template #default="{ row }"><el-select v-model="row.pddValueDraft" filterable clearable placeholder="搜索并选择 PDD 颜色" class="mapping-select" :disabled="!detail.pddLoaded" @change="markManual(row)"><el-option-group v-for="group in colorOptionGroups(row)" :key="group.label" :label="group.label"><el-option v-for="option in group.options" :key="`${group.label}-${option.value}`" :label="option.label" :value="option.value" /></el-option-group></el-select><div v-if="row.previewReason" class="muted preview-reason">{{ row.previewReason }}</div></template></el-table-column>
|
||||
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="rowStatus(row).type" size="small">{{ rowStatus(row).label }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="150"><template #default="{ row }"><el-button link type="primary" :disabled="!row.pddValueDraft" @click="clearDraft(row)">清除</el-button><el-button v-if="row.pddValueDraft === row.originalPddValue && row.mapping && row.mapping.status === 'pending'" link type="primary" @click="confirmPendingMapping(row)">确认</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="尺码匹配" name="size">
|
||||
<div class="tab-actions"><span class="muted">先按格式统一自动匹配;未匹配的项再用 AI 生成建议,置信度不足不会预填。</span><el-button type="primary" plain :loading="detail.aiSuggesting.size" :disabled="!canAIMatchSize" @click="previewAISuggestions('size')">AI 匹配</el-button></div>
|
||||
<el-empty v-if="!specRows('size').length" description="虾皮商品暂无尺码规格" />
|
||||
<el-table v-else :data="specRows('size')" border size="small">
|
||||
<el-table-column label="虾皮尺码" min-width="180"><template #default="{ row }"><div class="primary">{{ row.name }}</div><div class="muted">{{ row.source === 'manual' ? '人工添加' : 'SYB 导入' }}</div></template></el-table-column>
|
||||
<el-table-column label="PDD 尺码" min-width="360"><template #default="{ row }"><el-select v-model="row.pddValueDraft" filterable clearable placeholder="搜索并选择 PDD 尺码" class="mapping-select" :disabled="!detail.pddLoaded" @change="markManual(row)"><el-option v-for="option in pddSpecValues('size')" :key="option.name" :label="option.name" :value="option.name" /></el-select><div v-if="row.previewReason" class="muted preview-reason">{{ row.previewReason }}</div></template></el-table-column>
|
||||
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="rowStatus(row).type" size="small">{{ rowStatus(row).label }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="150"><template #default="{ row }"><el-button link type="primary" :disabled="!row.pddValueDraft" @click="clearDraft(row)">清除</el-button><el-button v-if="row.pddValueDraft === row.originalPddValue && row.mapping && row.mapping.status === 'pending'" link type="primary" @click="confirmPendingMapping(row)">确认</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<div class="section-heading"><h3>添加规格值</h3></div>
|
||||
<div class="quick-row"><span class="quick-label">维度</span><el-select v-model="addValueForm.role" size="small" style="width:110px"><el-option label="颜色" value="color" /><el-option label="尺码" value="size" /><el-option label="其他" value="other" /></el-select><el-input v-model="addValueForm.dimension" size="small" placeholder="维度名称,如 颜色 / 尺码" style="width:160px" /><el-input v-model="addValueForm.name" size="small" placeholder="规格值名称" style="width:160px" /><el-button size="small" type="primary" @click="submitAddValue">+ 添加规格值</el-button></div>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><el-button @click="closeDetail">关闭</el-button><el-button type="primary" :loading="detail.saving" :disabled="!hasMappingChanges" @click="saveAllMappings">保存修改</el-button></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="pddPicker.open" title="搜索并选择 PDD 商品" width="860px" append-to-body :close-on-click-modal="false">
|
||||
<el-input v-model="pddPicker.keyword" placeholder="goods_id、标题或店铺" clearable @clear="searchPddPicker" @keyup.enter="searchPddPicker"><template #append><el-button @click="searchPddPicker">搜索</el-button></template></el-input>
|
||||
<p class="muted picker-help">最近更新的 PDD 商品;可输入 goods_id、标题或店铺搜索。</p>
|
||||
<el-table v-loading="pddPicker.loading" :data="pddPicker.items" border size="small" style="margin-top:12px" max-height="360" empty-text="最近更新的 PDD 商品,可输入 goods_id、标题或店铺搜索">
|
||||
<el-table-column label="颜色图" width="86" align="center"><template #default="{ row }"><el-image v-if="row.colorImages?.[0]?.imagePath" class="picker-thumb" :src="row.colorImages[0].imagePath" :preview-src-list="[row.colorImages[0].imagePath]" fit="cover" lazy preview-teleported><template #error><span class="picker-image-placeholder">暂无图片</span></template></el-image><span v-else class="picker-image-placeholder">暂无图片</span></template></el-table-column>
|
||||
<el-table-column label="goods_id" prop="goodsId" width="150" /><el-table-column label="标题 / 店铺" min-width="200"><template #default="{ row }"><div>{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '未填写店铺' }}</div></template></el-table-column><el-table-column label="最近更新" width="150"><template #default="{ row }">{{ parseTime(row.updatedAt) }}</template></el-table-column><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'disabled' ? 'info' : 'success'">{{ row.status === 'disabled' ? '已停用' : '可用' }}</el-tag></template></el-table-column><el-table-column label="操作" width="80"><template #default="{ row }"><el-button type="primary" link :disabled="row.status === 'disabled'" @click="pickPdd(row)">选择</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<template #footer><el-button @click="pddPicker.open=false">取消</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="edit.open" title="编辑虾皮商品档案" size="560px" append-to-body :close-on-click-modal="false">
|
||||
<div class="drawer-body"><el-form :model="editData" label-position="top"><el-form-item label="商品标题"><el-input v-model="editData.title" maxlength="500" /></el-form-item><el-form-item label="店铺"><el-input v-model="editData.shopName" maxlength="255" /></el-form-item><el-form-item label="参考图 URL"><el-input v-model="editData.imageUrl" maxlength="2048" placeholder="SYB 提供的图片 URL" /></el-form-item><el-form-item label="售价(参考,分)"><el-input-number v-model="editData.salePriceCent" :min="0" :precision="0" /><span class="muted edit-help">币种由系统配置统一设置,不逐商品选择</span></el-form-item></el-form></div>
|
||||
<template #footer><el-button @click="edit.open=false">取消</el-button><el-button type="primary" :loading="edit.saving" @click="saveEdit">保存修改</el-button></template>
|
||||
</el-drawer>
|
||||
|
||||
<PddProductDetailDrawer v-model="pddDetail.open" :product-id="pddDetail.productId" @product-updated="onNestedPddUpdated" @purchase-created="$emit('purchase-created', $event)" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getShopeeProduct, updateShopeeProduct, linkShopeeProductPdd, addShopeeSpecValue,
|
||||
setShopeeSpecMapping, clearShopeeSpecMapping, confirmShopeeSpecMapping,
|
||||
suggestShopeeColorMappings, suggestShopeeSizeMappings
|
||||
} from '@/api/goauto/shopee-products'
|
||||
import { getPddProduct, listPddProducts } from '@/api/goauto/pdd-products'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
|
||||
|
||||
export default {
|
||||
name: 'ShopeeProductDetailDrawer',
|
||||
components: { PddProductDetailDrawer },
|
||||
props: {
|
||||
modelValue: { type: Boolean, default: false },
|
||||
productId: { type: Number, default: null },
|
||||
targetColor: { type: String, default: '' },
|
||||
action: { type: String, default: '' }
|
||||
},
|
||||
emits: ['update:modelValue', 'product-updated', 'mapping-updated', 'pdd-linked', 'purchase-created', 'switch-cancelled'],
|
||||
data() {
|
||||
return {
|
||||
detailDrawerOpen: false, detail: this.emptyDetail(), loadError: '', requestGeneration: 0, loadedProductId: null,
|
||||
pddPicker: { open: false, loading: false, keyword: '', items: [] }, pddDetail: { open: false, productId: null },
|
||||
edit: { open: false, saving: false, productId: null }, editData: { title: '', shopName: '', imageUrl: '', salePriceCent: null },
|
||||
addValueForm: { dimension: '', role: 'other', name: '' }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
allSpecRows() { return this.detail.product ? this.detail.product.specs.flatMap(d => d.values) : [] },
|
||||
hasMappingChanges() { return this.allSpecRows.some(row => (row.pddValueDraft || '') !== (row.originalPddValue || '')) },
|
||||
mappingProgressText() { const rows = this.allSpecRows.filter(row => row.role === 'color' || row.role === 'size'); const valid = rows.filter(row => row.pddValueDraft && this.pddValueExists(row.role, row.pddValueDraft)).length; return rows.length ? `${valid}/${rows.length} 已匹配` : '暂无可匹配规格' },
|
||||
canAIMatchColor() { return this.detail.pddLoaded && this.specRows('color').length > 0 && this.pddSpecValues('color').length > 0 },
|
||||
canAIMatchSize() { return this.detail.pddLoaded && this.specRows('size').length > 0 && this.pddSpecValues('size').length > 0 }
|
||||
},
|
||||
watch: {
|
||||
modelValue: { immediate: true, handler(open) { if (open) this.requestSwitch(this.productId); else this.closeInternals() } },
|
||||
productId(next, previous) { if (this.modelValue && next !== previous) this.requestSwitch(next) },
|
||||
targetColor(value) { this.detail.highlightColor = String(value || '').trim() },
|
||||
action(value) { if (value === 'link_pdd' && this.detail.product && !this.pddPicker.open) this.openPddPicker() }
|
||||
},
|
||||
methods: {
|
||||
emptyDetail() { return { loading: false, saving: false, aiSuggesting: { color: false, size: false }, product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } },
|
||||
validProductId(id) { return Number.isInteger(Number(id)) && Number(id) > 0 },
|
||||
isCurrent(generation, id) { return this.modelValue && generation === this.requestGeneration && Number(this.productId) === Number(id) },
|
||||
async confirmDiscard() { if (!this.hasMappingChanges) return true; return this.$confirm('当前有未保存的规格匹配,确定放弃吗?', '放弃修改?', { type: 'warning', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }).then(() => true).catch(() => false) },
|
||||
async requestSwitch(id) {
|
||||
if (!this.validProductId(id)) { this.loadError = '虾皮商品编号无效'; this.detailDrawerOpen = true; return }
|
||||
if (this.loadedProductId === Number(id) && this.detail.product) { this.detailDrawerOpen = true; return }
|
||||
if (this.detail.product && !await this.confirmDiscard()) { this.$emit('switch-cancelled', this.loadedProductId); return }
|
||||
await this.loadProduct(Number(id))
|
||||
},
|
||||
async loadProduct(id) {
|
||||
const generation = ++this.requestGeneration
|
||||
this.loadedProductId = null; this.detailDrawerOpen = true; this.detail = { ...this.emptyDetail(), loading: true, highlightColor: String(this.targetColor || '').trim() }; this.loadError = ''; this.pddPicker.open = false; this.pddDetail.open = false
|
||||
try {
|
||||
const r = await getShopeeProduct(id)
|
||||
if (!this.isCurrent(generation, id)) return
|
||||
this.detail.product = this.withDraftMappings(r.data.product); this.loadedProductId = id
|
||||
if (this.detail.product.pddProductId) await this.loadLinkedPdd(this.detail.product.pddProductId, generation, id)
|
||||
if (this.isCurrent(generation, id) && this.action === 'link_pdd') this.openPddPicker()
|
||||
} catch (error) {
|
||||
if (!this.isCurrent(generation, id)) return
|
||||
this.loadError = error?.response?.data?.message || error?.response?.data?.msg || '虾皮商品详情加载失败,请稍后重试'
|
||||
} finally { if (this.isCurrent(generation, id)) this.detail.loading = false }
|
||||
},
|
||||
reload() { return this.loadProduct(Number(this.productId)) },
|
||||
closeInternals() { this.requestGeneration++; this.detailDrawerOpen = false; this.pddPicker.open = false; this.pddDetail.open = false; this.edit.open = false },
|
||||
async beforeCloseDetail(done) { if (this.pddDetail.open) return; if (!await this.confirmDiscard()) return; done(); this.requestGeneration++; this.$emit('update:modelValue', false) },
|
||||
closeDetail() { this.beforeCloseDetail(() => { this.detailDrawerOpen = false }) },
|
||||
withDraftMappings(product) { return { ...product, specs: (product.specs || []).map(d => ({ ...d, values: d.values.map(v => ({ ...v, dimensionName: d.name, role: d.role, pddValueDraft: v.mapping ? v.mapping.pddValue : '', originalPddValue: v.mapping ? v.mapping.pddValue : '', draftSource: v.mapping ? v.mapping.source : 'manual', previewReason: '', draftConfidence: null })) })) } },
|
||||
async loadLinkedPdd(pddId, generation = this.requestGeneration, productId = this.loadedProductId) { this.detail.pddLoaded = false; this.detail.pddProduct = null; try { const r = await getPddProduct(pddId); if (!this.isCurrent(generation, productId)) return; this.detail.pddProduct = r.data.product } finally { if (this.isCurrent(generation, productId)) this.detail.pddLoaded = true } },
|
||||
async refreshDetail(eventName = 'mapping-updated') { const id = this.loadedProductId; const generation = this.requestGeneration; const r = await getShopeeProduct(id); if (!this.isCurrent(generation, id)) return; this.detail.product = this.withDraftMappings(r.data.product); if (this.detail.product.pddProductId) await this.loadLinkedPdd(this.detail.product.pddProductId, generation, id); this.$emit(eventName, { id, product: this.detail.product }) },
|
||||
priceText(row) { if (row.salePriceCent === null || row.salePriceCent === undefined) return '—'; return `${row.currency || ''} ${(row.salePriceCent / 100).toFixed(2)}` },
|
||||
mappingMeta(product) { if (!product) return { label: '-', type: 'info' }; if (!product.pddProductId) return { label: '未关联 PDD 商品', type: 'info' }; const values = (product.specs || []).flatMap(d => d.values); if (!values.length) return { label: '待完善', type: 'warning' }; const confirmed = values.filter(v => v.mapping && v.mapping.status === 'confirmed' && (!this.detail.pddLoaded || this.pddValueExists(v.role || '', v.mapping.pddValue))).length; return confirmed === values.length ? { label: '可采购', type: 'success' } : { label: `待完善 · ${confirmed}/${values.length} 已确认`, type: 'warning' } },
|
||||
openPddPicker() { this.pddPicker = { open: true, loading: false, keyword: '', items: [] }; this.searchPddPicker() },
|
||||
async searchPddPicker() { this.pddPicker.loading = true; try { const keyword = this.pddPicker.keyword.trim(); const r = await listPddProducts(keyword ? { page: 1, pageSize: 20, keyword } : { page: 1, pageSize: 5, status: 'active' }); this.pddPicker.items = r.data.items } finally { this.pddPicker.loading = false } },
|
||||
async pickPdd(row) { try { const r = await linkShopeeProductPdd(this.loadedProductId, { requestId: createRequestId(), pddProductId: row.id }); this.detail.product = this.withDraftMappings(r.data.product); await this.loadLinkedPdd(row.id); this.$emit('pdd-linked', { id: this.loadedProductId, pddProductId: row.id, product: this.detail.product }); ElMessage.success('已关联 PDD 商品') } finally { this.pddPicker.open = false } },
|
||||
openPddDetail(productId) { this.pddDetail = { open: true, productId: Number(productId) } },
|
||||
onNestedPddUpdated() { if (this.detail.product?.pddProductId) this.loadLinkedPdd(this.detail.product.pddProductId) },
|
||||
specRows(role) { return this.allSpecRows.filter(row => row.role === role) },
|
||||
pddSpecValues(role) { if (!this.detail.pddProduct) return []; return (this.detail.pddProduct.specs || []).filter(d => d.role === role).flatMap(d => d.values).filter(v => v.selectable !== false && v.name) },
|
||||
pddValueExists(role, value) { return this.pddSpecValues(role).some(item => item.name === value) },
|
||||
rowStatus(row) { if (!row.pddValueDraft) return { label: '待匹配', type: 'warning' }; if (!this.pddValueExists(row.role, row.pddValueDraft)) return { label: '已失效', type: 'danger' }; if (row.pddValueDraft !== row.originalPddValue) return { label: row.draftSource === 'ai_match' ? '待保存(AI 建议)' : '待保存', type: 'warning' }; if (row.mapping?.status === 'pending') return { label: 'AI 待确认', type: 'warning' }; return { label: '已匹配', type: 'success' } },
|
||||
colorOptionGroups(row) { const values = this.pddSpecValues('color'); const owners = new Map(); this.specRows('color').forEach(item => { if (!item.pddValueDraft || item === row) return; if (!owners.has(item.pddValueDraft)) owners.set(item.pddValueDraft, []); owners.get(item.pddValueDraft).push(item.name) }); const groups = []; if (row.pddValueDraft && this.pddValueExists('color', row.pddValueDraft)) { const current = values.find(item => item.name === row.pddValueDraft); groups.push({ label: '当前选择', options: [{ value: current.name, label: this.pddOptionLabel(current) }] }) } const unused = values.filter(item => item.name !== row.pddValueDraft && !owners.has(item.name)).map(item => ({ value: item.name, label: this.pddOptionLabel(item) })); const used = values.filter(item => item.name !== row.pddValueDraft && owners.has(item.name)).map(item => ({ value: item.name, label: `${this.pddOptionLabel(item)} · 已被“${owners.get(item.name).join('、')}”使用` })); if (unused.length) groups.push({ label: `未使用的颜色(${unused.length})`, options: unused }); if (used.length) groups.push({ label: '已经匹配(仍可选择)', options: used }); return groups },
|
||||
pddOptionLabel(option) { return option.priceCent === null || option.priceCent === undefined ? option.name : `${option.name} · ¥${(option.priceCent / 100).toFixed(2)}` },
|
||||
markManual(row) { row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null },
|
||||
clearDraft(row) { row.pddValueDraft = ''; row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null },
|
||||
mappingRowClass({ row }) { return this.detail.highlightColor && row.role === 'color' && row.name === this.detail.highlightColor ? 'mapping-row-highlight' : '' },
|
||||
async previewAISuggestions(role) { const id = this.loadedProductId; const generation = this.requestGeneration; this.detail.aiSuggesting[role] = true; try { const suggest = role === 'color' ? suggestShopeeColorMappings : suggestShopeeSizeMappings; const r = await suggest(id, { specContextVersion: this.detail.product.specContextVersion }); if (!this.isCurrent(generation, id)) return; const byName = new Map(r.data.items.map(item => [item.valueName, item])); this.specRows(role).forEach(row => { const item = byName.get(row.name); if (!item) return; row.previewReason = item.reason || ''; row.draftConfidence = item.confidence ?? null; if (item.apply && item.status !== 'preserved') { row.pddValueDraft = item.pddValue || ''; row.draftSource = item.status === 'matched' ? 'exact_match' : 'ai_match' } }); const suggestedCount = r.data.items.filter(item => item.status === 'ai_suggested').length; ElMessage.success(`匹配 ${r.data.matchedCount} 项,AI 建议但置信度不足 ${suggestedCount} 项,待人工选择 ${r.data.pendingCount} 项`) } finally { if (this.isCurrent(generation, id)) this.detail.aiSuggesting[role] = false } },
|
||||
async confirmPendingMapping(row) { await confirmShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }); await this.refreshDetail() },
|
||||
async saveAllMappings() { const changes = this.allSpecRows.filter(row => (row.pddValueDraft || '') !== (row.originalPddValue || '')); if (!changes.length) return; const invalid = changes.find(row => row.pddValueDraft && !this.pddValueExists(row.role, row.pddValueDraft)); if (invalid) { ElMessage.warning(`PDD 规格“${invalid.pddValueDraft}”已不存在,请重新选择`); return } this.detail.saving = true; try { for (const row of changes) { if (!row.pddValueDraft) { if (row.originalPddValue) await clearShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }); continue } const source = ['exact_match', 'ai_match'].includes(row.draftSource) ? row.draftSource : 'manual'; const payload = { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name, pddValue: row.pddValueDraft, source }; if (source === 'ai_match') { if (row.draftConfidence !== null && row.draftConfidence !== undefined) payload.confidence = row.draftConfidence; if (row.previewReason) payload.reason = row.previewReason } await setShopeeSpecMapping(this.loadedProductId, payload); if (source === 'exact_match') await confirmShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }) } ElMessage.success('规格匹配已保存'); await this.refreshDetail() } finally { this.detail.saving = false } },
|
||||
async submitAddValue() { const dimension = this.addValueForm.dimension.trim(); const name = this.addValueForm.name.trim(); if (!dimension || !name) { ElMessage.warning('请填写维度名称和规格值'); return } await addShopeeSpecValue(this.loadedProductId, { requestId: createRequestId(), dimension, role: this.addValueForm.role, name }); this.addValueForm = { dimension: '', role: 'other', name: '' }; await this.refreshDetail() },
|
||||
openEdit() { const p = this.detail.product; this.edit = { open: true, saving: false, productId: p.id }; this.editData = { title: p.title || '', shopName: p.shopName || '', imageUrl: p.imageUrl || '', salePriceCent: p.salePriceCent } },
|
||||
async saveEdit() { this.edit.saving = true; try { await updateShopeeProduct(this.edit.productId, { requestId: createRequestId(), ...this.editData }); ElMessage.success('虾皮商品资料已保存'); this.edit.open = false; await this.refreshDetail('product-updated') } finally { this.edit.saving = false } }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drawer-body{padding:0 4px 24px}.drawer-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.link{color:#1677ff;cursor:pointer}.notice{margin-bottom:16px}.shared-note{margin:8px 0 0}.section-heading{display:flex;align-items:center;justify-content:space-between;margin:24px 0 12px}.section-heading h3{margin:0}.mapping-progress{font-size:13px;color:#606266}.mapping-tabs{margin-top:4px}.mapping-select{width:100%}.tab-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:4px 0 12px}.preview-reason{margin-top:4px}:deep(.mapping-row-highlight td){background:#fff7d6!important}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.edit-help{margin-left:8px}@media(max-width:768px){.tab-actions{align-items:flex-start;flex-direction:column}}
|
||||
</style>
|
||||
@@ -1,522 +1,49 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
|
||||
<el-form-item><el-button type="primary" :icon="Plus" @click="openCreate">添加</el-button></el-form-item>
|
||||
<el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="虾皮商品ID、标题或店铺" clearable @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item label="范围"><el-select v-model="query.status" style="width: 150px"><el-option label="全部(不含已删除)" value="" /><el-option label="已删除" value="deleted" /></el-select></el-form-item>
|
||||
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
|
||||
<el-form-item><el-button :icon="Delete" type="danger" plain :disabled="selectedProducts.length === 0" @click="openBatchDelete">删除{{ selectedProducts.length ? ` (${selectedProducts.length})` : '' }}</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="query.status === 'deleted'" title="当前显示已删除商品,可逐条恢复。恢复后原有 PDD 关联与规格映射保持不变。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无虾皮商品" @selection-change="handleSelectionChange">
|
||||
<el-table-column v-if="query.status !== 'deleted'" type="selection" width="48" />
|
||||
<el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column>
|
||||
<el-table-column label="虾皮商品ID" prop="shopeeItemId" min-width="140" />
|
||||
<el-table-column label="标题 / 店铺" min-width="220"><template #default="{ row }"><div class="primary">{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '尚未填写店铺' }}</div></template></el-table-column>
|
||||
<el-table-column label="售价" width="120"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column>
|
||||
<el-table-column label="PDD 商品" min-width="160"><template #default="{ row }"><a v-if="row.pddProductId" class="link" href="javascript:void(0)" @click="openPddDetail(row.pddProductId)">PDD-{{ row.pddProductId }} ↗</a><span v-else class="muted">未关联</span></template></el-table-column>
|
||||
<el-table-column label="映射状态" min-width="180"><template #default="{ row }"><el-tag :type="mappingMeta(row).type">{{ mappingMeta(row).label }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button v-if="row.deleted" type="primary" link @click="restore(row)">恢复</el-button><el-button v-else type="primary" link @click="openDetail(row.id)">详情</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
<p class="scope-note">本页不支持按虾皮订单号搜索——商品档案不含订单数据;订单相关字段在 SYB 商品模块与采购任务模块查看。</p>
|
||||
</el-card>
|
||||
<BasicLayout><template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search"><el-form-item><el-button type="primary" :icon="Plus" @click="openCreate">添加</el-button></el-form-item><el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="虾皮商品ID、标题或店铺" clearable @keyup.enter="search" /></el-form-item><el-form-item label="范围"><el-select v-model="query.status" style="width:150px"><el-option label="全部(不含已删除)" value="" /><el-option label="已删除" value="deleted" /></el-select></el-form-item><el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item><el-form-item><el-button :icon="Delete" type="danger" plain :disabled="selectedProducts.length === 0" @click="openBatchDelete">删除{{ selectedProducts.length ? ` (${selectedProducts.length})` : '' }}</el-button></el-form-item></el-form>
|
||||
<el-alert v-if="query.status === 'deleted'" title="当前显示已删除商品,可逐条恢复。恢复后原有 PDD 关联与规格映射保持不变。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无虾皮商品" @selection-change="handleSelectionChange"><el-table-column v-if="query.status !== 'deleted'" type="selection" width="48" /><el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column><el-table-column label="虾皮商品ID" prop="shopeeItemId" min-width="140" /><el-table-column label="标题 / 店铺" min-width="220"><template #default="{ row }"><div class="primary">{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '尚未填写店铺' }}</div></template></el-table-column><el-table-column label="售价" width="120"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column><el-table-column label="PDD 商品" min-width="160"><template #default="{ row }"><a v-if="row.pddProductId" class="link" href="javascript:void(0)" @click="openPddDetail(row.pddProductId)">PDD-{{ row.pddProductId }} ↗</a><span v-else class="muted">未关联</span></template></el-table-column><el-table-column label="映射状态" min-width="180"><template #default="{ row }"><el-tag :type="mappingMeta(row).type">{{ mappingMeta(row).label }}</el-tag></template></el-table-column><el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button v-if="row.deleted" type="primary" link @click="restore(row)">恢复</el-button><el-button v-else type="primary" link @click="openDetail(row.id)">详情</el-button></template></el-table-column></el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" /><p class="scope-note">本页不支持按虾皮订单号搜索——商品档案不含订单数据;订单相关字段在 SYB 商品模块与采购任务模块查看。</p>
|
||||
</el-card>
|
||||
|
||||
<!-- 创建 -->
|
||||
<el-dialog v-model="createDialog.open" title="新建虾皮商品" width="640px" :close-on-click-modal="false">
|
||||
<el-form ref="createForm" :model="createData" :rules="createRules" label-position="top">
|
||||
<el-form-item label="虾皮商品ID" prop="shopeeItemId"><el-input v-model="createData.shopeeItemId" maxlength="64" placeholder="全局唯一,创建后不可修改" /></el-form-item>
|
||||
<div class="form-grid"><el-form-item label="商品标题"><el-input v-model="createData.title" maxlength="500" /></el-form-item><el-form-item label="店铺"><el-input v-model="createData.shopName" maxlength="255" /></el-form-item></div>
|
||||
<el-form-item label="规格值(可选)"><div class="spec-quick-add"><span class="muted">只录虾皮侧的颜色和尺码名称,不涉及 PDD;留空也可以保存,之后在详情页补充。</span>
|
||||
<div class="quick-row"><span class="quick-label">颜色</span><el-input v-model="quickColor" size="small" placeholder="例如 浅蓝色" @keyup.enter="addQuickValue('color')" /><el-button size="small" @click="addQuickValue('color')">+ 添加</el-button></div>
|
||||
<div class="chips"><el-tag v-for="(v, i) in colorValues" :key="v" closable size="small" @close="colorValues.splice(i, 1)">{{ v }}</el-tag></div>
|
||||
<div class="quick-row"><span class="quick-label">尺码</span><el-input v-model="quickSize" size="small" placeholder="例如 XL" @keyup.enter="addQuickValue('size')" /><el-button size="small" @click="addQuickValue('size')">+ 添加</el-button></div>
|
||||
<div class="chips"><el-tag v-for="(v, i) in sizeValues" :key="v" closable size="small" @close="sizeValues.splice(i, 1)">{{ v }}</el-tag></div>
|
||||
</div></el-form-item>
|
||||
<el-form-item label="PDD 商品(选填)"><el-button @click="openPddPicker('create')">{{ createData.pddProductId ? `已选择 PDD-${createData.pddProductId}` : '搜索并选择' }}</el-button><span v-if="createData.pddProductId" class="muted" style="margin-left:8px">选择后仍需完成规格映射才能采购</span></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="createDialog.open = false">取消</el-button><el-button type="primary" :loading="createDialog.saving" @click="submitCreate">保存</el-button></template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="createDialog.open" title="新建虾皮商品" width="640px" :close-on-click-modal="false"><el-form ref="createForm" :model="createData" :rules="createRules" label-position="top"><el-form-item label="虾皮商品ID" prop="shopeeItemId"><el-input v-model="createData.shopeeItemId" maxlength="64" placeholder="全局唯一,创建后不可修改" /></el-form-item><div class="form-grid"><el-form-item label="商品标题"><el-input v-model="createData.title" maxlength="500" /></el-form-item><el-form-item label="店铺"><el-input v-model="createData.shopName" maxlength="255" /></el-form-item></div><el-form-item label="规格值(可选)"><div class="spec-quick-add"><span class="muted">只录虾皮侧的颜色和尺码名称,不涉及 PDD;留空也可以保存,之后在详情页补充。</span><div class="quick-row"><span class="quick-label">颜色</span><el-input v-model="quickColor" size="small" placeholder="例如 浅蓝色" @keyup.enter="addQuickValue('color')" /><el-button size="small" @click="addQuickValue('color')">+ 添加</el-button></div><div class="chips"><el-tag v-for="(v, i) in colorValues" :key="v" closable size="small" @close="colorValues.splice(i, 1)">{{ v }}</el-tag></div><div class="quick-row"><span class="quick-label">尺码</span><el-input v-model="quickSize" size="small" placeholder="例如 XL" @keyup.enter="addQuickValue('size')" /><el-button size="small" @click="addQuickValue('size')">+ 添加</el-button></div><div class="chips"><el-tag v-for="(v, i) in sizeValues" :key="v" closable size="small" @close="sizeValues.splice(i, 1)">{{ v }}</el-tag></div></div></el-form-item><el-form-item label="PDD 商品(选填)"><el-button @click="openPddPicker">{{ createData.pddProductId ? `已选择 PDD-${createData.pddProductId}` : '搜索并选择' }}</el-button><span v-if="createData.pddProductId" class="muted create-help">选择后仍需完成规格映射才能采购</span></el-form-item></el-form><template #footer><el-button @click="createDialog.open=false">取消</el-button><el-button type="primary" :loading="createDialog.saving" @click="submitCreate">保存</el-button></template></el-dialog>
|
||||
|
||||
<!-- PDD 选择器 -->
|
||||
<el-dialog v-model="pddPicker.open" title="搜索并选择 PDD 商品" width="860px" :close-on-click-modal="false">
|
||||
<el-input v-model="pddPicker.keyword" placeholder="goods_id、标题或店铺" clearable @clear="searchPddPicker" @keyup.enter="searchPddPicker"><template #append><el-button @click="searchPddPicker">搜索</el-button></template></el-input>
|
||||
<p class="muted picker-help">最近更新的 PDD 商品;可输入 goods_id、标题或店铺搜索。</p>
|
||||
<el-table v-loading="pddPicker.loading" :data="pddPicker.items" border size="small" style="margin-top:12px" max-height="360" empty-text="最近更新的 PDD 商品,可输入 goods_id、标题或店铺搜索">
|
||||
<el-table-column label="颜色图" width="86" align="center"><template #default="{ row }"><el-image v-if="row.colorImages?.[0]?.imagePath" class="picker-thumb" :src="row.colorImages[0].imagePath" :preview-src-list="[row.colorImages[0].imagePath]" :alt="`${row.colorImages[0].color || ''}颜色图,单击或双击查看大图`" fit="cover" lazy preview-teleported><template #error><span class="picker-image-placeholder">暂无图片</span></template></el-image><span v-else class="picker-image-placeholder">暂无图片</span></template></el-table-column>
|
||||
<el-table-column label="goods_id" prop="goodsId" width="150" />
|
||||
<el-table-column label="标题 / 店铺" min-width="200"><template #default="{ row }"><div>{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '未填写店铺' }}</div></template></el-table-column>
|
||||
<el-table-column label="最近更新" width="150"><template #default="{ row }">{{ parseTime(row.updatedAt) }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'disabled' ? 'info' : 'success'">{{ row.status === 'disabled' ? '已停用' : '可用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="80"><template #default="{ row }"><el-button type="primary" link :disabled="row.status === 'disabled'" @click="pickPdd(row)">选择</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<template #footer><el-button @click="pddPicker.open = false">取消</el-button></template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="pddPicker.open" title="搜索并选择 PDD 商品" width="860px" :close-on-click-modal="false"><el-input v-model="pddPicker.keyword" placeholder="goods_id、标题或店铺" clearable @clear="searchPddPicker" @keyup.enter="searchPddPicker"><template #append><el-button @click="searchPddPicker">搜索</el-button></template></el-input><p class="muted picker-help">最近更新的 PDD 商品;可输入 goods_id、标题或店铺搜索。</p><el-table v-loading="pddPicker.loading" :data="pddPicker.items" border size="small" style="margin-top:12px" max-height="360"><el-table-column label="颜色图" width="86" align="center"><template #default="{ row }"><el-image v-if="row.colorImages?.[0]?.imagePath" class="picker-thumb" :src="row.colorImages[0].imagePath" :preview-src-list="[row.colorImages[0].imagePath]" :alt="`${row.colorImages[0].color || ''}颜色图,单击或双击查看大图`" fit="cover" lazy preview-teleported><template #error><span class="picker-image-placeholder">暂无图片</span></template></el-image><span v-else class="picker-image-placeholder">暂无图片</span></template></el-table-column><el-table-column label="goods_id" prop="goodsId" width="150" /><el-table-column label="标题 / 店铺" min-width="200"><template #default="{ row }"><div>{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '未填写店铺' }}</div></template></el-table-column><el-table-column label="最近更新" width="150"><template #default="{ row }">{{ parseTime(row.updatedAt) }}</template></el-table-column><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'disabled' ? 'info' : 'success'">{{ row.status === 'disabled' ? '已停用' : '可用' }}</el-tag></template></el-table-column><el-table-column label="操作" width="80"><template #default="{ row }"><el-button type="primary" link :disabled="row.status === 'disabled'" @click="pickPdd(row)">选择</el-button></template></el-table-column></el-table><template #footer><el-button @click="pddPicker.open=false">取消</el-button></template></el-dialog>
|
||||
|
||||
<!-- 批量删除确认 -->
|
||||
<el-dialog v-model="batchDelete.open" :title="batchDelete.step === 'confirm' ? '删除虾皮商品?' : '删除结果'" width="640px" :close-on-click-modal="false" @closed="batchDelete = emptyBatchDelete()">
|
||||
<template v-if="batchDelete.step === 'confirm'">
|
||||
<el-alert title="软删除:记录保留在数据库中,不会物理删除。删除后不出现在默认列表,不能被关联和映射;可在筛选器选择「已删除」查看并恢复。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<p>将对选中的 {{ batchDelete.products.length }} 条虾皮商品执行删除;被 SYB 明细或采购任务引用的商品会被服务端跳过,不影响其他条目。</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-alert :title="`已删除 ${batchDelete.deletedCount} 条,跳过 ${batchDelete.skippedCount} 条`" :type="batchDelete.skippedCount ? 'warning' : 'success'" :closable="false" show-icon class="notice" />
|
||||
<el-table :data="batchDelete.results" border size="small" max-height="320">
|
||||
<el-table-column label="ID" prop="id" width="90" />
|
||||
<el-table-column label="结果" width="100"><template #default="{ row }"><el-tag :type="row.status === 'deleted' ? 'success' : 'info'">{{ row.status === 'deleted' ? '已删除' : '已跳过' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="说明" min-width="200"><template #default="{ row }">{{ row.reason || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<template #footer>
|
||||
<template v-if="batchDelete.step === 'confirm'"><el-button @click="batchDelete.open = false">取消</el-button><el-button type="danger" :loading="batchDelete.saving" @click="submitBatchDelete">确认删除 {{ batchDelete.products.length }} 条</el-button></template>
|
||||
<el-button v-else type="primary" @click="batchDelete.open = false; load()">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="batchDelete.open" :title="batchDelete.step === 'confirm' ? '删除虾皮商品?' : '删除结果'" width="640px" :close-on-click-modal="false" @closed="batchDelete=emptyBatchDelete()"><template v-if="batchDelete.step === 'confirm'"><el-alert title="软删除:记录保留在数据库中,不会物理删除。删除后不出现在默认列表,不能被关联和映射;可在筛选器选择「已删除」查看并恢复。" type="warning" :closable="false" show-icon class="notice" /><p>将对选中的 {{ batchDelete.products.length }} 条虾皮商品执行删除;被 SYB 明细或采购任务引用的商品会被服务端跳过,不影响其他条目。</p></template><template v-else><el-alert :title="`已删除 ${batchDelete.deletedCount} 条,跳过 ${batchDelete.skippedCount} 条`" :type="batchDelete.skippedCount ? 'warning' : 'success'" :closable="false" show-icon class="notice" /><el-table :data="batchDelete.results" border size="small" max-height="320"><el-table-column label="ID" prop="id" width="90" /><el-table-column label="结果" width="100"><template #default="{ row }"><el-tag :type="row.status === 'deleted' ? 'success' : 'info'">{{ row.status === 'deleted' ? '已删除' : '已跳过' }}</el-tag></template></el-table-column><el-table-column label="说明" min-width="200"><template #default="{ row }">{{ row.reason || '—' }}</template></el-table-column></el-table></template><template #footer><template v-if="batchDelete.step === 'confirm'"><el-button @click="batchDelete.open=false">取消</el-button><el-button type="danger" :loading="batchDelete.saving" @click="submitBatchDelete">确认删除 {{ batchDelete.products.length }} 条</el-button></template><el-button v-else type="primary" @click="batchDelete.open=false; load()">关闭</el-button></template></el-dialog>
|
||||
|
||||
<!-- 详情 -->
|
||||
<el-drawer v-model="detail.open" title="虾皮商品详情" size="1080px" :before-close="beforeCloseDetail">
|
||||
<div v-loading="detail.loading" class="drawer-body">
|
||||
<template v-if="detail.product">
|
||||
<div class="drawer-actions"><el-tag :type="mappingMeta(detail.product).type">{{ mappingMeta(detail.product).label }}</el-tag><el-button type="primary" @click="openEdit">编辑档案</el-button></div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="虾皮商品ID">{{ detail.product.shopeeItemId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="店铺">{{ detail.product.shopName || '未填写' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="标题" :span="2">{{ detail.product.title || '未填写' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="售价">{{ priceText(detail.product) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="关联 PDD 商品"><a v-if="detail.product.pddProductId" class="link" href="javascript:void(0)" @click="openPddDetail(detail.product.pddProductId)">PDD-{{ detail.product.pddProductId }} ↗</a><span v-else class="muted">未关联</span> <el-button link type="primary" size="small" @click="openPddPicker('link')">{{ detail.product.pddProductId ? '更换' : '关联' }}</el-button></el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p v-if="detail.product.pddProductId && detail.product.sharedByPddCount > 1" class="muted shared-note">该 PDD 商品当前被 {{ detail.product.sharedByPddCount }} 个虾皮商品共用,允许共用。</p>
|
||||
|
||||
<div class="section-heading"><h3>规格匹配</h3><span class="mapping-progress">{{ mappingProgressText }}</span></div>
|
||||
<el-alert title="只保存虾皮规格与 PDD 规格的对应关系,不会修改两端的原始颜色和尺码。" type="info" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-if="!detail.product.pddProductId" title="请先关联 PDD 商品,再进行规格匹配。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-else-if="detail.pddLoaded && !pddSpecValues('color').length && !pddSpecValues('size').length" title="关联的 PDD 商品暂无可用规格,请先完成商品采集。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-tabs v-model="detail.activeSpecTab" class="mapping-tabs">
|
||||
<el-tab-pane label="颜色匹配" name="color">
|
||||
<div class="tab-actions"><span class="muted">AI 只生成未保存的建议,置信度不足不会预填,均需人工确认后保存。</span><el-button type="primary" plain :loading="detail.aiSuggesting.color" :disabled="!canAIMatchColor" @click="previewAISuggestions('color')">AI 匹配</el-button></div>
|
||||
<el-empty v-if="!specRows('color').length" description="虾皮商品暂无颜色规格" />
|
||||
<el-table v-else :data="specRows('color')" :row-class-name="mappingRowClass" border size="small">
|
||||
<el-table-column label="虾皮颜色" min-width="180"><template #default="{ row }"><div class="primary">{{ row.name }}</div><div class="muted">{{ row.source === 'manual' ? '人工添加' : 'SYB 导入' }}</div></template></el-table-column>
|
||||
<el-table-column label="PDD 颜色" min-width="360"><template #default="{ row }">
|
||||
<el-select v-model="row.pddValueDraft" filterable clearable placeholder="搜索并选择 PDD 颜色" class="mapping-select" :disabled="!detail.pddLoaded" @change="markManual(row)">
|
||||
<el-option-group v-for="group in colorOptionGroups(row)" :key="group.label" :label="group.label">
|
||||
<el-option v-for="option in group.options" :key="`${group.label}-${option.value}`" :label="option.label" :value="option.value" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
<div v-if="row.previewReason" class="muted preview-reason">{{ row.previewReason }}</div>
|
||||
</template></el-table-column>
|
||||
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="rowStatus(row).type" size="small">{{ rowStatus(row).label }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="150"><template #default="{ row }"><el-button link type="primary" :disabled="!row.pddValueDraft" @click="clearDraft(row)">清除</el-button><el-button v-if="row.pddValueDraft === row.originalPddValue && row.mapping && row.mapping.status === 'pending'" link type="primary" @click="confirmPendingMapping(row)">确认</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="尺码匹配" name="size">
|
||||
<div class="tab-actions"><span class="muted">先按格式统一自动匹配;未匹配的项再用 AI 生成建议,置信度不足不会预填。</span><el-button type="primary" plain :loading="detail.aiSuggesting.size" :disabled="!canAIMatchSize" @click="previewAISuggestions('size')">AI 匹配</el-button></div>
|
||||
<el-empty v-if="!specRows('size').length" description="虾皮商品暂无尺码规格" />
|
||||
<el-table v-else :data="specRows('size')" border size="small">
|
||||
<el-table-column label="虾皮尺码" min-width="180"><template #default="{ row }"><div class="primary">{{ row.name }}</div><div class="muted">{{ row.source === 'manual' ? '人工添加' : 'SYB 导入' }}</div></template></el-table-column>
|
||||
<el-table-column label="PDD 尺码" min-width="360"><template #default="{ row }"><el-select v-model="row.pddValueDraft" filterable clearable placeholder="搜索并选择 PDD 尺码" class="mapping-select" :disabled="!detail.pddLoaded" @change="markManual(row)"><el-option v-for="option in pddSpecValues('size')" :key="option.name" :label="option.name" :value="option.name" /></el-select><div v-if="row.previewReason" class="muted preview-reason">{{ row.previewReason }}</div></template></el-table-column>
|
||||
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="rowStatus(row).type" size="small">{{ rowStatus(row).label }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="150"><template #default="{ row }"><el-button link type="primary" :disabled="!row.pddValueDraft" @click="clearDraft(row)">清除</el-button><el-button v-if="row.pddValueDraft === row.originalPddValue && row.mapping && row.mapping.status === 'pending'" link type="primary" @click="confirmPendingMapping(row)">确认</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<div class="section-heading"><h3>添加规格值</h3></div>
|
||||
<div class="quick-row"><span class="quick-label">维度</span><el-select v-model="addValueForm.role" size="small" style="width:110px"><el-option label="颜色" value="color" /><el-option label="尺码" value="size" /><el-option label="其他" value="other" /></el-select><el-input v-model="addValueForm.dimension" size="small" placeholder="维度名称,如 颜色 / 尺码" style="width:160px" /><el-input v-model="addValueForm.name" size="small" placeholder="规格值名称" style="width:160px" /><el-button size="small" type="primary" @click="submitAddValue">+ 添加规格值</el-button></div>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><el-button @click="closeDetail">关闭</el-button><el-button type="primary" :loading="detail.saving" :disabled="!hasMappingChanges" @click="saveAllMappings">保存修改</el-button></template>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 编辑档案 -->
|
||||
<el-drawer v-model="edit.open" title="编辑虾皮商品档案" size="560px" :close-on-click-modal="false">
|
||||
<div v-loading="edit.loading" class="drawer-body">
|
||||
<el-form ref="editForm" :model="editData" label-position="top">
|
||||
<el-form-item label="商品标题"><el-input v-model="editData.title" maxlength="500" /></el-form-item>
|
||||
<el-form-item label="店铺"><el-input v-model="editData.shopName" maxlength="255" /></el-form-item>
|
||||
<el-form-item label="参考图 URL"><el-input v-model="editData.imageUrl" maxlength="2048" placeholder="SYB 提供的图片 URL" /></el-form-item>
|
||||
<el-form-item label="售价(参考,分)"><el-input-number v-model="editData.salePriceCent" :min="0" :precision="0" /><span class="muted" style="margin-left:8px">币种由系统配置统一设置,不逐商品选择</span></el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer><el-button @click="edit.open = false">取消</el-button><el-button type="primary" :loading="edit.saving" @click="saveEdit">保存修改</el-button></template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
<ShopeeProductDetailDrawer v-model="detail.open" :product-id="detail.productId" :target-color="detail.targetColor" :action="detail.action" @product-updated="load" @mapping-updated="load" @pdd-linked="load" @switch-cancelled="onSwitchCancelled" />
|
||||
<PddProductDetailDrawer v-model="pddDetail.open" :product-id="pddDetail.productId" />
|
||||
</template></BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, RefreshLeft, Search, Delete } from '@element-plus/icons-vue'
|
||||
import {
|
||||
listShopeeProducts, createShopeeProduct, getShopeeProduct, updateShopeeProduct,
|
||||
linkShopeeProductPdd, restoreShopeeProduct, addShopeeSpecValue, removeShopeeSpecValue,
|
||||
setShopeeSpecMapping, clearShopeeSpecMapping, confirmShopeeSpecMapping,
|
||||
suggestShopeeColorMappings, suggestShopeeSizeMappings, batchDeleteShopeeProducts
|
||||
} from '@/api/goauto/shopee-products'
|
||||
import { getPddProduct, listPddProducts } from '@/api/goauto/pdd-products'
|
||||
import { listShopeeProducts, createShopeeProduct, restoreShopeeProduct, batchDeleteShopeeProducts } from '@/api/goauto/shopee-products'
|
||||
import { listPddProducts } from '@/api/goauto/pdd-products'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
import ShopeeProductDetailDrawer from './ShopeeProductDetailDrawer.vue'
|
||||
import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoShopeeProducts',
|
||||
setup() { return { Plus, RefreshLeft, Search, Delete } },
|
||||
data() {
|
||||
return {
|
||||
loading: false, products: [], selectedProducts: [], total: 0,
|
||||
query: { page: 1, pageSize: 20, keyword: '', status: '' },
|
||||
createDialog: { open: false, saving: false },
|
||||
createData: this.emptyCreate(),
|
||||
createRules: { shopeeItemId: [{ required: true, message: '请输入虾皮商品ID', trigger: 'blur' }, { max: 64, message: '不能超过 64 个字符', trigger: 'blur' }] },
|
||||
quickColor: '', quickSize: '', colorValues: [], sizeValues: [],
|
||||
pddPicker: { open: false, loading: false, keyword: '', items: [], target: 'create' },
|
||||
batchDelete: this.emptyBatchDelete(),
|
||||
detail: this.emptyDetail(),
|
||||
edit: { open: false, loading: false, saving: false, productId: null },
|
||||
editData: { title: '', shopName: '', imageUrl: '', salePriceCent: null },
|
||||
addValueForm: { dimension: '', role: 'other', name: '' }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
allSpecRows() { return this.detail.product ? this.detail.product.specs.flatMap(d => d.values) : [] },
|
||||
hasMappingChanges() { return this.allSpecRows.some(row => (row.pddValueDraft || '') !== (row.originalPddValue || '')) },
|
||||
mappingProgressText() {
|
||||
const rows = this.allSpecRows.filter(row => row.role === 'color' || row.role === 'size')
|
||||
const valid = rows.filter(row => row.pddValueDraft && this.pddValueExists(row.role, row.pddValueDraft)).length
|
||||
return rows.length ? `${valid}/${rows.length} 已匹配` : '暂无可匹配规格'
|
||||
},
|
||||
canAIMatchColor() { return this.detail.pddLoaded && this.specRows('color').length > 0 && this.pddSpecValues('color').length > 0 },
|
||||
canAIMatchSize() { return this.detail.pddLoaded && this.specRows('size').length > 0 && this.pddSpecValues('size').length > 0 }
|
||||
},
|
||||
created() {
|
||||
this.load()
|
||||
const productId = Number(this.$route.query.productId)
|
||||
if (Number.isInteger(productId) && productId > 0) this.openRouteDetail(productId).catch(() => {})
|
||||
},
|
||||
name: 'GoAutoShopeeProducts', components: { ShopeeProductDetailDrawer, PddProductDetailDrawer }, setup() { return { Plus, RefreshLeft, Search, Delete } },
|
||||
data() { return { loading: false, products: [], selectedProducts: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '' }, createDialog: { open: false, saving: false }, createData: this.emptyCreate(), createRules: { shopeeItemId: [{ required: true, message: '请输入虾皮商品ID', trigger: 'blur' }, { max: 64, message: '不能超过 64 个字符', trigger: 'blur' }] }, quickColor: '', quickSize: '', colorValues: [], sizeValues: [], pddPicker: { open: false, loading: false, keyword: '', items: [] }, batchDelete: this.emptyBatchDelete(), detail: { open: false, productId: null, targetColor: '', action: '' }, pddDetail: { open: false, productId: null }} },
|
||||
watch: { '$route.query': { deep: true, handler() { this.applyRouteDetail() } }}, created() { this.load(); this.applyRouteDetail() }, activated() { this.applyRouteDetail() },
|
||||
methods: {
|
||||
emptyCreate() { return { shopeeItemId: '', title: '', shopName: '', pddProductId: null } },
|
||||
emptyBatchDelete() { return { open: false, saving: false, step: 'confirm', products: [], results: [], deletedCount: 0, skippedCount: 0 } },
|
||||
emptyDetail() { return { open: false, loading: false, saving: false, autoMatching: false, aiSuggesting: { color: false, size: false }, product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } },
|
||||
async load() {
|
||||
this.loading = true
|
||||
this.selectedProducts = []
|
||||
this.$refs.productTable?.clearSelection()
|
||||
try {
|
||||
const r = await listShopeeProducts(this.query)
|
||||
this.products = r.data.items
|
||||
this.total = r.data.total
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
search() { this.query.page = 1; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 20, keyword: '', status: '' }; this.load() },
|
||||
handleSelectionChange(rows) { this.selectedProducts = rows },
|
||||
priceText(row) { if (row.salePriceCent === null || row.salePriceCent === undefined) return '—'; return `${row.currency || ''} ${(row.salePriceCent / 100).toFixed(2)}` },
|
||||
roleLabel(role) { return { color: '颜色', size: '尺码', other: '其他' }[role] || role },
|
||||
mappingSourceLabel(source) { return { manual: '人工', exact_match: '精确匹配', ai_match: 'AI 建议' }[source] || source },
|
||||
mappingSourceType(source) { return { manual: 'success', exact_match: 'warning', ai_match: '' }[source] || 'info' },
|
||||
mappingMeta(product) {
|
||||
if (!product) return { label: '-', type: 'info' }
|
||||
if (!product.pddProductId) return { label: '未关联 PDD 商品', type: 'info' }
|
||||
const values = (product.specs || []).flatMap(d => d.values)
|
||||
if (!values.length) return { label: '待完善', type: 'warning' }
|
||||
const isOpenDetail = this.detail.product && product.id === this.detail.product.id && this.detail.pddLoaded
|
||||
const confirmed = values.filter(v => v.mapping && v.mapping.status === 'confirmed' && (!isOpenDetail || this.pddValueExists(v.role || '', v.mapping.pddValue))).length
|
||||
return confirmed === values.length ? { label: '可采购', type: 'success' } : { label: `待完善 · ${confirmed}/${values.length} 已确认`, type: 'warning' }
|
||||
},
|
||||
|
||||
// ---------------- 创建 ----------------
|
||||
openCreate() {
|
||||
this.createDialog = { open: true, saving: false }
|
||||
this.createData = this.emptyCreate()
|
||||
this.colorValues = []; this.sizeValues = []; this.quickColor = ''; this.quickSize = ''
|
||||
this.$nextTick(() => this.$refs.createForm?.clearValidate())
|
||||
},
|
||||
addQuickValue(role) {
|
||||
if (role === 'color') { const v = this.quickColor.trim(); if (v && !this.colorValues.includes(v)) this.colorValues.push(v); this.quickColor = '' } else { const v = this.quickSize.trim(); if (v && !this.sizeValues.includes(v)) this.sizeValues.push(v); this.quickSize = '' }
|
||||
},
|
||||
async submitCreate() {
|
||||
if (!await this.$refs.createForm.validate().catch(() => false)) return
|
||||
this.createDialog.saving = true
|
||||
try {
|
||||
const specs = []
|
||||
if (this.colorValues.length) specs.push({ name: '颜色', role: 'color', values: this.colorValues.map(name => ({ name, source: 'manual' })) })
|
||||
if (this.sizeValues.length) specs.push({ name: '尺码', role: 'size', values: this.sizeValues.map(name => ({ name, source: 'manual' })) })
|
||||
const r = await createShopeeProduct({
|
||||
requestId: createRequestId(), shopeeItemId: this.createData.shopeeItemId.trim(),
|
||||
title: this.createData.title.trim(), shopName: this.createData.shopName.trim(),
|
||||
pddProductId: this.createData.pddProductId || undefined, specs
|
||||
})
|
||||
ElMessage.success('虾皮商品已添加')
|
||||
this.createDialog.open = false
|
||||
await this.load()
|
||||
await this.openDetail(r.data.product.id)
|
||||
} finally {
|
||||
this.createDialog.saving = false
|
||||
}
|
||||
},
|
||||
|
||||
// ---------------- PDD 选择 ----------------
|
||||
openPddPicker(target) { this.pddPicker = { open: true, loading: false, keyword: '', items: [], target }; this.searchPddPicker() },
|
||||
async searchPddPicker() {
|
||||
this.pddPicker.loading = true
|
||||
try {
|
||||
const keyword = this.pddPicker.keyword.trim()
|
||||
const params = keyword ? { page: 1, pageSize: 20, keyword } : { page: 1, pageSize: 5, status: 'active' }
|
||||
const r = await listPddProducts(params)
|
||||
this.pddPicker.items = r.data.items
|
||||
} finally {
|
||||
this.pddPicker.loading = false
|
||||
}
|
||||
},
|
||||
async pickPdd(row) {
|
||||
if (this.pddPicker.target === 'create') {
|
||||
this.createData.pddProductId = row.id
|
||||
this.pddPicker.open = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await linkShopeeProductPdd(this.detail.product.id, { requestId: createRequestId(), pddProductId: row.id })
|
||||
this.detail.product = this.withDraftMappings(r.data.product)
|
||||
await this.loadLinkedPdd(row.id)
|
||||
ElMessage.success('已关联 PDD 商品')
|
||||
} finally {
|
||||
this.pddPicker.open = false
|
||||
}
|
||||
},
|
||||
openPddDetail(pddProductId) {
|
||||
const route = this.$router.resolve({ path: '/pdd-products/index', query: { productId: pddProductId }})
|
||||
window.open(route.href, '_blank', 'noopener')
|
||||
},
|
||||
|
||||
// ---------------- 详情 / 映射 ----------------
|
||||
async openRouteDetail(id) {
|
||||
try {
|
||||
await this.openDetail(id)
|
||||
} finally {
|
||||
if (String(this.$route.query.action || '') === 'link_pdd' && this.detail.product?.id === id) this.openPddPicker('link')
|
||||
}
|
||||
},
|
||||
async openDetail(id) {
|
||||
this.detail = { ...this.emptyDetail(), open: true, loading: true, highlightColor: String(this.$route.query.targetColor || '').trim() }
|
||||
try {
|
||||
const r = await getShopeeProduct(id)
|
||||
this.detail.product = this.withDraftMappings(r.data.product)
|
||||
if (this.detail.product.pddProductId) await this.loadLinkedPdd(this.detail.product.pddProductId)
|
||||
} finally {
|
||||
this.detail.loading = false
|
||||
}
|
||||
},
|
||||
withDraftMappings(product) {
|
||||
return {
|
||||
...product,
|
||||
specs: (product.specs || []).map(d => ({
|
||||
...d,
|
||||
values: d.values.map(v => ({
|
||||
...v, dimensionName: d.name, role: d.role,
|
||||
pddValueDraft: v.mapping ? v.mapping.pddValue : '',
|
||||
originalPddValue: v.mapping ? v.mapping.pddValue : '',
|
||||
draftSource: v.mapping ? v.mapping.source : 'manual', previewReason: '', draftConfidence: null
|
||||
}))
|
||||
}))
|
||||
}
|
||||
},
|
||||
async loadLinkedPdd(id) {
|
||||
this.detail.pddLoaded = false
|
||||
this.detail.pddProduct = null
|
||||
try {
|
||||
const r = await getPddProduct(id)
|
||||
this.detail.pddProduct = r.data.product
|
||||
} finally {
|
||||
this.detail.pddLoaded = true
|
||||
}
|
||||
},
|
||||
async refreshDetail() {
|
||||
const r = await getShopeeProduct(this.detail.product.id)
|
||||
this.detail.product = this.withDraftMappings(r.data.product)
|
||||
if (this.detail.product.pddProductId) await this.loadLinkedPdd(this.detail.product.pddProductId)
|
||||
await this.load()
|
||||
},
|
||||
specRows(role) { return this.allSpecRows.filter(row => row.role === role) },
|
||||
pddSpecValues(role) {
|
||||
if (!this.detail.pddProduct) return []
|
||||
return (this.detail.pddProduct.specs || []).filter(d => d.role === role).flatMap(d => d.values).filter(v => v.selectable !== false && v.name)
|
||||
},
|
||||
pddValueExists(role, value) { return this.pddSpecValues(role).some(item => item.name === value) },
|
||||
rowStatus(row) {
|
||||
if (!row.pddValueDraft) return { label: '待匹配', type: 'warning' }
|
||||
if (!this.pddValueExists(row.role, row.pddValueDraft)) return { label: '已失效', type: 'danger' }
|
||||
if (row.pddValueDraft !== row.originalPddValue) return { label: row.draftSource === 'ai_match' ? '待保存(AI 建议)' : '待保存', type: 'warning' }
|
||||
if (row.mapping && row.mapping.status === 'pending') return { label: 'AI 待确认', type: 'warning' }
|
||||
return { label: '已匹配', type: 'success' }
|
||||
},
|
||||
colorOptionGroups(row) {
|
||||
const values = this.pddSpecValues('color')
|
||||
const owners = new Map()
|
||||
this.specRows('color').forEach(item => {
|
||||
if (!item.pddValueDraft || item === row) return
|
||||
if (!owners.has(item.pddValueDraft)) owners.set(item.pddValueDraft, [])
|
||||
owners.get(item.pddValueDraft).push(item.name)
|
||||
})
|
||||
const groups = []
|
||||
if (row.pddValueDraft && this.pddValueExists('color', row.pddValueDraft)) {
|
||||
const current = values.find(item => item.name === row.pddValueDraft)
|
||||
groups.push({ label: '当前选择', options: [{ value: current.name, label: this.pddOptionLabel(current) }] })
|
||||
}
|
||||
const unused = values.filter(item => item.name !== row.pddValueDraft && !owners.has(item.name)).map(item => ({ value: item.name, label: this.pddOptionLabel(item) }))
|
||||
const used = values.filter(item => item.name !== row.pddValueDraft && owners.has(item.name)).map(item => ({ value: item.name, label: `${this.pddOptionLabel(item)} · 已被“${owners.get(item.name).join('、')}”使用` }))
|
||||
if (unused.length) groups.push({ label: `未使用的颜色(${unused.length})`, options: unused })
|
||||
if (used.length) groups.push({ label: '已经匹配(仍可选择)', options: used })
|
||||
return groups
|
||||
},
|
||||
pddOptionLabel(option) { return option.priceCent === null || option.priceCent === undefined ? option.name : `${option.name} · ¥${(option.priceCent / 100).toFixed(2)}` },
|
||||
markManual(row) { row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null },
|
||||
clearDraft(row) { row.pddValueDraft = ''; row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null },
|
||||
mappingRowClass({ row }) { return this.detail.highlightColor && row.role === 'color' && row.name === this.detail.highlightColor ? 'mapping-row-highlight' : '' },
|
||||
async previewAISuggestions(role) {
|
||||
this.detail.aiSuggesting[role] = true
|
||||
try {
|
||||
const suggest = role === 'color' ? suggestShopeeColorMappings : suggestShopeeSizeMappings
|
||||
const r = await suggest(this.detail.product.id, { specContextVersion: this.detail.product.specContextVersion })
|
||||
const byName = new Map(r.data.items.map(item => [item.valueName, item]))
|
||||
this.specRows(role).forEach(row => {
|
||||
const item = byName.get(row.name)
|
||||
if (!item) return
|
||||
row.previewReason = item.reason || ''
|
||||
row.draftConfidence = item.confidence ?? null
|
||||
// "preserved" is an already-confirmed mapping the preview echoes
|
||||
// back unchanged; only a fresh match/AI decision should touch the
|
||||
// draft. A low-confidence AI decision (apply=false) is shown via
|
||||
// previewReason but never prefilled — the operator must pick it.
|
||||
if (item.apply && item.status !== 'preserved') {
|
||||
row.pddValueDraft = item.pddValue || ''
|
||||
row.draftSource = item.status === 'matched' ? 'exact_match' : 'ai_match'
|
||||
}
|
||||
})
|
||||
const suggestedCount = r.data.items.filter(item => item.status === 'ai_suggested').length
|
||||
ElMessage.success(`匹配 ${r.data.matchedCount} 项,AI 建议但置信度不足 ${suggestedCount} 项,待人工选择 ${r.data.pendingCount} 项`)
|
||||
} finally {
|
||||
this.detail.aiSuggesting[role] = false
|
||||
}
|
||||
},
|
||||
async confirmPendingMapping(row) {
|
||||
await confirmShopeeSpecMapping(this.detail.product.id, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name })
|
||||
await this.refreshDetail()
|
||||
},
|
||||
async saveAllMappings() {
|
||||
const changes = this.allSpecRows.filter(row => (row.pddValueDraft || '') !== (row.originalPddValue || ''))
|
||||
if (!changes.length) return
|
||||
const invalid = changes.find(row => row.pddValueDraft && !this.pddValueExists(row.role, row.pddValueDraft))
|
||||
if (invalid) { ElMessage.warning(`PDD 规格“${invalid.pddValueDraft}”已不存在,请重新选择`); return }
|
||||
this.detail.saving = true
|
||||
try {
|
||||
for (const row of changes) {
|
||||
if (!row.pddValueDraft) {
|
||||
if (row.originalPddValue) await clearShopeeSpecMapping(this.detail.product.id, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name })
|
||||
continue
|
||||
}
|
||||
const source = ['exact_match', 'ai_match'].includes(row.draftSource) ? row.draftSource : 'manual'
|
||||
const payload = { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name, pddValue: row.pddValueDraft, source }
|
||||
if (source === 'ai_match') {
|
||||
if (row.draftConfidence !== null && row.draftConfidence !== undefined) payload.confidence = row.draftConfidence
|
||||
if (row.previewReason) payload.reason = row.previewReason
|
||||
}
|
||||
await setShopeeSpecMapping(this.detail.product.id, payload)
|
||||
// AI-sourced mappings are always written as pending by the server
|
||||
// (#40, #46) and must stay that way until an operator explicitly
|
||||
// confirms them, separately from the act of saving the draft.
|
||||
if (source === 'exact_match') await confirmShopeeSpecMapping(this.detail.product.id, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name })
|
||||
}
|
||||
ElMessage.success('规格匹配已保存')
|
||||
await this.refreshDetail()
|
||||
} finally {
|
||||
this.detail.saving = false
|
||||
}
|
||||
},
|
||||
closeDetail() { this.beforeCloseDetail(() => { this.detail.open = false }) },
|
||||
beforeCloseDetail(done) {
|
||||
if (!this.hasMappingChanges) { done(); return }
|
||||
this.$confirm('当前有未保存的规格匹配,确定放弃吗?', '放弃修改?', { type: 'warning', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }).then(() => done()).catch(() => {})
|
||||
},
|
||||
async submitAddValue() {
|
||||
const dimension = this.addValueForm.dimension.trim()
|
||||
const name = this.addValueForm.name.trim()
|
||||
if (!dimension || !name) { ElMessage.warning('请填写维度名称和规格值'); return }
|
||||
await addShopeeSpecValue(this.detail.product.id, { requestId: createRequestId(), dimension, role: this.addValueForm.role, name })
|
||||
this.addValueForm = { dimension: '', role: 'other', name: '' }
|
||||
await this.refreshDetail()
|
||||
},
|
||||
async removeValue(dimension, row) {
|
||||
await removeShopeeSpecValue(this.detail.product.id, { requestId: createRequestId(), dimension, name: row.name })
|
||||
await this.refreshDetail()
|
||||
},
|
||||
// ---------------- 编辑档案 ----------------
|
||||
openEdit() {
|
||||
const p = this.detail.product
|
||||
this.edit = { open: true, loading: false, saving: false, productId: p.id }
|
||||
this.editData = { title: p.title || '', shopName: p.shopName || '', imageUrl: p.imageUrl || '', salePriceCent: p.salePriceCent }
|
||||
},
|
||||
async saveEdit() {
|
||||
this.edit.saving = true
|
||||
try {
|
||||
await updateShopeeProduct(this.edit.productId, { requestId: createRequestId(), ...this.editData })
|
||||
ElMessage.success('虾皮商品资料已保存')
|
||||
this.edit.open = false
|
||||
await this.refreshDetail()
|
||||
} finally {
|
||||
this.edit.saving = false
|
||||
}
|
||||
},
|
||||
|
||||
// ---------------- 删除 / 恢复 ----------------
|
||||
openBatchDelete() { this.batchDelete = { ...this.emptyBatchDelete(), open: true, products: this.selectedProducts.map(item => ({ ...item })) } },
|
||||
async submitBatchDelete() {
|
||||
this.batchDelete.saving = true
|
||||
try {
|
||||
const r = await batchDeleteShopeeProducts({ requestId: createRequestId(), ids: this.batchDelete.products.map(item => item.id) })
|
||||
this.batchDelete.results = r.data.results
|
||||
this.batchDelete.deletedCount = r.data.results.filter(x => x.status === 'deleted').length
|
||||
this.batchDelete.skippedCount = r.data.results.filter(x => x.status === 'skipped').length
|
||||
this.batchDelete.step = 'result'
|
||||
} finally {
|
||||
this.batchDelete.saving = false
|
||||
}
|
||||
},
|
||||
async restore(row) {
|
||||
await restoreShopeeProduct(row.id, { requestId: createRequestId() })
|
||||
ElMessage.success('已恢复')
|
||||
await this.load()
|
||||
}
|
||||
emptyCreate() { return { shopeeItemId: '', title: '', shopName: '', pddProductId: null } }, emptyBatchDelete() { return { open: false, saving: false, step: 'confirm', products: [], results: [], deletedCount: 0, skippedCount: 0 } },
|
||||
applyRouteDetail() { const id = Number(this.$route.query.productId); if (!Number.isInteger(id) || id <= 0) return; this.detail = { open: true, productId: id, targetColor: String(this.$route.query.targetColor || '').trim(), action: String(this.$route.query.action || '') } },
|
||||
async load() { this.loading = true; this.selectedProducts = []; this.$refs.productTable?.clearSelection(); try { const r = await listShopeeProducts(this.query); this.products = r.data.items; this.total = r.data.total } finally { this.loading = false } }, search() { this.query.page = 1; this.load() }, reset() { this.query = { page: 1, pageSize: 20, keyword: '', status: '' }; this.load() }, handleSelectionChange(rows) { this.selectedProducts = rows },
|
||||
priceText(row) { if (row.salePriceCent === null || row.salePriceCent === undefined) return '—'; return `${row.currency || ''} ${(row.salePriceCent / 100).toFixed(2)}` }, mappingMeta(product) { if (!product.pddProductId) return { label: '未关联 PDD 商品', type: 'info' }; const values = (product.specs || []).flatMap(d => d.values); if (!values.length) return { label: '待完善', type: 'warning' }; const confirmed = values.filter(v => v.mapping?.status === 'confirmed').length; return confirmed === values.length ? { label: '可采购', type: 'success' } : { label: `待完善 · ${confirmed}/${values.length} 已确认`, type: 'warning' } },
|
||||
openDetail(id) { this.detail = { open: true, productId: Number(id), targetColor: '', action: '' } }, openPddDetail(id) { this.pddDetail = { open: true, productId: Number(id) } }, onSwitchCancelled(id) { this.detail.productId = Number(id) },
|
||||
openCreate() { this.createDialog = { open: true, saving: false }; this.createData = this.emptyCreate(); this.colorValues = []; this.sizeValues = []; this.quickColor = ''; this.quickSize = ''; this.$nextTick(() => this.$refs.createForm?.clearValidate()) }, addQuickValue(role) { if (role === 'color') { const v = this.quickColor.trim(); if (v && !this.colorValues.includes(v)) this.colorValues.push(v); this.quickColor = '' } else { const v = this.quickSize.trim(); if (v && !this.sizeValues.includes(v)) this.sizeValues.push(v); this.quickSize = '' } },
|
||||
async submitCreate() { if (!await this.$refs.createForm.validate().catch(() => false)) return; this.createDialog.saving = true; try { const specs = []; if (this.colorValues.length) specs.push({ name: '颜色', role: 'color', values: this.colorValues.map(name => ({ name, source: 'manual' })) }); if (this.sizeValues.length) specs.push({ name: '尺码', role: 'size', values: this.sizeValues.map(name => ({ name, source: 'manual' })) }); const r = await createShopeeProduct({ requestId: createRequestId(), shopeeItemId: this.createData.shopeeItemId.trim(), title: this.createData.title.trim(), shopName: this.createData.shopName.trim(), pddProductId: this.createData.pddProductId || undefined, specs }); ElMessage.success('虾皮商品已添加'); this.createDialog.open = false; await this.load(); this.openDetail(r.data.product.id) } finally { this.createDialog.saving = false } },
|
||||
openPddPicker() { this.pddPicker = { open: true, loading: false, keyword: '', items: [] }; this.searchPddPicker() }, async searchPddPicker() { this.pddPicker.loading = true; try { const keyword = this.pddPicker.keyword.trim(); const r = await listPddProducts(keyword ? { page: 1, pageSize: 20, keyword } : { page: 1, pageSize: 5, status: 'active' }); this.pddPicker.items = r.data.items } finally { this.pddPicker.loading = false } }, pickPdd(row) { this.createData.pddProductId = row.id; this.pddPicker.open = false },
|
||||
openBatchDelete() { this.batchDelete = { ...this.emptyBatchDelete(), open: true, products: this.selectedProducts.map(item => ({ ...item })) } }, async submitBatchDelete() { this.batchDelete.saving = true; try { const r = await batchDeleteShopeeProducts({ requestId: createRequestId(), ids: this.batchDelete.products.map(item => item.id) }); this.batchDelete.results = r.data.results; this.batchDelete.deletedCount = r.data.results.filter(x => x.status === 'deleted').length; this.batchDelete.skippedCount = r.data.results.filter(x => x.status === 'skipped').length; this.batchDelete.step = 'result' } finally { this.batchDelete.saving = false } }, async restore(row) { await restoreShopeeProduct(row.id, { requestId: createRequestId() }); ElMessage.success('已恢复'); await this.load() }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-card { min-height: calc(100vh - 124px); }
|
||||
.page-heading { display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:24px;h1{margin:0 0 6px;font-size:24px;color:#1f2937}p{margin:0;color:#606266;line-height:1.5} }
|
||||
.heading-actions { display:flex;gap:12px;flex-shrink:0 }
|
||||
.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
|
||||
.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}
|
||||
.thumb{width:48px;height:48px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}
|
||||
.picker-help{margin:8px 0 0}.picker-thumb{display:block;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{display:flex;align-items:center;justify-content:center;width:56px;height:56px;margin:auto;border-radius:4px;background:#f1f5f9;color:#909399;font-size:11px}
|
||||
.link{color:#1677ff;cursor:pointer}
|
||||
.scope-note{margin-top:12px;font-size:12px;color:#b91c1c}
|
||||
.notice{margin-bottom:16px}
|
||||
.drawer-body{padding:0 4px 24px}
|
||||
.drawer-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}
|
||||
.shared-note{margin:8px 0 0}
|
||||
.section-heading{display:flex;align-items:center;justify-content:space-between;margin:24px 0 12px;h3{margin:0}}
|
||||
.mapping-progress{font-size:13px;color:#606266}
|
||||
.mapping-tabs{margin-top:4px}
|
||||
.mapping-select{width:100%}
|
||||
.tab-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:4px 0 12px}
|
||||
.preview-reason{margin-top:4px}
|
||||
:deep(.mapping-row-highlight td){background:#fff7d6!important}
|
||||
.spec-block{margin-bottom:20px}.spec-title{display:flex;align-items:center;gap:8px;margin-bottom:8px;font-weight:600}
|
||||
.spec-quick-add{display:flex;flex-direction:column;gap:8px;width:100%}
|
||||
.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}
|
||||
.quick-label{font-size:12px;color:#606266;width:32px}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}
|
||||
@media(max-width:768px){.page-heading{align-items:stretch;flex-direction:column}.form-grid{grid-template-columns:1fr}.tab-actions{align-items:flex-start;flex-direction:column}}
|
||||
.page-card{min-height:calc(100vh - 124px)}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.thumb{width:48px;height:48px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.link{color:#1677ff;cursor:pointer}.scope-note{margin-top:12px;font-size:12px;color:#b91c1c}.notice{margin-bottom:16px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.spec-quick-add{display:flex;flex-direction:column;gap:8px;width:100%}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.chips{display:flex;flex-wrap:wrap;gap:6px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.create-help{margin-left:8px}@media(max-width:768px){.form-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
@@ -138,6 +138,9 @@
|
||||
</el-form>
|
||||
<template #footer><el-button @click="correct.open = false">取消</el-button><el-button type="primary" :loading="correct.saving" @click="submitCorrect">保存修正</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<ShopeeProductDetailDrawer v-model="shopeeDetail.open" :product-id="shopeeDetail.productId" :target-color="shopeeDetail.targetColor" :action="shopeeDetail.action" @product-updated="load" @mapping-updated="load" @pdd-linked="load" @purchase-created="load" @switch-cancelled="onShopeeSwitchCancelled" />
|
||||
<PddProductDetailDrawer v-model="pddDetail.open" :product-id="pddDetail.productId" @product-updated="load" @purchase-created="load" />
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
@@ -152,9 +155,12 @@ import { createPurchaseTasksBatch, previewPurchaseTasks } from '@/api/goauto/pur
|
||||
import { batchCreateCollectionTasks } from '@/api/goauto/collection-tasks'
|
||||
import { listCollectionRules } from '@/api/goauto/collection-rules'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
import ShopeeProductDetailDrawer from '../shopee-products/ShopeeProductDetailDrawer.vue'
|
||||
import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoSybProducts',
|
||||
components: { ShopeeProductDetailDrawer, PddProductDetailDrawer },
|
||||
setup() { return { Search, RefreshLeft } },
|
||||
data() {
|
||||
return {
|
||||
@@ -169,7 +175,9 @@ export default {
|
||||
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
|
||||
query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' },
|
||||
detail: { open: false, loading: false, item: null },
|
||||
correct: { open: false, saving: false, productId: null, targetColor: '', targetSize: '' }
|
||||
correct: { open: false, saving: false, productId: null, targetColor: '', targetSize: '' },
|
||||
shopeeDetail: { open: false, productId: null, targetColor: '', action: '' },
|
||||
pddDetail: { open: false, productId: null }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -272,16 +280,12 @@ export default {
|
||||
parseMeta(status) { return { success: { label: '成功', type: 'success' }, uncertain: { label: '存疑', type: 'warning' }, failed: { label: '失败', type: 'danger' }}[status] || { label: status || '-', type: 'info' } },
|
||||
openShopeeDetail(shopeeProductId, targetColor = '', action = '') {
|
||||
if (!shopeeProductId) { ElMessage.warning('该明细尚未关联虾皮商品'); return }
|
||||
const query = { productId: shopeeProductId }
|
||||
if (targetColor) query.targetColor = targetColor
|
||||
if (action) query.action = action
|
||||
const route = this.$router.resolve({ path: '/shopee-products/index', query })
|
||||
window.open(route.href, '_blank', 'noopener')
|
||||
this.shopeeDetail = { open: true, productId: Number(shopeeProductId), targetColor: String(targetColor || '').trim(), action: String(action || '') }
|
||||
},
|
||||
onShopeeSwitchCancelled(productId) { this.shopeeDetail.productId = Number(productId) },
|
||||
openPddDetail(pddProductId) {
|
||||
if (!pddProductId) { ElMessage.warning('该明细尚未关联 PDD 商品'); return }
|
||||
const route = this.$router.resolve({ path: '/pdd-products/index', query: { productId: pddProductId }})
|
||||
window.open(route.href, '_blank', 'noopener')
|
||||
this.pddDetail = { open: true, productId: Number(pddProductId) }
|
||||
},
|
||||
openPurchaseTask(taskId) { this.$router.push({ path: '/purchase-tasks/index', query: { taskId }}) },
|
||||
runPurchaseNextAction(row, readiness) {
|
||||
|
||||
@@ -50,3 +50,40 @@ test('PDD 选择器默认最近五条并支持搜索与清空回退', async({ pa
|
||||
expect(listURLs.at(-1)?.searchParams.get('pageSize')).toBe('5')
|
||||
expect(listURLs.at(-1)?.searchParams.get('status')).toBe('active')
|
||||
})
|
||||
|
||||
test('虾皮详情原地叠加 PDD 抽屉且低置信度 AI 建议不预填', async({ page, context }) => {
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname.startsWith('/src/api/')) return route.continue()
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '采购员', avatar: '', introduction: '', permissions: [] }}})
|
||||
if (url.pathname.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: routes }})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'syb', mapping: null }] }] }], total: 1, page: 1, pageSize: 20 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9/specs/mapping/suggest-colors')) {
|
||||
return route.fulfill({ json: { code: 200, data: { items: [{ valueName: '藏青色', pddValue: '黑色', status: 'ai_suggested', apply: false, confidence: 0.45, reason: '置信度不足,请人工选择' }], matchedCount: 0, pendingCount: 1 }}})
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9')) {
|
||||
return route.fulfill({ json: { code: 200, data: { product: { id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specContextVersion: 3, specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'syb', mapping: null }] }] }}}})
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/pdd-products/17/related-syb-products')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/pdd-products/17')) return route.fulfill({ json: { code: 200, data: { product: { id: 17, goodsId: 'P17', title: 'PDD 叠加详情', shopName: 'PDD 店', status: 'active', url: 'https://mobile.yangkeduo.com/goods.html?goods_id=17', specs: [{ name: '颜色分类', role: 'color', values: [{ name: '黑色', selectable: true, priceCent: 1299 }] }], colorImages: [] }, relatedShopeeProducts: [] }}})
|
||||
return route.fulfill({ json: { code: 200, data: [] }})
|
||||
})
|
||||
|
||||
await page.goto('/#/shopee-products')
|
||||
let popupCount = 0
|
||||
page.on('popup', () => { popupCount += 1 })
|
||||
await page.getByRole('button', { name: '详情', exact: true }).click()
|
||||
await expect(page.getByText('虾皮商品详情', { exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'AI 匹配', exact: true }).click()
|
||||
await expect(page.getByText('置信度不足,请人工选择', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('.el-drawer:visible .mapping-select input').first()).toHaveValue('')
|
||||
await page.getByLabel('虾皮商品详情').getByText('PDD-17 ↗', { exact: true }).click()
|
||||
await expect(page.locator('.el-drawer:visible').getByText('PDD 商品详情', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('PDD 叠加详情', { exact: true })).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByText('PDD 叠加详情', { exact: true })).toBeHidden()
|
||||
await expect(page.getByText('虾皮商品详情', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('置信度不足,请人工选择', { exact: true })).toBeVisible()
|
||||
expect(popupCount).toBe(0)
|
||||
})
|
||||
|
||||
@@ -101,7 +101,7 @@ test('店铺选项加载失败时仍可输入历史店铺查询', async({ page,
|
||||
expect(listURLs.at(-1)?.searchParams.get('shopName')).toBe('已删除店铺')
|
||||
})
|
||||
|
||||
test('PDD 待采集的下一步打开对应 PDD 商品而不是蝦皮商品', async({ page, context }) => {
|
||||
test('PDD 待采集的下一步在 SYB 列表上方打开对应 PDD 商品抽屉', async({ page, context }) => {
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url())
|
||||
@@ -114,11 +114,12 @@ test('PDD 待采集的下一步打开对应 PDD 商品而不是蝦皮商品', as
|
||||
})
|
||||
|
||||
await page.goto('/#/syb-products')
|
||||
const popupPromise = page.waitForEvent('popup')
|
||||
let popupCount = 0
|
||||
page.on('popup', () => { popupCount += 1 })
|
||||
await page.getByRole('button', { name: '查看 PDD 商品' }).click()
|
||||
const popup = await popupPromise
|
||||
await expect.poll(() => popup.url()).toContain('/#/pdd-products/index?productId=17')
|
||||
expect(popup.url()).not.toContain('/shopee-products/index')
|
||||
await expect(page.getByText('PDD 商品详情', { exact: true }).last()).toBeVisible()
|
||||
await expect(page.getByText('SYB-PDD-31', { exact: true })).toBeVisible()
|
||||
expect(popupCount).toBe(0)
|
||||
})
|
||||
|
||||
test('未关联 PDD 显示去关联并直接打开 PDD 商品选择器', async({ page, context }) => {
|
||||
@@ -140,11 +141,13 @@ test('未关联 PDD 显示去关联并直接打开 PDD 商品选择器', async({
|
||||
await expect(page.getByRole('button', { name: '去关联', exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '去匹配', exact: true })).toBeVisible()
|
||||
|
||||
const popupPromise = page.waitForEvent('popup')
|
||||
let popupCount = 0
|
||||
page.on('popup', () => { popupCount += 1 })
|
||||
await page.getByRole('button', { name: '去关联', exact: true }).click()
|
||||
const popup = await popupPromise
|
||||
await expect.poll(() => popup.url()).toContain('/#/shopee-products/index?productId=9&action=link_pdd')
|
||||
await expect(popup.getByText('搜索并选择 PDD 商品', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('虾皮商品详情', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('搜索并选择 PDD 商品', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('SYB-LINK-41', { exact: true })).toBeVisible()
|
||||
expect(popupCount).toBe(0)
|
||||
})
|
||||
|
||||
test('SYB 商品页不读取或展示后台同步状态', async({ page, context }) => {
|
||||
|
||||
Reference in New Issue
Block a user