feat(web-admin): add mp webview CRM product list page.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
a2769c86dc
commit
74e2267623
1 file changed
+291
-7
@@ -1,16 +1,300 @@
|
||||
<template>
|
||||
<MpWebviewShell>
|
||||
<header class="mp-wv-header">
|
||||
<div class="mp-wv-header__row">
|
||||
<h1 class="mp-wv-header__title">{{ title }}</h1>
|
||||
<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-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>
|
||||
</header>
|
||||
<div class="mp-wv-empty">页面建设中</div>
|
||||
<div
|
||||
class="mp-wv-filter-mask"
|
||||
:class="{ open: !!openFilter }"
|
||||
@click="closeFilter"
|
||||
/>
|
||||
|
||||
<div class="mp-wv-pad-speed">
|
||||
<div
|
||||
v-if="loading && !cards.length"
|
||||
class="mp-plan-form-loading mp-plan-form-loading--page"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="mp-plan-form-loading__orb" aria-hidden="true">
|
||||
<span class="mp-plan-form-loading__dot" />
|
||||
</div>
|
||||
<p class="mp-plan-form-loading__title">正在加载产品</p>
|
||||
<p class="mp-plan-form-loading__desc">请稍候,正在获取产品列表…</p>
|
||||
<div class="mp-plan-form-loading__skel" aria-hidden="true">
|
||||
<div class="mp-wv-skel mp-wv-skel--block" />
|
||||
<div class="mp-wv-skel mp-wv-skel--mid" />
|
||||
<div class="mp-wv-skel mp-wv-skel--line" />
|
||||
<div class="mp-wv-skel mp-wv-skel--short" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!cards.length" class="mp-wv-empty">
|
||||
<div class="mp-wv-empty__icon" aria-hidden="true">📦</div>
|
||||
<div class="mp-wv-empty__title">暂无产品</div>
|
||||
<div class="mp-wv-empty__desc">可调整状态筛选或搜索后再试</div>
|
||||
</div>
|
||||
<article
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
class="mp-wv-card"
|
||||
@click="goDetail(card.id)"
|
||||
>
|
||||
<div class="mp-wv-card__title">{{ card.productName }}</div>
|
||||
<div class="mp-wv-card__meta">{{ card.productNo }} · {{ card.model }}</div>
|
||||
<div class="mp-wv-card__meta">{{ card.categoryPath }}</div>
|
||||
<div class="mp-wv-card__tags">
|
||||
<el-tag :type="card.statusTagType" size="small">{{ card.statusLabel }}</el-tag>
|
||||
<el-tag v-if="card.showToxic" type="danger" size="small">易制毒</el-tag>
|
||||
<el-tag v-if="card.showExplosive" type="warning" size="small">易制爆</el-tag>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
<button class="mp-wv-speed-main" type="button" aria-label="快捷操作" @click="toggleSpeed">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</MpWebviewShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
|
||||
import { fetchProductList, fetchCategoryTree } from '@/api/crm/product'
|
||||
import {
|
||||
MP_PRODUCT_STATUS_FILTERS,
|
||||
buildProductListQuery,
|
||||
buildProductCard,
|
||||
type MpProductStatusFilter
|
||||
} from '@/utils/mp-crm-product-card'
|
||||
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'
|
||||
|
||||
const title = '产品管理'
|
||||
type ProductRow = {
|
||||
id: string
|
||||
productNo?: string
|
||||
productName?: string
|
||||
categoryId?: string
|
||||
categoryName?: string
|
||||
model?: string
|
||||
status?: string
|
||||
isToxic?: number
|
||||
isExplosive?: number
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const keyword = ref('')
|
||||
const statusFilter = ref<MpProductStatusFilter>('on')
|
||||
const openFilter = ref<'' | 'status'>('')
|
||||
const speedOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
const forbidden = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const products = ref<ProductRow[]>([])
|
||||
const categoryPathMap = ref<Map<string, string>>(new Map())
|
||||
let categoryMapLoaded = false
|
||||
|
||||
const statusLabel = computed(
|
||||
() =>
|
||||
MP_PRODUCT_STATUS_FILTERS.find((item) => item.value === statusFilter.value)?.label || '上架'
|
||||
)
|
||||
|
||||
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 || '—'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const hasMore = computed(() => products.value.length < total.value)
|
||||
|
||||
async function ensureCategoryPathMap() {
|
||||
if (categoryMapLoaded) return
|
||||
try {
|
||||
const res = await fetchCategoryTree()
|
||||
const roots = (res as any)?.data || res || []
|
||||
categoryPathMap.value = buildPathLabelMapFromTreeRoots(Array.isArray(roots) ? roots : [], {
|
||||
idKey: 'id',
|
||||
nameKey: 'categoryName',
|
||||
childrenKey: 'children'
|
||||
})
|
||||
} catch {
|
||||
categoryPathMap.value = new Map()
|
||||
} finally {
|
||||
categoryMapLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPage(reset: boolean) {
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
products.value = []
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await ensureCategoryPathMap()
|
||||
const res = await fetchProductList(
|
||||
buildProductListQuery({
|
||||
page: page.value,
|
||||
pageSize,
|
||||
statusFilter: statusFilter.value,
|
||||
keyword: keyword.value
|
||||
})
|
||||
)
|
||||
const data = (res as any)?.data || res || {}
|
||||
const list = (data.list || data.rows || []) as ProductRow[]
|
||||
total.value = data.total ?? 0
|
||||
products.value = reset ? list : products.value.concat(list)
|
||||
forbidden.value = false
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError && error.code === ApiStatus.forbidden) {
|
||||
forbidden.value = true
|
||||
products.value = []
|
||||
return
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reload() {
|
||||
void fetchPage(true)
|
||||
}
|
||||
|
||||
function closeFilter() {
|
||||
openFilter.value = ''
|
||||
}
|
||||
|
||||
function toggleFilter() {
|
||||
speedOpen.value = false
|
||||
openFilter.value = openFilter.value === 'status' ? '' : 'status'
|
||||
}
|
||||
|
||||
function pickStatus(value: MpProductStatusFilter) {
|
||||
statusFilter.value = value
|
||||
closeFilter()
|
||||
reload()
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
page.value += 1
|
||||
void fetchPage(false)
|
||||
}
|
||||
|
||||
function toggleSpeed() {
|
||||
closeFilter()
|
||||
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 goDetail(id: string) {
|
||||
void router.push(`/m/crm/product/${id}`)
|
||||
}
|
||||
|
||||
function onShellReady() {
|
||||
categoryMapLoaded = false
|
||||
statusFilter.value = 'on'
|
||||
reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mp-wv-card__title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #087f57;
|
||||
}
|
||||
|
||||
.mp-wv-card__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user