589 lines
24 KiB
Vue
589 lines
24 KiB
Vue
<template>
|
||
<el-card class="release-record-manager">
|
||
<template #header>
|
||
<div class="card-header">发布记录</div>
|
||
</template>
|
||
<div class="filter-bar">
|
||
<el-select v-model="searchParams.shop_type" placeholder="店铺类型" clearable style="width: 140px"
|
||
@change="handleSearch">
|
||
<el-option label="拼多多" :value="1" />
|
||
<el-option label="孔夫子" :value="2" />
|
||
<el-option label="闲鱼" :value="5" />
|
||
</el-select>
|
||
<el-select v-model="searchParams.status" placeholder="发布状态" clearable style="width: 140px">
|
||
<el-option label="发布成功" :value="0" />
|
||
<el-option label="任务已创建未发送到店铺" :value="1" />
|
||
<el-option label="发送到店铺失败" :value="2" />
|
||
</el-select>
|
||
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
||
<el-button :icon="Refresh" @click="resetSearch">重置</el-button>
|
||
</div>
|
||
|
||
<!-- 未选择店铺类型时显示空状态 -->
|
||
<template v-if="!searchParams.shop_type">
|
||
<el-empty description="请先选择店铺类型" :image-size="200" />
|
||
</template>
|
||
|
||
<!-- 选择店铺类型后:tabs 切换 -->
|
||
<template v-else>
|
||
<el-tabs v-model="activeTab" type="border-card" @tab-change="handleTabChange"
|
||
:key="searchParams.shop_type ?? 'none'">
|
||
<el-tab-pane v-for="shop in tableData" :key="'pane-' + shop.id + '-' + productsLoadKey" :label="shop.shop_alias_name"
|
||
:name="String(shop.id)">
|
||
<div style="margin-bottom: 10px;" class="tab-header">
|
||
<span>
|
||
<el-button type="primary" size="small" @click="handleSelectAll(shop.id)">全选</el-button>
|
||
<el-button type="success" size="small" @click="handleSelectInverse(shop.id)">反选</el-button>
|
||
<el-button type="warning" size="small">重试</el-button>
|
||
<el-button type="danger" size="small">发布</el-button>
|
||
</span>
|
||
|
||
<span>
|
||
<el-tag type="primary" size="small" style="margin-left: 5px;">发布商品 {{ productPagination.total }} 个</el-tag>
|
||
<el-tag type="success" size="small" style="margin-left: 5px;">成功 {{ shop.success_count ?? '-' }}</el-tag>
|
||
<el-tag type="warning" size="small" style="margin-left: 5px;">任务已创建未发送到店铺 {{ shop.not_sent_count ?? '-' }}</el-tag>
|
||
<el-tag type="danger" size="small" style="margin-left: 5px;">发送到店铺失败 {{ shop.failed_count ?? '-' }}</el-tag>
|
||
</span>
|
||
</div>
|
||
|
||
<el-table :data="shop.products || []" v-loading="loading" border stripe style="width: 100%"
|
||
:key="'table-' + shop.id + '-' + productsLoadKey"
|
||
:ref="(el) => setTableRef(shop.id, el)">
|
||
<el-table-column type="selection" :reserve-selection="true" align="center" />
|
||
<el-table-column label="商品名称" min-width="120" show-overflow-tooltip align="center">
|
||
<template #default="{ row }">
|
||
{{ row.name || '-' }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="实拍图" width="100" align="center">
|
||
<template #default="{ row }">
|
||
<template v-if="getImageList(row.live_image).length > 0">
|
||
<el-popover
|
||
placement="right"
|
||
:width="360"
|
||
trigger="hover"
|
||
:open-delay="300"
|
||
:close-delay="100"
|
||
:disabled="false"
|
||
:teleported="true"
|
||
>
|
||
<template #reference>
|
||
<el-image
|
||
:src="getImageList(row.live_image)[0]"
|
||
:preview-src-list="getImageList(row.live_image)"
|
||
:initial-index="0"
|
||
class="image-thumb-single"
|
||
fit="cover"
|
||
preview-teleported
|
||
/>
|
||
</template>
|
||
<div class="popover-image-gallery">
|
||
<div class="popover-gallery-title">
|
||
共{{ getImageList(row.live_image).length }}张图片
|
||
</div>
|
||
<div class="popover-gallery-list">
|
||
<el-image
|
||
v-for="(img, idx) in getImageList(row.live_image)"
|
||
:key="idx"
|
||
:src="img"
|
||
:preview-src-list="getImageList(row.live_image)"
|
||
:initial-index="idx"
|
||
class="popover-gallery-item"
|
||
fit="cover"
|
||
preview-teleported
|
||
/>
|
||
</div>
|
||
</div>
|
||
</el-popover>
|
||
</template>
|
||
<span v-else style="color: #c0c4cc">暂无</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="ISBN" min-width="130" show-overflow-tooltip align="center">
|
||
<template #default="{ row }">
|
||
{{ row.barcode || '--' }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="quantity" label="库存" width="80" align="center" />
|
||
<el-table-column label="价格(元)" width="100" align="center">
|
||
<template #default="{ row }">{{ (row.sale_price / 100).toFixed(2) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="仓库-库位" min-width="120" align="center" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<el-tag size="small" type="danger">{{ row.warehouse_name || '未落位' }}</el-tag> -
|
||
<el-tag size="small">{{ row.location_code || '未落位' }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="批次管理" width="90" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.is_batch_managed === 1 ? 'success' : 'info'" size="small">
|
||
{{ row.is_batch_managed === 1 ? '是' : '否' }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="效期管理" width="90" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.is_shelf_life_managed === 1 ? 'success' : 'info'" size="small">
|
||
{{ row.is_shelf_life_managed === 1 ? '是' : '否' }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="发布状态" width="190" align="center">
|
||
<template #default="{ row }">
|
||
<template v-if="row.out_task_log_id == 0 && row.status_in_shop == 0">
|
||
<el-tag type="warning" effect="plain" size="small">任务已创建未发送到店铺</el-tag>
|
||
</template>
|
||
<template v-else-if="row.out_task_log_id > 0 && row.status_in_shop == 0">
|
||
<el-tag type="danger" effect="plain" size="small">发送到店铺失败</el-tag>
|
||
</template>
|
||
<template v-else-if="row.out_task_log_id > 0 && row.status_in_shop == 1">
|
||
<el-tag type="success" effect="plain" size="small">发布成功</el-tag>
|
||
</template>
|
||
<template v-else>
|
||
<el-tag effect="plain" size="small">未知状态</el-tag>
|
||
</template>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="发布消息" min-width="120" show-overflow-tooltip align="center">
|
||
<template #default="{ row }">
|
||
{{ row.msg || '-' }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="160" align="center">
|
||
<template #default="{ row }">
|
||
<el-button type="primary" link size="small"
|
||
@click="handleRetry(activeShopId, row)">重试</el-button>
|
||
<el-button type="danger" link size="small"
|
||
>删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<!-- 商品分页 -->
|
||
<el-pagination
|
||
v-if="productPagination.total > productPagination.pageSize"
|
||
v-model:current-page="productPagination.current"
|
||
v-model:page-size="productPagination.pageSize"
|
||
:page-sizes="[10, 20, 50, 100]"
|
||
:total="productPagination.total"
|
||
layout="total, sizes, prev, pager, next, jumper"
|
||
@size-change="handleProductSizeChange"
|
||
@current-change="handleProductCurrentChange"
|
||
style="margin-top: 10px; justify-content: flex-end;" />
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</template>
|
||
</el-card>
|
||
</template>
|
||
|
||
<script lang="ts">
|
||
import { defineComponent, ref, reactive, computed, onMounted } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||
import dayjs from 'dayjs'
|
||
import { fetchReleaseRecordList, retryRelease } from '@/api/releaseRecord'
|
||
import { getShopList } from '@/api/shop'
|
||
import { fetchShopProducts } from '@/api/product'
|
||
|
||
/** 店铺类型标签映射 */
|
||
const shopTypeMap: Record<number, string> = {
|
||
1: 'success',
|
||
2: 'warning',
|
||
5: 'info'
|
||
}
|
||
|
||
interface ProductItem {
|
||
id: number
|
||
name: string
|
||
barcode: string
|
||
live_image: any
|
||
price: number
|
||
sale_price: number
|
||
quantity: number
|
||
warehouse_name: string
|
||
location_code: string
|
||
is_batch_managed: number
|
||
is_shelf_life_managed: number
|
||
out_task_log_id: number
|
||
status_in_shop: number
|
||
msg: string
|
||
created_at: number
|
||
updated_at: number
|
||
}
|
||
|
||
interface ShopItem {
|
||
id: number
|
||
shop_name: string
|
||
shop_alias_name: string
|
||
shop_type: number
|
||
shop_type_name: string
|
||
created_at: number
|
||
updated_at: number
|
||
products: ProductItem[]
|
||
success_count?: number
|
||
not_sent_count?: number
|
||
failed_count?: number
|
||
}
|
||
|
||
export default defineComponent({
|
||
name: 'ReleaseRecord',
|
||
setup() {
|
||
const loading = ref<boolean>(false)
|
||
const tableData = ref<ShopItem[]>([])
|
||
const activeTab = ref<string>('')
|
||
|
||
const searchParams = reactive<{
|
||
shop_type: number | null
|
||
status: number | null
|
||
}>({
|
||
shop_type: 2,
|
||
status: null
|
||
})
|
||
|
||
/** 当前 tab 内商品的分页 */
|
||
const productPagination = reactive({
|
||
current: 1,
|
||
pageSize: 10,
|
||
total: 0
|
||
})
|
||
|
||
/** 当前激活的店铺 ID */
|
||
const activeShopId = computed<number>(() => Number(activeTab.value))
|
||
|
||
/** 商品数据加载计数器,递增使 el-table 重新挂载,绕开 el-table 内部 watcher 冲突 */
|
||
const productsLoadKey = ref(0)
|
||
|
||
const shopTypeTag = (type: number): string => shopTypeMap[type] || 'info'
|
||
|
||
const formatTimestamp = (timestamp?: number | string | null): string => {
|
||
if (!timestamp && timestamp !== 0) return '-'
|
||
return dayjs.unix(Number(timestamp)).format('YYYY-MM-DD HH:mm:ss')
|
||
}
|
||
|
||
const getImageList = (liveImage: any): string[] => {
|
||
if (!liveImage) return []
|
||
if (Array.isArray(liveImage)) {
|
||
const result: string[] = []
|
||
for (const item of liveImage) {
|
||
if (typeof item === 'string') {
|
||
if (item.includes(',')) {
|
||
result.push(...item.split(',').map((u: string) => u.trim()).filter(Boolean))
|
||
} else {
|
||
result.push(item)
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
if (typeof liveImage === 'string') {
|
||
if (liveImage.includes(',')) {
|
||
return liveImage.split(',').map((u: string) => u.trim()).filter(Boolean)
|
||
}
|
||
return [liveImage]
|
||
}
|
||
return []
|
||
}
|
||
|
||
const getFirstImage = (liveImage: any): string => {
|
||
if (!liveImage) return ''
|
||
if (Array.isArray(liveImage) && liveImage.length > 0) {
|
||
const first = String(liveImage[0])
|
||
if (first.includes(',')) {
|
||
return first.split(',')[0].trim()
|
||
}
|
||
return first
|
||
}
|
||
if (typeof liveImage === 'string') {
|
||
if (liveImage.includes(',')) {
|
||
return liveImage.split(',')[0].trim()
|
||
}
|
||
return liveImage
|
||
}
|
||
return ''
|
||
}
|
||
|
||
const loadList = async (): Promise<void> => {
|
||
if (!searchParams.shop_type) {
|
||
tableData.value = []
|
||
return
|
||
}
|
||
loading.value = true
|
||
try {
|
||
// 并行获取店铺列表(tab 用)和发布记录数据(商品列表用)
|
||
const [shopRes, recordRes] = await Promise.all([
|
||
getShopList({
|
||
shop_type: searchParams.shop_type
|
||
}),
|
||
fetchReleaseRecordList({
|
||
shop_type: searchParams.shop_type || undefined,
|
||
status: searchParams.status ?? undefined,
|
||
page: 1,
|
||
pageSize: 9999
|
||
})
|
||
])
|
||
|
||
// 将发布记录中的 products 合并到对应店铺
|
||
const recordMap = new Map(
|
||
(recordRes.list || []).map(shop => [shop.id, shop.products || []])
|
||
)
|
||
const mergedList = (shopRes.list || []).map(shop => ({
|
||
...shop,
|
||
products: recordMap.get(shop.id) || []
|
||
}))
|
||
|
||
// 先赋值 tableData,再激活第一个 tab
|
||
// 确保 handleTabChange 触发时能通过 tableData 找到对应店铺
|
||
tableData.value = mergedList
|
||
|
||
if (mergedList.length > 0 && !activeTab.value) {
|
||
activeTab.value = String(mergedList[0].id)
|
||
}
|
||
|
||
// 每次进入页面时主动加载当前 tab 的商品数据(含分页统计)
|
||
if (activeTab.value) {
|
||
await loadActiveShopProducts()
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error({ message: '获取发布记录失败', customClass: 'scan-error-message' })
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const refreshList = (): void => {
|
||
void loadList()
|
||
}
|
||
|
||
const handleSearch = (): void => {
|
||
activeTab.value = ''
|
||
void loadList()
|
||
}
|
||
|
||
const resetSearch = (): void => {
|
||
searchParams.shop_type = null
|
||
searchParams.status = null
|
||
refreshList()
|
||
}
|
||
|
||
const handleRetry = (shopId: number, product: ProductItem): void => {
|
||
const shop = tableData.value.find(s => s.id === shopId)
|
||
if (!shop) return
|
||
retryRelease(shop.id, product.id, shop.shop_type).then(() => {
|
||
ElMessage.success({ message: '重试成功', customClass: 'scan-success-message' })
|
||
void loadList()
|
||
}).catch(() => {
|
||
ElMessage.error({ message: '重试失败', customClass: 'scan-error-message' })
|
||
})
|
||
}
|
||
|
||
const handleTabChange = async (tabName: string): Promise<void> => {
|
||
if (!tabName) return
|
||
|
||
// 切换 tab 时重置商品分页到第一页
|
||
productPagination.current = 1
|
||
try {
|
||
const { products, total, success_count, not_sent_count, failed_count } = await fetchShopProducts(
|
||
tabName,
|
||
productPagination.current,
|
||
productPagination.pageSize
|
||
)
|
||
// 使用字符串比较,避免后端返回的 shop.id 类型与 Number(tabName) 不匹配
|
||
const shop = tableData.value.find(s => String(s.id) === tabName)
|
||
if (shop) {
|
||
shop.products = products
|
||
shop.success_count = success_count
|
||
shop.not_sent_count = not_sent_count
|
||
shop.failed_count = failed_count
|
||
productPagination.total = total
|
||
}
|
||
// 先赋值数据,再递增 key 强制表格重新挂载,确保新表格读到最新数据
|
||
productsLoadKey.value++
|
||
} catch (error) {
|
||
console.error('[ReleaseRecord] 获取店铺商品失败:', error)
|
||
}
|
||
}
|
||
|
||
/** 加载当前激活店铺的商品数据(分页) */
|
||
const loadActiveShopProducts = async (): Promise<void> => {
|
||
const tabName = activeTab.value
|
||
if (!tabName) return
|
||
try {
|
||
const { products, total, success_count, not_sent_count, failed_count } = await fetchShopProducts(
|
||
tabName,
|
||
productPagination.current,
|
||
productPagination.pageSize
|
||
)
|
||
// 使用字符串比较,避免后端返回的 shop.id 类型与 Number(tabName) 不匹配
|
||
const shop = tableData.value.find(s => String(s.id) === tabName)
|
||
if (shop) {
|
||
shop.products = products
|
||
shop.success_count = success_count
|
||
shop.not_sent_count = not_sent_count
|
||
shop.failed_count = failed_count
|
||
productPagination.total = total
|
||
}
|
||
// 先赋值数据,再递增 key 强制表格重新挂载,确保新表格读到最新数据
|
||
productsLoadKey.value++
|
||
} catch (error) {
|
||
console.error('[ReleaseRecord] 获取店铺商品失败:', error)
|
||
}
|
||
}
|
||
|
||
/** 商品分页 - 切换页码 */
|
||
const handleProductCurrentChange = (page: number): void => {
|
||
productPagination.current = page
|
||
loadActiveShopProducts()
|
||
}
|
||
|
||
/** 商品分页 - 切换每页条数 */
|
||
const handleProductSizeChange = (size: number): void => {
|
||
productPagination.pageSize = size
|
||
productPagination.current = 1
|
||
loadActiveShopProducts()
|
||
}
|
||
|
||
/** 存储每个店铺表格的 el-table 实例 */
|
||
const tableRefs = reactive<Record<number, any>>({})
|
||
|
||
const setTableRef = (shopId: number, el: any): void => {
|
||
if (el) tableRefs[shopId] = el
|
||
}
|
||
|
||
/** 全选:选中当前店铺的所有行 */
|
||
const handleSelectAll = (shopId: number): void => {
|
||
const table = tableRefs[shopId]
|
||
if (!table) return
|
||
table.toggleAllSelection()
|
||
}
|
||
|
||
/** 反选:反转当前店铺的选中状态 */
|
||
const handleSelectInverse = (shopId: number): void => {
|
||
const shop = tableData.value.find(s => s.id === shopId)
|
||
if (!shop) return
|
||
const table = tableRefs[shopId]
|
||
if (!table) return
|
||
const products = shop.products || []
|
||
// 获取当前选中的行
|
||
const selection = table.getSelectionRows()
|
||
const selectedIds = new Set(selection.map((r: any) => r.id))
|
||
// 先清空所有选中
|
||
table.clearSelection()
|
||
// 选中未被选中的行
|
||
products.forEach(product => {
|
||
if (!selectedIds.has(product.id)) {
|
||
table.toggleRowSelection(product, true)
|
||
}
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
void loadList()
|
||
})
|
||
|
||
return {
|
||
loading, tableData, activeTab, activeShopId,
|
||
searchParams, productPagination, productsLoadKey,
|
||
Search, Refresh,
|
||
shopTypeTag, formatTimestamp, getFirstImage, getImageList,
|
||
handleSearch, resetSearch, handleTabChange,
|
||
handleProductCurrentChange, handleProductSizeChange,
|
||
handleRetry, handleSelectAll, handleSelectInverse, setTableRef
|
||
}
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.release-record-manager {
|
||
width: 100%;
|
||
}
|
||
|
||
.card-header {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
|
||
.filter-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
align-items: center;
|
||
background: white;
|
||
padding: 16px 20px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
:deep(.el-table) {
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.expand-content {
|
||
padding: 16px 20px;
|
||
background: #fafafa;
|
||
}
|
||
|
||
.expand-header {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.expand-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
padding-left: 8px;
|
||
border-left: 3px solid #409eff;
|
||
}
|
||
|
||
.tab-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
/* 两端对齐 */
|
||
align-items: center;
|
||
/* 垂直居中 */
|
||
}
|
||
|
||
/* 实拍图 - 单张缩略图 */
|
||
.image-thumb-single {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
border: 1px solid #ebeef5;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 悬浮画廊 */
|
||
.popover-image-gallery {
|
||
padding: 4px;
|
||
}
|
||
|
||
.popover-gallery-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
margin-bottom: 10px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid #ebeef5;
|
||
}
|
||
|
||
.popover-gallery-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.popover-gallery-item {
|
||
width: 64px;
|
||
height: 64px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
border: 1px solid #ebeef5;
|
||
flex-shrink: 0;
|
||
}
|
||
</style>
|