feat(#41): add syb product admin page

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
QiuSW
2026-08-19 14:49:12 +08:00
co-authored by Claude Opus 5
parent e6c8b52164
commit 03a56565ef
3 changed files with 272 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
import request from '@/utils/request'
export function listSybProducts(params) {
return request({ url: '/api/admin/v1/syb-products', method: 'get', params })
}
export function getSybProduct(productId) {
return request({ url: `/api/admin/v1/syb-products/${productId}`, method: 'get' })
}
export function reparseSybProduct(productId, data) {
return request({ url: `/api/admin/v1/syb-products/${productId}/reparse`, method: 'post', data })
}
export function reparseSybProductsBatch(data) {
return request({ url: '/api/admin/v1/syb-products/reparse-batch', method: 'post', data })
}
export function correctSybProduct(productId, data) {
return request({ url: `/api/admin/v1/syb-products/${productId}/correction`, method: 'patch', data })
}
+13
View File
@@ -119,6 +119,19 @@ export const constantRoutes = [
}
]
},
{
path: '/syb-products',
component: Layout,
redirect: '/syb-products/index',
children: [
{
path: 'index',
component: () => import('@/views/goauto/syb-products/index'),
name: 'GoAutoSybProducts',
meta: { title: 'SYB 商品', icon: 'tickets' }
}
]
},
{
path: '/collection-rules',
component: Layout,
+238
View File
@@ -0,0 +1,238 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="page-card" shadow="never">
<div class="page-heading">
<div><h1>SYB 商品列表</h1><p>货运宝(顺云宝 ERP)货运单商品明细;忠实保存来源数据,不创建采购任务。</p></div>
</div>
<el-alert title="本页暂无「从 SYB 导入」入口:服务端尚未接入顺云宝 ERP 的真实接口凭据,导入功能待后续工单补充。当前数据仅能通过服务端内部调用写入。" type="warning" :closable="false" show-icon class="notice" />
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
<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.parseStatus" clearable placeholder="全部" style="width: 140px"><el-option label="成功" value="success" /><el-option label="存疑" value="uncertain" /><el-option label="失败" value="failed" /></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>
<div class="batch-toolbar" aria-live="polite">
<span>已选择 <strong>{{ selectedProducts.length }}</strong> 条</span>
<el-button :disabled="selectedProducts.length === 0" @click="openReparseBatch">重新解析</el-button>
<span class="muted">「批量创建采购任务」入口由 #44 注入本页,本模块只提供明细数据与勾选能力。</span>
</div>
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
<el-table-column 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="货运单号" prop="orderCode" min-width="150" />
<el-table-column label="虾皮商品" min-width="200"><template #default="{ row }"><a class="link" href="javascript:void(0)" @click="openShopeeDetail(row.shopeeProductId)">{{ row.shopeeItemId }} ↗</a><div class="muted">{{ row.productTitle || '未取到标题' }}</div></template></el-table-column>
<el-table-column label="店铺" prop="shopName" min-width="100" />
<el-table-column label="目标颜色 / 尺码" min-width="160"><template #default="{ row }"><div :class="{ 'missing': !row.targetColor }">颜色:{{ row.targetColor || '—' }}</div><div :class="{ 'missing': !row.targetSize }">尺码:{{ row.targetSize || '—' }}</div></template></el-table-column>
<el-table-column label="数量" prop="quantity" width="70" />
<el-table-column label="售价" width="110"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column>
<el-table-column label="虾皮关联" width="100"><template #default="{ row }"><el-tag :type="row.shopeeProductId ? 'success' : 'info'">{{ row.shopeeProductId ? '已关联' : '未关联' }}</el-tag></template></el-table-column>
<el-table-column label="解析状态" width="160"><template #default="{ row }"><el-tag :type="parseMeta(row.parseStatus).type">{{ parseMeta(row.parseStatus).label }}</el-tag><el-tag v-if="row.manuallyConfirmed" size="small" type="success" effect="plain" style="margin-left:4px">人工已确认</el-tag></template></el-table-column>
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button 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">本页不展示采购任务、PDD 订单号、快递单号和支付状态。</p>
</el-card>
<!-- 批量重新解析 -->
<el-dialog v-model="reparseBatch.open" title="批量重新解析" width="560px" :close-on-click-modal="false">
<el-alert title="适用于解析规则更新后批量重跑,只读取已保存的原始 JSON,不请求 SYB 接口。" type="info" :closable="false" show-icon class="notice" />
<p>将对选中的 {{ reparseBatch.ids.length }} 条明细重新解析。</p>
<el-checkbox v-model="reparseBatch.force">强制覆盖人工已确认的明细</el-checkbox>
<p class="muted">默认跳过人工已确认的明细,避免规则重跑覆盖人工结果;勾选后才会覆盖。</p>
<template #footer><el-button @click="reparseBatch.open = false">取消</el-button><el-button type="primary" :loading="reparseBatch.saving" @click="submitReparseBatch">开始重新解析</el-button></template>
</el-dialog>
<!-- 重新解析结果 -->
<el-dialog v-model="reparseResult.open" title="重新解析结果" width="640px">
<el-alert :title="`已重新解析 ${reparseResult.reparsedCount} 条,跳过(人工已确认)${reparseResult.skippedCount} 条,无变化 ${reparseResult.unchangedCount} 条`" type="success" :closable="false" show-icon class="notice" />
<el-table :data="reparseResult.results" border size="small" max-height="320">
<el-table-column label="ID" prop="sybProductId" width="90" />
<el-table-column label="结果" width="140"><template #default="{ row }"><el-tag :type="reparseOutcomeType(row.outcome)">{{ reparseOutcomeLabel(row.outcome) }}</el-tag></template></el-table-column>
<el-table-column label="状态变化" min-width="200"><template #default="{ row }">{{ parseMeta(row.oldStatus).label }} → {{ parseMeta(row.newStatus).label }}</template></el-table-column>
</el-table>
<template #footer><el-button type="primary" @click="reparseResult.open = false; load()">关闭</el-button></template>
</el-dialog>
<!-- 详情 -->
<el-drawer v-model="detail.open" title="SYB 商品明细详情" size="960px">
<div v-loading="detail.loading" class="drawer-body">
<template v-if="detail.item">
<el-alert v-if="detail.item.parseStatus !== 'success'" :title="detail.item.parseStatus === 'failed' ? '解析失败:' + detail.item.parseNote : '解析存疑:' + detail.item.parseNote" :type="detail.item.parseStatus === 'failed' ? 'error' : 'warning'" :closable="false" show-icon class="notice" />
<p class="muted purchase-note">存疑不阻断采购:采购时由服务端 AI 拿 SYB 原始颜色尺码匹配 PDD 实际规格,不经过虾皮档案映射表。人工修正可提高匹配准确度,但不是采购的前置条件;失败必须处理,否则采购会明确失败。</p>
<div class="split">
<div class="split-col">
<h3 class="section-title">结构化字段</h3>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="货运单号">{{ detail.item.orderCode }}</el-descriptions-item>
<el-descriptions-item label="来源明细ID">{{ detail.item.detailId }}</el-descriptions-item>
<el-descriptions-item label="虾皮商品ID"><a class="link" href="javascript:void(0)" @click="openShopeeDetail(detail.item.shopeeProductId)">{{ detail.item.shopeeItemId }} ↗</a></el-descriptions-item>
<el-descriptions-item label="商品标题">{{ detail.item.productTitle || '未取到标题' }}</el-descriptions-item>
<el-descriptions-item label="店铺">{{ detail.item.shopName }}</el-descriptions-item>
<el-descriptions-item label="目标颜色">{{ detail.item.targetColor || '—' }}</el-descriptions-item>
<el-descriptions-item label="目标尺码">{{ detail.item.targetSize || '—' }}</el-descriptions-item>
<el-descriptions-item label="数量">{{ detail.item.quantity }}</el-descriptions-item>
<el-descriptions-item label="成交单价">{{ priceText(detail.item) }}</el-descriptions-item>
<el-descriptions-item label="解析状态"><el-tag :type="parseMeta(detail.item.parseStatus).type">{{ parseMeta(detail.item.parseStatus).label }}</el-tag></el-descriptions-item>
<el-descriptions-item label="解析说明">{{ detail.item.parseNote || '—' }}</el-descriptions-item>
<el-descriptions-item label="人工确认">{{ detail.item.manuallyConfirmed ? '是' : '否' }}</el-descriptions-item>
</el-descriptions>
<div class="drawer-actions">
<el-button @click="openReparseSingle">重新解析</el-button>
<el-button type="primary" @click="openCorrect">人工修正</el-button>
</div>
</div>
<div class="split-col">
<h3 class="section-title">原始 JSON(保留用于重新解析)</h3>
<pre class="raw-json">{{ formattedRaw }}</pre>
<p class="muted">采购相关字段(purchaseStatus / purchaseCode / pruchaseId)仅保留在原始 JSON 中,不进入结构化字段,也不驱动本系统任何采购动作。</p>
</div>
</div>
</template>
</div>
</el-drawer>
<!-- 人工修正 -->
<el-dialog v-model="correct.open" title="人工修正解析结果" width="560px" :close-on-click-modal="false">
<el-alert title="原始 JSON 永不改写,可随时按规则重新解析回溯;修正后标记为「人工确认」,规则批量重跑默认不覆盖。" type="warning" :closable="false" show-icon class="notice" />
<el-form label-position="top">
<el-form-item label="目标颜色"><el-input v-model="correct.targetColor" placeholder="留空表示未映射" /></el-form-item>
<el-form-item label="目标尺码"><el-input v-model="correct.targetSize" placeholder="留空表示未映射" /></el-form-item>
</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>
</template>
</BasicLayout>
</template>
<script>
import { ElMessage } from 'element-plus'
import { Search, RefreshLeft } from '@element-plus/icons-vue'
import { listSybProducts, getSybProduct, reparseSybProduct, reparseSybProductsBatch, correctSybProduct } from '@/api/goauto/syb-products'
export default {
name: 'GoAutoSybProducts',
setup() { return { Search, RefreshLeft } },
data() {
return {
loading: false, products: [], selectedProducts: [], total: 0,
query: { page: 1, pageSize: 20, keyword: '', parseStatus: '' },
reparseBatch: { open: false, saving: false, ids: [], force: false },
reparseResult: { open: false, results: [], reparsedCount: 0, skippedCount: 0, unchangedCount: 0 },
detail: { open: false, loading: false, item: null },
correct: { open: false, saving: false, productId: null, targetColor: '', targetSize: '' }
}
},
computed: {
formattedRaw() {
if (!this.detail.item) return ''
try { return JSON.stringify(JSON.parse(this.detail.item.rawJson), null, 2) } catch { return this.detail.item.rawJson }
}
},
created() { this.load() },
methods: {
async load() {
this.loading = true
this.selectedProducts = []
this.$refs.productTable?.clearSelection()
try {
const r = await listSybProducts(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: '', parseStatus: '' }; this.load() },
handleSelectionChange(rows) { this.selectedProducts = rows },
priceText(row) { if (row.unitPriceCent === null || row.unitPriceCent === undefined) return '—'; return `¥${(row.unitPriceCent / 100).toFixed(2)}` },
parseMeta(status) { return { success: { label: '成功', type: 'success' }, uncertain: { label: '存疑', type: 'warning' }, failed: { label: '失败', type: 'danger' }}[status] || { label: status || '-', type: 'info' } },
reparseOutcomeLabel(outcome) { return { reparsed: '已更新', skipped_manual: '已跳过(人工确认)', unchanged: '无变化' }[outcome] || outcome },
reparseOutcomeType(outcome) { return { reparsed: 'success', skipped_manual: 'info', unchanged: '' }[outcome] || 'info' },
openShopeeDetail(shopeeProductId) {
if (!shopeeProductId) { ElMessage.warning('该明细尚未关联虾皮商品'); return }
const route = this.$router.resolve({ path: '/shopee-products/index', query: { productId: shopeeProductId }})
window.open(route.href, '_blank', 'noopener')
},
// ---------------- 批量重新解析 ----------------
openReparseBatch() { this.reparseBatch = { open: true, saving: false, ids: this.selectedProducts.map(item => item.id), force: false } },
async submitReparseBatch() {
this.reparseBatch.saving = true
try {
const r = await reparseSybProductsBatch({ ids: this.reparseBatch.ids, force: this.reparseBatch.force })
const results = r.data.results
this.reparseResult = {
open: true, results,
reparsedCount: results.filter(x => x.outcome === 'reparsed').length,
skippedCount: results.filter(x => x.outcome === 'skipped_manual').length,
unchangedCount: results.filter(x => x.outcome === 'unchanged').length
}
this.reparseBatch.open = false
} finally {
this.reparseBatch.saving = false
}
},
// ---------------- 详情 ----------------
async openDetail(id) {
this.detail = { open: true, loading: true, item: null }
try {
const r = await getSybProduct(id)
this.detail.item = r.data.item
} finally {
this.detail.loading = false
}
},
async refreshDetail() {
const r = await getSybProduct(this.detail.item.id)
this.detail.item = r.data.item
await this.load()
},
async openReparseSingle() {
await reparseSybProduct(this.detail.item.id, { force: false })
ElMessage.success('已重新解析')
await this.refreshDetail()
},
// ---------------- 人工修正 ----------------
openCorrect() {
this.correct = { open: true, saving: false, productId: this.detail.item.id, targetColor: this.detail.item.targetColor || '', targetSize: this.detail.item.targetSize || '' }
},
async submitCorrect() {
this.correct.saving = true
try {
await correctSybProduct(this.correct.productId, { targetColor: this.correct.targetColor.trim(), targetSize: this.correct.targetSize.trim() })
ElMessage.success('已保存人工修正')
this.correct.open = false
await this.refreshDetail()
} finally {
this.correct.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:16px;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}
.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}
.missing{color:#b91c1c}
.thumb{width:44px;height:44px;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}
.purchase-note{margin:0 0 16px}
.drawer-body{padding:0 4px 24px}
.split{display:grid;grid-template-columns:1fr 1fr;gap:24px}
.split-col{min-width:0}
.section-title{margin:0 0 12px}
.drawer-actions{display:flex;gap:12px;margin-top:16px}
.raw-json{max-height:520px;overflow:auto;padding:12px;background:#0f172a;color:#e2e8f0;border-radius:6px;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
@media(max-width:900px){.split{grid-template-columns:1fr}}
</style>