Files
medical-mall/pages/mall/merchant/products.uvue
2026-03-20 15:43:33 +08:00

765 lines
17 KiB
Plaintext

<<<<<<< HEAD
<!-- 商家端 - 商品管理列表页面 -->
=======
<!-- 商家端 - 商品管理列表页面 -->
>>>>>>> local-backup-root-cyj
<template>
<view class="products-page">
<!-- 搜索栏 -->
<view class="search-bar">
<input
class="search-input"
type="text"
v-model="searchKeyword"
placeholder="搜索商品名称"
@confirm="handleSearch"
/>
<view class="search-btn" @click="handleSearch">搜索</view>
</view>
<!-- 筛选标签 -->
<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>
<!-- 商品列表 -->
<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)"
>
<image
:src="product.main_image_url || '/static/images/default-product.png'"
class="product-image"
mode="aspectFill"
/>
<view class="product-info">
<view class="product-header">
<text class="product-name">{{ product.name }}</text>
<text class="product-status" :class="getStatusClass(product.status)">
{{ getStatusText(product.status) }}
</text>
</view>
<text class="product-subtitle">{{ product.subtitle || '暂无描述' }}</text>
<<<<<<< HEAD
<view class="product-tags" v-if="product.tags">
<text v-if="product.is_hot" class="tag hot">热</text>
<text v-if="product.is_new" class="tag new">新</text>
<text v-if="product.is_featured" class="tag recommend">荐</text>
</view>
=======
<view class="product-tags">
<text v-if="product.is_hot" class="tag hot">热</text>
<text v-if="product.is_new" class="tag new">新</text>
<text v-if="product.is_featured" class="tag recommend">荐</text>
<text v-if="product.is_vip_discount" class="tag vip">VIP</text>
</view>
>>>>>>> local-backup-root-cyj
<view class="product-stats">
<view class="price-row">
<text class="current-price">¥{{ product.base_price }}</text>
<text v-if="product.market_price" class="original-price">¥{{ product.market_price }}</text>
</view>
<view class="stock-row">
<text class="stock">库存: {{ product.total_stock || 0 }}</text>
<text class="sales">销量: {{ product.sale_count || 0 }}</text>
</view>
</view>
</view>
<view class="product-actions" @click.stop>
<view
class="action-btn"
:class="product.status === 1 ? 'warning' : 'success'"
@click="toggleStatus(product)"
>
{{ product.status === 1 ? '下架' : '上架' }}
</view>
<view class="action-btn default" @click="editProduct(product.id)">编辑</view>
<view class="action-btn 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 class="add-product-btn" @click="addProduct">
<text class="add-icon">+</text>
<text class="add-text">添加商品</text>
</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
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
}
},
onLoad(options: any) {
const type = options.type as string
if (type === 'add') {
this.addProduct()
} else if (type === 'low_stock') {
this.currentFilter = 'low_stock'
}
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') {
<<<<<<< HEAD
query = query.eq('status', 0)
=======
query = query.eq('status', 2)
>>>>>>> local-backup-root-cyj
} 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,
<<<<<<< HEAD
status: prodObj.getNumber('status') || 0,
=======
status: prodObj.getNumber('status') || 1,
>>>>>>> local-backup-root-cyj
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) {
<<<<<<< HEAD
const newStatus = product.status === 1 ? 0 : 1
=======
const newStatus = product.status === 1 ? 2 : 1
>>>>>>> local-backup-root-cyj
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'
<<<<<<< HEAD
if (status === 0) return 'status-offsale'
=======
if (status === 2 || status === 0) return 'status-offsale'
>>>>>>> local-backup-root-cyj
return 'status-pending'
},
getStatusText(status: number): string {
if (status === 1) return '在售'
<<<<<<< HEAD
if (status === 0) return '已下架'
=======
if (status === 2 || status === 0) return '已下架'
>>>>>>> local-backup-root-cyj
return '待审核'
}
}
}
</script>
<style>
.products-page {
background-color: #f5f5f5;
min-height: 100vh;
padding-bottom: 140rpx;
}
.search-bar {
display: flex;
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: #007AFF;
color: #fff;
font-size: 26rpx;
border-radius: 32rpx;
}
.filter-tabs {
display: flex;
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: #007AFF;
font-weight: bold;
}
.filter-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 4rpx;
background-color: #007AFF;
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: #007AFF;
color: #fff;
font-size: 28rpx;
border-radius: 40rpx;
}
.product-card {
display: flex;
background-color: #fff;
border-radius: 16rpx;
margin-bottom: 20rpx;
padding: 24rpx;
flex-wrap: wrap;
}
.product-image {
width: 180rpx;
height: 180rpx;
border-radius: 12rpx;
margin-right: 20rpx;
background-color: #f5f5f5;
}
.product-info {
flex: 1;
min-width: 0;
}
.product-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10rpx;
}
.product-name {
font-size: 28rpx;
color: #333;
font-weight: 500;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.product-status {
font-size: 22rpx;
padding: 4rpx 12rpx;
border-radius: 12rpx;
margin-left: 10rpx;
flex-shrink: 0;
}
.status-onsale {
background-color: #E8F5E9;
color: #4CAF50;
}
.status-offsale {
background-color: #FFEBEE;
color: #F44336;
}
.status-pending {
background-color: #FFF3E0;
color: #FF9800;
}
.product-subtitle {
font-size: 24rpx;
color: #999;
display: block;
margin-bottom: 10rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-tags {
display: flex;
gap: 10rpx;
margin-bottom: 10rpx;
}
.tag {
font-size: 20rpx;
padding: 4rpx 10rpx;
border-radius: 8rpx;
}
.tag.hot {
background-color: #FF5722;
color: #fff;
}
.tag.new {
background-color: #2196F3;
color: #fff;
}
<<<<<<< HEAD
.tag.recommend {
background-color: #9C27B0;
color: #fff;
}
=======
.tag.recommend {
background-color: #9C27B0;
color: #fff;
}
.tag.vip {
background-color: #FFC107;
color: #333;
font-weight: bold;
}
>>>>>>> local-backup-root-cyj
.product-stats {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.price-row {
display: flex;
align-items: baseline;
}
.current-price {
font-size: 32rpx;
color: #FF3B30;
font-weight: bold;
}
.original-price {
font-size: 24rpx;
color: #999;
text-decoration: line-through;
margin-left: 16rpx;
}
.stock-row {
display: flex;
justify-content: space-between;
font-size: 22rpx;
color: #999;
}
.product-actions {
width: 100%;
display: flex;
justify-content: flex-end;
gap: 16rpx;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f5f5f5;
}
.action-btn {
padding: 12rpx 24rpx;
font-size: 24rpx;
border-radius: 24rpx;
}
.action-btn.success {
background-color: #E8F5E9;
color: #4CAF50;
}
.action-btn.warning {
background-color: #FFF3E0;
color: #FF9800;
}
.action-btn.default {
background-color: #F5F5F5;
color: #666;
}
.action-btn.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: 300rpx;
height: 88rpx;
background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%);
border-radius: 44rpx;
box-shadow: 0 8rpx 20rpx rgba(0, 122, 255, 0.3);
}
.add-icon {
font-size: 40rpx;
color: #fff;
margin-right: 10rpx;
}
.add-text {
font-size: 30rpx;
color: #fff;
font-weight: bold;
}
</style>
<<<<<<< HEAD
=======
>>>>>>> local-backup-root-cyj