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

394 lines
14 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="inventory-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 === 'low' }" @click="switchFilter('low')">器械预警</view>
<view class="filter-tab" :class="{ active: currentFilter === 'out' }" @click="switchFilter('out')">已售罄</view>
</view>
<view class="stats-bar">
<view class="stat-item">
<text class="stat-value">{{ stats.totalProducts }}</text>
<text class="stat-label">在售服务项目数</text>
</view>
<view class="stat-item warning">
<text class="stat-value">{{ stats.lowStock }}</text>
<text class="stat-label">器械预警</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ stats.outOfStock }}</text>
<text class="stat-label">已售罄</text>
</view>
</view>
<scroll-view class="inventory-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>
<view v-else>
<view v-for="product in products" :key="product.id" class="product-card">
<image :src="product.main_image_url || '/static/images/default-product.png'" class="product-image" mode="aspectFill"/>
<view class="product-info">
<text class="product-name">{{ product.name }}</text>
<view class="stock-info">
<text class="stock-label">库存:</text>
<text class="stock-value" :class="getStockClass(product.total_stock)">{{ product.total_stock }}</text>
</view>
<view class="warning-info" v-if="product.total_stock <= (product.warning_stock || 10)">
<text class="warning-text">库存/名额不足</text>
</view>
</view>
<view class="product-actions">
<view class="action-btn" @click="editStock(product)">调整库存</view>
</view>
</view>
</view>
</scroll-view>
<view v-if="showStockModal" class="modal-mask" @click="closeStockModal">
<view class="modal-content" @click.stop>
<view class="modal-header"><text class="modal-title">调整库存</text><text class="modal-close" @click="closeStockModal">×</text></view>
<view class="modal-body">
<view class="form-item">
<text class="label">服务项目</text>
<text class="value">{{ currentProduct?.name }}</text>
</view>
<view class="form-item">
<text class="label">当前库存</text>
<text class="value">{{ currentProduct?.total_stock }}</text>
</view>
<view class="adjust-type">
<view class="type-btn" :class="{ active: adjustType === 'set' }" @click="adjustType = 'set'">直接设为</view>
<view class="type-btn" :class="{ active: adjustType === 'add' }" @click="adjustType = 'add'">增加</view>
<view class="type-btn" :class="{ active: adjustType === 'sub' }" @click="adjustType = 'sub'">减少</view>
</view>
<view class="form-item">
<text class="label">{{ adjustType === 'set' ? '新库存数量' : '调整数值' }}</text>
<input class="input" type="number" v-model="newStock" :placeholder="adjustType === 'set' ? '请输入新库存' : '请输入数值'"/>
</view>
<view class="form-item">
<text class="label">备注 (可选)</text>
<input class="input" v-model="stockRemark" placeholder="如:入库、损耗等"/>
</view>
</view>
<view class="modal-footer">
<view class="modal-btn cancel" @click="closeStockModal">取消</view>
<view class="modal-btn confirm" @click="saveStock">确认提交</view>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import { USE_MOCK, MOCK_MERCHANT_ID, getMockInventoryStats, MOCK_INVENTORY_PRODUCTS } from '@/pages/mall/merchant/mock/merchant-mock-data.uts'
type ProductType = {
id: string
name: string
main_image_url: string
total_stock: number
warning_stock: number
}
export default {
data() {
return {
currentFilter: 'all',
products: [] as ProductType[],
loading: false,
refreshing: false,
page: 1,
limit: 20,
hasMore: true,
merchantId: '',
stats: { totalProducts: 0, lowStock: 0, outOfStock: 0 },
showStockModal: false,
currentProduct: null as ProductType | null,
newStock: '',
adjustType: 'set', // 'set', 'add', 'sub'
stockRemark: ''
}
},
onLoad() {
this.initMerchantId()
},
onShow() {
this.page = 1
this.loadProducts()
this.loadStats()
},
methods: {
async initMerchantId() {
if (USE_MOCK) {
this.merchantId = MOCK_MERCHANT_ID
return
}
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) {}
},
async loadProducts() {
if (this.loading && this.page === 1) return
this.loading = true
if (USE_MOCK) {
const all = MOCK_INVENTORY_PRODUCTS
const filtered: typeof all = []
for (let i = 0; i < all.length; i++) {
const p = all[i]
if (this.currentFilter === 'low' && (p.total_stock <= 0 || p.total_stock > p.warning_stock)) continue
if (this.currentFilter === 'out' && p.total_stock !== 0) continue
filtered.push(p)
}
this.products = filtered as ProductType[]
this.hasMore = false
this.loading = false
this.refreshing = false
return
}
try {
let query = supa.from('ml_products')
.select('id, name, main_image_url, total_stock, warning_stock')
.eq('merchant_id', this.merchantId)
.order('total_stock', { ascending: true })
.page(this.page)
.limit(this.limit)
if (this.currentFilter === 'low') {
query = query.lte('total_stock', 10) // 简化处理,实际应关联 warning_stock
} else if (this.currentFilter === 'out') {
query = query.eq('total_stock', 0)
}
const response = await query.execute()
if (response.error != null) {
console.error('加载服务项目失败:', response.error)
return
}
const rawData = response.data as any[]
if (!rawData) return
const productsData: ProductType[] = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
productsData.push({
id: item.getString('id') || '',
name: item.getString('name') || '',
main_image_url: item.getString('main_image_url') || '',
total_stock: item.getNumber('total_stock') || 0,
warning_stock: item.getNumber('warning_stock') || 10
} 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
}
},
async loadStats() {
if (USE_MOCK) {
this.stats = getMockInventoryStats()
return
}
try {
const response = await supa.from('ml_products').select('id, total_stock, warning_stock', { count: 'exact' }).eq('merchant_id', this.merchantId).execute()
if (response.error != null || !response.data) return
let total = 0, low = 0, out = 0
const rawData = response.data as any[]
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
total++
const stock = item.getNumber('total_stock') || 0
const warning = item.getNumber('warning_stock') || 10
if (stock === 0) out++
else if (stock <= warning) low++
}
this.stats = { totalProducts: total, lowStock: low, outOfStock: out }
} catch (e) {}
},
switchFilter(filter: string) {
this.currentFilter = filter
this.loadProducts()
},
onRefresh() {
this.refreshing = true
this.loadProducts()
this.loadStats()
},
loadMore() {
if (this.hasMore && !this.loading) {
this.page++
this.loadProducts()
}
},
editStock(product: ProductType) {
this.currentProduct = product
this.newStock = ''
this.adjustType = 'set'
this.stockRemark = ''
this.showStockModal = true
},
closeStockModal() {
this.showStockModal = false
this.currentProduct = null
this.newStock = ''
},
async saveStock() {
const val = parseInt(this.newStock)
if (isNaN(val)) {
uni.showToast({ title: '请输入有效数值', icon: 'none' })
return
}
let finalStock = 0
if (this.adjustType === 'set') {
finalStock = val
} else if (this.adjustType === 'add') {
finalStock = (this.currentProduct?.total_stock || 0) + val
} else if (this.adjustType === 'sub') {
finalStock = (this.currentProduct?.total_stock || 0) - val
}
if (finalStock < 0) {
uni.showToast({ title: '最终库存不能小于0', icon: 'none' })
return
}
uni.showLoading({ title: '更新中...' })
try {
const response = await supa.from('ml_products')
.update({
total_stock: finalStock,
available_stock: finalStock,
updated_at: new Date().toISOString()
})
.eq('id', this.currentProduct!.id)
.execute()
if (response.error != null) {
uni.showToast({ title: '保存失败', icon: 'none' })
return
}
uni.showToast({ title: '更新成功', icon: 'success' })
this.closeStockModal()
this.page = 1
this.loadProducts()
this.loadStats()
} catch (e) {
uni.showToast({ title: '操作异常', icon: 'none' })
} finally {
uni.hideLoading()
}
},
getStockClass(stock: number): string {
if (stock === 0) return 'out'
if (stock <= 10) return 'low'
return 'normal'
}
}
}
</script>
<style>
.inventory-page { background-color: #f5f5f5; min-height: 100vh; }
.stats-bar { display: flex;
flex-direction: row;
background-color: #fff; padding: 30rpx 20rpx; margin-bottom: 20rpx;
}
.stat-item { flex: 1; text-align: center; }
.stat-value { font-size: 40rpx; font-weight: bold; color: #333; display: block; }
.stat-item.warning .stat-value { color: #FF9800; }
.stat-label { font-size: 24rpx; color: #999; }
.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; }
.inventory-list { padding: 0 20rpx; height: calc(100vh - 280rpx); }
.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; }
.product-card { display: flex; flex-direction: row; align-items: center; background-color: #fff; border-radius: 16rpx; padding: 20rpx; margin-bottom: 16rpx; }
.product-image { width: 120rpx; height: 120rpx; border-radius: 8rpx; margin-right: 20rpx; background-color: #f5f5f5; }
.product-info { flex: 1; }
.product-name { font-size: 28rpx; color: #333; font-weight: 500; display: block; margin-bottom: 10rpx; }
.stock-info { display: flex; flex-direction: row; align-items: center; }
.stock-label { font-size: 24rpx; color: #999; margin-right: 10rpx; }
.stock-value { font-size: 28rpx; font-weight: bold; }
.stock-value.normal { color: #4CAF50; }
.stock-value.low { color: #FF9800; }
.stock-value.out { color: #F44336; }
.warning-info { margin-top: 8rpx; }
.warning-text { font-size: 22rpx; color: #F44336; }
.product-actions { margin-left: 20rpx; }
.action-btn { padding: 12rpx 24rpx; font-size: 24rpx; background-color: #E3F7ED; color: #09C39D; border-radius: 24rpx; }
.modal-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal-content { width: 80%; background-color: #fff; border-radius: 16rpx; }
.modal-body { padding: 30rpx; }
.adjust-type { display: flex; flex-direction: row; justify-content: space-between; margin-bottom: 30rpx; }
.type-btn { flex: 1; height: 64rpx; line-height: 64rpx; text-align: center; font-size: 24rpx; background-color: #f5f5f5; color: #666; margin: 0 10rpx; border-radius: 32rpx; border: 1rpx solid #eee; }
.type-btn.active { background-color: #E3F7ED; color: #09C39D; border-color: #09C39D; }
.form-item { margin-bottom: 20rpx; }
.form-item .label { font-size: 26rpx; color: #999; display: block; margin-bottom: 10rpx; }
.form-item .value { font-size: 28rpx; color: #333; }
.input { height: 72rpx; border: 1rpx solid #e5e5e5; border-radius: 8rpx; padding: 0 20rpx; font-size: 28rpx; }
.modal-footer { display: flex; flex-direction: row; border-top: 1rpx solid #f5f5f5; }
.modal-btn { flex: 1; height: 88rpx; line-height: 88rpx; text-align: center; font-size: 28rpx; }
.modal-btn.cancel { color: #666; border-right: 1rpx solid #f5f5f5; }
.modal-btn.confirm { color: #09C39D; font-weight: bold; }
</style>