feat(web-admin): wire product pages to scheme A/B confirm dialogs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-10-18 18:16:17 +08:00
1 parent 321e0d05fa
commit 1b7007c773
3 files changed
+768 -328

No files matched your search

@@ -92,7 +92,7 @@
position="bottom" position="bottom"
round round
teleport="body" teleport="body"
class="mp-customer-vant-popup" class="mp-customer-vant-popup mp-product-form-popup"
:style="{ height: '82%' }" :style="{ height: '82%' }"
:close-on-click-overlay="!submitLoading" :close-on-click-overlay="!submitLoading"
@update:show="onFormShow" @update:show="onFormShow"
@@ -105,8 +105,10 @@
@click-left="closeForm" @click-left="closeForm"
/> />
<div class="mp-customer-pool-vant__body"> <div class="mp-customer-pool-vant__body">
<van-cell-group inset> <section class="mp-wv-section">
<van-field label="上级分类" label-align="top"> <h3>分类信息</h3>
<van-cell-group inset class="mp-customer-fields-vant">
<van-field label="上级分类">
<template #input> <template #input>
<el-tree-select <el-tree-select
v-model="formData.parentId" v-model="formData.parentId"
@@ -127,6 +129,7 @@
v-model="formData.categoryName" v-model="formData.categoryName"
label="分类名称" label="分类名称"
required required
clearable
placeholder="请输入分类名称" placeholder="请输入分类名称"
:error-message="nameError" :error-message="nameError"
/> />
@@ -136,23 +139,15 @@
label="排序号" label="排序号"
placeholder="数值越小越靠前" placeholder="数值越小越靠前"
/> />
<van-field label="状态" label-align="top"> <van-field
<template #input> is-link
<el-select readonly
v-model="formData.status" label="状态"
required
:model-value="statusLabel"
placeholder="请选择状态" placeholder="请选择状态"
style="width: 100%" @click="statusPickerOpen = true"
teleported
>
<el-option
v-for="opt in dictStore.getOptions('sys_normal_disable')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/> />
</el-select>
</template>
</van-field>
<van-field <van-field
v-model="formData.remark" v-model="formData.remark"
rows="3" rows="3"
@@ -162,21 +157,38 @@
placeholder="请输入备注" placeholder="请输入备注"
/> />
</van-cell-group> </van-cell-group>
</section>
</div> </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 :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>
</div> </div>
</van-popup> </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> </div>
</MpWebviewShell> </MpWebviewShell>
</template> </template>
<script setup lang="ts"> <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 MpWebviewShell from '@/layouts/MpWebviewShell.vue'
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue' import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
import { import {
@@ -229,6 +241,7 @@
const formTitle = ref('') const formTitle = ref('')
const submitLoading = ref(false) const submitLoading = ref(false)
const nameError = ref('') const nameError = ref('')
const statusPickerOpen = ref(false)
const categoryTreeOptions = ref<CategoryNode[]>([]) const categoryTreeOptions = ref<CategoryNode[]>([])
const formData = reactive({ const formData = reactive({
id: '', 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(() => const canDeleteActionNode = computed(() =>
canDeleteByCreator(actionNode.value as Record<string, unknown> | null) canDeleteByCreator(actionNode.value as Record<string, unknown> | null)
) )
@@ -407,7 +431,11 @@
return return
} }
try { try {
await showConfirmDialog({ title: '提示', message: '是否确认删除该产品分类?' }) await showProductConfirmDialog({
kind: 'danger',
title: '删除分类',
message: '是否确认删除该产品分类?'
})
} catch { } catch {
return return
} }
@@ -426,7 +454,14 @@
if (!submitLoading.value) formOpen.value = show if (!submitLoading.value) formOpen.value = show
} }
function closeForm() { 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() { async function submitForm() {
@@ -467,7 +502,7 @@
<style scoped lang="scss"> <style scoped lang="scss">
.mp-cat-header-btn { .mp-cat-header-btn {
height: 32px; height: 30px;
padding: 0 10px; padding: 0 10px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
@@ -567,9 +602,9 @@
.mp-cat-row__add, .mp-cat-row__add,
.mp-cat-row__more { .mp-cat-row__more {
flex-shrink: 0; flex-shrink: 0;
width: 36px; width: 30px;
height: 36px; height: 30px;
font-size: 18px; font-size: 16px;
line-height: 1; line-height: 1;
color: #087f57; color: #087f57;
cursor: pointer; cursor: pointer;
@@ -579,8 +614,8 @@
} }
.mp-cat-row__add { .mp-cat-row__add {
width: 32px; width: 30px;
height: 32px; height: 30px;
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
} }
+261 -140
View File
@@ -1,47 +1,47 @@
<template> <template>
<MpWebviewShell @ready="onShellReady"> <MpWebviewShell @ready="onShellReady">
<header class="mp-wv-header"> <div class="mp-product-page">
<div class="mp-wv-header__row"> <MpBusinessHeader title="产品详情" back-label="返回产品列表" @back="goList" />
<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 <div v-if="loading" class="mp-product-loading">
v-if="loading" <van-loading vertical>正在加载产品详情</van-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> </div>
<p class="mp-plan-form-loading__title">正在加载产品详情</p> <van-empty v-else-if="errorText" :description="errorText">
<p class="mp-plan-form-loading__desc">请稍候,正在获取产品信息…</p> <van-button round type="primary" @click="loadDetail">重试</van-button>
<div class="mp-plan-form-loading__skel" aria-hidden="true"> </van-empty>
<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"> <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"> <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.productNo || '—' }}</span></div>
<div class="mp-wv-kv"><span>产品名称</span><span>{{ detail.productName || '—' }}</span></div> <div class="mp-wv-kv"><span>产品名称</span><span>{{ detail.productName || '—' }}</span></div>
<div class="mp-wv-kv"> <div class="mp-wv-kv"
<span>拼音首字母</span><span>{{ detail.namePinyinAbbr || '—' }}</span> ><span>拼音首字母</span><span>{{ detail.namePinyinAbbr || '—' }}</span></div
</div> >
<div class="mp-wv-kv"><span>产品分类</span><span>{{ categoryPathText }}</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.model || '—' }}</span></div>
<div class="mp-wv-kv"><span>条码</span><span>{{ detail.barcode || '—' }}</span></div> <div class="mp-wv-kv"><span>条码</span><span>{{ detail.barcode || '—' }}</span></div>
@@ -51,57 +51,56 @@
<div class="mp-wv-kv"> <div class="mp-wv-kv">
<span>状态</span> <span>状态</span>
<span> <span>
<el-tag :type="statusTagType" size="small">{{ statusLabel }}</el-tag> <span class="status-tag" :class="`status-tag--${statusTagType}`">{{
statusLabel
}}</span>
</span> </span>
</div> </div>
<div class="mp-wv-kv"> <div class="mp-wv-kv">
<span>危化标识</span> <span>危化标识</span>
<span class="mp-wv-tag-row"> <span class="mp-product-tag-row">
<el-tag v-if="showToxic" type="danger" size="small">易制毒</el-tag> <span v-if="showToxic" class="status-tag status-tag--danger">易制毒</span>
<el-tag v-if="showExplosive" type="warning" size="small">易制爆</el-tag> <span v-if="showExplosive" class="status-tag status-tag--warning">易制爆</span>
<template v-if="!showToxic && !showExplosive">—</template> <template v-if="!showToxic && !showExplosive">—</template>
</span> </span>
</div> </div>
</section> </section>
<section class="mp-wv-section"> <section class="mp-wv-section">
<h3>描述 / 备注</h3> <h3>描述 / 备注</h3>
<div class="mp-wv-kv"><span>产品描述</span><span class="mp-wv-pre">{{ descriptionText }}</span></div> <div class="mp-wv-kv"
<div class="mp-wv-kv"><span>备注</span><span class="mp-wv-pre">{{ remarkText }}</span></div> ><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>
<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> </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"> <section class="mp-wv-section">
<h3>品牌列表</h3> <h3>品牌列表</h3>
<template v-if="brands.length"> <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>{{ b.brandName || '—' }}</span>
<span> <span>
<el-tag :type="productStatusTagType(b.status)" size="small"> <span
{{ productStatusLabel(b.status) }} class="status-tag"
</el-tag> :class="`status-tag--${productStatusTagType(b.status)}`"
>{{ productStatusLabel(b.status) }}</span
>
</span> </span>
</div> </div>
</template> </template>
<p v-else class="mp-wv-card__meta">暂无品牌</p> <van-empty v-else description="暂无品牌" image-size="64" />
</section> </section>
<section class="mp-wv-section"> <section class="mp-wv-section">
<h3>别名列表</h3> <h3>别名列表</h3>
<template v-if="aliases.length"> <template v-if="aliases.length">
@@ -114,41 +113,60 @@
<span>{{ a.aliasName || '—' }}</span> <span>{{ a.aliasName || '—' }}</span>
</div> </div>
</template> </template>
<p v-else class="mp-wv-card__meta">暂无别名</p> <van-empty v-else description="暂无别名" image-size="64" />
</section> </section>
</div> </div>
<div class="mp-wv-fab-bar"> <div
<button :ref="(el) => setSectionRef('media', el)"
class="mp-wv-btn mp-wv-btn--primary" class="mp-product-anchor"
type="button" :style="{ scrollMarginTop: `${scrollMargin}px` }"
:disabled="acting"
@click="goEdit"
> >
编辑 <section class="mp-wv-section">
</button> <h3>图片</h3>
<button class="mp-wv-btn" type="button" :disabled="acting" @click="toggleStatus"> <div v-if="imageList.length" class="mp-wv-img-grid">
{{ statusActionLabel }} <van-image
</button> v-for="(url, idx) in imageList"
<button :key="`${url}-${idx}`"
class="mp-wv-btn is-danger" class="mp-wv-img-grid__item"
type="button" 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" :disabled="acting || !canDelete"
:title="canDelete ? '' : CREATOR_DELETE_TIP" :title="canDelete ? '' : CREATOR_DELETE_TIP"
@click="onDelete" @click="onDelete"
> >
删除 删除
</button> </van-button>
</div> </div>
</template> </template>
</div>
</MpWebviewShell> </MpWebviewShell>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { showFailToast, showImagePreview, showSuccessToast } from 'vant'
import { useRoute, useRouter } from 'vue-router' import { showProductConfirmDialog } from '@/utils/mp-crm-product-confirm'
import { ElMessage, ElMessageBox } from 'element-plus'
import MpWebviewShell from '@/layouts/MpWebviewShell.vue' import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
import { import {
fetchProductDetail, fetchProductDetail,
fetchUpdateProductStatus, fetchUpdateProductStatus,
@@ -163,6 +181,16 @@
import { productStatusLabel, productStatusTagType } from '@/utils/mp-crm-product-card' import { productStatusLabel, productStatusTagType } from '@/utils/mp-crm-product-card'
import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path' import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
import '@/styles/mp-webview.scss' 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 route = useRoute()
const router = useRouter() const router = useRouter()
@@ -170,18 +198,21 @@
const acting = ref(false) const acting = ref(false)
const errorText = ref('') const errorText = ref('')
const detail = ref<ProductDetail | null>(null) const detail = ref<ProductDetail | null>(null)
const activeTab = ref<DetailTabKey>('base')
const stickyOffset = ref(0)
const categoryPathMap = ref<Map<string, string>>(new Map()) const categoryPathMap = ref<Map<string, string>>(new Map())
const sectionEls: Partial<Record<DetailTabKey, HTMLElement>> = {}
let categoryMapLoaded = false 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 statusLabel = computed(() => productStatusLabel(detail.value?.status))
const statusTagType = computed(() => productStatusTagType(detail.value?.status)) const statusTagType = computed(() => productStatusTagType(detail.value?.status))
const statusActionLabel = computed(() => const statusActionLabel = computed(() => (detail.value?.status === 'on' ? '下架' : '上架'))
detail.value?.status === 'on' ? '下架' : '上架'
)
const showToxic = computed(() => Number(detail.value?.isToxic) === 1) const showToxic = computed(() => Number(detail.value?.isToxic) === 1)
const showExplosive = computed(() => Number(detail.value?.isExplosive) === 1) const showExplosive = computed(() => Number(detail.value?.isExplosive) === 1)
const canDelete = computed(() => canDeleteByCreator(detail.value as Record<string, unknown>)) const canDelete = computed(() => canDeleteByCreator(detail.value as Record<string, unknown>))
@@ -222,26 +253,73 @@
const brands = computed<ProductBrandItem[]>(() => detail.value?.brands || []) const brands = computed<ProductBrandItem[]>(() => detail.value?.brands || [])
const aliases = computed<ProductAliasItem[]>(() => detail.value?.aliases || []) 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() { async function ensureCategoryPathMap() {
if (categoryMapLoaded) return if (categoryMapLoaded) return
const categoryRequestId = ++categoryRequestSeq
try { try {
const res = await fetchCategoryTree() const res = await fetchCategoryTree()
const roots = (res as { data?: unknown })?.data || res || [] const roots = (res as { data?: unknown })?.data || res || []
if (categoryRequestId !== categoryRequestSeq) return
categoryPathMap.value = buildPathLabelMapFromTreeRoots(Array.isArray(roots) ? roots : [], { categoryPathMap.value = buildPathLabelMapFromTreeRoots(Array.isArray(roots) ? roots : [], {
idKey: 'id', idKey: 'id',
nameKey: 'categoryName', nameKey: 'categoryName',
childrenKey: 'children' childrenKey: 'children'
}) })
} catch {
categoryPathMap.value = new Map()
} finally {
categoryMapLoaded = true categoryMapLoaded = true
} catch {
if (categoryRequestId !== categoryRequestSeq) return
categoryPathMap.value = new Map()
categoryMapLoaded = false
} }
} }
async function loadDetail() { async function loadDetail() {
const id = String(route.params.id || '') const requestId = ++requestSeq
if (!id) { const requestedId = String(route.params.id || '')
if (!requestedId) {
errorText.value = '缺少产品 ID' errorText.value = '缺少产品 ID'
loading.value = false loading.value = false
return return
@@ -249,14 +327,20 @@
loading.value = true loading.value = true
try { try {
await ensureCategoryPathMap() 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 detail.value = ((res as { data?: ProductDetail }).data ?? res) as ProductDetail
errorText.value = '' errorText.value = ''
activeTab.value = 'base'
await nextTick()
syncStickyOffset()
if (typeof window !== 'undefined') window.scrollTo({ top: 0 })
} catch (error) { } catch (error) {
if (requestId !== requestSeq || requestedId !== String(route.params.id || '')) return
detail.value = null detail.value = null
errorText.value = error instanceof Error ? error.message : '加载失败' errorText.value = error instanceof Error ? error.message : '加载失败'
} finally { } finally {
loading.value = false if (requestId === requestSeq) loading.value = false
} }
} }
@@ -276,10 +360,10 @@
const nextStatus = d.status === 'on' ? 'off' : 'on' const nextStatus = d.status === 'on' ? 'off' : 'on'
const label = nextStatus === 'on' ? '上架' : '下架' const label = nextStatus === 'on' ? '上架' : '下架'
try { try {
await ElMessageBox.confirm(`确认${label}该产品?`, '提示', { await showProductConfirmDialog({
confirmButtonText: '确定', kind: 'default',
cancelButtonText: '取消', title: `${label}产品`,
type: 'warning' message: `确认${label}该产品?`
}) })
} catch { } catch {
return return
@@ -287,10 +371,10 @@
acting.value = true acting.value = true
try { try {
await fetchUpdateProductStatus({ ids: [d.id], status: nextStatus }) await fetchUpdateProductStatus({ ids: [d.id], status: nextStatus })
ElMessage.success(`${label}成功`) showSuccessToast(`${label}成功`)
await loadDetail() await loadDetail()
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : `${label}失败`) showFailToast(error instanceof Error ? error.message : `${label}失败`)
} finally { } finally {
acting.value = false acting.value = false
} }
@@ -300,14 +384,14 @@
const d = detail.value const d = detail.value
if (!d?.id) return if (!d?.id) return
if (!canDeleteByCreator(d as Record<string, unknown>)) { if (!canDeleteByCreator(d as Record<string, unknown>)) {
ElMessage.warning(CREATOR_DELETE_TIP) showFailToast(CREATOR_DELETE_TIP)
return return
} }
try { try {
await ElMessageBox.confirm('确认删除该产品?删除后不可恢复。', '提示', { await showProductConfirmDialog({
confirmButtonText: '确定', kind: 'danger',
cancelButtonText: '取消', title: '删除产品',
type: 'warning' message: '确认删除该产品?删除后不可恢复。'
}) })
} catch { } catch {
return return
@@ -315,62 +399,99 @@
acting.value = true acting.value = true
try { try {
await fetchDeleteProduct({ id: d.id }) await fetchDeleteProduct({ id: d.id })
ElMessage.success('删除成功') showSuccessToast('删除成功')
void router.push(MP_CRM_PRODUCT_LIST_PATH) void router.push(MP_CRM_PRODUCT_LIST_PATH)
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败') showFailToast(error instanceof Error ? error.message : '删除失败')
} finally { } finally {
acting.value = false acting.value = false
} }
} }
function onShellReady() { function onShellReady() {
categoryRequestSeq += 1
categoryMapLoaded = false
activeTab.value = 'base'
void loadDetail() 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> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.mp-wv-header__sub { .status-tag {
margin-top: 4px; display: inline-flex;
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; 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 { .mp-wv-pre {
white-space: pre-wrap;
word-break: break-word; word-break: break-word;
white-space: pre-wrap;
} }
.mp-wv-img-grid { .mp-wv-img-grid {
display: grid; display: flex;
grid-template-columns: repeat(3, 1fr); flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.mp-wv-img-grid__item { .mp-wv-img-grid__item {
width: 100%; flex: 0 0 72px;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden; overflow: hidden;
background: #f7faf8; border-radius: 8px;
}
.is-danger {
color: #d14343 !important;
} }
</style> </style>
+442 -158
View File
@@ -1,140 +1,266 @@
<template> <template>
<MpWebviewShell @ready="onShellReady"> <MpWebviewShell @ready="onShellReady">
<div v-if="forbidden" class="mp-wv-empty">无权限</div> <div class="mp-product-page mp-product-list">
<template v-else> <MpBusinessHeader
<header class="mp-wv-header"> :keyword="keyword"
<div class="mp-wv-header__row"> title="产品管理"
<button class="mp-wv-header__back" type="button" aria-label="返回工作台" @click="goBack"> searchable
← search-placeholder="产品名称 / 编号 / 拼音"
</button> search-label="搜索产品"
<h1 class="mp-wv-header__title">产品管理</h1> back-label="返回工作台"
</div> @back="goBack"
<input
v-model="keyword"
class="mp-wv-search"
type="search"
placeholder="产品名称 / 编号 / 拼音"
@search="reload" @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> <van-dropdown-item
v-model="model"
<div class="mp-wv-filter-wrap"> teleport="body"
<div class="mp-wv-filter-bar"> :options="modelDropdownOptions"
<button @change="reload"
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
ref="moreDropdown"
teleport="body"
:title="moreActive ? '更多·已筛' : '更多'"
@open="onMoreOpen"
/>
</van-dropdown-menu>
</template>
</MpBusinessHeader>
<div class="mp-wv-pad-speed"> <van-empty v-if="forbidden" description="无权限">
<div <van-button round type="primary" @click="goBack">返回工作台</van-button>
v-if="loading && !cards.length" </van-empty>
class="mp-plan-form-loading mp-plan-form-loading--page"
role="status" <template v-else>
aria-live="polite" <p v-if="errorText && products.length" class="mp-product-error" role="alert">
aria-busy="true" {{ errorText }}
> <van-button size="small" type="primary" plain @click="loadMore">重试</van-button>
<div class="mp-plan-form-loading__orb" aria-hidden="true"> </p>
<span class="mp-plan-form-loading__dot" />
</div> <div class="mp-product-content mp-wv-pad-speed">
<p class="mp-plan-form-loading__title">正在加载产品</p> <div v-if="loading && !products.length" class="mp-product-loading">
<p class="mp-plan-form-loading__desc">请稍候,正在获取产品列表…</p> <van-loading vertical>正在加载产品列表</van-loading>
<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>
</div> </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 <article
v-for="card in cards" v-for="card in cards"
:key="card.id" :key="card.id"
class="mp-wv-card" class="mp-wv-card mp-product-list-card"
:aria-label="`查看产品:${card.productName}`"
@click="goDetail(card.id)" @click="goDetail(card.id)"
> >
<div class="mp-wv-card__title">{{ card.productName }}</div> <div class="mp-wv-card__head">
<div class="mp-wv-card__meta">{{ card.productNo }} · {{ card.model }}</div> <div class="mp-product-list-card__head-main">
<div class="mp-wv-card__meta">{{ card.categoryPath }}</div> <div class="mp-product-list-card__titles">
<div class="mp-wv-card__tags"> <div class="mp-wv-card__no">
<el-tag :type="card.statusTagType" size="small">{{ card.statusLabel }}</el-tag> {{ card.productName }}
<el-tag v-if="card.showToxic" type="danger" size="small">易制毒</el-tag> <span
<el-tag v-if="card.showExplosive" type="warning" size="small">易制爆</el-tag> 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> </div>
</article> </article>
<div v-if="hasMore" class="mp-wv-toolbar" style="padding: 0 0 12px"> <van-empty v-if="!products.length && !errorText" description="暂无产品,可调整筛选后重试" />
<button <p v-if="products.length" class="mp-product-load-hint" role="status">
class="mp-wv-btn mp-wv-btn--block" <van-loading v-if="loading" size="16px" />
type="button" <span>{{
loading
? '加载中…'
: hasMore
? `已加载 ${products.length}/${total},上拉或点此加载更多`
: `共 ${total} 条`
}}</span>
</p>
<van-button
v-if="hasMore"
block
round
:disabled="loading" :disabled="loading"
style="margin-bottom: 16px"
@click="loadMore" @click="loadMore"
> >
<span v-if="loading" class="mp-wv-spinner mp-wv-spinner--sm" aria-hidden="true" /> {{ loading ? '加载中…' : '加载更多' }}
<span>{{ loading ? '加载中…' : `加载更多(${products.length}/${total})` }}</span> </van-button>
</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>
</div> </div>
</van-popup>
<div class="mp-wv-speed-mask" :class="{ open: speedOpen }" @click="speedOpen = false" /> <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-dial" :class="{ open: speedOpen }">
<div class="mp-wv-speed-menu"> <div class="mp-wv-speed-menu">
<button class="mp-wv-speed-item" type="button" @click="goCategories">产品分类</button> <van-button round size="small" @click="goCategories">产品分类</van-button>
<button class="mp-wv-speed-item primary" type="button" @click="goNew">新增产品</button> <van-button round size="small" type="primary" @click="goNew">新增产品</van-button>
</div> </div>
<button class="mp-wv-speed-main" type="button" aria-label="快捷操作" @click="toggleSpeed"> <van-button
+ class="mp-wv-speed-main"
</button> type="primary"
round
icon="plus"
aria-label="快捷操作"
@click="toggleSpeed"
/>
</div> </div>
</template> </template>
</div>
</MpWebviewShell> </MpWebviewShell>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { showFailToast, showSuccessToast } from 'vant'
import { useRouter } from 'vue-router' import { showProductConfirmDialog } from '@/utils/mp-crm-product-confirm'
import MpWebviewShell from '@/layouts/MpWebviewShell.vue' import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
import { fetchProductList, fetchCategoryTree } from '@/api/crm/product' import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
import { import {
MP_PRODUCT_STATUS_FILTERS, fetchProductList,
buildProductListQuery, fetchCategoryTree,
buildProductCard, fetchProductColumnFacetValues,
type MpProductStatusFilter fetchUpdateProductStatus,
} from '@/utils/mp-crm-product-card' 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 { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
import { goToMiniWorkbench } from '@/utils/mp-webview' import { goToMiniWorkbench } from '@/utils/mp-webview'
import { HttpError } from '@/utils/http/error' import { HttpError } from '@/utils/http/error'
import { ApiStatus } from '@/utils/http/status' import { ApiStatus } from '@/utils/http/status'
import '@/styles/mp-webview.scss' import '@/styles/mp-webview.scss'
import './product.scss'
type ProductRow = { type ProductRow = {
id: string id: string
@@ -146,155 +272,313 @@
status?: string status?: string
isToxic?: number isToxic?: number
isExplosive?: 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 router = useRouter()
const keyword = ref('') const keyword = ref('')
const statusFilter = ref<MpProductStatusFilter>('on') const categoryId = ref('')
const openFilter = ref<'' | 'status'>('') const model = ref('')
const priceMin = ref('')
const priceMax = ref('')
const moreOpen = ref(false)
const speedOpen = ref(false) const speedOpen = ref(false)
const loading = ref(false) const loading = ref(false)
const forbidden = ref(false) const forbidden = ref(false)
const page = ref(1) const errorText = ref('')
const actingId = ref('')
const pageNum = ref(1)
const pageSize = 20 const pageSize = 20
const total = ref(0) const total = ref(0)
const products = ref<ProductRow[]>([]) const products = ref<ProductRow[]>([])
const expandedIds = ref<Set<string>>(new Set())
const categoryRoots = ref<CategoryNode[]>([])
const categoryPathMap = ref<Map<string, string>>(new Map()) const categoryPathMap = ref<Map<string, string>>(new Map())
let categoryMapLoaded = false 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( const moreDraft = reactive({
() => keyword: '',
MP_PRODUCT_STATUS_FILTERS.find((item) => item.value === statusFilter.value)?.label || '上架' 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(() => const cards = computed(() =>
products.value.map((row) => { products.value.map((row) => {
const cid = String(row.categoryId || '').trim() const cid = String(row.categoryId || '').trim()
const path = cid ? categoryPathMap.value.get(cid) : '' const path = cid ? categoryPathMap.value.get(cid) : ''
return buildProductCard({ return buildProductCard({ ...row, categoryPath: path || row.categoryName || '—' })
...row,
categoryPath: path || row.categoryName || '—'
})
}) })
) )
const hasMore = computed(() => products.value.length < total.value) const hasMore = computed(() => products.value.length < total.value)
async function ensureCategoryPathMap() { async function ensureCategoryPathMap() {
if (categoryMapLoaded) return if (categoryMapLoaded) return
const categoryRequestId = ++categoryRequestSeq
try { try {
const res = await fetchCategoryTree() const res = await fetchCategoryTree()
const roots = (res as any)?.data || res || [] 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', idKey: 'id',
nameKey: 'categoryName', nameKey: 'categoryName',
childrenKey: 'children' childrenKey: 'children'
}) })
} catch {
categoryPathMap.value = new Map()
} finally {
categoryMapLoaded = true 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) { 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) { if (reset) {
page.value = 1
products.value = [] products.value = []
total.value = 0
expandedIds.value = new Set()
} }
loading.value = true loading.value = true
errorText.value = ''
try { try {
await ensureCategoryPathMap() await Promise.all([ensureCategoryPathMap(), ensureModelOptions()])
const res = await fetchProductList( const res = await fetchProductList(
buildProductListQuery({ buildProductListQuery({
page: page.value, page: requestedPage,
pageSize, 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 data = (res as any)?.data || res || {}
const list = (data.list || data.rows || []) as ProductRow[] const list = (data.list || data.rows || []) as ProductRow[]
if (requestId !== requestSeq || snapshot !== requestSnapshot()) return
total.value = data.total ?? 0 total.value = data.total ?? 0
products.value = reset ? list : products.value.concat(list) products.value = reset ? list : products.value.concat(list)
pageNum.value = requestedPage
forbidden.value = false forbidden.value = false
} catch (error) { } catch (error) {
if (requestId !== requestSeq) return
if (error instanceof HttpError && error.code === ApiStatus.forbidden) { if (error instanceof HttpError && error.code === ApiStatus.forbidden) {
forbidden.value = true forbidden.value = true
products.value = [] products.value = []
return return
} }
throw error errorText.value =
error instanceof Error
? error.message
: reset
? '产品加载失败,请重试'
: '分页加载失败,请重试'
} finally { } finally {
loading.value = false if (requestId === requestSeq) loading.value = false
} }
} }
function reload() { function reload() {
void fetchPage(true) void fetchPage(true)
} }
function onKeywordUpdate(value: string) {
function closeFilter() { const cleared = Boolean(keyword.value.trim()) && !value.trim()
openFilter.value = '' keyword.value = value
if (cleared) reload()
} }
function onMoreOpen() {
function toggleFilter() { Object.assign(moreDraft, {
speedOpen.value = false keyword: keyword.value,
openFilter.value = openFilter.value === 'status' ? '' : 'status' model: model.value,
priceMin: priceMin.value,
priceMax: priceMax.value
})
moreOpen.value = true
nextTick(() => moreDropdown.value?.toggle?.(false))
} }
function resetMoreFilters() {
function pickStatus(value: MpProductStatusFilter) { Object.assign(moreDraft, { keyword: '', model: '', priceMin: '', priceMax: '' })
statusFilter.value = value }
closeFilter() function applyMoreFilters() {
keyword.value = moreDraft.keyword.trim()
model.value = moreDraft.model.trim()
priceMin.value = moreDraft.priceMin
priceMax.value = moreDraft.priceMax
moreOpen.value = false
reload() reload()
} }
function loadMore() { function loadMore() {
page.value += 1
void fetchPage(false) 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() { function toggleSpeed() {
closeFilter() moreOpen.value = false
speedOpen.value = !speedOpen.value speedOpen.value = !speedOpen.value
} }
function goBack() { function goBack() {
goToMiniWorkbench() goToMiniWorkbench()
} }
function goNew() { function goNew() {
speedOpen.value = false speedOpen.value = false
void router.push('/m/crm/product/new') void router.push('/m/crm/product/new')
} }
function goCategories() { function goCategories() {
speedOpen.value = false speedOpen.value = false
void router.push('/m/crm/product/categories') void router.push('/m/crm/product/categories')
} }
function goEdit(id: string) {
void router.push(`/m/crm/product/${id}/edit`)
}
function goDetail(id: string) { function goDetail(id: string) {
void router.push(`/m/crm/product/${id}`) void router.push(`/m/crm/product/${id}`)
} }
function onShellReady() { function onShellReady() {
categoryRequestSeq += 1
categoryMapLoaded = false 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() reload()
} }
</script> </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>