feat(web-admin): add mp webview CRM product detail actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-10-15 09:51:39 +08:00
1 parent 74e2267623
commit 1d3a4f5155
1 file changed
+364 -4
+364 -4
View File
@@ -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>