daShangDao_psiWebApp/src/views/releaseRecord/releaseRecord.vue
97694731 939d55c950
Some checks failed
CI / build (18.x) (push) Failing after 1m34s
CI / build (20.x) (push) Failing after 34s
CI / deploy-preview (push) Has been skipped
CI / lint (push) Failing after 34s
CI / test (push) Failing after 34s
CI / security (push) Failing after 35s
6.22lct bug 修改
2026-06-22 17:10:51 +08:00

589 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>