728 lines
16 KiB
Plaintext
728 lines
16 KiB
Plaintext
<!-- 商家端 - 商品管理列表页面 -->
|
||
<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>
|