feat(web-admin): add mp webview CRM product list page.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-10-15 09:48:25 +08:00
1 parent a2769c86dc
commit 74e2267623
1 file changed
+291 -7
+291 -7
View File
@@ -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>