Files
medical-mall/pages/mall/merchant/products.uvue

728 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="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>
<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>
<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') {
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;
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;
}
.tag.recommend {
background-color: #9C27B0;
color: #fff;
}
.tag.vip {
background-color: #FFC107;
color: #333;
font-weight: bold;
}
.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>