|
|
@@ -1,140 +1,266 @@
|
|
|
<template>
|
|
|
<MpWebviewShell @ready="onShellReady">
|
|
|
- <div v-if="forbidden" class="mp-wv-empty">无权限</div>
|
|
|
- <template v-else>
|
|
|
- <header class="mp-wv-header">
|
|
|
- <div class="mp-wv-header__row">
|
|
|
- <button class="mp-wv-header__back" type="button" aria-label="返回工作台" @click="goBack">
|
|
|
- ←
|
|
|
- </button>
|
|
|
- <h1 class="mp-wv-header__title">产品管理</h1>
|
|
|
- </div>
|
|
|
- <input
|
|
|
- v-model="keyword"
|
|
|
- class="mp-wv-search"
|
|
|
- type="search"
|
|
|
- placeholder="产品名称 / 编号 / 拼音"
|
|
|
- @search="reload"
|
|
|
- @keyup.enter="reload"
|
|
|
- />
|
|
|
- </header>
|
|
|
+ <div class="mp-product-page mp-product-list">
|
|
|
+ <MpBusinessHeader
|
|
|
+ :keyword="keyword"
|
|
|
+ title="产品管理"
|
|
|
+ searchable
|
|
|
+ search-placeholder="产品名称 / 编号 / 拼音"
|
|
|
+ search-label="搜索产品"
|
|
|
+ back-label="返回工作台"
|
|
|
+ @back="goBack"
|
|
|
+ @search="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"
|
|
|
+ />
|
|
|
+ <van-dropdown-item
|
|
|
+ v-model="model"
|
|
|
+ teleport="body"
|
|
|
+ :options="modelDropdownOptions"
|
|
|
+ @change="reload"
|
|
|
+ />
|
|
|
+ <van-dropdown-item
|
|
|
+ ref="moreDropdown"
|
|
|
+ teleport="body"
|
|
|
+ :title="moreActive ? '更多·已筛' : '更多'"
|
|
|
+ @open="onMoreOpen"
|
|
|
+ />
|
|
|
+ </van-dropdown-menu>
|
|
|
+ </template>
|
|
|
+ </MpBusinessHeader>
|
|
|
|
|
|
- <div class="mp-wv-filter-wrap">
|
|
|
- <div class="mp-wv-filter-bar">
|
|
|
- <button
|
|
|
- class="mp-wv-filter-trig"
|
|
|
- :class="{ open: openFilter === 'status' }"
|
|
|
- type="button"
|
|
|
- @click="toggleFilter"
|
|
|
- >
|
|
|
- <span class="mp-wv-filter-trig__val">{{ statusLabel }}</span>
|
|
|
- <span class="mp-wv-filter-trig__arrow" aria-hidden="true">▼</span>
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- <div class="mp-wv-filter-panel" :class="{ open: openFilter === 'status' }">
|
|
|
- <button
|
|
|
- v-for="item in MP_PRODUCT_STATUS_FILTERS"
|
|
|
- :key="item.value"
|
|
|
- class="mp-wv-filter-opt"
|
|
|
- :class="{ active: statusFilter === item.value }"
|
|
|
- type="button"
|
|
|
- @click="pickStatus(item.value)"
|
|
|
- >
|
|
|
- <span>{{ item.label }}</span>
|
|
|
- <span class="mp-wv-filter-opt__check" aria-hidden="true">✓</span>
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div
|
|
|
- class="mp-wv-filter-mask"
|
|
|
- :class="{ open: !!openFilter }"
|
|
|
- @click="closeFilter"
|
|
|
- />
|
|
|
+ <van-empty v-if="forbidden" description="无权限">
|
|
|
+ <van-button round type="primary" @click="goBack">返回工作台</van-button>
|
|
|
+ </van-empty>
|
|
|
|
|
|
- <div class="mp-wv-pad-speed">
|
|
|
- <div
|
|
|
- v-if="loading && !cards.length"
|
|
|
- class="mp-plan-form-loading mp-plan-form-loading--page"
|
|
|
- role="status"
|
|
|
- aria-live="polite"
|
|
|
- aria-busy="true"
|
|
|
- >
|
|
|
- <div class="mp-plan-form-loading__orb" aria-hidden="true">
|
|
|
- <span class="mp-plan-form-loading__dot" />
|
|
|
- </div>
|
|
|
- <p class="mp-plan-form-loading__title">正在加载产品</p>
|
|
|
- <p class="mp-plan-form-loading__desc">请稍候,正在获取产品列表…</p>
|
|
|
- <div class="mp-plan-form-loading__skel" aria-hidden="true">
|
|
|
- <div class="mp-wv-skel mp-wv-skel--block" />
|
|
|
- <div class="mp-wv-skel mp-wv-skel--mid" />
|
|
|
- <div class="mp-wv-skel mp-wv-skel--line" />
|
|
|
- <div class="mp-wv-skel mp-wv-skel--short" />
|
|
|
+ <template v-else>
|
|
|
+ <p v-if="errorText && products.length" class="mp-product-error" role="alert">
|
|
|
+ {{ errorText }}
|
|
|
+ <van-button size="small" type="primary" plain @click="loadMore">重试</van-button>
|
|
|
+ </p>
|
|
|
+
|
|
|
+ <div class="mp-product-content mp-wv-pad-speed">
|
|
|
+ <div v-if="loading && !products.length" class="mp-product-loading">
|
|
|
+ <van-loading vertical>正在加载产品列表</van-loading>
|
|
|
</div>
|
|
|
+ <template v-else>
|
|
|
+ <van-empty v-if="errorText && !products.length" :description="errorText">
|
|
|
+ <van-button round type="primary" @click="reload">重试</van-button>
|
|
|
+ </van-empty>
|
|
|
+
|
|
|
+ <article
|
|
|
+ v-for="card in cards"
|
|
|
+ :key="card.id"
|
|
|
+ class="mp-wv-card mp-product-list-card"
|
|
|
+ :aria-label="`查看产品:${card.productName}`"
|
|
|
+ @click="goDetail(card.id)"
|
|
|
+ >
|
|
|
+ <div class="mp-wv-card__head">
|
|
|
+ <div class="mp-product-list-card__head-main">
|
|
|
+ <div class="mp-product-list-card__titles">
|
|
|
+ <div class="mp-wv-card__no">
|
|
|
+ {{ card.productName }}
|
|
|
+ <span
|
|
|
+ v-if="card.showToxic"
|
|
|
+ class="status-tag status-tag--danger mp-product-hazard"
|
|
|
+ >毒</span
|
|
|
+ >
|
|
|
+ <span
|
|
|
+ v-if="card.showExplosive"
|
|
|
+ class="status-tag status-tag--warning mp-product-hazard"
|
|
|
+ >爆</span
|
|
|
+ >
|
|
|
+ </div>
|
|
|
+ <div class="mp-product-list-card__meta">{{ card.metaLine }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <span class="status-tag" :class="`status-tag--${card.statusTagType}`">{{
|
|
|
+ card.statusLabel
|
|
|
+ }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="mp-product-list-card__preview">
|
|
|
+ <div
|
|
|
+ v-for="field in card.previewFields"
|
|
|
+ :key="field.key"
|
|
|
+ class="mp-product-list-card__line"
|
|
|
+ >
|
|
|
+ <span>{{ field.label }}</span>
|
|
|
+ <span>{{ field.value }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <button
|
|
|
+ class="mp-product-list-card__toggle"
|
|
|
+ type="button"
|
|
|
+ :aria-expanded="isExpanded(card.id)"
|
|
|
+ @click="toggleExpand(card.id, $event)"
|
|
|
+ >
|
|
|
+ {{ isExpanded(card.id) ? '收起' : '更多字段' }}
|
|
|
+ <span class="mp-product-list-card__toggle-icon" aria-hidden="true">{{
|
|
|
+ isExpanded(card.id) ? '▴' : '▾'
|
|
|
+ }}</span>
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <div v-show="isExpanded(card.id)" class="mp-product-list-card__extra">
|
|
|
+ <div
|
|
|
+ v-for="field in card.extraFields"
|
|
|
+ :key="field.key"
|
|
|
+ class="mp-product-list-card__row"
|
|
|
+ >
|
|
|
+ <span>{{ field.label }}</span>
|
|
|
+ <span>{{ field.value }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="mp-wv-card-actions" @click.stop>
|
|
|
+ <button class="mp-wv-btn" type="button" @click="goEdit(card.id)">编辑</button>
|
|
|
+ <button
|
|
|
+ class="mp-wv-btn"
|
|
|
+ type="button"
|
|
|
+ :disabled="actingId === card.id"
|
|
|
+ @click="toggleStatus(card)"
|
|
|
+ >
|
|
|
+ {{ card.status === 'on' ? '下架' : '上架' }}
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ class="mp-wv-btn is-danger"
|
|
|
+ type="button"
|
|
|
+ :disabled="actingId === card.id || !canDeleteCard(card)"
|
|
|
+ :title="canDeleteCard(card) ? '' : CREATOR_DELETE_TIP"
|
|
|
+ @click="deleteCard(card)"
|
|
|
+ >
|
|
|
+ 删除
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ class="mp-wv-btn mp-wv-btn--primary"
|
|
|
+ type="button"
|
|
|
+ @click="goDetail(card.id)"
|
|
|
+ >
|
|
|
+ 查看
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </article>
|
|
|
+
|
|
|
+ <van-empty v-if="!products.length && !errorText" description="暂无产品,可调整筛选后重试" />
|
|
|
+ <p v-if="products.length" class="mp-product-load-hint" role="status">
|
|
|
+ <van-loading v-if="loading" size="16px" />
|
|
|
+ <span>{{
|
|
|
+ loading
|
|
|
+ ? '加载中…'
|
|
|
+ : hasMore
|
|
|
+ ? `已加载 ${products.length}/${total},上拉或点此加载更多`
|
|
|
+ : `共 ${total} 条`
|
|
|
+ }}</span>
|
|
|
+ </p>
|
|
|
+ <van-button
|
|
|
+ v-if="hasMore"
|
|
|
+ block
|
|
|
+ round
|
|
|
+ :disabled="loading"
|
|
|
+ style="margin-bottom: 16px"
|
|
|
+ @click="loadMore"
|
|
|
+ >
|
|
|
+ {{ loading ? '加载中…' : '加载更多' }}
|
|
|
+ </van-button>
|
|
|
+ </template>
|
|
|
</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>
|
|
|
- <article
|
|
|
- v-for="card in cards"
|
|
|
- :key="card.id"
|
|
|
- class="mp-wv-card"
|
|
|
- @click="goDetail(card.id)"
|
|
|
+
|
|
|
+ <van-popup
|
|
|
+ :show="moreOpen"
|
|
|
+ position="bottom"
|
|
|
+ round
|
|
|
+ teleport="body"
|
|
|
+ class="mp-customer-vant-popup"
|
|
|
+ :style="{ height: '70%' }"
|
|
|
+ @update:show="moreOpen = $event"
|
|
|
>
|
|
|
- <div class="mp-wv-card__title">{{ card.productName }}</div>
|
|
|
- <div class="mp-wv-card__meta">{{ card.productNo }} · {{ card.model }}</div>
|
|
|
- <div class="mp-wv-card__meta">{{ card.categoryPath }}</div>
|
|
|
- <div class="mp-wv-card__tags">
|
|
|
- <el-tag :type="card.statusTagType" size="small">{{ card.statusLabel }}</el-tag>
|
|
|
- <el-tag v-if="card.showToxic" type="danger" size="small">易制毒</el-tag>
|
|
|
- <el-tag v-if="card.showExplosive" type="warning" size="small">易制爆</el-tag>
|
|
|
+ <div class="mp-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>
|
|
|
- </article>
|
|
|
-
|
|
|
- <div v-if="hasMore" class="mp-wv-toolbar" style="padding: 0 0 12px">
|
|
|
- <button
|
|
|
- class="mp-wv-btn mp-wv-btn--block"
|
|
|
- type="button"
|
|
|
- :disabled="loading"
|
|
|
- @click="loadMore"
|
|
|
- >
|
|
|
- <span v-if="loading" class="mp-wv-spinner mp-wv-spinner--sm" aria-hidden="true" />
|
|
|
- <span>{{ loading ? '加载中…' : `加载更多(${products.length}/${total})` }}</span>
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
+ </van-popup>
|
|
|
|
|
|
- <div class="mp-wv-speed-mask" :class="{ open: speedOpen }" @click="speedOpen = false" />
|
|
|
- <div class="mp-wv-speed-dial" :class="{ open: speedOpen }">
|
|
|
- <div class="mp-wv-speed-menu">
|
|
|
- <button class="mp-wv-speed-item" type="button" @click="goCategories">产品分类</button>
|
|
|
- <button class="mp-wv-speed-item primary" type="button" @click="goNew">新增产品</button>
|
|
|
+ <div class="mp-wv-speed-mask" :class="{ open: speedOpen }" @click="speedOpen = false" />
|
|
|
+ <div class="mp-wv-speed-dial" :class="{ open: speedOpen }">
|
|
|
+ <div class="mp-wv-speed-menu">
|
|
|
+ <van-button round size="small" @click="goCategories">产品分类</van-button>
|
|
|
+ <van-button round size="small" type="primary" @click="goNew">新增产品</van-button>
|
|
|
+ </div>
|
|
|
+ <van-button
|
|
|
+ class="mp-wv-speed-main"
|
|
|
+ type="primary"
|
|
|
+ round
|
|
|
+ icon="plus"
|
|
|
+ aria-label="快捷操作"
|
|
|
+ @click="toggleSpeed"
|
|
|
+ />
|
|
|
</div>
|
|
|
- <button class="mp-wv-speed-main" type="button" aria-label="快捷操作" @click="toggleSpeed">
|
|
|
- +
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </template>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
</MpWebviewShell>
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
- import { computed, ref } from 'vue'
|
|
|
- import { useRouter } from 'vue-router'
|
|
|
+ import { showFailToast, showSuccessToast } from 'vant'
|
|
|
+ import { showProductConfirmDialog } from '@/utils/mp-crm-product-confirm'
|
|
|
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
|
|
|
- import { fetchProductList, fetchCategoryTree } from '@/api/crm/product'
|
|
|
+ import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
|
|
|
import {
|
|
|
- MP_PRODUCT_STATUS_FILTERS,
|
|
|
- buildProductListQuery,
|
|
|
- buildProductCard,
|
|
|
- type MpProductStatusFilter
|
|
|
- } from '@/utils/mp-crm-product-card'
|
|
|
+ fetchProductList,
|
|
|
+ fetchCategoryTree,
|
|
|
+ fetchProductColumnFacetValues,
|
|
|
+ fetchUpdateProductStatus,
|
|
|
+ fetchDeleteProduct
|
|
|
+ } from '@/api/crm/product'
|
|
|
+ import { buildProductListQuery, buildProductCard } from '@/utils/mp-crm-product-card'
|
|
|
+ import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
|
|
|
import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
|
|
|
import { goToMiniWorkbench } from '@/utils/mp-webview'
|
|
|
import { HttpError } from '@/utils/http/error'
|
|
|
import { ApiStatus } from '@/utils/http/status'
|
|
|
import '@/styles/mp-webview.scss'
|
|
|
+ import './product.scss'
|
|
|
|
|
|
type ProductRow = {
|
|
|
id: string
|
|
|
@@ -146,155 +272,313 @@
|
|
|
status?: string
|
|
|
isToxic?: number
|
|
|
isExplosive?: number
|
|
|
+ createTime?: string
|
|
|
+ createBy?: string
|
|
|
+ images?: string
|
|
|
+ brands?: Array<{ brandName?: string }>
|
|
|
+ aliases?: Array<{ aliasName?: string }>
|
|
|
+ price?: number | string
|
|
|
}
|
|
|
+ type CategoryNode = { id?: string; categoryName?: string; children?: CategoryNode[] }
|
|
|
+ type ProductCard = ReturnType<typeof buildProductCard>
|
|
|
|
|
|
const router = useRouter()
|
|
|
const keyword = ref('')
|
|
|
- const statusFilter = ref<MpProductStatusFilter>('on')
|
|
|
- const openFilter = ref<'' | 'status'>('')
|
|
|
+ const categoryId = ref('')
|
|
|
+ const model = ref('')
|
|
|
+ const priceMin = ref('')
|
|
|
+ const priceMax = ref('')
|
|
|
+ const moreOpen = ref(false)
|
|
|
const speedOpen = ref(false)
|
|
|
const loading = ref(false)
|
|
|
const forbidden = ref(false)
|
|
|
- const page = ref(1)
|
|
|
+ const errorText = ref('')
|
|
|
+ const actingId = ref('')
|
|
|
+ const pageNum = ref(1)
|
|
|
const pageSize = 20
|
|
|
const total = ref(0)
|
|
|
const products = ref<ProductRow[]>([])
|
|
|
+ const expandedIds = ref<Set<string>>(new Set())
|
|
|
+ const categoryRoots = ref<CategoryNode[]>([])
|
|
|
const categoryPathMap = ref<Map<string, string>>(new Map())
|
|
|
let categoryMapLoaded = false
|
|
|
+ let categoryRequestSeq = 0
|
|
|
+ const modelOptions = ref<Array<{ text: string; value: string }>>([
|
|
|
+ { text: '全部型号', value: '' }
|
|
|
+ ])
|
|
|
+ let modelOptionsLoaded = false
|
|
|
+ let modelRequestSeq = 0
|
|
|
+ let requestSeq = 0
|
|
|
+ const moreDropdown = ref<{ toggle?: (show?: boolean) => void } | null>(null)
|
|
|
|
|
|
- const statusLabel = computed(
|
|
|
- () =>
|
|
|
- MP_PRODUCT_STATUS_FILTERS.find((item) => item.value === statusFilter.value)?.label || '上架'
|
|
|
- )
|
|
|
+ const moreDraft = reactive({
|
|
|
+ keyword: '',
|
|
|
+ model: '',
|
|
|
+ priceMin: '',
|
|
|
+ priceMax: ''
|
|
|
+ })
|
|
|
|
|
|
+ function flattenCategories(nodes: CategoryNode[], parents: string[] = []) {
|
|
|
+ return nodes.flatMap((node) => {
|
|
|
+ const path = [...parents, String(node.categoryName || '')].filter(Boolean)
|
|
|
+ const current = node.id ? [{ text: path.join(' / '), value: String(node.id) }] : []
|
|
|
+ return current.concat(flattenCategories(node.children || [], path))
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const categoryDropdownOptions = computed(() => [
|
|
|
+ { text: '全部分类', value: '' },
|
|
|
+ ...flattenCategories(categoryRoots.value)
|
|
|
+ ])
|
|
|
+ const modelDropdownOptions = computed(() => modelOptions.value)
|
|
|
+ const moreActive = computed(() => Boolean(priceMin.value || priceMax.value))
|
|
|
const cards = computed(() =>
|
|
|
products.value.map((row) => {
|
|
|
const cid = String(row.categoryId || '').trim()
|
|
|
const path = cid ? categoryPathMap.value.get(cid) : ''
|
|
|
- return buildProductCard({
|
|
|
- ...row,
|
|
|
- categoryPath: path || row.categoryName || '—'
|
|
|
- })
|
|
|
+ return buildProductCard({ ...row, categoryPath: path || row.categoryName || '—' })
|
|
|
})
|
|
|
)
|
|
|
-
|
|
|
const hasMore = computed(() => products.value.length < total.value)
|
|
|
|
|
|
async function ensureCategoryPathMap() {
|
|
|
if (categoryMapLoaded) return
|
|
|
+ const categoryRequestId = ++categoryRequestSeq
|
|
|
try {
|
|
|
const res = await fetchCategoryTree()
|
|
|
const roots = (res as any)?.data || res || []
|
|
|
- categoryPathMap.value = buildPathLabelMapFromTreeRoots(Array.isArray(roots) ? roots : [], {
|
|
|
+ if (categoryRequestId !== categoryRequestSeq) return
|
|
|
+ categoryRoots.value = Array.isArray(roots) ? roots : []
|
|
|
+ categoryPathMap.value = buildPathLabelMapFromTreeRoots(categoryRoots.value, {
|
|
|
idKey: 'id',
|
|
|
nameKey: 'categoryName',
|
|
|
childrenKey: 'children'
|
|
|
})
|
|
|
+ categoryMapLoaded = true
|
|
|
} catch {
|
|
|
+ if (categoryRequestId !== categoryRequestSeq) return
|
|
|
+ categoryRoots.value = []
|
|
|
categoryPathMap.value = new Map()
|
|
|
- } finally {
|
|
|
- categoryMapLoaded = true
|
|
|
+ categoryMapLoaded = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ async function ensureModelOptions() {
|
|
|
+ if (modelOptionsLoaded) return
|
|
|
+ const modelRequestId = ++modelRequestSeq
|
|
|
+ try {
|
|
|
+ const res = await fetchProductColumnFacetValues({ field: 'model', limit: 500 })
|
|
|
+ const data = (res as any)?.data || res || {}
|
|
|
+ const list = (Array.isArray(data) ? data : data.list || []) as Array<{
|
|
|
+ value?: string
|
|
|
+ label?: string
|
|
|
+ }>
|
|
|
+ if (modelRequestId !== modelRequestSeq) return
|
|
|
+ modelOptions.value = [
|
|
|
+ { text: '全部型号', value: '' },
|
|
|
+ ...list
|
|
|
+ .map((item) => ({
|
|
|
+ value: String(item.value || '').trim(),
|
|
|
+ text: String(item.label || item.value || '').trim()
|
|
|
+ }))
|
|
|
+ .filter((item) => item.value)
|
|
|
+ ]
|
|
|
+ modelOptionsLoaded = true
|
|
|
+ } catch {
|
|
|
+ if (modelRequestId !== modelRequestSeq) return
|
|
|
+ modelOptions.value = [{ text: '全部型号', value: '' }]
|
|
|
+ modelOptionsLoaded = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function requestSnapshot() {
|
|
|
+ return JSON.stringify({
|
|
|
+ keyword: keyword.value.trim(),
|
|
|
+ categoryId: categoryId.value,
|
|
|
+ model: model.value.trim(),
|
|
|
+ priceMin: priceMin.value,
|
|
|
+ priceMax: priceMax.value
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ function isExpanded(id: string) {
|
|
|
+ return expandedIds.value.has(String(id))
|
|
|
+ }
|
|
|
+ function toggleExpand(id: string, e?: Event) {
|
|
|
+ e?.stopPropagation()
|
|
|
+ const key = String(id)
|
|
|
+ const next = new Set(expandedIds.value)
|
|
|
+ if (next.has(key)) next.delete(key)
|
|
|
+ else next.add(key)
|
|
|
+ expandedIds.value = next
|
|
|
+ }
|
|
|
+
|
|
|
async function fetchPage(reset: boolean) {
|
|
|
+ if (!reset && loading.value) return
|
|
|
+ const requestId = ++requestSeq
|
|
|
+ const snapshot = requestSnapshot()
|
|
|
+ const requestedPage = reset ? 1 : pageNum.value + 1
|
|
|
if (reset) {
|
|
|
- page.value = 1
|
|
|
products.value = []
|
|
|
+ total.value = 0
|
|
|
+ expandedIds.value = new Set()
|
|
|
}
|
|
|
loading.value = true
|
|
|
+ errorText.value = ''
|
|
|
try {
|
|
|
- await ensureCategoryPathMap()
|
|
|
+ await Promise.all([ensureCategoryPathMap(), ensureModelOptions()])
|
|
|
const res = await fetchProductList(
|
|
|
buildProductListQuery({
|
|
|
- page: page.value,
|
|
|
+ page: requestedPage,
|
|
|
pageSize,
|
|
|
- statusFilter: statusFilter.value,
|
|
|
- keyword: keyword.value
|
|
|
+ keyword: keyword.value,
|
|
|
+ categoryId: categoryId.value,
|
|
|
+ model: model.value,
|
|
|
+ priceMin: priceMin.value === '' ? undefined : Number(priceMin.value),
|
|
|
+ priceMax: priceMax.value === '' ? undefined : Number(priceMax.value)
|
|
|
})
|
|
|
)
|
|
|
const data = (res as any)?.data || res || {}
|
|
|
const list = (data.list || data.rows || []) as ProductRow[]
|
|
|
+ if (requestId !== requestSeq || snapshot !== requestSnapshot()) return
|
|
|
total.value = data.total ?? 0
|
|
|
products.value = reset ? list : products.value.concat(list)
|
|
|
+ pageNum.value = requestedPage
|
|
|
forbidden.value = false
|
|
|
} catch (error) {
|
|
|
+ if (requestId !== requestSeq) return
|
|
|
if (error instanceof HttpError && error.code === ApiStatus.forbidden) {
|
|
|
forbidden.value = true
|
|
|
products.value = []
|
|
|
return
|
|
|
}
|
|
|
- throw error
|
|
|
+ errorText.value =
|
|
|
+ error instanceof Error
|
|
|
+ ? error.message
|
|
|
+ : reset
|
|
|
+ ? '产品加载失败,请重试'
|
|
|
+ : '分页加载失败,请重试'
|
|
|
} finally {
|
|
|
- loading.value = false
|
|
|
+ if (requestId === requestSeq) loading.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function reload() {
|
|
|
void fetchPage(true)
|
|
|
}
|
|
|
-
|
|
|
- function closeFilter() {
|
|
|
- openFilter.value = ''
|
|
|
+ function onKeywordUpdate(value: string) {
|
|
|
+ const cleared = Boolean(keyword.value.trim()) && !value.trim()
|
|
|
+ keyword.value = value
|
|
|
+ if (cleared) reload()
|
|
|
}
|
|
|
-
|
|
|
- function toggleFilter() {
|
|
|
- speedOpen.value = false
|
|
|
- openFilter.value = openFilter.value === 'status' ? '' : 'status'
|
|
|
+ function onMoreOpen() {
|
|
|
+ Object.assign(moreDraft, {
|
|
|
+ keyword: keyword.value,
|
|
|
+ model: model.value,
|
|
|
+ priceMin: priceMin.value,
|
|
|
+ priceMax: priceMax.value
|
|
|
+ })
|
|
|
+ moreOpen.value = true
|
|
|
+ nextTick(() => moreDropdown.value?.toggle?.(false))
|
|
|
}
|
|
|
-
|
|
|
- function pickStatus(value: MpProductStatusFilter) {
|
|
|
- statusFilter.value = value
|
|
|
- closeFilter()
|
|
|
+ function resetMoreFilters() {
|
|
|
+ Object.assign(moreDraft, { keyword: '', model: '', priceMin: '', priceMax: '' })
|
|
|
+ }
|
|
|
+ function applyMoreFilters() {
|
|
|
+ keyword.value = moreDraft.keyword.trim()
|
|
|
+ model.value = moreDraft.model.trim()
|
|
|
+ priceMin.value = moreDraft.priceMin
|
|
|
+ priceMax.value = moreDraft.priceMax
|
|
|
+ moreOpen.value = false
|
|
|
reload()
|
|
|
}
|
|
|
-
|
|
|
function loadMore() {
|
|
|
- page.value += 1
|
|
|
void fetchPage(false)
|
|
|
}
|
|
|
-
|
|
|
+ function canDeleteCard(card: ProductCard) {
|
|
|
+ return canDeleteByCreator(card as Record<string, unknown>)
|
|
|
+ }
|
|
|
+ async function toggleStatus(card: ProductCard) {
|
|
|
+ const nextStatus = card.status === 'on' ? 'off' : 'on'
|
|
|
+ const label = nextStatus === 'on' ? '上架' : '下架'
|
|
|
+ try {
|
|
|
+ await showProductConfirmDialog({
|
|
|
+ kind: 'default',
|
|
|
+ title: `${label}产品`,
|
|
|
+ message: `确认${label}该产品?`
|
|
|
+ })
|
|
|
+ } catch {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ actingId.value = card.id
|
|
|
+ try {
|
|
|
+ await fetchUpdateProductStatus({ ids: [card.id], status: nextStatus })
|
|
|
+ showSuccessToast(`${label}成功`)
|
|
|
+ reload()
|
|
|
+ } catch (error) {
|
|
|
+ showFailToast(error instanceof Error ? error.message : `${label}失败`)
|
|
|
+ } finally {
|
|
|
+ actingId.value = ''
|
|
|
+ }
|
|
|
+ }
|
|
|
+ async function deleteCard(card: ProductCard) {
|
|
|
+ if (!canDeleteCard(card)) {
|
|
|
+ showFailToast(CREATOR_DELETE_TIP)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await showProductConfirmDialog({
|
|
|
+ kind: 'danger',
|
|
|
+ title: '删除产品',
|
|
|
+ message: '确认删除该产品?删除后不可恢复。'
|
|
|
+ })
|
|
|
+ } catch {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ actingId.value = card.id
|
|
|
+ try {
|
|
|
+ await fetchDeleteProduct({ id: card.id })
|
|
|
+ showSuccessToast('删除成功')
|
|
|
+ reload()
|
|
|
+ } catch (error) {
|
|
|
+ showFailToast(error instanceof Error ? error.message : '删除失败')
|
|
|
+ } finally {
|
|
|
+ actingId.value = ''
|
|
|
+ }
|
|
|
+ }
|
|
|
function toggleSpeed() {
|
|
|
- closeFilter()
|
|
|
+ moreOpen.value = false
|
|
|
speedOpen.value = !speedOpen.value
|
|
|
}
|
|
|
-
|
|
|
function goBack() {
|
|
|
goToMiniWorkbench()
|
|
|
}
|
|
|
-
|
|
|
function goNew() {
|
|
|
speedOpen.value = false
|
|
|
void router.push('/m/crm/product/new')
|
|
|
}
|
|
|
-
|
|
|
function goCategories() {
|
|
|
speedOpen.value = false
|
|
|
void router.push('/m/crm/product/categories')
|
|
|
}
|
|
|
-
|
|
|
+ function goEdit(id: string) {
|
|
|
+ void router.push(`/m/crm/product/${id}/edit`)
|
|
|
+ }
|
|
|
function goDetail(id: string) {
|
|
|
void router.push(`/m/crm/product/${id}`)
|
|
|
}
|
|
|
-
|
|
|
function onShellReady() {
|
|
|
+ categoryRequestSeq += 1
|
|
|
categoryMapLoaded = false
|
|
|
- statusFilter.value = 'on'
|
|
|
+ modelRequestSeq += 1
|
|
|
+ modelOptionsLoaded = false
|
|
|
+ keyword.value = ''
|
|
|
+ categoryId.value = ''
|
|
|
+ model.value = ''
|
|
|
+ priceMin.value = ''
|
|
|
+ priceMax.value = ''
|
|
|
+ speedOpen.value = false
|
|
|
+ expandedIds.value = new Set()
|
|
|
reload()
|
|
|
}
|
|
|
</script>
|
|
|
-
|
|
|
-<style scoped lang="scss">
|
|
|
- .mp-wv-card__title {
|
|
|
- font-size: 15px;
|
|
|
- font-weight: 700;
|
|
|
- color: #087f57;
|
|
|
- }
|
|
|
-
|
|
|
- .mp-wv-card__tags {
|
|
|
- display: flex;
|
|
|
- flex-wrap: wrap;
|
|
|
- gap: 6px;
|
|
|
- margin-top: 8px;
|
|
|
- }
|
|
|
-</style>
|