admin_new/src/modules/supplier/good/GoodListView.vue

741 lines
40 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.

<script setup lang="ts">
/**
* F006 实物商品管理 (W5.x.x+5 整改) - 完整版
*
* 沿用老 vue supplier/good.vue 1628 行完整功能:
* - 搜索: 商品名/供应商/分类/品牌
* - 状态 tab: 全部/待审核/已通过/已驳回/已下架
* - 表格列: 封面/商品/供应商/3级分类/品牌/单位/申请时间/铺货/审核意见/状态
* - 操作: 详情(常驻) + 审核/上下架/改分类/删除(收纳⋯) [W17.6 主次分离]
* - 批量: 批量审核/批量驳回/批量下架/批量改分类/批量删除
* - 详情弹窗: 含 SKU 列表 (编码/组合/封面/原价/现价/秒杀价/拼团价/库存/最小购买)
* - 审核弹窗: 通过/驳回 + 审核意见
* - 修改分类弹窗: 单个/批量
*
* 字段完全沿用老 vue: supplierGoodId, icon, title, enterpriseName,
* firstCategoryName, secondCategoryName, thirdCategoryName, brandName,
* unitName, onShelf, checkOpinion, checkStatus
*/
import { ref, onMounted, computed, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
import ListTabs from '@/components/ListTabs/ListTabs.vue'
import { httpGet, httpPost } from '@/utils/request'
interface Good {
id: string
supplierGoodId: string
icon: string | null
title: string
enterpriseName: string
firstCategoryName: string
secondCategoryName: string
thirdCategoryName: string
brandName: string
unitName: string
onShelf: number
checkOpinion: string
checkStatus: number
createDate: string
raw: any
// SKU 列表 (详情用)
skuList?: SkuItem[]
}
interface SkuItem {
id: string
skuCode: string
propertyNames: string
icon: string | null
oldPrice: number
newPrice: number
seckillPrice: number | null
groupPrice: number | null
store: number
buyMin: number
raw: any
}
// 状态映射 (沿用老 vue checkStatus 1=待审核 2=已通过 3=已驳回, onShelf 0=下架 1=在售)
const CHECK_STATUS_MAP: Record<number, { label: string; color: string; bg: string }> = {
1: { label: '待审核', color: 'var(--st-warn-fg)', bg: 'var(--st-warn-bg)' },
2: { label: '已通过', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
3: { label: '已驳回', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
}
const ONSHELF_STATUS_MAP: Record<number, { label: string; color: string; bg: string }> = {
0: { label: '已下架', color: 'var(--st-lv4-fg)', bg: 'var(--st-lv4-bg)' },
1: { label: '已上架', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' }
}
const STATUS_TABS = [
{ value: '', label: '全部' },
{ value: '1', label: '待审核' },
{ value: '2', label: '已通过' },
{ value: '3', label: '已驳回' }
]
// FALLBACK (老 vue 字段, 端点不可用时降级)
const FALLBACK: Good[] = [
{
id: '1', supplierGoodId: 'G20260705001', icon: null, title: '工业传感器 X100 高精度',
enterpriseName: '深圳科技股份有限公司', firstCategoryName: '工业设备', secondCategoryName: '传感器', thirdCategoryName: '工业传感器',
brandName: '西门子', unitName: '台', onShelf: 1, checkOpinion: '通过', checkStatus: 2, createDate: '2026-07-05 10:24:33', raw: {},
skuList: [
{ id: 's1', skuCode: 'X100-A001', propertyNames: '型号:X100 精度:0.01mm', icon: null, oldPrice: 2800, newPrice: 2500, seckillPrice: 2200, groupPrice: 2000, store: 100, buyMin: 1, raw: {} },
{ id: 's2', skuCode: 'X100-B002', propertyNames: '型号:X100 精度:0.05mm', icon: null, oldPrice: 2500, newPrice: 2200, seckillPrice: null, groupPrice: 1900, store: 80, buyMin: 1, raw: {} }
]
},
{
id: '2', supplierGoodId: 'G20260705002', icon: null, title: '智能控制器 Y200 多协议',
enterpriseName: '上海制造企业', firstCategoryName: '工业设备', secondCategoryName: '控制器', thirdCategoryName: '智能控制器',
brandName: '三菱', unitName: '台', onShelf: 1, checkOpinion: '', checkStatus: 1, createDate: '2026-07-05 11:15:00', raw: {}
},
{
id: '3', supplierGoodId: 'G20260705003', icon: null, title: '高精度测量仪 0.001mm',
enterpriseName: '北京电子有限公司', firstCategoryName: '仪器仪表', secondCategoryName: '测量仪', thirdCategoryName: '高精度测量仪',
brandName: '欧姆龙', unitName: '台', onShelf: 0, checkOpinion: '资料不全', checkStatus: 3, createDate: '2026-07-05 09:00:00', raw: {}
}
]
const list = ref<Good[]>([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const limit = ref(10)
const error = ref<string | null>(null)
const selection = ref<Good[]>([])
// 状态统计 (与 tab 解耦, 只跟搜索条件关联 - 跨 tab 一致)
const checkStats = reactive({ pending: 0, approved: 0, rejected: 0 })
// W17.6: 商品总数 + SKU 总数 (实物商品下, 全量, 不分 checkStatus)
const counts = reactive({ goodCount: 0, skuCount: 0 })
const filters = reactive({
title: '',
enterpriseName: '',
checkStatus: ''
})
// 商品详情/审核抽屉 (2026-09-18 重设计: 大抽屉分区展示 完整信息 + 审核操作条)
const drawerVisible = ref(false)
const drawerLoading = ref(false)
const drawerRow = ref<Good | null>(null)
const drawerInfo = ref<any>(null)
const drawerFocusAudit = ref(false)
const drawerAuditForm = reactive({ checkStatus: 2, checkOpinion: '' })
const galleryActive = ref(0)
const categoryDialogVisible = ref(false)
const categoryData = ref<Good | null>(null)
const categoryForm = reactive({ firstCategoryId: '', secondCategoryId: '', thirdCategoryId: '' })
const batchCategoryDialogVisible = ref(false)
const batchRejectDialogVisible = ref(false)
const batchRejectForm = reactive({ reason: '' })
// httpGet/httpPost 已解包 code===0 的业务 data(拦截器已 reject 非 0 code),
// 拿到值即为成功;兼容对象/布尔/null 返回(与 InquiryDetailView 修复同源)
function isOk(r: any) {
return !(r && typeof r === 'object' && 'code' in r && Number(r.code) !== 0)
}
const columns = [
{ type: 'selection' as const, label: '', width: 50 },
{ prop: 'supplierGoodId', label: '商品ID', width: 100 },
{ prop: 'title', label: '商品名称', minWidth: 320, slot: 'title' },
{ prop: 'enterpriseName', label: '供应商', minWidth: 180, showOverflowTooltip: true },
{ prop: 'categoryName', label: '分类', minWidth: 180, slot: 'category' },
{ prop: 'brandName', label: '品牌', width: 110 },
{ prop: 'unitName', label: '单位', width: 90, minWidth: 90, align: 'center' as const },
{ prop: 'onShelf', label: '铺货', width: 100, align: 'center' as const, slot: 'onShelf' },
{ prop: 'checkStatus', label: '状态', width: 100, align: 'center' as const, slot: 'checkStatus' },
{ prop: 'createDate', label: '申请时间', width: 170 },
{ label: '操作', width: 140, align: 'center' as const, fixed: 'right' as const, slot: 'action' }
]
async function buildSearchParamsForStats() {
const params: any = { type: 1 }
if (filters.title) params.title = filters.title
return params
}
async function loadStats() {
try {
const base = await buildSearchParamsForStats()
const [r1, r2, r3] = await Promise.all([
httpGet('/supplier/good/pageList', { ...base, checkStatus: 1, page: 1, limit: 1 }),
httpGet('/supplier/good/pageList', { ...base, checkStatus: 2, page: 1, limit: 1 }),
httpGet('/supplier/good/pageList', { ...base, checkStatus: 3, page: 1, limit: 1 })
])
checkStats.pending = Number(r1?.total ?? 0)
checkStats.approved = Number(r2?.total ?? 0)
checkStats.rejected = Number(r3?.total ?? 0)
} catch {
checkStats.pending = 0
checkStats.approved = 0
checkStats.rejected = 0
}
}
// W17.6: 全量商品数 + 全量 SKU 数 (实物, type=1, 不分 checkStatus / 搜索)
async function loadCounts() {
try {
const r: any = await httpGet('/supplier/good/sku-count', { type: 1 })
counts.goodCount = Number(r?.goodCount ?? 0)
counts.skuCount = Number(r?.skuCount ?? 0)
} catch {
counts.goodCount = 0
counts.skuCount = 0
}
}
async function load() {
loading.value = true
error.value = null
try {
// W13.1: /supplier/good/pageList 返完整字段 — 比 search 端点更全
// 2026-09-20: 筛选条件下推到后端 (原仅前端过滤当前页, 导致 tab 计数 42 与列表 16106 条不一致)
const queryParams: Record<string, any> = {
page: page.value,
limit: limit.value,
type: 1 // 1=实物商品 (2=服务商品, 在 ServiceGoodListView 单独展示)
}
if (filters.title) queryParams.title = filters.title
if (filters.checkStatus) queryParams.checkStatus = Number(filters.checkStatus)
if (filters.enterpriseName) queryParams.enterpriseName = filters.enterpriseName
const data = await httpGet<{ total: number | string; list: any[] }>('/supplier/good/pageList', queryParams)
let rows = (data.list || []).map((it: any) => ({
id: String(it.id),
supplierGoodId: it.supplierGoodId || String(it.id),
icon: it.icon || null,
title: it.title || '--',
enterpriseName: it.enterpriseName || '--',
firstCategoryName: it.firstCategoryName || '',
secondCategoryName: it.secondCategoryName || '',
thirdCategoryName: it.thirdCategoryName || '',
brandName: it.brandName || '--',
unitName: it.unitName || '--',
onShelf: it.onShelf ?? 1,
checkOpinion: it.checkOpinion || '',
checkStatus: it.checkStatus ?? 2,
createDate: it.createDate || '',
raw: it
}))
// 筛选已由后端条件完成 (checkStatus/enterpriseName/title 下推), rows 即当前页数据
list.value = rows
total.value = Number(data.total) || 0
} catch (e: any) {
error.value = e?.msg || e?.message || '加载失败'
list.value = []
total.value = 0
} finally {
loading.value = false
}
}
function onSearch() { page.value = 1; Promise.all([load(), loadStats(), loadCounts()]) }
function onReset() { filters.title = ''; filters.enterpriseName = ''; filters.checkStatus = ''; page.value = 1; Promise.all([load(), loadStats(), loadCounts()]) }
function onPageChange(p: number) { page.value = p; load() }
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
function onSelectionChange(rows: Good[]) { selection.value = rows }
const tabItems = computed(() => [
// 全部 = 待审核 + 已通过 + 已驳回 (跨 tab 一致, 跟搜索条件关联)
{ key: '', label: '全部', count: checkStats.pending + checkStats.approved + checkStats.rejected },
{ key: '1', label: '待审核', count: checkStats.pending },
{ key: '2', label: '已通过', count: checkStats.approved },
{ key: '3', label: '已驳回', count: checkStats.rejected }
])
function onStatusTab(v: string) { filters.checkStatus = v; page.value = 1; load() }
function splitImages(v: any): string[] {
return String(v || '').split(',').map((s: string) => s.trim()).filter(Boolean)
}
/** 打开商品详情/审核抽屉 (focusAudit=true 时审核入口进入并定位操作条) */
async function openGoodDrawer(row: Good, focusAudit = false) {
drawerRow.value = row
drawerInfo.value = null
drawerFocusAudit.value = focusAudit
drawerAuditForm.checkStatus = 2
drawerAuditForm.checkOpinion = ''
galleryActive.value = 0
drawerVisible.value = true
drawerLoading.value = true
try {
// 列表行 id 是 tb_good(铺货表) 的 id; 详情接口要 tb_supplier_good.id -> 用行的 supplierGoodId
const detailId = Number(row.raw?.supplierGoodId) || Number(row.raw?.supplier_good_id) || Number(row.id)
const r: any = await httpGet('/supplier/supplierGood/info', { id: detailId })
// httpGet 已解包: r 即 SupplierGoodDTO (含 images/content/skuList/skuDetailList)
drawerInfo.value = r && (r.id || r.title) ? r : (row.raw || row)
} catch {
drawerInfo.value = row.raw || row
} finally { drawerLoading.value = false }
if (focusAudit) {
setTimeout(() => document.querySelector('.rr-gd-audit')?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 400)
}
}
const drawerImages = computed(() => splitImages(drawerInfo.value?.images))
const drawerSkuTotalStore = computed(() => (drawerInfo.value?.skuDetailList || []).reduce((s: number, it: any) => s + (Number(it.store) || 0), 0))
const drawerCategoryPath = computed(() => [drawerInfo.value?.firstCategoryName, drawerInfo.value?.secondCategoryName, drawerInfo.value?.thirdCategoryName].filter(Boolean).join(' / '))
function onShowDetail(row: Good) { openGoodDrawer(row, false) }
function onShowCheckDialog(row: Good) { openGoodDrawer(row, true) }
async function onSubmitDrawerAudit() {
const f = drawerAuditForm
if (f.checkStatus === 3 && !f.checkOpinion.trim()) { ElMessage.warning('驳回时请填写审核意见'); return }
const id = drawerRow.value?.id || drawerInfo.value?.id
try {
const r: any = await httpGet('/supplier/good/checkGood', {
id, checkStatus: f.checkStatus, checkOpinion: f.checkOpinion.trim()
})
if (isOk(r)) {
ElMessage.success(f.checkStatus === 2 ? '审核通过' : '已驳回')
drawerVisible.value = false
load()
} else { ElMessage.error(r?.msg || '操作失败') }
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
}
async function onOnShelf(row: Good) {
try { await ElMessageBox.confirm(`确认${row.onShelf === 1 ? '下架' : '上架'}「${row.title}」?`, '提示', { type: 'warning' }) } catch { return }
try {
const r: any = await httpGet('/supplier/good/onShelfGood', { id: row.id })
if (isOk(r)) { ElMessage.success(row.onShelf === 1 ? '已下架' : '已上架'); load() }
else ElMessage.error(r?.msg || '操作失败')
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
}
function onShowCategoryDialog(row: Good) { categoryData.value = row; categoryDialogVisible.value = true }
async function onSubmitCategory() {
try {
const r: any = await httpPost('/supplier/good/update', {
id: categoryData.value?.id || categoryData.value?.raw?.id,
firstCategoryId: categoryForm.firstCategoryId || undefined,
secondCategoryId: categoryForm.secondCategoryId || undefined,
thirdCategoryId: categoryForm.thirdCategoryId || undefined
})
if (isOk(r)) { ElMessage.success('分类已修改'); categoryDialogVisible.value = false; load() }
else ElMessage.error(r?.msg || '操作失败')
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
}
async function onDelete(row: Good) {
try { await ElMessageBox.confirm(`确认删除「${row.title}」?`, '提示', { type: 'warning' }) } catch { return }
try {
const r: any = await httpGet('/supplier/good/delete', { id: row.id })
if (isOk(r)) { ElMessage.success('已删除'); load() }
else ElMessage.error(r?.msg || '操作失败')
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
}
function onBatchCheck() {
if (selection.value.length === 0) { ElMessage.warning('请先选择商品'); return }
ElMessageBox.confirm(`确认批量通过 ${selection.value.length} 个商品?`, '提示', { type: 'success' }).then(async () => {
try { for (const s of selection.value) { await httpPost('/supplier/good/checkGood', { id: s.id, checkStatus: 2, checkOpinion: '批量审核通过' }) } ElMessage.success(`批量审核通过 ${selection.value.length} 个`); load() }
catch (e: any) { ElMessage.error(e?.msg || '批量审核失败'); load() }
}).catch(() => {})
}
function onBatchReject() { if (selection.value.length === 0) { ElMessage.warning('请先选择商品'); return } batchRejectDialogVisible.value = true }
function onSubmitBatchReject() {
if (!batchRejectForm.reason.trim()) { ElMessage.warning('请填写驳回原因'); return }
Promise.all(selection.value.map(s => httpPost('/supplier/good/checkGood', { id: s.id, checkStatus: 3, checkOpinion: batchRejectForm.reason }))).then(() => {
ElMessage.success(`已驳回 ${selection.value.length} 个商品`); batchRejectDialogVisible.value = false; load()
}).catch((e: any) => ElMessage.error(e?.msg || '批量驳回失败'))
}
function onBatchOnShelf() {
if (selection.value.length === 0) { ElMessage.warning('请先选择商品'); return }
ElMessageBox.confirm(`确认批量下架 ${selection.value.length} 个商品?`, '提示', { type: 'warning' }).then(async () => {
try { for (const s of selection.value) { await httpPost('/supplier/good/goodOnShelf', { id: s.id, onShelf: 0 }) } ElMessage.success(`批量下架 ${selection.value.length} 个`); load() }
catch (e: any) { ElMessage.error(e?.msg || '批量下架失败'); load() }
}).catch(() => {})
}
function onShowBatchCategoryDialog() {
if (selection.value.length === 0) { ElMessage.warning('请先选择商品'); return }
batchCategoryDialogVisible.value = true
}
function onSubmitBatchCategory() {
Promise.all(selection.value.map(s => httpPost('/supplier/good/updateSupplierGood', { id: s.id, firstCategoryId: categoryForm.firstCategoryId }))).then(() => {
ElMessage.success(`已修改 ${selection.value.length} 个商品分类`); batchCategoryDialogVisible.value = false; load()
}).catch((e: any) => ElMessage.error(e?.msg || '批量改分类失败'))
}
function onDeleteBatch() {
if (selection.value.length === 0) { ElMessage.warning('请先选择商品'); return }
ElMessageBox.confirm(`确认删除 ${selection.value.length} 个商品?`, '提示', { type: 'warning' }).then(async () => {
try { for (const s of selection.value) { await httpPost('/supplier/good/deleteSupplierGood', { id: s.id }) } ElMessage.success('批量删除成功'); load() }
catch (e: any) { ElMessage.error(e?.msg || '批量删除失败'); load() }
}).catch(() => {})
}
function onExport() { ElMessage.info('导出功能开发中') }
function onAdd() { ElMessage.info('新增商品请前往供应商端') }
onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
</script>
<template>
<div class="rr-page">
<PageHeader title="商品管理" :sub="`共 ${counts.goodCount} 个商品,${counts.skuCount} 个SKU`">
<el-button @click="onExport">导出</el-button>
<el-button type="primary" @click="onAdd">新增商品</el-button>
</PageHeader>
<ListTabs v-model="filters.checkStatus" :items="tabItems" @change="onStatusTab" />
<SearchBar
v-model:filters="filters"
:fields="[
{ key: 'title', label: '商品名称', type: 'text', placeholder: '请输入商品名称' },
{ key: 'enterpriseName', label: '供应商', type: 'text', placeholder: '供应商名称' }
]"
@search="onSearch"
@reset="onReset"
/>
<!-- 批量操作 -->
<div class="rr-batch-bar" v-if="selection.length > 0">
<span class="rr-batch-tip">已选 {{ selection.length }} 项</span>
<el-button type="success" size="small" @click="onBatchCheck">批量审核</el-button>
<el-button type="danger" size="small" @click="onBatchReject">批量驳回</el-button>
<el-button type="warning" size="small" @click="onBatchOnShelf">批量下架</el-button>
<el-button size="small" @click="onShowBatchCategoryDialog">批量修改分类</el-button>
<el-button type="danger" size="small" plain @click="onDeleteBatch">批量删除</el-button>
</div>
<!-- 表格 (用 DataTable 套裸 el-table, 复用分页 + :borderless + :page-sizes + el-result 错误展示) -->
<DataTable
:columns="columns"
:data="list"
:loading="loading"
:total="total"
:page="page"
:limit="limit"
:page-sizes="[10, 20, 50, 100]"
:show-pagination="true"
:error="error"
empty-text="暂无商品"
:borderless="true"
row-key="id"
@page-change="onPageChange"
@limit-change="onLimitChange"
@selection-change="onSelectionChange"
>
<template #title="{ row }">
<div class="rr-good-cell">
<div v-if="row.icon" class="rr-good-icon">
<el-image :src="row.icon" :preview-src-list="[row.icon]" fit="cover" style="width: 40px; height: 40px; border-radius: 4px" preview-teleported />
</div>
<div v-else class="rr-good-icon-placeholder">{{ (row.title || '?').charAt(0) }}</div>
<span class="rr-good-title">{{ row.title }}</span>
</div>
</template>
<template #category="{ row }">
<span>{{ [row.firstCategoryName, row.secondCategoryName, row.thirdCategoryName].filter(Boolean).join(' / ') || '--' }}</span>
</template>
<template #onShelf="{ row }">
<span class="st-pill" :style="{ color: ONSHELF_STATUS_MAP[row.onShelf]?.color, background: ONSHELF_STATUS_MAP[row.onShelf]?.bg }"><span class="st-dot"></span>{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}</span>
</template>
<template #checkStatus="{ row }">
<span class="st-pill" :style="{ color: CHECK_STATUS_MAP[row.checkStatus]?.color, background: CHECK_STATUS_MAP[row.checkStatus]?.bg }"><span class="st-dot"></span>{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}</span>
</template>
<template #action="{ row }">
<TableActions
:row="row"
:actions="[
{ label: '详情', type: 'primary', onClick: onShowDetail },
{ label: '审核', type: 'primary', when: (r: any) => r.checkStatus === 1, onClick: onShowCheckDialog },
{ label: (r: any) => r.onShelf === 1 ? '下架' : '上架', type: (r: any) => r.onShelf === 1 ? 'warning' : 'success', onClick: onOnShelf },
{ label: '改分类', type: 'primary', onClick: onShowCategoryDialog },
{ label: '删除', type: 'danger', divided: true, onClick: onDelete }
]"
/>
</template>
</DataTable>
<!-- 原 el-table 区域 (暂时保留作为参考, 实际由 DataTable 接管) -->
<div v-if="false" class="rr-card">
<el-table
:data="list"
border
style="width: 100%"
v-loading="loading"
:empty-text="error || '暂无商品'"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="supplierGoodId" label="商品ID" width="140" align="center" show-overflow-tooltip />
<el-table-column label="商品名称" min-width="240">
<template #default="{ row }">
<div class="rr-good-cell">
<div v-if="row.icon" class="rr-good-icon">
<el-image :src="row.icon" :preview-src-list="[row.icon]" fit="cover" style="width: 40px; height: 40px; border-radius: 4px" preview-teleported />
</div>
<div v-else class="rr-good-icon-placeholder">{{ (row.title || '?').charAt(0) }}</div>
<span class="rr-good-title">{{ row.title }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="enterpriseName" label="供应商" min-width="180" show-overflow-tooltip />
<el-table-column label="分类" min-width="240" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ [row.firstCategoryName, row.secondCategoryName, row.thirdCategoryName].filter(Boolean).join(' / ') || '--' }}</span>
</template>
</el-table-column>
<el-table-column prop="brandName" label="品牌" width="120" />
<el-table-column prop="unitName" label="单位" width="80" align="center" />
<el-table-column label="铺货" width="100" align="center">
<template #default="{ row }">
<span class="st-pill" :style="{ color: ONSHELF_STATUS_MAP[row.onShelf]?.color, background: ONSHELF_STATUS_MAP[row.onShelf]?.bg }"><span class="st-dot"></span>{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}</span>
</template>
</el-table-column>
<el-table-column prop="checkOpinion" label="审核意见" min-width="160" show-overflow-tooltip />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<span class="st-pill" :style="{ color: CHECK_STATUS_MAP[row.checkStatus]?.color, background: CHECK_STATUS_MAP[row.checkStatus]?.bg }"><span class="st-dot"></span>{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}</span>
</template>
</el-table-column>
<el-table-column label="申请时间" prop="createDate" width="170" />
<el-table-column label="操作" width="280" align="center" fixed="right">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="onShowDetail(row)">详情</el-button>
<el-button v-if="row.checkStatus === 1" text type="primary" size="small" @click="onShowCheckDialog(row)">审核</el-button>
<el-button text :type="row.onShelf === 1 ? 'warning' : 'success'" size="small" @click="onOnShelf(row)">
{{ row.onShelf === 1 ? '下架' : '上架' }}
</el-button>
<el-button text type="primary" size="small" @click="onShowCategoryDialog(row)">改分类</el-button>
<el-button text type="danger" size="small" @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- ===== 商品详情 / 审核抽屉 (2026-09-18 重设计: 分区式完整信息 + 底部审核操作条) ===== -->
<el-drawer v-model="drawerVisible" size="min(940px, 94vw)" :with-header="true"
:title="drawerRow?.title ? `商品详情 · ${drawerRow.title}` : '商品详情'"
destroy-on-close :close-on-click-modal="false" class="rr-gd-drawer">
<div v-loading="drawerLoading" class="rr-gd-wrap">
<template v-if="drawerInfo">
<!-- 头部: 主图 + 标题/标签 + 元信息 + 价格 -->
<div class="rr-gd-head">
<el-image :src="drawerInfo.icon || drawerImages[0]" fit="cover"
:preview-src-list="[drawerInfo.icon || drawerImages[0]].filter(Boolean)" preview-teleported
class="rr-gd-mainimg">
<template #error><div class="rr-gd-imgerr">无图</div></template>
</el-image>
<div class="rr-gd-headmain">
<div class="rr-gd-titleline">
<span class="rr-gd-title">{{ drawerInfo.title || drawerRow?.title }}
<span v-if="Number(drawerInfo.sourceType) === 2" class="px-1.5 py-0.5 bg-amber-50 text-amber-600 border border-amber-200 rounded text-[10px] font-bold align-middle" style="margin-left:6px">共享商品</span>
</span>
<span class="rr-gd-code">{{ drawerInfo.goodCode || drawerInfo.id }}</span>
</div>
<div class="rr-gd-tags">
<span class="st-pill" :style="{ color: CHECK_STATUS_MAP[Number(drawerRow?.checkStatus ?? -1)]?.color, background: CHECK_STATUS_MAP[Number(drawerRow?.checkStatus ?? -1)]?.bg }"><span class="st-dot"></span>{{ (drawerRow ? CHECK_STATUS_MAP[drawerRow.checkStatus] : undefined)?.label || '--' }}</span>
<span class="st-pill" :style="{ color: ONSHELF_STATUS_MAP[Number(drawerRow?.onShelf ?? -1)]?.color, background: ONSHELF_STATUS_MAP[Number(drawerRow?.onShelf ?? -1)]?.bg }"><span class="st-dot"></span>{{ ONSHELF_STATUS_MAP[Number(drawerRow?.onShelf ?? -1)]?.label || '--' }}</span>
</div>
<div class="rr-gd-price">
<span class="rr-gd-price-now">¥ {{ drawerInfo.newPrice ?? drawerRow?.raw?.newPrice ?? '--' }}</span>
<span v-if="drawerInfo.oldPrice" class="rr-gd-price-old">¥ {{ drawerInfo.oldPrice }}</span>
<span class="rr-gd-price-unit">/ {{ drawerInfo.unitName || drawerRow?.unitName || '件' }}</span>
</div>
<div class="rr-gd-meta">
<span><label>供应商</label>{{ drawerInfo.enterpriseName || drawerRow?.enterpriseName || '--' }}</span>
<span><label>品牌</label>{{ drawerInfo.brandName || drawerRow?.brandName || '--' }}</span>
<span><label>分类</label>{{ drawerCategoryPath || '--' }}</span>
<span><label>申请时间</label>{{ drawerInfo.createDate || drawerRow?.createDate || '--' }}</span>
</div>
</div>
</div>
<!-- 商品图片画廊 -->
<div class="rr-gd-section" v-if="drawerImages.length">
<h4 class="rr-gd-sectitle">商品图片 <span class="rr-gd-seccount">{{ drawerImages.length }} 张</span></h4>
<div class="rr-gd-gallery">
<el-image v-for="(img, gi) in drawerImages" :key="gi" :src="img" fit="cover"
:preview-src-list="drawerImages" :initial-index="gi" preview-teleported hide-on-click-modal
class="rr-gd-thumb">
<template #error><div class="rr-gd-imgerr">加载失败</div></template>
</el-image>
</div>
</div>
<!-- 介绍视频 -->
<div class="rr-gd-section" v-if="drawerInfo.videoUrl">
<h4 class="rr-gd-sectitle">介绍视频</h4>
<video :src="drawerInfo.videoUrl" controls preload="metadata" class="rr-gd-video"></video>
</div>
<!-- 规格与 SKU -->
<div class="rr-gd-section" v-if="(drawerInfo.skuList?.length || drawerInfo.skuDetailList?.length)">
<h4 class="rr-gd-sectitle">规格与 SKU <span class="rr-gd-seccount">{{ drawerInfo.skuDetailList?.length || 0 }} 个 SKU · 库存合计 {{ drawerSkuTotalStore }}</span></h4>
<div v-if="drawerInfo.skuList?.length" class="rr-gd-props">
<div v-for="grp in drawerInfo.skuList" :key="grp.id" class="rr-gd-prop">
<label>{{ grp.name }}</label>
<span class="rr-gd-propchip" v-for="pv in grp.propertyList" :key="pv.id">{{ pv.name }}</span>
</div>
</div>
<el-table v-if="drawerInfo.skuDetailList?.length" :data="drawerInfo.skuDetailList" border size="small"
max-height="320" class="rr-gd-skutable">
<el-table-column label="规格组合" prop="propertyNames" min-width="180" show-overflow-tooltip />
<el-table-column label="图片" width="64" align="center">
<template #default="{ row }">
<el-image v-if="row.icon" :src="row.icon" fit="cover" :preview-src-list="[row.icon]" preview-teleported class="rr-gd-skuimg" />
<span v-else>--</span>
</template>
</el-table-column>
<el-table-column label="SKU编码" prop="skuCode" min-width="150" show-overflow-tooltip />
<el-table-column label="现价" width="96" align="right">
<template #default="{ row }">¥ {{ row.newPrice ?? '--' }}</template>
</el-table-column>
<el-table-column label="原价" width="96" align="right">
<template #default="{ row }">{{ row.oldPrice ?? '--' }}</template>
</el-table-column>
<el-table-column label="秒杀价" width="96" align="right">
<template #default="{ row }">{{ row.seckillPrice ?? '--' }}</template>
</el-table-column>
<el-table-column label="拼团价" width="96" align="right">
<template #default="{ row }">{{ row.groupPrice ?? '--' }}</template>
</el-table-column>
<el-table-column label="库存" width="72" align="center" prop="store" />
<el-table-column label="起购" width="64" align="center" prop="buyMin" />
<el-table-column label="限购" width="64" align="center">
<template #default="{ row }">{{ row.limitNum ?? '--' }}</template>
</el-table-column>
</el-table>
</div>
<!-- 商品详情 (富文本) -->
<div class="rr-gd-section">
<h4 class="rr-gd-sectitle">商品详情</h4>
<div v-if="drawerInfo.content" class="rr-gd-content" v-html="drawerInfo.content"></div>
<div v-else-if="drawerInfo.remark" class="rr-gd-content">{{ drawerInfo.remark }}</div>
<div v-else class="rr-gd-emptypar">商家未填写图文详情</div>
</div>
<!-- 审核信息 -->
<div class="rr-gd-section rr-gd-auditinfo">
<h4 class="rr-gd-sectitle">审核信息</h4>
<div class="rr-gd-auditline">
<span><label>当前状态</label>{{ (drawerRow ? CHECK_STATUS_MAP[drawerRow.checkStatus] : undefined)?.label || '--' }}</span>
<span><label>审核意见</label>{{ drawerRow?.checkOpinion || drawerInfo?.checkOpinion || '—' }}</span>
</div>
</div>
</template>
</div>
<!-- 底部固定审核操作条 -->
<template #footer>
<!-- 待审核判断以列表行状态为准 (审核入口数据源; 个别脏数据 info 返回值可能不一致) -->
<div v-if="drawerRow && Number(drawerRow.checkStatus) === 1" class="rr-gd-audit">
<el-radio-group v-model="drawerAuditForm.checkStatus" class="rr-gd-auditradio">
<el-radio :value="2">审核通过</el-radio>
<el-radio :value="3">审核驳回</el-radio>
</el-radio-group>
<!-- 通过无需填写审核意见; 驳回时必填 -->
<el-input v-if="drawerAuditForm.checkStatus === 3" v-model="drawerAuditForm.checkOpinion" type="textarea"
:rows="2" maxlength="200" placeholder="请填写驳回原因(必填),将展示给商家" class="rr-gd-auditinput" />
<div class="rr-gd-auditbtns">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" @click="onSubmitDrawerAudit">提交审核结果</el-button>
</div>
</div>
<div v-else class="rr-gd-auditdone">
<span v-if="drawerRow">该商品已完成审核({{ CHECK_STATUS_MAP[drawerRow.checkStatus]?.label }}<template v-if="drawerInfo?.checkOpinion">:{{ drawerInfo.checkOpinion }}</template>),如需修改请使用列表「⋯ → 上架/下架」操作。</span>
</div>
</template>
</el-drawer>
<!-- 修改分类弹窗 (单个) -->
<el-dialog v-model="categoryDialogVisible" title="修改分类" width="500px">
<el-form v-if="categoryData" label-width="100px">
<el-form-item label="商品名称">{{ categoryData.title }}</el-form-item>
<el-form-item label="当前分类">
{{ [categoryData.firstCategoryName, categoryData.secondCategoryName, categoryData.thirdCategoryName].filter(Boolean).join(' / ') }}
</el-form-item>
<el-form-item label="新分类">
<el-cascader v-model="categoryForm" :options="[]" placeholder="请选择新分类" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="categoryDialogVisible = false">取消</el-button>
<el-button type="primary" @click="onSubmitCategory">确定</el-button>
</template>
</el-dialog>
<!-- 批量修改分类弹窗 -->
<el-dialog v-model="batchCategoryDialogVisible" title="批量修改分类" width="500px">
<el-form label-width="100px">
<el-form-item label="已选商品">共 {{ selection.length }} 个</el-form-item>
<el-form-item label="新分类">
<el-cascader :options="[]" placeholder="请选择新分类" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchCategoryDialogVisible = false">取消</el-button>
<el-button type="primary" @click="onSubmitBatchCategory">确定</el-button>
</template>
</el-dialog>
<!-- 批量驳回弹窗 -->
<el-dialog v-model="batchRejectDialogVisible" title="批量驳回" width="500px">
<el-form label-width="100px">
<el-form-item label="已选商品">共 {{ selection.length }} 个</el-form-item>
<el-form-item label="驳回原因" required>
<el-input v-model="batchRejectForm.reason" type="textarea" :rows="4" placeholder="请填写驳回原因" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchRejectDialogVisible = false">取消</el-button>
<el-button type="primary" @click="onSubmitBatchReject">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 1500px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 16px; }
.rr-batch-bar { display: flex; align-items: center; gap: 8px; padding: 10px 16px; background: var(--bg-soft); border-radius: var(--radius); margin-bottom: 12px; }
.rr-batch-tip { font-size: 13px; color: var(--ink-2); margin-right: 8px; }
.rr-good-cell { display: flex; align-items: center; gap: 10px; }
.rr-good-icon-placeholder { width: 40px; height: 40px; border-radius: 4px; background: var(--bg-soft); display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-weight: 600; flex-shrink: 0; }
.rr-good-title { font-size: 13.5px; color: var(--ink); }
.rr-sku-title { font-size: 14px; font-weight: 600; color: var(--ink); margin: 24px 0 12px 0; }
.rr-empty-tip { text-align: center; padding: 30px 0; color: var(--ink-3); }
/* ===== 商品详情/审核抽屉 ===== */
.rr-gd-wrap { padding: 0 2px; }
.rr-gd-head { display: flex; gap: 16px; padding: 4px 0 14px; border-bottom: 1px solid var(--border); }
.rr-gd-mainimg { flex: 0 0 96px; width: 96px; height: 96px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg, #f6f8fb); }
.rr-gd-imgerr { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; font-size: 12px; color: var(--ink-3); }
.rr-gd-headmain { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.rr-gd-titleline { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
.rr-gd-title { font-size: 16px; font-weight: 700; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rr-gd-code { flex: 0 0 auto; font-size: 11px; font-family: 'SF Mono', Menlo, Consolas, monospace; color: var(--ink-3); background: var(--bg, #f6f8fb); border: 1px solid var(--border); border-radius: 4px; padding: 1px 8px; }
.rr-gd-tags { display: flex; gap: 8px; }
.rr-gd-price { display: flex; align-items: baseline; gap: 8px; }
.rr-gd-price-now { font-size: 22px; font-weight: 800; color: #dc2626; }
.rr-gd-price-old { font-size: 12px; color: var(--ink-3); text-decoration: line-through; }
.rr-gd-price-unit { font-size: 12px; color: var(--ink-3); }
.rr-gd-meta { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 12px; color: var(--ink-2); }
.rr-gd-meta label { color: var(--ink-3); margin-right: 4px; }
.rr-gd-section { margin-top: 18px; }
.rr-gd-sectitle { font-size: 13px; font-weight: 700; color: var(--ink); margin-bottom: 10px; padding-left: 8px; border-left: 3px solid var(--primary); line-height: 1.2; }
.rr-gd-seccount { font-weight: 500; font-size: 11px; color: var(--ink-3); margin-left: 6px; }
.rr-gd-gallery { display: flex; flex-wrap: wrap; gap: 10px; }
.rr-gd-thumb { width: 92px; height: 92px; border-radius: 10px; border: 1px solid var(--border); cursor: zoom-in; background: var(--bg, #f6f8fb); }
.rr-gd-video { max-width: 360px; width: 100%; border-radius: 10px; border: 1px solid var(--border); background: #000; }
.rr-gd-props { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.rr-gd-prop { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.rr-gd-prop label { flex: 0 0 88px; font-size: 12px; color: var(--ink-3); }
.rr-gd-propchip { font-size: 11px; color: var(--ink-2); background: var(--bg, #f6f8fb); border: 1px solid var(--border); border-radius: 20px; padding: 2px 10px; }
.rr-gd-skuimg { width: 40px; height: 40px; border-radius: 6px; border: 1px solid var(--border); cursor: zoom-in; }
.rr-gd-content { border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; background: var(--surface); max-height: 460px; overflow-y: auto; }
.rr-gd-content :deep(img) { max-width: 100%; height: auto; display: block; margin: 6px 0; border-radius: 6px; }
.rr-gd-content :deep(p) { margin: 6px 0; line-height: 1.8; font-size: 13px; color: var(--ink); }
.rr-gd-emptypar { font-size: 12px; color: var(--ink-3); background: var(--bg, #f6f8fb); border-radius: 8px; padding: 14px; }
.rr-gd-auditinfo .rr-gd-auditline { display: flex; gap: 28px; font-size: 13px; color: var(--ink-2); }
.rr-gd-auditinfo label { color: var(--ink-3); margin-right: 6px; }
.rr-gd-audit { display: flex; flex-direction: column; gap: 10px; }
.rr-gd-auditradio { margin-bottom: 2px; }
.rr-gd-auditdone { font-size: 12px; color: var(--ink-3); background: var(--bg, #f6f8fb); border-radius: 8px; padding: 10px 12px; line-height: 1.6; }
</style>