feat(#31): implement PDD product archive module

This commit is contained in:
QiuSW
2026-08-17 16:04:18 +08:00
parent 7df4546e63
commit a7a453633e
16 changed files with 648 additions and 188 deletions
+4
View File
@@ -8,6 +8,10 @@ export function createPddProduct(data) {
return request({ url: '/api/admin/v1/pdd-products', method: 'post', data })
}
export function getPddProduct(productId) {
return request({ url: `/api/admin/v1/pdd-products/${productId}`, method: 'get' })
}
export function updatePddProduct(productId, data) {
return request({ url: `/api/admin/v1/pdd-products/${productId}`, method: 'patch', data })
}
+74 -121
View File
@@ -1,70 +1,66 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="product-card" shadow="never">
<el-card class="page-card" shadow="never">
<div class="page-heading">
<div>
<h1>PDD 商品</h1>
<p>人工添加商品链接,系统会提取 goods_id 并统一链接格式。</p>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">添加 PDD 链接</el-button>
<div><h1>PDD 商品</h1><p>维护可重复采集和采购的商品资料;添加时只需填写包含 goods_id 的链接。</p></div>
<el-button type="primary" :icon="Plus" @click="openCreate">添加 PDD 商品</el-button>
</div>
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="handleQuery">
<el-form-item label="Goods ID">
<el-input v-model="query.goodsId" placeholder="输入完整或部分 goods_id" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
<el-button :icon="RefreshLeft" @click="resetQuery">重置</el-button>
</el-form-item>
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
<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-table v-loading="loading" :data="products" border stripe empty-text="暂无 PDD 商品,请先添加链接">
<el-table-column label="ID" prop="id" width="90" />
<el-table-column label="Goods ID" prop="goodsId" min-width="180" />
<el-table-column label="当前链接" min-width="420">
<template #default="{ row }">
<a class="product-link" :href="row.url" target="_blank" rel="noopener noreferrer">{{ row.url }}</a>
</template>
</el-table-column>
<el-table-column label="更新时间" width="180">
<template #default="{ row }">{{ parseTime(row.updatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="openEdit(row)">修改链接</el-button>
</template>
</el-table-column>
<el-table v-loading="loading" :data="products" border stripe empty-text="暂无 PDD 商品,请先添加商品链接">
<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="状态" width="100"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag></template></el-table-column>
<el-table-column label="销量 / 评价" width="140"><template #default="{ row }"><div>销量 {{ countText(row.salesCount) }}</div><div class="muted">评价 {{ countText(row.reviewCount) }}</div></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="getList"
/>
<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="dialog.open" :title="dialog.productId ? '修改 PDD 链接' : '添加 PDD 链接'" width="640px" :close-on-click-modal="false">
<el-alert
title="链接必须包含数字 goods_id;保存后会移除无关追踪参数。"
type="info"
:closable="false"
show-icon
class="dialog-alert"
/>
<el-form ref="productForm" :model="form" :rules="rules" label-position="top">
<el-form-item label="PDD 商品链接" prop="url">
<el-input v-model="form.url" type="textarea" :rows="4" placeholder="https://mobile.yangkeduo.com/goods.html?goods_id=..." />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialog.open = false">取消</el-button>
<el-button type="primary" :loading="dialog.saving" @click="save">保存</el-button>
</template>
<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-drawer v-model="detail.open" title="PDD 商品详情" size="720px">
<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><el-button type="primary" @click="openEdit(detail.product.id)">编辑商品</el-button></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 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></div>
</template>
</div>
</el-drawer>
<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>
</template>
@@ -72,76 +68,33 @@
<script>
import { ElMessage } from 'element-plus'
import { Plus, RefreshLeft, Search } from '@element-plus/icons-vue'
import { createPddProduct, listPddProducts, updatePddProduct } from '@/api/goauto/pdd-products'
import { createPddProduct, getPddProduct, listPddProducts, updatePddProduct } from '@/api/goauto/pdd-products'
let uid = 0
export default {
name: 'GoAutoPddProducts',
setup() { return { Plus, RefreshLeft, Search } },
data() {
return {
loading: false,
products: [],
total: 0,
query: { page: 1, pageSize: 20, goodsId: '' },
dialog: { open: false, saving: false, productId: null },
form: { url: '' },
rules: {
url: [
{ required: true, message: '请输入 PDD 商品链接', trigger: 'blur' },
{ max: 4096, message: '链接不能超过 4096 个字符', trigger: 'blur' }
]
}
}
},
created() { this.getList() },
name: 'GoAutoPddProducts', setup() { return { Plus, RefreshLeft, Search } },
data() { return { loading: false, products: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '' }, createDialog: { open: false, saving: false }, createData: { url: '' }, detail: { open: false, loading: false, product: null }, edit: { open: false, loading: false, saving: false, productId: null }, editData: this.emptyEdit(), urlRules: { url: [{ required: true, message: '请输入 PDD 商品链接', trigger: 'blur' }, { max: 4096, message: '链接不能超过 4096 个字符', trigger: 'blur' }] }} },
created() { this.load() },
methods: {
async getList() {
this.loading = true
try {
const response = await listPddProducts(this.query)
this.products = response.data.items
this.total = response.data.total
} finally { this.loading = false }
},
handleQuery() { this.query.page = 1; this.getList() },
resetQuery() { this.query = { page: 1, pageSize: 20, goodsId: '' }; this.getList() },
openCreate() {
this.dialog = { open: true, saving: false, productId: null }
this.form = { url: '' }
this.$nextTick(() => this.$refs.productForm?.clearValidate())
},
openEdit(row) {
this.dialog = { open: true, saving: false, productId: row.id }
this.form = { url: row.url }
this.$nextTick(() => this.$refs.productForm?.clearValidate())
},
async save() {
const valid = await this.$refs.productForm.validate().catch(() => false)
if (!valid) return
this.dialog.saving = true
const payload = { requestId: window.crypto.randomUUID(), url: this.form.url.trim() }
try {
if (this.dialog.productId) await updatePddProduct(this.dialog.productId, payload)
else await createPddProduct(payload)
ElMessage.success(this.dialog.productId ? '商品链接已更新' : 'PDD 商品已添加')
this.dialog.open = false
await this.getList()
} finally { this.dialog.saving = false }
}
emptyEdit() { return { url: '', title: '', shopName: '', salesCount: null, reviewCount: null, status: 'active', specs: [] } },
async load() { this.loading = true; try { const r = await listPddProducts(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() },
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)}` },
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)}` },
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: crypto.randomUUID(), 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 }; try { const r = await getPddProduct(id); this.detail.product = r.data.product } finally { this.detail.loading = 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: crypto.randomUUID(), 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 } }
}
}
</script>
<style lang="scss" scoped>
.product-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; color: #1f2937; font-size: 24px; line-height: 1.35; }
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; }
.product-link { color: #1677ff; line-height: 1.5; overflow-wrap: anywhere; }
.product-link:focus-visible { outline: 2px solid #1677ff; outline-offset: 2px; }
.dialog-alert { margin-bottom: 20px; }
@media (max-width: 768px) { .page-heading { align-items: stretch; flex-direction: column; } }
.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}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.notice{margin-bottom: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}.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}.link{color:#1677ff;overflow-wrap:anywhere}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.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}.form-grid{grid-template-columns:1fr}}
</style>