|
|
@@ -1,16 +1,376 @@
|
|
|
<template>
|
|
|
- <MpWebviewShell>
|
|
|
+ <MpWebviewShell @ready="onShellReady">
|
|
|
<header class="mp-wv-header">
|
|
|
<div class="mp-wv-header__row">
|
|
|
- <h1 class="mp-wv-header__title">{{ title }}</h1>
|
|
|
+ <button class="mp-wv-header__back" type="button" aria-label="返回列表" @click="goList">
|
|
|
+ ←
|
|
|
+ </button>
|
|
|
+ <h1 class="mp-wv-header__title">{{ headerTitle }}</h1>
|
|
|
+ <el-tag v-if="detail && !loading" :type="statusTagType" size="small">{{ statusLabel }}</el-tag>
|
|
|
+ </div>
|
|
|
+ <div v-if="detail && !loading" class="mp-wv-header__sub">
|
|
|
+ {{ detail.productNo || '—' }} · {{ detail.model || '—' }}
|
|
|
</div>
|
|
|
</header>
|
|
|
- <div class="mp-wv-empty">页面建设中</div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-if="loading"
|
|
|
+ class="mp-plan-form-loading mp-plan-form-loading--page"
|
|
|
+ role="status"
|
|
|
+ aria-live="polite"
|
|
|
+ aria-busy="true"
|
|
|
+ >
|
|
|
+ <div class="mp-plan-form-loading__orb" aria-hidden="true">
|
|
|
+ <span class="mp-plan-form-loading__dot" />
|
|
|
+ </div>
|
|
|
+ <p class="mp-plan-form-loading__title">正在加载产品详情</p>
|
|
|
+ <p class="mp-plan-form-loading__desc">请稍候,正在获取产品信息…</p>
|
|
|
+ <div class="mp-plan-form-loading__skel" aria-hidden="true">
|
|
|
+ <div class="mp-wv-skel mp-wv-skel--block" />
|
|
|
+ <div class="mp-wv-skel mp-wv-skel--mid" />
|
|
|
+ <div class="mp-wv-skel mp-wv-skel--line" />
|
|
|
+ <div class="mp-wv-skel mp-wv-skel--short" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="errorText" class="mp-wv-empty">{{ errorText }}</div>
|
|
|
+ <template v-else-if="detail">
|
|
|
+ <div class="mp-wv-detail-body">
|
|
|
+ <section class="mp-wv-section">
|
|
|
+ <h3>基础信息</h3>
|
|
|
+ <div class="mp-wv-kv"><span>产品编号</span><span>{{ detail.productNo || '—' }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>产品名称</span><span>{{ detail.productName || '—' }}</span></div>
|
|
|
+ <div class="mp-wv-kv">
|
|
|
+ <span>拼音首字母</span><span>{{ detail.namePinyinAbbr || '—' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="mp-wv-kv"><span>产品分类</span><span>{{ categoryPathText }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>规格型号</span><span>{{ detail.model || '—' }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>条码</span><span>{{ detail.barcode || '—' }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>税率</span><span>{{ taxRateText }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>库存</span><span>{{ stockText }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>低库存阈值</span><span>{{ lowStockText }}</span></div>
|
|
|
+ <div class="mp-wv-kv">
|
|
|
+ <span>状态</span>
|
|
|
+ <span>
|
|
|
+ <el-tag :type="statusTagType" size="small">{{ statusLabel }}</el-tag>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="mp-wv-kv">
|
|
|
+ <span>危化标识</span>
|
|
|
+ <span class="mp-wv-tag-row">
|
|
|
+ <el-tag v-if="showToxic" type="danger" size="small">易制毒</el-tag>
|
|
|
+ <el-tag v-if="showExplosive" type="warning" size="small">易制爆</el-tag>
|
|
|
+ <template v-if="!showToxic && !showExplosive">—</template>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="mp-wv-section">
|
|
|
+ <h3>描述 / 备注</h3>
|
|
|
+ <div class="mp-wv-kv"><span>产品描述</span><span class="mp-wv-pre">{{ descriptionText }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>备注</span><span class="mp-wv-pre">{{ remarkText }}</span></div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="mp-wv-section">
|
|
|
+ <h3>图片</h3>
|
|
|
+ <div v-if="imageList.length" class="mp-wv-img-grid">
|
|
|
+ <el-image
|
|
|
+ v-for="(url, idx) in imageList"
|
|
|
+ :key="`${url}-${idx}`"
|
|
|
+ class="mp-wv-img-grid__item"
|
|
|
+ :src="url"
|
|
|
+ :preview-src-list="imageList"
|
|
|
+ :initial-index="idx"
|
|
|
+ fit="cover"
|
|
|
+ preview-teleported
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <p v-else class="mp-wv-card__meta">暂无图片</p>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="mp-wv-section">
|
|
|
+ <h3>品牌列表</h3>
|
|
|
+ <template v-if="brands.length">
|
|
|
+ <div v-for="(b, idx) in brands" :key="b.id || `${b.brandName}-${idx}`" class="mp-wv-kv">
|
|
|
+ <span>{{ b.brandName || '—' }}</span>
|
|
|
+ <span>
|
|
|
+ <el-tag :type="productStatusTagType(b.status)" size="small">
|
|
|
+ {{ productStatusLabel(b.status) }}
|
|
|
+ </el-tag>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <p v-else class="mp-wv-card__meta">暂无品牌</p>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="mp-wv-section">
|
|
|
+ <h3>别名列表</h3>
|
|
|
+ <template v-if="aliases.length">
|
|
|
+ <div
|
|
|
+ v-for="(a, idx) in aliases"
|
|
|
+ :key="a.id || `${a.aliasName}-${idx}`"
|
|
|
+ class="mp-wv-kv"
|
|
|
+ >
|
|
|
+ <span>别名</span>
|
|
|
+ <span>{{ a.aliasName || '—' }}</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <p v-else class="mp-wv-card__meta">暂无别名</p>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="mp-wv-fab-bar">
|
|
|
+ <button
|
|
|
+ class="mp-wv-btn mp-wv-btn--primary"
|
|
|
+ type="button"
|
|
|
+ :disabled="acting"
|
|
|
+ @click="goEdit"
|
|
|
+ >
|
|
|
+ 编辑
|
|
|
+ </button>
|
|
|
+ <button class="mp-wv-btn" type="button" :disabled="acting" @click="toggleStatus">
|
|
|
+ {{ statusActionLabel }}
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ class="mp-wv-btn is-danger"
|
|
|
+ type="button"
|
|
|
+ :disabled="acting || !canDelete"
|
|
|
+ :title="canDelete ? '' : CREATOR_DELETE_TIP"
|
|
|
+ @click="onDelete"
|
|
|
+ >
|
|
|
+ 删除
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
</MpWebviewShell>
|
|
|
</template>
|
|
|
+
|
|
|
<script setup lang="ts">
|
|
|
+ import { computed, ref } from 'vue'
|
|
|
+ import { useRoute, useRouter } from 'vue-router'
|
|
|
+ import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
|
|
|
+ import {
|
|
|
+ fetchProductDetail,
|
|
|
+ fetchUpdateProductStatus,
|
|
|
+ fetchDeleteProduct,
|
|
|
+ fetchCategoryTree,
|
|
|
+ type ProductAliasItem,
|
|
|
+ type ProductBrandItem,
|
|
|
+ type ProductDetail
|
|
|
+ } from '@/api/crm/product'
|
|
|
+ import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
|
|
|
+ import { MP_CRM_PRODUCT_LIST_PATH } from '@/utils/mp-webview'
|
|
|
+ import { productStatusLabel, productStatusTagType } from '@/utils/mp-crm-product-card'
|
|
|
+ import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
|
|
|
import '@/styles/mp-webview.scss'
|
|
|
|
|
|
- const title = '产品详情'
|
|
|
+ const route = useRoute()
|
|
|
+ const router = useRouter()
|
|
|
+ const loading = ref(true)
|
|
|
+ const acting = ref(false)
|
|
|
+ const errorText = ref('')
|
|
|
+ const detail = ref<ProductDetail | null>(null)
|
|
|
+ const categoryPathMap = ref<Map<string, string>>(new Map())
|
|
|
+ let categoryMapLoaded = false
|
|
|
+
|
|
|
+ const headerTitle = computed(() => {
|
|
|
+ if (loading.value) return '产品详情'
|
|
|
+ return detail.value?.productName || '产品详情'
|
|
|
+ })
|
|
|
+ const statusLabel = computed(() => productStatusLabel(detail.value?.status))
|
|
|
+ const statusTagType = computed(() => productStatusTagType(detail.value?.status))
|
|
|
+ const statusActionLabel = computed(() =>
|
|
|
+ detail.value?.status === 'on' ? '下架' : '上架'
|
|
|
+ )
|
|
|
+ const showToxic = computed(() => Number(detail.value?.isToxic) === 1)
|
|
|
+ const showExplosive = computed(() => Number(detail.value?.isExplosive) === 1)
|
|
|
+ const canDelete = computed(() => canDeleteByCreator(detail.value as Record<string, unknown>))
|
|
|
+
|
|
|
+ const categoryPathText = computed(() => {
|
|
|
+ const d = detail.value
|
|
|
+ if (!d) return '—'
|
|
|
+ const cid = String(d.categoryId || '').trim()
|
|
|
+ const path = cid ? categoryPathMap.value.get(cid) : ''
|
|
|
+ return path || d.categoryName || '—'
|
|
|
+ })
|
|
|
+
|
|
|
+ const taxRateText = computed(() => {
|
|
|
+ const n = detail.value?.taxRate
|
|
|
+ if (n === undefined || n === null || Number.isNaN(Number(n))) return '—'
|
|
|
+ return `${n}%`
|
|
|
+ })
|
|
|
+ const stockText = computed(() => {
|
|
|
+ const n = detail.value?.stock
|
|
|
+ return n === undefined || n === null ? '—' : String(n)
|
|
|
+ })
|
|
|
+ const lowStockText = computed(() => {
|
|
|
+ const n = detail.value?.lowStock
|
|
|
+ return n === undefined || n === null ? '—' : String(n)
|
|
|
+ })
|
|
|
+ const descriptionText = computed(() => detail.value?.description?.trim() || '—')
|
|
|
+ const remarkText = computed(() => detail.value?.remark?.trim() || '—')
|
|
|
+
|
|
|
+ const imageList = computed(() => {
|
|
|
+ const images = detail.value?.images
|
|
|
+ if (!images) return [] as string[]
|
|
|
+ return String(images)
|
|
|
+ .split(',')
|
|
|
+ .map((s) => s.trim())
|
|
|
+ .filter(Boolean)
|
|
|
+ })
|
|
|
+
|
|
|
+ const brands = computed<ProductBrandItem[]>(() => detail.value?.brands || [])
|
|
|
+ const aliases = computed<ProductAliasItem[]>(() => detail.value?.aliases || [])
|
|
|
+
|
|
|
+ async function ensureCategoryPathMap() {
|
|
|
+ if (categoryMapLoaded) return
|
|
|
+ try {
|
|
|
+ const res = await fetchCategoryTree()
|
|
|
+ const roots = (res as { data?: unknown })?.data || res || []
|
|
|
+ categoryPathMap.value = buildPathLabelMapFromTreeRoots(Array.isArray(roots) ? roots : [], {
|
|
|
+ idKey: 'id',
|
|
|
+ nameKey: 'categoryName',
|
|
|
+ childrenKey: 'children'
|
|
|
+ })
|
|
|
+ } catch {
|
|
|
+ categoryPathMap.value = new Map()
|
|
|
+ } finally {
|
|
|
+ categoryMapLoaded = true
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadDetail() {
|
|
|
+ const id = String(route.params.id || '')
|
|
|
+ if (!id) {
|
|
|
+ errorText.value = '缺少产品 ID'
|
|
|
+ loading.value = false
|
|
|
+ return
|
|
|
+ }
|
|
|
+ loading.value = true
|
|
|
+ try {
|
|
|
+ await ensureCategoryPathMap()
|
|
|
+ const res = await fetchProductDetail({ id })
|
|
|
+ detail.value = ((res as { data?: ProductDetail }).data ?? res) as ProductDetail
|
|
|
+ errorText.value = ''
|
|
|
+ } catch (error) {
|
|
|
+ detail.value = null
|
|
|
+ errorText.value = error instanceof Error ? error.message : '加载失败'
|
|
|
+ } finally {
|
|
|
+ loading.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function goList() {
|
|
|
+ void router.push(MP_CRM_PRODUCT_LIST_PATH)
|
|
|
+ }
|
|
|
+
|
|
|
+ function goEdit() {
|
|
|
+ const id = detail.value?.id
|
|
|
+ if (!id) return
|
|
|
+ void router.push(`/m/crm/product/${id}/edit`)
|
|
|
+ }
|
|
|
+
|
|
|
+ async function toggleStatus() {
|
|
|
+ const d = detail.value
|
|
|
+ if (!d?.id) return
|
|
|
+ const nextStatus = d.status === 'on' ? 'off' : 'on'
|
|
|
+ const label = nextStatus === 'on' ? '上架' : '下架'
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm(`确认${label}该产品?`, '提示', {
|
|
|
+ confirmButtonText: '确定',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ type: 'warning'
|
|
|
+ })
|
|
|
+ } catch {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ acting.value = true
|
|
|
+ try {
|
|
|
+ await fetchUpdateProductStatus({ ids: [d.id], status: nextStatus })
|
|
|
+ ElMessage.success(`${label}成功`)
|
|
|
+ await loadDetail()
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error(error instanceof Error ? error.message : `${label}失败`)
|
|
|
+ } finally {
|
|
|
+ acting.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function onDelete() {
|
|
|
+ const d = detail.value
|
|
|
+ if (!d?.id) return
|
|
|
+ if (!canDeleteByCreator(d as Record<string, unknown>)) {
|
|
|
+ ElMessage.warning(CREATOR_DELETE_TIP)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm('确认删除该产品?删除后不可恢复。', '提示', {
|
|
|
+ confirmButtonText: '确定',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ type: 'warning'
|
|
|
+ })
|
|
|
+ } catch {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ acting.value = true
|
|
|
+ try {
|
|
|
+ await fetchDeleteProduct({ id: d.id })
|
|
|
+ ElMessage.success('删除成功')
|
|
|
+ void router.push(MP_CRM_PRODUCT_LIST_PATH)
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
|
|
+ } finally {
|
|
|
+ acting.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function onShellReady() {
|
|
|
+ void loadDetail()
|
|
|
+ }
|
|
|
</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+ .mp-wv-header__sub {
|
|
|
+ margin-top: 4px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: rgba(255, 255, 255, 0.9);
|
|
|
+ }
|
|
|
+
|
|
|
+ .mp-wv-detail-body {
|
|
|
+ padding-bottom: 12px;
|
|
|
+ width: 100%;
|
|
|
+ max-width: 100%;
|
|
|
+ overflow-x: hidden;
|
|
|
+ box-sizing: border-box;
|
|
|
+ }
|
|
|
+
|
|
|
+ .mp-wv-tag-row {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 6px;
|
|
|
+ align-items: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ .mp-wv-pre {
|
|
|
+ white-space: pre-wrap;
|
|
|
+ word-break: break-word;
|
|
|
+ }
|
|
|
+
|
|
|
+ .mp-wv-img-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(3, 1fr);
|
|
|
+ gap: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .mp-wv-img-grid__item {
|
|
|
+ width: 100%;
|
|
|
+ aspect-ratio: 1;
|
|
|
+ border-radius: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #f7faf8;
|
|
|
+ }
|
|
|
+
|
|
|
+ .is-danger {
|
|
|
+ color: #d14343 !important;
|
|
|
+ }
|
|
|
+</style>
|