Files
medical-mall/pages/mall/merchant/products.uvue
2026-04-13 11:32:31 +08:00

750 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 机构端 - 服务管理列表页面 -->
<template>
<view class="products-page">
<!-- #ifdef MP-WEIXIN -->
<view style="padding-top: var(--status-bar-height); background-color: #ffffff; display: flex; flex-direction: row; align-items: flex-end; border-bottom: 1rpx solid #eeeeee; box-sizing: border-box; height: calc(88rpx + var(--status-bar-height));">
<view style="display: flex; flex-direction: row; align-items: center; padding: 0 30rpx; height: 88rpx;" @click="uni.navigateBack()">
<text style="font-size: 44rpx; color: #333333; line-height: 1; margin-right: 6rpx;"></text>
<text style="font-size: 28rpx; color: #333333;">返回</text>
</view>
</view>
<!-- #endif -->
<!-- 筛选标签 -->
<view class="filter-tabs">
<view
class="filter-tab"
:class="{ active: currentFilter === 'all' }"
@click="switchFilter('all')"
>
全部
</view>
<view
class="filter-tab"
:class="{ active: currentFilter === 'onsale' }"
@click="switchFilter('onsale')"
>
可预约
</view>
<view
class="filter-tab"
:class="{ active: currentFilter === 'offsale' }"
@click="switchFilter('offsale')"
>
暂停服务
</view>
<view
class="filter-tab"
:class="{ active: currentFilter === 'low_stock' }"
@click="switchFilter('low_stock')"
>
器械预警
</view>
</view>
<!-- 搜索栏 -->
<view class="search-bar">
<input
class="search-input"
type="text"
v-model="searchKeyword"
placeholder="搜索服务/商品名称"
@confirm="handleSearch"
/>
<view class="search-btn" @click="handleSearch">搜索</view>
</view>
<!-- 服务列表 -->
<scroll-view
class="products-list"
scroll-y
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<view v-if="loading && products.length === 0" class="loading-container">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="products.length === 0" class="empty-container">
<text class="empty-icon">🏥</text>
<text class="empty-text">暂无服务项目</text>
<view class="add-first-btn" @click="addProduct">添加第一个服务项目</view>
</view>
<view v-else>
<view
v-for="product in products"
:key="product.id"
class="product-card"
@click="viewProductDetail(product.id)"
>
<!-- 上半部分:图片 + 服务信息 -->
<view class="product-main">
<image
:src="product.main_image_url || '/static/images/default-product.png'"
class="product-thumb"
mode="aspectFill"
/>
<view class="product-content">
<!-- 第一层:服务名称 + 状态 badge -->
<view class="product-header">
<text class="product-name">{{ product.name }}</text>
<text class="product-status-badge" :class="getStatusClass(product.status)">
{{ getStatusText(product.status) }}
</text>
</view>
<!-- 第二层:描述 -->
<text class="product-desc">{{ product.subtitle || '暂无描述' }}</text>
<!-- 标签行(按需展示) -->
<view v-if="product.is_hot || product.is_new || product.is_featured || product.is_vip_discount" class="product-tags">
<text v-if="product.is_hot" class="tag tag-hot">热</text>
<text v-if="product.is_new" class="tag tag-new">新</text>
<text v-if="product.is_featured" class="tag tag-recommend">荐</text>
<text v-if="product.is_vip_discount" class="tag tag-vip">关怀</text>
</view>
<!-- 第三层:价格高亮 -->
<view class="product-price">
<text class="price-current">¥{{ product.base_price }}</text>
<text v-if="product.market_price" class="price-original">¥{{ product.market_price }}</text>
</view>
<!-- 第四层:名额、服务次数辅助信息 -->
<view class="product-meta">
<text class="meta-item">可约名额 {{ product.total_stock || 0 }}</text>
<text class="meta-sep">·</text>
<text class="meta-item">服务次数 {{ product.sale_count || 0 }}</text>
</view>
</view>
</view>
<!-- 分割线 -->
<view class="product-divider"></view>
<!-- 操作栏 -->
<view class="product-actions" @click.stop>
<view
class="action-btn action-secondary"
@click="toggleStatus(product)"
>
{{ product.status === 1 ? '暂停服务' : '开启服务' }}
</view>
<view class="action-btn action-edit" @click="editProduct(product.id)">编辑</view>
<view class="action-btn action-danger" @click="deleteProduct(product)">删除</view>
</view>
</view>
</view>
<view v-if="loadingMore" class="load-more">
<text class="load-more-text">加载中...</text>
</view>
<view v-if="!hasMore && products.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</scroll-view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type ProductType = {
id: string
merchant_id: string
category_id: string
name: string
subtitle: string
description: string
main_image_url: string
image_urls: string
base_price: number
market_price: number
total_stock: number
sale_count: number
status: number
is_hot: boolean
is_new: boolean
is_featured: boolean
is_vip_discount: boolean
tags: string
created_at: string
updated_at: string
}
export default {
data() {
return {
searchKeyword: '',
currentFilter: 'all',
products: [] as ProductType[],
loading: false,
loadingMore: false,
refreshing: false,
page: 1,
limit: 20,
hasMore: true,
merchantId: '',
lowStockThreshold: 10
}
},
async onLoad(options: any) {
const type = options.type as string
if (type === 'add') {
this.addProduct()
} else if (type === 'low_stock') {
this.currentFilter = 'low_stock'
}
await this.initMerchantId()
},
onShow() {
this.loadProducts()
},
methods: {
async initMerchantId() {
try {
const session = supa.getSession()
if (session != null && session.user != null) {
this.merchantId = session.user.getString('id') || ''
}
if (!this.merchantId) {
this.merchantId = uni.getStorageSync('user_id') || ''
}
} catch (e) {
console.error('获取商户ID失败:', e)
}
},
async loadProducts() {
if (this.loading) return
this.loading = true
try {
let query = supa
.from('ml_products')
.select('*')
.eq('merchant_id', this.merchantId)
.order('created_at', { ascending: false })
.page(this.page)
.limit(this.limit)
if (this.searchKeyword) {
query = query.like('name', `%${this.searchKeyword}%`)
}
if (this.currentFilter === 'onsale') {
query = query.eq('status', 1)
} else if (this.currentFilter === 'offsale') {
query = query.eq('status', 2)
} else if (this.currentFilter === 'low_stock') {
query = query.lte('total_stock', this.lowStockThreshold).gte('total_stock', 0)
}
const response = await query.execute()
if (response.error != null) {
console.error('获取商品失败:', response.error)
uni.showToast({ title: '加载失败', icon: 'none' })
return
}
const rawData = response.data as any[]
if (rawData == null || rawData.length === 0) {
this.products = []
this.hasMore = false
return
}
const productsData: ProductType[] = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i]
const prodObj = item as UTSJSONObject
productsData.push({
id: prodObj.getString('id') || '',
merchant_id: prodObj.getString('merchant_id') || '',
category_id: prodObj.getString('category_id') || '',
name: prodObj.getString('name') || '',
subtitle: prodObj.getString('subtitle') || '',
description: prodObj.getString('description') || '',
main_image_url: prodObj.getString('main_image_url') || '',
image_urls: prodObj.getString('image_urls') || '',
base_price: prodObj.getNumber('base_price') || 0,
market_price: prodObj.getNumber('market_price') || 0,
total_stock: prodObj.getNumber('total_stock') || 0,
sale_count: prodObj.getNumber('sale_count') || 0,
status: prodObj.getNumber('status') || 1,
is_hot: prodObj.getBoolean('is_hot') || false,
is_new: prodObj.getBoolean('is_new') || false,
is_featured: prodObj.getBoolean('is_featured') || false,
tags: prodObj.getString('tags') || '',
created_at: prodObj.getString('created_at') || '',
updated_at: prodObj.getString('updated_at') || ''
} as ProductType)
}
if (this.page === 1) {
this.products = productsData
} else {
this.products = [...this.products, ...productsData]
}
this.hasMore = rawData.length >= this.limit
} catch (e) {
console.error('获取商品异常:', e)
} finally {
this.loading = false
this.refreshing = false
}
},
switchFilter(filter: string) {
this.currentFilter = filter
this.page = 1
this.hasMore = true
this.loadProducts()
},
handleSearch() {
this.page = 1
this.hasMore = true
this.loadProducts()
},
onRefresh() {
this.refreshing = true
this.page = 1
this.loadProducts()
},
loadMore() {
if (!this.loadingMore && this.hasMore) {
this.loadingMore = true
this.page++
this.loadProducts().then(() => {
this.loadingMore = false
})
}
},
viewProductDetail(productId: string) {
uni.navigateTo({
url: `/pages/mall/merchant/product-detail?productId=${productId}`
})
},
editProduct(productId: string) {
uni.navigateTo({
url: `/pages/mall/merchant/product-edit?productId=${productId}`
})
},
addProduct() {
uni.navigateTo({
url: '/pages/mall/merchant/product-edit'
})
},
async toggleStatus(product: ProductType) {
const newStatus = product.status === 1 ? 2 : 1
const actionText = newStatus === 1 ? '开启服务' : '暂停服务'
uni.showModal({
title: `确认${actionText}`,
content: `确定要${actionText}该服务项目吗?`,
success: async (res) => {
if (res.confirm) {
try {
const response = await supa
.from('ml_products')
.update({
status: newStatus,
updated_at: new Date().toISOString()
})
.eq('id', product.id)
.execute()
if (response.error != null) {
uni.showToast({ title: '操作失败', icon: 'none' })
return
}
product.status = newStatus
uni.showToast({ title: `${actionText}成功`, icon: 'success' })
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
}
})
},
async deleteProduct(product: ProductType) {
uni.showModal({
title: '确认删除',
content: '删除后将无法恢复,确定要删除该服务项目吗?',
success: async (res) => {
if (res.confirm) {
try {
const response = await supa
.from('ml_products')
.delete()
.eq('id', product.id)
.execute()
if (response.error != null) {
uni.showToast({ title: '删除失败', icon: 'none' })
return
}
uni.showToast({ title: '删除成功', icon: 'success' })
this.loadProducts()
} catch (e) {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
},
getStatusClass(status: number): string {
if (status === 1) return 'status-onsale'
if (status === 2 || status === 0) return 'status-offsale'
return 'status-pending'
},
getStatusText(status: number): string {
if (status === 1) return '在售'
if (status === 2 || status === 0) return '已下线'
return '待审核'
}
}
}
</script>
<style>
.products-page {
background-color: #f5f5f5;
min-height: 100vh;
padding-bottom: 140rpx;
}
.search-bar {
display: flex;
flex-direction: row;
align-items: center;
padding: 20rpx 30rpx;
background-color: #fff;
}
.search-input {
flex: 1;
height: 64rpx;
background-color: #f5f5f5;
border-radius: 32rpx;
padding: 0 30rpx;
font-size: 26rpx;
}
.search-btn {
margin-left: 20rpx;
padding: 16rpx 30rpx;
background-color: #09C39D;
color: #fff;
font-size: 26rpx;
border-radius: 32rpx;
}
.filter-tabs {
display: flex;
flex-direction: row;
background-color: #fff;
padding: 0 20rpx;
margin-bottom: 20rpx;
}
.filter-tab {
flex: 1;
text-align: center;
padding: 24rpx 0;
font-size: 26rpx;
color: #666;
position: relative;
}
.filter-tab.active {
color: #09C39D;
font-weight: bold;
}
.filter-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 4rpx;
background-color: #09C39D;
border-radius: 2rpx;
}
.products-list {
padding: 0 20rpx;
height: calc(100vh - 260rpx);
}
.loading-container, .empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 100rpx;
margin-bottom: 20rpx;
}
.empty-text, .loading-text {
font-size: 28rpx;
color: #999;
}
.add-first-btn {
margin-top: 30rpx;
padding: 20rpx 60rpx;
background-color: #09C39D;
color: #fff;
font-size: 28rpx;
border-radius: 40rpx;
}
.product-card {
background-color: #fff;
border-radius: 16rpx;
margin-bottom: 20rpx;
overflow: hidden;
}
.product-main {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 24rpx;
}
.product-thumb {
width: 160rpx;
height: 160rpx;
border-radius: 12rpx;
background-color: #f0f0f0;
flex-shrink: 0;
margin-right: 20rpx;
}
.product-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.product-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.product-name {
font-size: 28rpx;
color: #222;
font-weight: 600;
flex: 1;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-right: 12rpx;
}
.product-status-badge {
font-size: 20rpx;
padding: 4rpx 14rpx;
border-radius: 20rpx;
flex-shrink: 0;
font-weight: 500;
}
.status-onsale {
background-color: #E8F5E9;
color: #4CAF50;
}
.status-offsale {
background-color: #F5F5F5;
color: #999;
}
.status-pending {
background-color: #E8F0FE;
color: #09C39D;
}
.product-desc {
font-size: 24rpx;
color: #aaa;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
.product-tags {
display: flex;
flex-direction: row;
gap: 8rpx;
}
.tag {
font-size: 18rpx;
padding: 3rpx 10rpx;
border-radius: 6rpx;
}
.tag-hot {
background-color: #FF5722;
color: #fff;
}
.tag-new {
background-color: #09C39D;
color: #fff;
}
.tag-recommend {
background-color: #9C27B0;
color: #fff;
}
.tag-vip {
background-color: #e8f0fe;
color: #09C39D;
font-weight: bold;
}
.product-price {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 12rpx;
}
.price-current {
font-size: 34rpx;
color: rgb(225, 37, 27);
font-weight: bold;
}
.price-original {
font-size: 22rpx;
color: #ccc;
text-decoration: line-through;
}
.product-meta {
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
}
.meta-item {
font-size: 22rpx;
color: #bbb;
}
.meta-sep {
font-size: 22rpx;
color: #ddd;
}
.product-divider {
height: 1rpx;
background-color: #f5f5f5;
margin: 0 24rpx;
}
.product-actions {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
padding: 16rpx 24rpx;
gap: 16rpx;
}
.action-btn {
padding: 12rpx 28rpx;
font-size: 24rpx;
border-radius: 32rpx;
}
.action-edit {
background-color: #09C39D;
color: #fff;
}
.action-secondary {
background-color: #f5f5f5;
color: #666;
}
.action-danger {
background-color: #FFEBEE;
color: #F44336;
}
.load-more, .no-more {
padding: 30rpx 0;
text-align: center;
}
.load-more-text, .no-more-text {
font-size: 24rpx;
color: #999;
}
.add-product-btn {
position: fixed;
bottom: 30rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 320rpx;
height: 88rpx;
background: linear-gradient(135deg, #A6F1E4 0%, #69DFC2 100%);
border-radius: 44rpx;
box-shadow: 0 8rpx 20rpx rgba(66, 121, 240, 0.35);
}
.add-icon {
font-size: 40rpx;
color: #fff;
margin-right: 10rpx;
}
.add-text {
font-size: 30rpx;
color: #fff;
font-weight: bold;
}
</style>