feat(web-admin): wire product pages to scheme A/B confirm dialogs.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
321e0d05fa
commit
1b7007c773
3 files changed
+768
-328
No files matched your search
@@ -92,7 +92,7 @@
|
||||
position="bottom"
|
||||
round
|
||||
teleport="body"
|
||||
class="mp-customer-vant-popup"
|
||||
class="mp-customer-vant-popup mp-product-form-popup"
|
||||
:style="{ height: '82%' }"
|
||||
:close-on-click-overlay="!submitLoading"
|
||||
@update:show="onFormShow"
|
||||
@@ -105,8 +105,10 @@
|
||||
@click-left="closeForm"
|
||||
/>
|
||||
<div class="mp-customer-pool-vant__body">
|
||||
<van-cell-group inset>
|
||||
<van-field label="上级分类" label-align="top">
|
||||
<section class="mp-wv-section">
|
||||
<h3>分类信息</h3>
|
||||
<van-cell-group inset class="mp-customer-fields-vant">
|
||||
<van-field label="上级分类">
|
||||
<template #input>
|
||||
<el-tree-select
|
||||
v-model="formData.parentId"
|
||||
@@ -127,6 +129,7 @@
|
||||
v-model="formData.categoryName"
|
||||
label="分类名称"
|
||||
required
|
||||
clearable
|
||||
placeholder="请输入分类名称"
|
||||
:error-message="nameError"
|
||||
/>
|
||||
@@ -136,23 +139,15 @@
|
||||
label="排序号"
|
||||
placeholder="数值越小越靠前"
|
||||
/>
|
||||
<van-field label="状态" label-align="top">
|
||||
<template #input>
|
||||
<el-select
|
||||
v-model="formData.status"
|
||||
<van-field
|
||||
is-link
|
||||
readonly
|
||||
label="状态"
|
||||
required
|
||||
:model-value="statusLabel"
|
||||
placeholder="请选择状态"
|
||||
style="width: 100%"
|
||||
teleported
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in dictStore.getOptions('sys_normal_disable')"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
@click="statusPickerOpen = true"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
v-model="formData.remark"
|
||||
rows="3"
|
||||
@@ -162,21 +157,38 @@
|
||||
placeholder="请输入备注"
|
||||
/>
|
||||
</van-cell-group>
|
||||
</section>
|
||||
</div>
|
||||
<div class="mp-customer-pool-vant__foot">
|
||||
<div class="mp-customer-pool-vant__foot mp-product-form-popup__foot">
|
||||
<van-button round :disabled="submitLoading" @click="closeForm">取消</van-button>
|
||||
<van-button round type="primary" :loading="submitLoading" @click="submitForm"
|
||||
>确定</van-button
|
||||
<van-button
|
||||
round
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
:disabled="submitLoading"
|
||||
@click="submitForm"
|
||||
>
|
||||
保存分类
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model:show="statusPickerOpen" position="bottom" round teleport="body">
|
||||
<van-picker
|
||||
:columns="statusPickerColumns"
|
||||
:model-value="[formData.status || '0']"
|
||||
@confirm="onStatusConfirm"
|
||||
@cancel="statusPickerOpen = false"
|
||||
/>
|
||||
</van-popup>
|
||||
</div>
|
||||
</MpWebviewShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { showConfirmDialog, showFailToast, showSuccessToast } from 'vant'
|
||||
import { showFailToast, showSuccessToast } from 'vant'
|
||||
import { showProductConfirmDialog } from '@/utils/mp-crm-product-confirm'
|
||||
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
|
||||
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
|
||||
import {
|
||||
@@ -229,6 +241,7 @@
|
||||
const formTitle = ref('')
|
||||
const submitLoading = ref(false)
|
||||
const nameError = ref('')
|
||||
const statusPickerOpen = ref(false)
|
||||
const categoryTreeOptions = ref<CategoryNode[]>([])
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
@@ -246,6 +259,17 @@
|
||||
}
|
||||
})
|
||||
|
||||
const statusPickerColumns = computed(() =>
|
||||
dictStore.getOptions('sys_normal_disable').map((o) => ({
|
||||
text: o.label,
|
||||
value: String(o.value)
|
||||
}))
|
||||
)
|
||||
|
||||
const statusLabel = computed(
|
||||
() => dictStore.getLabel('sys_normal_disable', formData.status) || ''
|
||||
)
|
||||
|
||||
const canDeleteActionNode = computed(() =>
|
||||
canDeleteByCreator(actionNode.value as Record<string, unknown> | null)
|
||||
)
|
||||
@@ -407,7 +431,11 @@
|
||||
return
|
||||
}
|
||||
try {
|
||||
await showConfirmDialog({ title: '提示', message: '是否确认删除该产品分类?' })
|
||||
await showProductConfirmDialog({
|
||||
kind: 'danger',
|
||||
title: '删除分类',
|
||||
message: '是否确认删除该产品分类?'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
@@ -426,7 +454,14 @@
|
||||
if (!submitLoading.value) formOpen.value = show
|
||||
}
|
||||
function closeForm() {
|
||||
if (!submitLoading.value) formOpen.value = false
|
||||
if (!submitLoading.value) {
|
||||
formOpen.value = false
|
||||
statusPickerOpen.value = false
|
||||
}
|
||||
}
|
||||
function onStatusConfirm({ selectedValues }: { selectedValues: string[] }) {
|
||||
formData.status = selectedValues[0] || '0'
|
||||
statusPickerOpen.value = false
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
@@ -467,7 +502,7 @@
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mp-cat-header-btn {
|
||||
height: 32px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -567,9 +602,9 @@
|
||||
.mp-cat-row__add,
|
||||
.mp-cat-row__more {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 18px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
color: #087f57;
|
||||
cursor: pointer;
|
||||
@@ -579,8 +614,8 @@
|
||||
}
|
||||
|
||||
.mp-cat-row__add {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
<template>
|
||||
<MpWebviewShell @ready="onShellReady">
|
||||
<header class="mp-wv-header">
|
||||
<div class="mp-wv-header__row">
|
||||
<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-product-page">
|
||||
<MpBusinessHeader title="产品详情" back-label="返回产品列表" @back="goList" />
|
||||
|
||||
<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 v-if="loading" class="mp-product-loading">
|
||||
<van-loading vertical>正在加载产品详情</van-loading>
|
||||
</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>
|
||||
<van-empty v-else-if="errorText" :description="errorText">
|
||||
<van-button round type="primary" @click="loadDetail">重试</van-button>
|
||||
</van-empty>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<div class="mp-wv-detail-body">
|
||||
<nav
|
||||
class="mp-product-detail-tabs"
|
||||
:style="{ top: `${stickyOffset}px` }"
|
||||
aria-label="产品详情分类"
|
||||
>
|
||||
<button
|
||||
v-for="tab in detailTabs"
|
||||
:key="tab.key"
|
||||
class="mp-product-detail-tab mp-wv-touch-target"
|
||||
:class="{ 'is-active': activeTab === tab.key }"
|
||||
type="button"
|
||||
:aria-pressed="activeTab === tab.key"
|
||||
@click="scrollToSection(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="mp-product-detail-body">
|
||||
<div
|
||||
:ref="(el) => setSectionRef('base', el)"
|
||||
class="mp-product-anchor"
|
||||
:style="{ scrollMarginTop: `${scrollMargin}px` }"
|
||||
>
|
||||
<section class="mp-wv-section">
|
||||
<h3>基础信息</h3>
|
||||
<h3>{{ detail.productName || '产品详情' }}</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>{{ 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>
|
||||
@@ -51,57 +51,56 @@
|
||||
<div class="mp-wv-kv">
|
||||
<span>状态</span>
|
||||
<span>
|
||||
<el-tag :type="statusTagType" size="small">{{ statusLabel }}</el-tag>
|
||||
<span class="status-tag" :class="`status-tag--${statusTagType}`">{{
|
||||
statusLabel
|
||||
}}</span>
|
||||
</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>
|
||||
<span class="mp-product-tag-row">
|
||||
<span v-if="showToxic" class="status-tag status-tag--danger">易制毒</span>
|
||||
<span v-if="showExplosive" class="status-tag status-tag--warning">易制爆</span>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div
|
||||
:ref="(el) => setSectionRef('relations', el)"
|
||||
class="mp-product-anchor"
|
||||
:style="{ scrollMarginTop: `${scrollMargin}px` }"
|
||||
>
|
||||
<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">
|
||||
<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
|
||||
class="status-tag"
|
||||
:class="`status-tag--${productStatusTagType(b.status)}`"
|
||||
>{{ productStatusLabel(b.status) }}</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="mp-wv-card__meta">暂无品牌</p>
|
||||
<van-empty v-else description="暂无品牌" image-size="64" />
|
||||
</section>
|
||||
|
||||
<section class="mp-wv-section">
|
||||
<h3>别名列表</h3>
|
||||
<template v-if="aliases.length">
|
||||
@@ -114,41 +113,60 @@
|
||||
<span>{{ a.aliasName || '—' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="mp-wv-card__meta">暂无别名</p>
|
||||
<van-empty v-else description="暂无别名" image-size="64" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="mp-wv-fab-bar">
|
||||
<button
|
||||
class="mp-wv-btn mp-wv-btn--primary"
|
||||
type="button"
|
||||
:disabled="acting"
|
||||
@click="goEdit"
|
||||
<div
|
||||
:ref="(el) => setSectionRef('media', el)"
|
||||
class="mp-product-anchor"
|
||||
:style="{ scrollMarginTop: `${scrollMargin}px` }"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button class="mp-wv-btn" type="button" :disabled="acting" @click="toggleStatus">
|
||||
{{ statusActionLabel }}
|
||||
</button>
|
||||
<button
|
||||
class="mp-wv-btn is-danger"
|
||||
type="button"
|
||||
<section class="mp-wv-section">
|
||||
<h3>图片</h3>
|
||||
<div v-if="imageList.length" class="mp-wv-img-grid">
|
||||
<van-image
|
||||
v-for="(url, idx) in imageList"
|
||||
:key="`${url}-${idx}`"
|
||||
class="mp-wv-img-grid__item"
|
||||
width="72"
|
||||
height="72"
|
||||
fit="cover"
|
||||
:src="url"
|
||||
@click="previewImages(idx)"
|
||||
/>
|
||||
</div>
|
||||
<van-empty v-else description="暂无图片" image-size="64" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mp-wv-fab-bar">
|
||||
<van-button round type="primary" :disabled="acting" @click="goEdit">编辑</van-button>
|
||||
<van-button round :disabled="acting" @click="toggleStatus">{{
|
||||
statusActionLabel
|
||||
}}</van-button>
|
||||
<van-button
|
||||
round
|
||||
type="danger"
|
||||
plain
|
||||
:disabled="acting || !canDelete"
|
||||
:title="canDelete ? '' : CREATOR_DELETE_TIP"
|
||||
@click="onDelete"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</van-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</MpWebviewShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { showFailToast, showImagePreview, showSuccessToast } from 'vant'
|
||||
import { showProductConfirmDialog } from '@/utils/mp-crm-product-confirm'
|
||||
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
|
||||
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
|
||||
import {
|
||||
fetchProductDetail,
|
||||
fetchUpdateProductStatus,
|
||||
@@ -163,6 +181,16 @@
|
||||
import { productStatusLabel, productStatusTagType } from '@/utils/mp-crm-product-card'
|
||||
import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
|
||||
import '@/styles/mp-webview.scss'
|
||||
import './product.scss'
|
||||
|
||||
type DetailTabKey = 'base' | 'relations' | 'media'
|
||||
|
||||
const TAB_BAR_HEIGHT = 44
|
||||
const detailTabs = [
|
||||
{ key: 'base' as const, label: '基础信息' },
|
||||
{ key: 'relations' as const, label: '品牌与别名' },
|
||||
{ key: 'media' as const, label: '产品图片' }
|
||||
]
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -170,18 +198,21 @@
|
||||
const acting = ref(false)
|
||||
const errorText = ref('')
|
||||
const detail = ref<ProductDetail | null>(null)
|
||||
const activeTab = ref<DetailTabKey>('base')
|
||||
const stickyOffset = ref(0)
|
||||
const categoryPathMap = ref<Map<string, string>>(new Map())
|
||||
const sectionEls: Partial<Record<DetailTabKey, HTMLElement>> = {}
|
||||
let categoryMapLoaded = false
|
||||
let categoryRequestSeq = 0
|
||||
let requestSeq = 0
|
||||
let scrollLockUntil = 0
|
||||
let scrollRaf = 0
|
||||
|
||||
const scrollMargin = computed(() => stickyOffset.value + TAB_BAR_HEIGHT)
|
||||
|
||||
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 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>))
|
||||
@@ -222,26 +253,73 @@
|
||||
const brands = computed<ProductBrandItem[]>(() => detail.value?.brands || [])
|
||||
const aliases = computed<ProductAliasItem[]>(() => detail.value?.aliases || [])
|
||||
|
||||
function setSectionRef(key: DetailTabKey, el: Element | null) {
|
||||
if (el instanceof HTMLElement) sectionEls[key] = el
|
||||
else delete sectionEls[key]
|
||||
}
|
||||
|
||||
function syncStickyOffset() {
|
||||
if (typeof document === 'undefined') return
|
||||
const header = document.querySelector('.mp-product-page > .mp-wv-header') as HTMLElement | null
|
||||
stickyOffset.value = Math.ceil(header?.getBoundingClientRect().height || 0)
|
||||
}
|
||||
|
||||
function scrollToSection(key: DetailTabKey) {
|
||||
activeTab.value = key
|
||||
scrollLockUntil = Date.now() + 600
|
||||
const el = sectionEls[key]
|
||||
if (!el) return
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
function syncActiveTabFromScroll() {
|
||||
if (Date.now() < scrollLockUntil) return
|
||||
const probe = stickyOffset.value + TAB_BAR_HEIGHT + 8
|
||||
let current: DetailTabKey = 'base'
|
||||
for (const tab of detailTabs) {
|
||||
const el = sectionEls[tab.key]
|
||||
if (!el) continue
|
||||
if (el.getBoundingClientRect().top <= probe) current = tab.key
|
||||
}
|
||||
if (activeTab.value !== current) activeTab.value = current
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (scrollRaf) return
|
||||
scrollRaf = window.requestAnimationFrame(() => {
|
||||
scrollRaf = 0
|
||||
syncActiveTabFromScroll()
|
||||
})
|
||||
}
|
||||
|
||||
function previewImages(startPosition: number) {
|
||||
showImagePreview({ images: imageList.value, startPosition })
|
||||
}
|
||||
|
||||
async function ensureCategoryPathMap() {
|
||||
if (categoryMapLoaded) return
|
||||
const categoryRequestId = ++categoryRequestSeq
|
||||
try {
|
||||
const res = await fetchCategoryTree()
|
||||
const roots = (res as { data?: unknown })?.data || res || []
|
||||
if (categoryRequestId !== categoryRequestSeq) return
|
||||
categoryPathMap.value = buildPathLabelMapFromTreeRoots(Array.isArray(roots) ? roots : [], {
|
||||
idKey: 'id',
|
||||
nameKey: 'categoryName',
|
||||
childrenKey: 'children'
|
||||
})
|
||||
} catch {
|
||||
categoryPathMap.value = new Map()
|
||||
} finally {
|
||||
categoryMapLoaded = true
|
||||
} catch {
|
||||
if (categoryRequestId !== categoryRequestSeq) return
|
||||
categoryPathMap.value = new Map()
|
||||
categoryMapLoaded = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
const id = String(route.params.id || '')
|
||||
if (!id) {
|
||||
const requestId = ++requestSeq
|
||||
const requestedId = String(route.params.id || '')
|
||||
if (!requestedId) {
|
||||
errorText.value = '缺少产品 ID'
|
||||
loading.value = false
|
||||
return
|
||||
@@ -249,14 +327,20 @@
|
||||
loading.value = true
|
||||
try {
|
||||
await ensureCategoryPathMap()
|
||||
const res = await fetchProductDetail({ id })
|
||||
const res = await fetchProductDetail({ id: requestedId })
|
||||
if (requestId !== requestSeq || requestedId !== String(route.params.id || '')) return
|
||||
detail.value = ((res as { data?: ProductDetail }).data ?? res) as ProductDetail
|
||||
errorText.value = ''
|
||||
activeTab.value = 'base'
|
||||
await nextTick()
|
||||
syncStickyOffset()
|
||||
if (typeof window !== 'undefined') window.scrollTo({ top: 0 })
|
||||
} catch (error) {
|
||||
if (requestId !== requestSeq || requestedId !== String(route.params.id || '')) return
|
||||
detail.value = null
|
||||
errorText.value = error instanceof Error ? error.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === requestSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,10 +360,10 @@
|
||||
const nextStatus = d.status === 'on' ? 'off' : 'on'
|
||||
const label = nextStatus === 'on' ? '上架' : '下架'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${label}该产品?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
await showProductConfirmDialog({
|
||||
kind: 'default',
|
||||
title: `${label}产品`,
|
||||
message: `确认${label}该产品?`
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
@@ -287,10 +371,10 @@
|
||||
acting.value = true
|
||||
try {
|
||||
await fetchUpdateProductStatus({ ids: [d.id], status: nextStatus })
|
||||
ElMessage.success(`${label}成功`)
|
||||
showSuccessToast(`${label}成功`)
|
||||
await loadDetail()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : `${label}失败`)
|
||||
showFailToast(error instanceof Error ? error.message : `${label}失败`)
|
||||
} finally {
|
||||
acting.value = false
|
||||
}
|
||||
@@ -300,14 +384,14 @@
|
||||
const d = detail.value
|
||||
if (!d?.id) return
|
||||
if (!canDeleteByCreator(d as Record<string, unknown>)) {
|
||||
ElMessage.warning(CREATOR_DELETE_TIP)
|
||||
showFailToast(CREATOR_DELETE_TIP)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm('确认删除该产品?删除后不可恢复。', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
await showProductConfirmDialog({
|
||||
kind: 'danger',
|
||||
title: '删除产品',
|
||||
message: '确认删除该产品?删除后不可恢复。'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
@@ -315,62 +399,99 @@
|
||||
acting.value = true
|
||||
try {
|
||||
await fetchDeleteProduct({ id: d.id })
|
||||
ElMessage.success('删除成功')
|
||||
showSuccessToast('删除成功')
|
||||
void router.push(MP_CRM_PRODUCT_LIST_PATH)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
showFailToast(error instanceof Error ? error.message : '删除失败')
|
||||
} finally {
|
||||
acting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onShellReady() {
|
||||
categoryRequestSeq += 1
|
||||
categoryMapLoaded = false
|
||||
activeTab.value = 'base'
|
||||
void loadDetail()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncStickyOffset()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('resize', syncStickyOffset)
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', syncStickyOffset)
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
if (scrollRaf) window.cancelAnimationFrame(scrollRaf)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
(id, previousId) => {
|
||||
if (previousId !== undefined && id !== previousId) {
|
||||
activeTab.value = 'base'
|
||||
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;
|
||||
.status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.status-tag--success,
|
||||
.status-tag--primary {
|
||||
color: #087f57;
|
||||
background: #e9f9f2;
|
||||
border-color: #b7ebce;
|
||||
}
|
||||
|
||||
.status-tag--warning {
|
||||
color: #a16207;
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
.status-tag--danger {
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.status-tag--info {
|
||||
color: #475569;
|
||||
background: #f1f5f9;
|
||||
border-color: #dbe4ee;
|
||||
}
|
||||
|
||||
.mp-wv-pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.mp-wv-img-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mp-wv-img-grid__item {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
flex: 0 0 72px;
|
||||
overflow: hidden;
|
||||
background: #f7faf8;
|
||||
}
|
||||
|
||||
.is-danger {
|
||||
color: #d14343 !important;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,140 +1,266 @@
|
||||
<template>
|
||||
<MpWebviewShell @ready="onShellReady">
|
||||
<div v-if="forbidden" class="mp-wv-empty">无权限</div>
|
||||
<template v-else>
|
||||
<header class="mp-wv-header">
|
||||
<div class="mp-wv-header__row">
|
||||
<button class="mp-wv-header__back" type="button" aria-label="返回工作台" @click="goBack">
|
||||
←
|
||||
</button>
|
||||
<h1 class="mp-wv-header__title">产品管理</h1>
|
||||
</div>
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="mp-wv-search"
|
||||
type="search"
|
||||
placeholder="产品名称 / 编号 / 拼音"
|
||||
<div class="mp-product-page mp-product-list">
|
||||
<MpBusinessHeader
|
||||
:keyword="keyword"
|
||||
title="产品管理"
|
||||
searchable
|
||||
search-placeholder="产品名称 / 编号 / 拼音"
|
||||
search-label="搜索产品"
|
||||
back-label="返回工作台"
|
||||
@back="goBack"
|
||||
@search="reload"
|
||||
@keyup.enter="reload"
|
||||
@update:keyword="onKeywordUpdate"
|
||||
>
|
||||
<template v-if="!forbidden" #after-search>
|
||||
<van-dropdown-menu class="mp-product-dd" :z-index="3000">
|
||||
<van-dropdown-item
|
||||
v-model="categoryId"
|
||||
teleport="body"
|
||||
:options="categoryDropdownOptions"
|
||||
@change="reload"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div class="mp-wv-filter-wrap">
|
||||
<div class="mp-wv-filter-bar">
|
||||
<button
|
||||
class="mp-wv-filter-trig"
|
||||
:class="{ open: openFilter === 'status' }"
|
||||
type="button"
|
||||
@click="toggleFilter"
|
||||
>
|
||||
<span class="mp-wv-filter-trig__val">{{ statusLabel }}</span>
|
||||
<span class="mp-wv-filter-trig__arrow" aria-hidden="true">▼</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mp-wv-filter-panel" :class="{ open: openFilter === 'status' }">
|
||||
<button
|
||||
v-for="item in MP_PRODUCT_STATUS_FILTERS"
|
||||
:key="item.value"
|
||||
class="mp-wv-filter-opt"
|
||||
:class="{ active: statusFilter === item.value }"
|
||||
type="button"
|
||||
@click="pickStatus(item.value)"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="mp-wv-filter-opt__check" aria-hidden="true">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mp-wv-filter-mask"
|
||||
:class="{ open: !!openFilter }"
|
||||
@click="closeFilter"
|
||||
<van-dropdown-item
|
||||
v-model="model"
|
||||
teleport="body"
|
||||
:options="modelDropdownOptions"
|
||||
@change="reload"
|
||||
/>
|
||||
<van-dropdown-item
|
||||
ref="moreDropdown"
|
||||
teleport="body"
|
||||
:title="moreActive ? '更多·已筛' : '更多'"
|
||||
@open="onMoreOpen"
|
||||
/>
|
||||
</van-dropdown-menu>
|
||||
</template>
|
||||
</MpBusinessHeader>
|
||||
|
||||
<div class="mp-wv-pad-speed">
|
||||
<div
|
||||
v-if="loading && !cards.length"
|
||||
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="!cards.length" class="mp-wv-empty">
|
||||
<div class="mp-wv-empty__icon" aria-hidden="true">📦</div>
|
||||
<div class="mp-wv-empty__title">暂无产品</div>
|
||||
<div class="mp-wv-empty__desc">可调整状态筛选或搜索后再试</div>
|
||||
<van-empty v-if="forbidden" description="无权限">
|
||||
<van-button round type="primary" @click="goBack">返回工作台</van-button>
|
||||
</van-empty>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="errorText && products.length" class="mp-product-error" role="alert">
|
||||
{{ errorText }}
|
||||
<van-button size="small" type="primary" plain @click="loadMore">重试</van-button>
|
||||
</p>
|
||||
|
||||
<div class="mp-product-content mp-wv-pad-speed">
|
||||
<div v-if="loading && !products.length" class="mp-product-loading">
|
||||
<van-loading vertical>正在加载产品列表</van-loading>
|
||||
</div>
|
||||
<template v-else>
|
||||
<van-empty v-if="errorText && !products.length" :description="errorText">
|
||||
<van-button round type="primary" @click="reload">重试</van-button>
|
||||
</van-empty>
|
||||
|
||||
<article
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
class="mp-wv-card"
|
||||
class="mp-wv-card mp-product-list-card"
|
||||
:aria-label="`查看产品:${card.productName}`"
|
||||
@click="goDetail(card.id)"
|
||||
>
|
||||
<div class="mp-wv-card__title">{{ card.productName }}</div>
|
||||
<div class="mp-wv-card__meta">{{ card.productNo }} · {{ card.model }}</div>
|
||||
<div class="mp-wv-card__meta">{{ card.categoryPath }}</div>
|
||||
<div class="mp-wv-card__tags">
|
||||
<el-tag :type="card.statusTagType" size="small">{{ card.statusLabel }}</el-tag>
|
||||
<el-tag v-if="card.showToxic" type="danger" size="small">易制毒</el-tag>
|
||||
<el-tag v-if="card.showExplosive" type="warning" size="small">易制爆</el-tag>
|
||||
<div class="mp-wv-card__head">
|
||||
<div class="mp-product-list-card__head-main">
|
||||
<div class="mp-product-list-card__titles">
|
||||
<div class="mp-wv-card__no">
|
||||
{{ card.productName }}
|
||||
<span
|
||||
v-if="card.showToxic"
|
||||
class="status-tag status-tag--danger mp-product-hazard"
|
||||
>毒</span
|
||||
>
|
||||
<span
|
||||
v-if="card.showExplosive"
|
||||
class="status-tag status-tag--warning mp-product-hazard"
|
||||
>爆</span
|
||||
>
|
||||
</div>
|
||||
<div class="mp-product-list-card__meta">{{ card.metaLine }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="status-tag" :class="`status-tag--${card.statusTagType}`">{{
|
||||
card.statusLabel
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="mp-product-list-card__preview">
|
||||
<div
|
||||
v-for="field in card.previewFields"
|
||||
:key="field.key"
|
||||
class="mp-product-list-card__line"
|
||||
>
|
||||
<span>{{ field.label }}</span>
|
||||
<span>{{ field.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="mp-product-list-card__toggle"
|
||||
type="button"
|
||||
:aria-expanded="isExpanded(card.id)"
|
||||
@click="toggleExpand(card.id, $event)"
|
||||
>
|
||||
{{ isExpanded(card.id) ? '收起' : '更多字段' }}
|
||||
<span class="mp-product-list-card__toggle-icon" aria-hidden="true">{{
|
||||
isExpanded(card.id) ? '▴' : '▾'
|
||||
}}</span>
|
||||
</button>
|
||||
|
||||
<div v-show="isExpanded(card.id)" class="mp-product-list-card__extra">
|
||||
<div
|
||||
v-for="field in card.extraFields"
|
||||
:key="field.key"
|
||||
class="mp-product-list-card__row"
|
||||
>
|
||||
<span>{{ field.label }}</span>
|
||||
<span>{{ field.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mp-wv-card-actions" @click.stop>
|
||||
<button class="mp-wv-btn" type="button" @click="goEdit(card.id)">编辑</button>
|
||||
<button
|
||||
class="mp-wv-btn"
|
||||
type="button"
|
||||
:disabled="actingId === card.id"
|
||||
@click="toggleStatus(card)"
|
||||
>
|
||||
{{ card.status === 'on' ? '下架' : '上架' }}
|
||||
</button>
|
||||
<button
|
||||
class="mp-wv-btn is-danger"
|
||||
type="button"
|
||||
:disabled="actingId === card.id || !canDeleteCard(card)"
|
||||
:title="canDeleteCard(card) ? '' : CREATOR_DELETE_TIP"
|
||||
@click="deleteCard(card)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
<button
|
||||
class="mp-wv-btn mp-wv-btn--primary"
|
||||
type="button"
|
||||
@click="goDetail(card.id)"
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="hasMore" class="mp-wv-toolbar" style="padding: 0 0 12px">
|
||||
<button
|
||||
class="mp-wv-btn mp-wv-btn--block"
|
||||
type="button"
|
||||
<van-empty v-if="!products.length && !errorText" description="暂无产品,可调整筛选后重试" />
|
||||
<p v-if="products.length" class="mp-product-load-hint" role="status">
|
||||
<van-loading v-if="loading" size="16px" />
|
||||
<span>{{
|
||||
loading
|
||||
? '加载中…'
|
||||
: hasMore
|
||||
? `已加载 ${products.length}/${total},上拉或点此加载更多`
|
||||
: `共 ${total} 条`
|
||||
}}</span>
|
||||
</p>
|
||||
<van-button
|
||||
v-if="hasMore"
|
||||
block
|
||||
round
|
||||
:disabled="loading"
|
||||
style="margin-bottom: 16px"
|
||||
@click="loadMore"
|
||||
>
|
||||
<span v-if="loading" class="mp-wv-spinner mp-wv-spinner--sm" aria-hidden="true" />
|
||||
<span>{{ loading ? '加载中…' : `加载更多(${products.length}/${total})` }}</span>
|
||||
</button>
|
||||
{{ loading ? '加载中…' : '加载更多' }}
|
||||
</van-button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<van-popup
|
||||
:show="moreOpen"
|
||||
position="bottom"
|
||||
round
|
||||
teleport="body"
|
||||
class="mp-customer-vant-popup"
|
||||
:style="{ height: '70%' }"
|
||||
@update:show="moreOpen = $event"
|
||||
>
|
||||
<div class="mp-customer-filter-vant">
|
||||
<van-nav-bar title="更多筛选" left-text="关闭" @click-left="moreOpen = false" />
|
||||
<div class="mp-product-filter-vant__body">
|
||||
<van-cell-group inset>
|
||||
<van-field
|
||||
v-model="moreDraft.keyword"
|
||||
label="模糊检索"
|
||||
placeholder="产品名称 / 编号 / 拼音"
|
||||
clearable
|
||||
/>
|
||||
<van-field
|
||||
v-model="moreDraft.model"
|
||||
label="规格型号"
|
||||
placeholder="输入规格型号"
|
||||
clearable
|
||||
/>
|
||||
<van-field
|
||||
v-model="moreDraft.priceMin"
|
||||
type="number"
|
||||
label="标准价最低"
|
||||
placeholder="最低价"
|
||||
clearable
|
||||
/>
|
||||
<van-field
|
||||
v-model="moreDraft.priceMax"
|
||||
type="number"
|
||||
label="标准价最高"
|
||||
placeholder="最高价"
|
||||
clearable
|
||||
/>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
<div class="mp-product-filter-vant__foot">
|
||||
<van-button round @click="resetMoreFilters">重置</van-button>
|
||||
<van-button round type="primary" @click="applyMoreFilters">确定</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<div class="mp-wv-speed-mask" :class="{ open: speedOpen }" @click="speedOpen = false" />
|
||||
<div class="mp-wv-speed-dial" :class="{ open: speedOpen }">
|
||||
<div class="mp-wv-speed-menu">
|
||||
<button class="mp-wv-speed-item" type="button" @click="goCategories">产品分类</button>
|
||||
<button class="mp-wv-speed-item primary" type="button" @click="goNew">新增产品</button>
|
||||
<van-button round size="small" @click="goCategories">产品分类</van-button>
|
||||
<van-button round size="small" type="primary" @click="goNew">新增产品</van-button>
|
||||
</div>
|
||||
<button class="mp-wv-speed-main" type="button" aria-label="快捷操作" @click="toggleSpeed">
|
||||
+
|
||||
</button>
|
||||
<van-button
|
||||
class="mp-wv-speed-main"
|
||||
type="primary"
|
||||
round
|
||||
icon="plus"
|
||||
aria-label="快捷操作"
|
||||
@click="toggleSpeed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</MpWebviewShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showFailToast, showSuccessToast } from 'vant'
|
||||
import { showProductConfirmDialog } from '@/utils/mp-crm-product-confirm'
|
||||
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
|
||||
import { fetchProductList, fetchCategoryTree } from '@/api/crm/product'
|
||||
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
|
||||
import {
|
||||
MP_PRODUCT_STATUS_FILTERS,
|
||||
buildProductListQuery,
|
||||
buildProductCard,
|
||||
type MpProductStatusFilter
|
||||
} from '@/utils/mp-crm-product-card'
|
||||
fetchProductList,
|
||||
fetchCategoryTree,
|
||||
fetchProductColumnFacetValues,
|
||||
fetchUpdateProductStatus,
|
||||
fetchDeleteProduct
|
||||
} from '@/api/crm/product'
|
||||
import { buildProductListQuery, buildProductCard } from '@/utils/mp-crm-product-card'
|
||||
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
|
||||
import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
|
||||
import { goToMiniWorkbench } from '@/utils/mp-webview'
|
||||
import { HttpError } from '@/utils/http/error'
|
||||
import { ApiStatus } from '@/utils/http/status'
|
||||
import '@/styles/mp-webview.scss'
|
||||
import './product.scss'
|
||||
|
||||
type ProductRow = {
|
||||
id: string
|
||||
@@ -146,155 +272,313 @@
|
||||
status?: string
|
||||
isToxic?: number
|
||||
isExplosive?: number
|
||||
createTime?: string
|
||||
createBy?: string
|
||||
images?: string
|
||||
brands?: Array<{ brandName?: string }>
|
||||
aliases?: Array<{ aliasName?: string }>
|
||||
price?: number | string
|
||||
}
|
||||
type CategoryNode = { id?: string; categoryName?: string; children?: CategoryNode[] }
|
||||
type ProductCard = ReturnType<typeof buildProductCard>
|
||||
|
||||
const router = useRouter()
|
||||
const keyword = ref('')
|
||||
const statusFilter = ref<MpProductStatusFilter>('on')
|
||||
const openFilter = ref<'' | 'status'>('')
|
||||
const categoryId = ref('')
|
||||
const model = ref('')
|
||||
const priceMin = ref('')
|
||||
const priceMax = ref('')
|
||||
const moreOpen = ref(false)
|
||||
const speedOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
const forbidden = ref(false)
|
||||
const page = ref(1)
|
||||
const errorText = ref('')
|
||||
const actingId = ref('')
|
||||
const pageNum = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const products = ref<ProductRow[]>([])
|
||||
const expandedIds = ref<Set<string>>(new Set())
|
||||
const categoryRoots = ref<CategoryNode[]>([])
|
||||
const categoryPathMap = ref<Map<string, string>>(new Map())
|
||||
let categoryMapLoaded = false
|
||||
let categoryRequestSeq = 0
|
||||
const modelOptions = ref<Array<{ text: string; value: string }>>([
|
||||
{ text: '全部型号', value: '' }
|
||||
])
|
||||
let modelOptionsLoaded = false
|
||||
let modelRequestSeq = 0
|
||||
let requestSeq = 0
|
||||
const moreDropdown = ref<{ toggle?: (show?: boolean) => void } | null>(null)
|
||||
|
||||
const statusLabel = computed(
|
||||
() =>
|
||||
MP_PRODUCT_STATUS_FILTERS.find((item) => item.value === statusFilter.value)?.label || '上架'
|
||||
)
|
||||
const moreDraft = reactive({
|
||||
keyword: '',
|
||||
model: '',
|
||||
priceMin: '',
|
||||
priceMax: ''
|
||||
})
|
||||
|
||||
function flattenCategories(nodes: CategoryNode[], parents: string[] = []) {
|
||||
return nodes.flatMap((node) => {
|
||||
const path = [...parents, String(node.categoryName || '')].filter(Boolean)
|
||||
const current = node.id ? [{ text: path.join(' / '), value: String(node.id) }] : []
|
||||
return current.concat(flattenCategories(node.children || [], path))
|
||||
})
|
||||
}
|
||||
|
||||
const categoryDropdownOptions = computed(() => [
|
||||
{ text: '全部分类', value: '' },
|
||||
...flattenCategories(categoryRoots.value)
|
||||
])
|
||||
const modelDropdownOptions = computed(() => modelOptions.value)
|
||||
const moreActive = computed(() => Boolean(priceMin.value || priceMax.value))
|
||||
const cards = computed(() =>
|
||||
products.value.map((row) => {
|
||||
const cid = String(row.categoryId || '').trim()
|
||||
const path = cid ? categoryPathMap.value.get(cid) : ''
|
||||
return buildProductCard({
|
||||
...row,
|
||||
categoryPath: path || row.categoryName || '—'
|
||||
})
|
||||
return buildProductCard({ ...row, categoryPath: path || row.categoryName || '—' })
|
||||
})
|
||||
)
|
||||
|
||||
const hasMore = computed(() => products.value.length < total.value)
|
||||
|
||||
async function ensureCategoryPathMap() {
|
||||
if (categoryMapLoaded) return
|
||||
const categoryRequestId = ++categoryRequestSeq
|
||||
try {
|
||||
const res = await fetchCategoryTree()
|
||||
const roots = (res as any)?.data || res || []
|
||||
categoryPathMap.value = buildPathLabelMapFromTreeRoots(Array.isArray(roots) ? roots : [], {
|
||||
if (categoryRequestId !== categoryRequestSeq) return
|
||||
categoryRoots.value = Array.isArray(roots) ? roots : []
|
||||
categoryPathMap.value = buildPathLabelMapFromTreeRoots(categoryRoots.value, {
|
||||
idKey: 'id',
|
||||
nameKey: 'categoryName',
|
||||
childrenKey: 'children'
|
||||
})
|
||||
} catch {
|
||||
categoryPathMap.value = new Map()
|
||||
} finally {
|
||||
categoryMapLoaded = true
|
||||
} catch {
|
||||
if (categoryRequestId !== categoryRequestSeq) return
|
||||
categoryRoots.value = []
|
||||
categoryPathMap.value = new Map()
|
||||
categoryMapLoaded = false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureModelOptions() {
|
||||
if (modelOptionsLoaded) return
|
||||
const modelRequestId = ++modelRequestSeq
|
||||
try {
|
||||
const res = await fetchProductColumnFacetValues({ field: 'model', limit: 500 })
|
||||
const data = (res as any)?.data || res || {}
|
||||
const list = (Array.isArray(data) ? data : data.list || []) as Array<{
|
||||
value?: string
|
||||
label?: string
|
||||
}>
|
||||
if (modelRequestId !== modelRequestSeq) return
|
||||
modelOptions.value = [
|
||||
{ text: '全部型号', value: '' },
|
||||
...list
|
||||
.map((item) => ({
|
||||
value: String(item.value || '').trim(),
|
||||
text: String(item.label || item.value || '').trim()
|
||||
}))
|
||||
.filter((item) => item.value)
|
||||
]
|
||||
modelOptionsLoaded = true
|
||||
} catch {
|
||||
if (modelRequestId !== modelRequestSeq) return
|
||||
modelOptions.value = [{ text: '全部型号', value: '' }]
|
||||
modelOptionsLoaded = false
|
||||
}
|
||||
}
|
||||
|
||||
function requestSnapshot() {
|
||||
return JSON.stringify({
|
||||
keyword: keyword.value.trim(),
|
||||
categoryId: categoryId.value,
|
||||
model: model.value.trim(),
|
||||
priceMin: priceMin.value,
|
||||
priceMax: priceMax.value
|
||||
})
|
||||
}
|
||||
|
||||
function isExpanded(id: string) {
|
||||
return expandedIds.value.has(String(id))
|
||||
}
|
||||
function toggleExpand(id: string, e?: Event) {
|
||||
e?.stopPropagation()
|
||||
const key = String(id)
|
||||
const next = new Set(expandedIds.value)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
expandedIds.value = next
|
||||
}
|
||||
|
||||
async function fetchPage(reset: boolean) {
|
||||
if (!reset && loading.value) return
|
||||
const requestId = ++requestSeq
|
||||
const snapshot = requestSnapshot()
|
||||
const requestedPage = reset ? 1 : pageNum.value + 1
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
products.value = []
|
||||
total.value = 0
|
||||
expandedIds.value = new Set()
|
||||
}
|
||||
loading.value = true
|
||||
errorText.value = ''
|
||||
try {
|
||||
await ensureCategoryPathMap()
|
||||
await Promise.all([ensureCategoryPathMap(), ensureModelOptions()])
|
||||
const res = await fetchProductList(
|
||||
buildProductListQuery({
|
||||
page: page.value,
|
||||
page: requestedPage,
|
||||
pageSize,
|
||||
statusFilter: statusFilter.value,
|
||||
keyword: keyword.value
|
||||
keyword: keyword.value,
|
||||
categoryId: categoryId.value,
|
||||
model: model.value,
|
||||
priceMin: priceMin.value === '' ? undefined : Number(priceMin.value),
|
||||
priceMax: priceMax.value === '' ? undefined : Number(priceMax.value)
|
||||
})
|
||||
)
|
||||
const data = (res as any)?.data || res || {}
|
||||
const list = (data.list || data.rows || []) as ProductRow[]
|
||||
if (requestId !== requestSeq || snapshot !== requestSnapshot()) return
|
||||
total.value = data.total ?? 0
|
||||
products.value = reset ? list : products.value.concat(list)
|
||||
pageNum.value = requestedPage
|
||||
forbidden.value = false
|
||||
} catch (error) {
|
||||
if (requestId !== requestSeq) return
|
||||
if (error instanceof HttpError && error.code === ApiStatus.forbidden) {
|
||||
forbidden.value = true
|
||||
products.value = []
|
||||
return
|
||||
}
|
||||
throw error
|
||||
errorText.value =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: reset
|
||||
? '产品加载失败,请重试'
|
||||
: '分页加载失败,请重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === requestSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reload() {
|
||||
void fetchPage(true)
|
||||
}
|
||||
|
||||
function closeFilter() {
|
||||
openFilter.value = ''
|
||||
function onKeywordUpdate(value: string) {
|
||||
const cleared = Boolean(keyword.value.trim()) && !value.trim()
|
||||
keyword.value = value
|
||||
if (cleared) reload()
|
||||
}
|
||||
|
||||
function toggleFilter() {
|
||||
speedOpen.value = false
|
||||
openFilter.value = openFilter.value === 'status' ? '' : 'status'
|
||||
function onMoreOpen() {
|
||||
Object.assign(moreDraft, {
|
||||
keyword: keyword.value,
|
||||
model: model.value,
|
||||
priceMin: priceMin.value,
|
||||
priceMax: priceMax.value
|
||||
})
|
||||
moreOpen.value = true
|
||||
nextTick(() => moreDropdown.value?.toggle?.(false))
|
||||
}
|
||||
|
||||
function pickStatus(value: MpProductStatusFilter) {
|
||||
statusFilter.value = value
|
||||
closeFilter()
|
||||
function resetMoreFilters() {
|
||||
Object.assign(moreDraft, { keyword: '', model: '', priceMin: '', priceMax: '' })
|
||||
}
|
||||
function applyMoreFilters() {
|
||||
keyword.value = moreDraft.keyword.trim()
|
||||
model.value = moreDraft.model.trim()
|
||||
priceMin.value = moreDraft.priceMin
|
||||
priceMax.value = moreDraft.priceMax
|
||||
moreOpen.value = false
|
||||
reload()
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
page.value += 1
|
||||
void fetchPage(false)
|
||||
}
|
||||
|
||||
function canDeleteCard(card: ProductCard) {
|
||||
return canDeleteByCreator(card as Record<string, unknown>)
|
||||
}
|
||||
async function toggleStatus(card: ProductCard) {
|
||||
const nextStatus = card.status === 'on' ? 'off' : 'on'
|
||||
const label = nextStatus === 'on' ? '上架' : '下架'
|
||||
try {
|
||||
await showProductConfirmDialog({
|
||||
kind: 'default',
|
||||
title: `${label}产品`,
|
||||
message: `确认${label}该产品?`
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
actingId.value = card.id
|
||||
try {
|
||||
await fetchUpdateProductStatus({ ids: [card.id], status: nextStatus })
|
||||
showSuccessToast(`${label}成功`)
|
||||
reload()
|
||||
} catch (error) {
|
||||
showFailToast(error instanceof Error ? error.message : `${label}失败`)
|
||||
} finally {
|
||||
actingId.value = ''
|
||||
}
|
||||
}
|
||||
async function deleteCard(card: ProductCard) {
|
||||
if (!canDeleteCard(card)) {
|
||||
showFailToast(CREATOR_DELETE_TIP)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await showProductConfirmDialog({
|
||||
kind: 'danger',
|
||||
title: '删除产品',
|
||||
message: '确认删除该产品?删除后不可恢复。'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
actingId.value = card.id
|
||||
try {
|
||||
await fetchDeleteProduct({ id: card.id })
|
||||
showSuccessToast('删除成功')
|
||||
reload()
|
||||
} catch (error) {
|
||||
showFailToast(error instanceof Error ? error.message : '删除失败')
|
||||
} finally {
|
||||
actingId.value = ''
|
||||
}
|
||||
}
|
||||
function toggleSpeed() {
|
||||
closeFilter()
|
||||
moreOpen.value = false
|
||||
speedOpen.value = !speedOpen.value
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
goToMiniWorkbench()
|
||||
}
|
||||
|
||||
function goNew() {
|
||||
speedOpen.value = false
|
||||
void router.push('/m/crm/product/new')
|
||||
}
|
||||
|
||||
function goCategories() {
|
||||
speedOpen.value = false
|
||||
void router.push('/m/crm/product/categories')
|
||||
}
|
||||
|
||||
function goEdit(id: string) {
|
||||
void router.push(`/m/crm/product/${id}/edit`)
|
||||
}
|
||||
function goDetail(id: string) {
|
||||
void router.push(`/m/crm/product/${id}`)
|
||||
}
|
||||
|
||||
function onShellReady() {
|
||||
categoryRequestSeq += 1
|
||||
categoryMapLoaded = false
|
||||
statusFilter.value = 'on'
|
||||
modelRequestSeq += 1
|
||||
modelOptionsLoaded = false
|
||||
keyword.value = ''
|
||||
categoryId.value = ''
|
||||
model.value = ''
|
||||
priceMin.value = ''
|
||||
priceMax.value = ''
|
||||
speedOpen.value = false
|
||||
expandedIds.value = new Set()
|
||||
reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mp-wv-card__title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #087f57;
|
||||
}
|
||||
|
||||
.mp-wv-card__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user