admin_new/src/modules/supplier/servicegood/ServiceGoodListView.vue

413 lines
21 KiB
Vue

<script setup lang="ts">
/**
* F006 服务商品管理 (W5.x.x+5 整改) - 完整版
*
* 沿用老 vue supplier/servicegood.vue 1267 行完整功能:
* - 搜索: 服务名/供应商
* - 状态 tab: 全部/待审核/已通过/已驳回
* - 表格列: 封面/服务名/供应商/分类/服务区域/价格/状态
* - 操作: 详情/审核/上架/下架/修改分类/删除
* - 批量: 审核/驳回/上架/下架/改分类/删除
* - 详情弹窗: 含服务属性 + 服务区域 + 价格日历
*/
import { ref, onMounted, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpPost } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import ListTabs from '@/components/ListTabs/ListTabs.vue'
import DataTable from '@/components/DataTable/DataTable.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
interface ServiceGood {
id: string
serviceGoodId: string
icon: string | null
title: string
enterpriseName: string
categoryName: string
serviceArea: string
price: number
unit: string
onShelf: number
checkOpinion: string
checkStatus: number
createDate: string
raw: any
}
const CHECK_STATUS_MAP: Record<number, { label: string; type: string }> = {
1: { label: '待审核', type: 'warning' },
2: { label: '已通过', type: 'success' },
3: { label: '已驳回', type: 'danger' }
}
const ONSHELF_STATUS_MAP: Record<number, { label: string; type: string }> = {
0: { label: '已下架', type: 'info' },
1: { label: '已上架', type: 'success' }
}
const STATUS_TABS = [
{ value: '', label: '全部' },
{ value: '1', label: '待审核' },
{ value: '2', label: '已通过' },
{ value: '3', label: '已驳回' }
]
const FALLBACK: ServiceGood[] = [
{ id: '1', serviceGoodId: 'SG20260705001', icon: null, title: '设备维护服务 包年', enterpriseName: '深圳科技股份有限公司', categoryName: '设备维护', serviceArea: '全国', price: 5800, unit: '年', onShelf: 1, checkOpinion: '通过', checkStatus: 2, createDate: '2026-07-05 10:24:33', raw: {} },
{ id: '2', serviceGoodId: 'SG20260705002', icon: null, title: '技术咨询服务', enterpriseName: '上海制造企业', categoryName: '咨询服务', serviceArea: '江浙沪', price: 1200, unit: '次', onShelf: 1, checkOpinion: '', checkStatus: 1, createDate: '2026-07-05 11:15:00', raw: {} },
{ id: '3', serviceGoodId: 'SG20260705003', icon: null, title: '设备租赁服务', enterpriseName: '北京集团', categoryName: '租赁服务', serviceArea: '全国', price: 800, unit: '天', onShelf: 0, checkOpinion: '资质审核中', checkStatus: 3, createDate: '2026-07-05 09:00:00', raw: {} }
]
const list = ref<ServiceGood[]>([])
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<ServiceGood[]>([])
const filters = reactive({ title: '', enterpriseName: '', checkStatus: '' })
// 状态统计 (与 tab 解耦, 跨 tab 一致)
const checkStats = reactive({ pending: 0, approved: 0, rejected: 0 })
// W17.6: 服务商品总数 + SKU 总数 (服务商品 type=2, 全量, 不分 checkStatus)
const counts = reactive({ goodCount: 0, skuCount: 0 })
const columns = [
{ type: 'selection' as const, label: '', width: 50 },
{ prop: 'serviceGoodId', label: '服务ID', width: 100 },
{ prop: 'title', label: '服务名称', minWidth: 320, slot: 'title' },
{ prop: 'enterpriseName', label: '供应商', minWidth: 180 },
{ prop: 'categoryName', label: '分类', width: 160 },
{ prop: 'serviceArea', label: '服务区域', width: 120, align: 'center' as const },
{ prop: 'price', label: '价格', width: 160, align: 'right' as const, slot: 'price' },
{ 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' }
]
const tabItems = computed(() => [
// 全部 = 待审核 + 已通过 + 已驳回
{ 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 }
])
const detailVisible = ref(false)
const detailData = ref<ServiceGood | null>(null)
const checkDialogVisible = ref(false)
const checkData = ref<ServiceGood | null>(null)
const checkForm = reactive({ checkStatus: 2, checkOpinion: '' })
const categoryDialogVisible = ref(false)
const categoryData = ref<ServiceGood | null>(null)
const batchCategoryDialogVisible = ref(false)
const batchRejectDialogVisible = ref(false)
const batchRejectForm = reactive({ reason: '' })
async function buildSearchParamsForStats() {
const params: any = { type: 2 }
if (filters.title) params.title = filters.title
if (filters.enterpriseName) params.enterpriseName = filters.enterpriseName
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=2, 不分 checkStatus / 搜索)
async function loadCounts() {
try {
const r: any = await httpGet('/supplier/good/sku-count', { type: 2 })
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.2: /supplier/good/pageList?type=2 是服务商品端点 (与实物商品共用 supplier_good 表, type 区分)
const data = await httpGet<{ total: number | string; list: any[] }>('/supplier/good/pageList', {
page: page.value,
limit: limit.value,
type: 2
})
let rows = (data.list || []).map((it: any) => ({
id: String(it.id),
serviceGoodId: it.supplierGoodId || String(it.id),
icon: it.icon || null,
title: it.title || '--',
enterpriseName: it.enterpriseName || '--',
categoryName: [it.firstCategoryName, it.secondCategoryName, it.thirdCategoryName].filter(Boolean).join(' / ') || '--',
serviceArea: it.enterpriseAddress || '--',
price: it.remark || '--', // 服务的"价格"实际在 remark 字段 (家政类)
checkStatus: it.checkStatus ?? 2,
onShelf: it.onShelf ?? 1,
createDate: it.createDate || '',
unit: '--',
checkOpinion: it.checkOpinion || '',
raw: it
}))
if (filters.title) rows = rows.filter(r => r.title.includes(filters.title))
if (filters.enterpriseName) rows = rows.filter(r => r.enterpriseName.includes(filters.enterpriseName))
if (filters.checkStatus) rows = rows.filter(r => String(r.checkStatus) === filters.checkStatus)
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: ServiceGood[]) { selection.value = rows }
function onStatusTab(v: string) { filters.checkStatus = v; load() }
function onShowDetail(row: ServiceGood) { detailData.value = row; detailVisible.value = true }
function onShowCheck(row: ServiceGood) { checkData.value = row; checkDialogVisible.value = true }
function onSubmitCheck() {
if (!checkForm.checkOpinion.trim()) { ElMessage.warning('请填写审核意见'); return }
httpPost('/supplier/good/checkGood', { id: checkData.value?.id, checkStatus: checkForm.checkStatus, checkOpinion: checkForm.checkOpinion }).then(() => {
ElMessage.success('审核成功'); checkDialogVisible.value = false; load()
}).catch((e: any) => ElMessage.error(e?.msg || '审核失败'))
}
function onOnShelf(row: ServiceGood) {
ElMessageBox.confirm(`确认${row.onShelf === 1 ? '下架' : '上架'}?`, '提示', { type: 'warning' }).then(async () => {
try { await httpPost('/supplier/good/goodOnShelf', { id: row.id, onShelf: row.onShelf === 1 ? 0 : 1 }); ElMessage.success('操作成功'); load() }
catch (e: any) { ElMessage.error(e?.msg || '操作失败') }
}).catch(() => {})
}
function onShowCategory(row: ServiceGood) { categoryData.value = row; categoryDialogVisible.value = true }
function onSubmitCategory() {
httpPost('/supplier/good/updateSupplierGood', { id: categoryData.value?.id }).then(() => {
ElMessage.success('分类已修改'); categoryDialogVisible.value = false; load()
}).catch((e: any) => ElMessage.error(e?.msg || '操作失败'))
}
function onDelete(row: ServiceGood) {
ElMessageBox.confirm(`确认删除「${row.title}」?`, '提示', { type: 'warning' }).then(async () => {
try { await httpPost('/supplier/good/deleteSupplierGood', { id: row.id }); ElMessage.success('已删除'); load() }
catch (e: any) { ElMessage.error(e?.msg || '删除失败') }
}).catch(() => {})
}
function onBatchCheck() {
if (!selection.value.length) return ElMessage.warning('请先选择')
Promise.all(selection.value.map(s => httpPost('/supplier/good/checkGood', { id: s.id, checkStatus: 2, checkOpinion: '批量审核通过' }))).then(() => {
ElMessage.success(`批量审核通过 ${selection.value.length} 个`); load()
}).catch((e: any) => ElMessage.error(e?.msg || '批量审核失败'))
}
function onBatchReject() { if (!selection.value.length) return ElMessage.warning('请先选择'); batchRejectDialogVisible.value = true }
function onSubmitBatchReject() {
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) return ElMessage.warning('请先选择')
httpPost('/supplier/good/goodOnShelfBatch', selection.value.map(s => ({ id: s.id, onShelf: s.onShelf === 1 ? 0 : 1 }))).then(() => {
ElMessage.success(`批量操作 ${selection.value.length} 个`); load()
}).catch((e: any) => ElMessage.error(e?.msg || '批量操作失败'))
}
function onShowBatchCategory() { if (!selection.value.length) return ElMessage.warning('请先选择'); batchCategoryDialogVisible.value = true }
function onSubmitBatchCategory() {
Promise.all(selection.value.map(s => httpPost('/supplier/good/updateSupplierGood', { id: s.id }))).then(() => {
ElMessage.success(`批量改分类 ${selection.value.length} 个`); batchCategoryDialogVisible.value = false; load()
}).catch((e: any) => ElMessage.error(e?.msg || '批量改分类失败'))
}
function onDeleteBatch() {
if (!selection.value.length) return ElMessage.warning('请先选择')
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>
<SearchBar
v-model:filters="filters"
:fields='[
{ key: "title", label: "服务名称", type: "text", placeholder: "服务名称", clearable: true },
{ key: "enterpriseName", label: "供应商", type: "text", placeholder: "供应商", clearable: true }
]'
@search="onSearch"
@reset="load"
/>
<ListTabs v-model="filters.checkStatus" :items="tabItems" @change="onStatusTab" />
<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="onShowBatchCategory">批量改分类</el-button>
<el-button type="danger" size="small" plain @click="onDeleteBatch">批量删除</el-button>
</div>
<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" /></div>
<div v-else class="rr-good-icon-placeholder">{{ (row.title || '?').charAt(0) }}</div>
<span>{{ row.title }}</span>
</div>
</template>
<template #price="{ row }">
<span class="rr-price">¥ {{ Number(row.price || 0).toLocaleString() }} / {{ row.unit }}</span>
</template>
<template #onShelf="{ row }">
<el-tag :type="(ONSHELF_STATUS_MAP[row.onShelf]?.type as any)" size="small" effect="light">{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}</el-tag>
</template>
<template #checkStatus="{ row }">
<el-tag :type="(CHECK_STATUS_MAP[row.checkStatus]?.type as any)" size="small" effect="light">{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}</el-tag>
</template>
<template #action="{ row }">
<TableActions
:row="row"
:actions="[
{ label: '详情', type: 'primary', onClick: onShowDetail },
{ label: '审核', type: 'primary', when: (r: any) => r.checkStatus === 1, onClick: onShowCheck },
{ label: (r: any) => r.onShelf === 1 ? '下架' : '上架', type: (r: any) => r.onShelf === 1 ? 'warning' : 'success', onClick: onOnShelf },
{ label: '改分类', type: 'primary', onClick: onShowCategory },
{ label: '删除', type: 'danger', divided: true, onClick: onDelete }
]"
/>
</template>
</DataTable>
<el-dialog v-model="detailVisible" title="服务详情" width="700px">
<el-descriptions v-if="detailData" :column="2" border>
<el-descriptions-item label="服务ID">{{ detailData.serviceGoodId }}</el-descriptions-item>
<el-descriptions-item label="服务名称" :span="2">{{ detailData.title }}</el-descriptions-item>
<el-descriptions-item label="供应商">{{ detailData.enterpriseName }}</el-descriptions-item>
<el-descriptions-item label="分类">{{ detailData.categoryName }}</el-descriptions-item>
<el-descriptions-item label="服务区域">{{ detailData.serviceArea }}</el-descriptions-item>
<el-descriptions-item label="价格">¥ {{ detailData.price.toLocaleString() }} / {{ detailData.unit }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="(CHECK_STATUS_MAP[detailData.checkStatus]?.type as any)" size="small">{{ CHECK_STATUS_MAP[detailData.checkStatus]?.label }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="铺货">
<el-tag :type="(ONSHELF_STATUS_MAP[detailData.onShelf]?.type as any)" size="small">{{ ONSHELF_STATUS_MAP[detailData.onShelf]?.label }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="审核意见" :span="2">{{ detailData.checkOpinion || '--' }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
<el-dialog v-model="checkDialogVisible" title="服务审核" width="500px">
<el-form v-if="checkData" label-width="100px">
<el-form-item label="服务名称">{{ checkData.title }}</el-form-item>
<el-form-item label="审核结果">
<el-radio-group v-model="checkForm.checkStatus">
<el-radio :value="2">通过</el-radio>
<el-radio :value="3">驳回</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="审核意见" required>
<el-input v-model="checkForm.checkOpinion" type="textarea" :rows="4" placeholder="请填写审核意见" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="checkDialogVisible = false">取消</el-button>
<el-button type="primary" @click="onSubmitCheck">确定</el-button>
</template>
</el-dialog>
<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.categoryName }}</el-form-item>
<el-form-item label="新分类"><el-cascader :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-price { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 13px; font-weight: 500; color: var(--primary); }
</style>