feat(web-admin): add mp webview CRM product detail actions.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
74e2267623
commit
1d3a4f5155
1 file changed
+364
-4
@@ -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>
|
||||
Reference in new issue
Block a user