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"
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
Reference in new issue
Block a user