Files
medical-mall/pages/mall/admin/product/product-management/index.uvue

749 lines
21 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="product-list-page">
<!-- 店铺门禁:无店铺时显示空态 -->
<view v-if="shopLoading" class="shop-guard-loading">
<text class="sgl-txt">加载中...</text>
</view>
<view v-else-if="!hasShop" class="shop-guard-empty">
<text class="sge-icon">🏦</text>
<text class="sge-title">您还没有店铺</text>
<text class="sge-desc">先创建店铺,才能发布商品</text>
<button class="sge-btn" @click="goCreateShop">立即创建店铺</button>
</view>
<!-- 正常商品列表 -->
<template v-else>
<!-- 1. 搜索表单 -->
<view class="search-card">
<view class="search-row">
<view class="search-item">
<text class="label">商品搜索:</text>
<input class="mock-input" placeholder="请输入商品名称/关键字/ID" />
</view>
<view class="search-item">
<text class="label">商品类型:</text>
<view class="mock-select">
<text>全部</text>
<text class="arrow">▼</text>
</view>
</view>
<view class="search-item">
<text class="label">商品分类:</text>
<view class="mock-select">
<text>请选择</text>
<text class="arrow">▼</text>
</view>
</view>
<view class="search-btns">
<button class="btn-primary">查询</button>
<button class="btn-reset">重置</button>
<view class="expand-control">
<text class="expand-txt">展开</text>
<text class="expand-arrow">▼</text>
</view>
</view>
</view>
<view class="search-row mt-12">
<view class="search-item">
<text class="label">配送方式:</text>
<view class="mock-select">
<text>全部</text>
<text class="arrow">▼</text>
</view>
</view>
</view>
</view>
<!-- 2. 商品状态 Tabs -->
<view class="status-tabs-wrap">
<view class="status-tabs">
<view
v-for="(tab, index) in statusTabs"
:key="index"
class="tab-item"
:class="{ active: activeStatus === tab.key }"
@click="activeStatus = tab.key"
>
<text>{{ tab.label }}({{ tab.count }})</text>
</view>
</view>
</view>
<!-- 3. 操作按钮行 -->
<view class="action-bar">
<view class="left-actions">
<button class="btn-add" @click="goEdit(null)">添加商品</button>
<button class="btn-collect">商品采集</button>
<view class="btn-dropdown">
<text>批量修改</text>
<text class="arrow">▼</text>
</view>
<view class="btn-dropdown">
<text>商品迁移</text>
<text class="arrow">▼</text>
</view>
<button class="btn-export">数据导出</button>
</view>
</view>
<!-- 4. 商品列表表格 -->
<view class="list-card">
<view class="table-v5">
<view class="th-row">
<view class="th col-check"><text>□</text></view>
<view class="th col-id"><text>商品ID</text></view>
<view class="th col-img"><text>商品图</text></view>
<view class="th col-name"><text>商品名称</text></view>
<view class="th col-activity"><text>参与活动</text></view>
<view class="th col-type"><text>商品类型</text></view>
<view class="th col-price"><text>商品售价</text></view>
<view class="th col-sales"><text>销量</text></view>
<view class="th col-stock"><text>库存</text></view>
<view class="th col-sort"><text>排序</text></view>
<view class="th col-status"><text>状态</text></view>
<view class="th col-op"><text>操作</text></view>
</view>
<view v-for="(item, index) in productList" :key="index"
class="tr-row"
:style="{ zIndex: activeDropdownId === item.id ? 1000 : 1 }"
>
<view class="td col-check"><text>□</text></view>
<view class="td col-id"><text>{{ item.id }}</text></view>
<view class="td col-img">
<image class="p-img" :src="item.image" mode="aspectFill" />
</view>
<view class="td col-name">
<text class="p-name-txt">{{ item.name }}</text>
</view>
<view class="td col-activity">
<view class="activity-tags">
<text v-for="tag in item.activities" :key="tag" class="tag" :class="tag">{{ getActivityName(tag) }}</text>
<text v-if="item.activities.length === 0">-</text>
</view>
</view>
<view class="td col-type"><text>{{ item.typeName }}</text></view>
<view class="td col-price"><text>{{ item.price }}</text></view>
<view class="td col-sales"><text>{{ item.sales }}</text></view>
<view class="td col-stock"><text>{{ item.stock }}</text></view>
<view class="td col-sort"><text>{{ item.sort }}</text></view>
<view class="td col-status">
<StatusSwitch
:modelValue="item.status === 1"
activeText="上架"
inactiveText="下架"
@update:modelValue="(val : boolean) => item.status = (val ? 1 : 0)"
/>
</view>
<view class="td col-op op-group">
<text class="op-link" @click.stop="goEdit(item.id)">编辑</text>
<text class="op-divider">|</text>
<view class="more-hover-box"
@mouseenter="activeDropdownId = item.id"
@mouseleave="activeDropdownId = null"
>
<view class="more-trigger-txt">
<text class="more-link-text">更多</text>
<text class="more-arrow-icon"></text>
</view>
<view class="dropdown-list-container" v-show="activeDropdownId === item.id">
<view class="dropdown-triangle"></view>
<view class="dropdown-menu-body">
<text class="menu-item" @click.stop="goReviews(item.id)">查看评论</text>
<text class="menu-item" @click.stop="goMemberPrice(item.id)">会员价管理</text>
<text class="menu-item" @click.stop="uni.showToast({title:'佣金管理开发中', icon:'none'})">佣金管理</text>
<text class="menu-item danger-item" @click.stop="moveToRecycle(item.id)">{{ activeStatus === 'recycle' ? '恢复商品' : '移到回收站' }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 5. 分页 -->
<CommonPagination
v-if="total > 0"
:total="total"
:loading="false"
:currentPage="currentPage"
:pageSize="pageSize"
:pageSizeOptionLabels="pageSizeOptionLabels"
:pageSizeIndex="pageSizeIndex"
:visiblePages="visiblePages"
:totalPage="totalPage"
:jumpPageInput="jumpPageInput"
@page-size-change="handlePageSizeChange"
@page-change="handlePageChange"
@update:jumpPageInput="(val : string) => { jumpPageInput = val }"
@jump-page="handleJumpPage"
/>
</view>
</template>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
import StatusSwitch from '@/components/StatusSwitch.uvue'
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
const hasShop = ref(false)
const shopLoading = ref(true)
const total = ref(0)
// 分页状态适配层
const currentPage = ref(1)
const pageSize = ref(10)
let jumpPageInput = ''
const pageSizeOptions = [10, 20, 30, 50, 100]
const pageSizeOptionLabels = computed((): string[] => pageSizeOptions.map((s: number): string => `${s} 条/页`))
const pageSizeIndex = computed((): number => {
const i = pageSizeOptions.indexOf(pageSize.value)
return i === -1 ? 0 : i
})
const totalPage = computed((): number => Math.ceil(total.value / pageSize.value))
const visiblePages = computed((): number[] => {
const cur = currentPage.value
const tot = totalPage.value
if (tot <= 7) {
const pages: number[] = []
for (let i = 1; i <= tot; i++) pages.push(i)
return pages
}
if (cur <= 4) return [1, 2, 3, 4, 5, -1, tot]
if (cur >= tot - 3) return [1, -1, tot - 4, tot - 3, tot - 2, tot - 1, tot]
return [1, -1, cur - 1, cur, cur + 1, -1, tot]
})
const handlePageChange = (p: number) => {
if (p < 1 || p > totalPage.value || p === currentPage.value) return
currentPage.value = p
jumpPageInput = ''
}
const handlePageSizeChange = (e: any) => {
let val = 0
if (typeof e.detail.value === 'string') val = parseInt(e.detail.value)
else val = e.detail.value as number
pageSize.value = pageSizeOptions[val]
currentPage.value = 1
}
const handleJumpPage = () => {
let jumpTo = parseInt(jumpPageInput)
if (isNaN(jumpTo)) return
if (jumpTo < 1) jumpTo = 1
if (jumpTo > totalPage.value) jumpTo = totalPage.value
jumpPageInput = String(jumpTo)
if (jumpTo !== currentPage.value) currentPage.value = jumpTo
}
const activeStatus = ref('selling')
const activeDropdownId = ref<number | null>(null)
const statusTabs = ref([
{ key: 'selling', label: '出售中的商品', count: 0 },
{ key: 'warehouse', label: '仓库中的商品', count: 0 },
{ key: 'soldout', label: '已经售罄商品', count: 0 },
{ key: 'alarm', label: '警戒库存商品', count: 0 },
{ key: 'recycle', label: '回收站的商品', count: 0 },
])
const productList = ref<any[]>([])
// 监听 activeStatus 变化
watch(activeStatus, () => {
fetchProducts()
})
// 商品模块店铺门禁
onMounted(async () => {
await checkShop()
uni.$on('REFRESH_PRODUCT_LIST', () => {
fetchProducts()
})
})
async function checkShop() {
shopLoading.value = true
try {
await ensureSupabaseReady()
const userId = supa.getSession().user?.getString('id')
if (!userId) {
hasShop.value = false
shopLoading.value = false
return
}
// 查询 ml_shops 确认当前用户是否已建店
const res = await supa.from('ml_shops')
.select('merchant_id, shop_name, status')
.eq('merchant_id', userId)
.single()
.execute()
if (res.error != null || !res.data) {
hasShop.value = false
} else {
const rawData = res.data
let shopRow: UTSJSONObject | null = null
if (Array.isArray(rawData)) {
shopRow = (rawData as Array<UTSJSONObject>).length > 0 ? (rawData as Array<UTSJSONObject>)[0] : null
} else {
shopRow = rawData as UTSJSONObject
}
hasShop.value = shopRow != null
}
} catch (e: any) {
console.warn('[ProductList] 店铺检查异常:', e)
hasShop.value = false
} finally {
shopLoading.value = false
}
if (hasShop.value) {
fetchProducts()
}
}
function goCreateShop() {
openRoute('shop_manage')
}
onUnmounted(() => {
uni.$off('REFRESH_PRODUCT_LIST')
})
// 从 ml_products 表中获取商品数据
async function fetchProducts() {
await ensureSupabaseReady()
// merchant_id 来自 ml_shops 所关联的 ak_users.id即 auth user id
const currentMerchantId = supa.getSession().user?.getString('id')
if (!currentMerchantId) {
uni.showToast({ title: '未获取到商家信息,请重新登录', icon: 'none' })
return
}
try {
const query = supa
.from('ml_products')
.select('id, name, main_image_url, base_price, available_stock, status, created_at', { count: 'exact' })
.eq('merchant_id', currentMerchantId)
// 根据 activeStatus 过滤
// 1:上架 2:下架 3:草稿 4:删除
// selling: 1, warehouse: 2+3, soldout: stock=0, alarm: stock<10, recycle: 4
if (activeStatus.value === 'selling') {
query.eq('status', 1)
} else if (activeStatus.value === 'warehouse') {
query.in('status', [2, 3])
} else if (activeStatus.value === 'recycle') {
query.eq('status', 4)
} else if (activeStatus.value === 'soldout') {
query.eq('available_stock', 0)
} else if (activeStatus.value === 'alarm') {
query.lt('available_stock', 10)
}
const { data, error, count } = await query.order('created_at', { ascending: false }).execute()
if (error) {
console.error('Fetch products error:', error)
uni.showToast({ title: '加载失败: ' + error.message, icon: 'none' })
return
}
if (data != null) {
const dataArray = data as Array<UTSJSONObject>
productList.value = dataArray.map((item: UTSJSONObject): any => {
return {
id: item.get('id'),
image: item.get('main_image_url') || '',
name: item.get('name') || '未命名商品',
activities: [],
typeName: '普通商品',
price: item.get('base_price') != null ? Number(item.get('base_price')).toFixed(2) : '0.00',
sales: 0,
stock: item.get('available_stock') || 0,
sort: 0,
status: item.get('status') || 0
}
})
total.value = count || dataArray.length
// 更新 Tab 计数 (简单同步当前列表总数到对应 Tab)
statusTabs.value.forEach(tab => {
if (tab.key === activeStatus.value) {
tab.count = total.value
}
})
}
} catch (err: any) {
console.error('获取商品列表失败:', err)
uni.showToast({ title: '加载失败: ' + (err.message || ''), icon: 'none' })
}
}
function getActivityName(tag: string): string {
if (tag === 'kj') return '砍价'
if (tag === 'pt') return '拼团'
if (tag === 'ms') return '秒杀'
return tag
}
function goEdit(id: number | null) {
if (id !== null) {
uni.setStorageSync('edit_product_id', id)
} else {
uni.removeStorageSync('edit_product_id')
}
openRoute('product_edit')
}
function goReviews(id: number) {
openRoute('product_productReply')
}
function goMemberPrice(id: number) {
openRoute('product_member_price')
}
function moveToRecycle(id: number) {
const action = activeStatus.value === 'recycle' ? '恢复' : '移到回收站';
uni.showModal({
title: '提示',
content: `确认要将该商品${action}吗?`,
success: (res) => {
if (res.confirm) {
uni.showToast({ title: '操作成功', icon: 'success' });
}
}
})
}
</script>
<style scoped lang="scss">
.product-list-page {
/* 使用 Layout 的背景和内边距 */
min-height: auto;
}
/* 店铺门禁状态 */
.shop-guard-loading {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
background: #fff;
border-radius: 4px;
}
.sgl-txt { font-size: 14px; color: #999; }
.shop-guard-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 40px;
background: #fff;
border-radius: 4px;
}
.sge-icon { font-size: 56px; margin-bottom: 16px; }
.sge-title { font-size: 18px; font-weight: 600; color: #333; margin-bottom: 8px; }
.sge-desc { font-size: 13px; color: #999; margin-bottom: 28px; }
.sge-btn {
padding: 0 32px;
height: 40px;
background: #1890ff;
color: #fff;
border-radius: 4px;
font-size: 14px;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.search-card {
background: #fff;
padding: var(--admin-card-padding);
border-radius: 4px;
margin-bottom: var(--admin-section-gap);
}
.search-row {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 24px;
}
.mt-12 { margin-top: 12px; }
.search-item {
display: flex;
flex-direction: row;
align-items: center;
.label { font-size: 14px; color: #606266; width: 80px; text-align: right; }
}
.mock-input {
width: 200px;
height: 32px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 0 12px;
font-size: 13px;
}
.mock-select {
width: 160px;
height: 32px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 0 12px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: #606266;
.arrow { font-size: 10px; color: #c0c4cc; }
}
.search-btns {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
}
.btn-primary { background: #1890ff; color: #fff; height: 32px; padding: 0 16px; border-radius: 4px; font-size: 13px; border: none; }
.btn-reset { background: #fff; color: #606266; height: 32px; padding: 0 16px; border-radius: 4px; font-size: 13px; border: 1px solid #dcdfe6; }
.expand-control { display: flex; flex-direction: row; align-items: center; gap: 4px; cursor: pointer; }
.expand-txt { font-size: 13px; color: #1890ff; }
.expand-arrow { font-size: 10px; color: #1890ff; }
.status-tabs-wrap {
margin-bottom: 20px;
}
.status-tabs {
display: flex;
flex-direction: row;
border-bottom: 2px solid #f0f2f5;
}
.tab-item {
padding: 12px 20px;
font-size: 14px;
color: #606266;
position: relative;
cursor: pointer;
&.active {
color: #1890ff;
&::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 2px;
background: #1890ff;
}
}
}
.action-bar {
margin-bottom: 16px;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.left-actions {
display: flex;
flex-direction: row;
gap: 12px;
align-items: center;
}
.btn-add { background: #1890ff; color: #fff; height: 32px; padding: 0 12px; border-radius: 4px; font-size: 13px; border: none; }
.btn-collect { background: #13ce66; color: #fff; height: 32px; padding: 0 12px; border-radius: 4px; font-size: 13px; border: none; }
.btn-export { background: #fff; color: #606266; height: 32px; padding: 0 12px; border-radius: 4px; font-size: 13px; border: 1px solid #dcdfe6; }
.btn-dropdown {
height: 32px;
padding: 0 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
font-size: 13px;
color: #606266;
.arrow { font-size: 10px; color: #c4cc; }
}
.list-card {
background: #fff;
border-radius: 4px;
padding-bottom: 20px;
position: relative;
overflow: visible;
}
.table-v5 {
width: 100%;
overflow: visible;
}
.th-row {
display: flex;
flex-direction: row;
background-color: #f2f2f2;
}
.th {
padding: 12px 8px;
font-size: 13px;
font-weight: bold;
color: #333;
display: flex;
align-items: center;
justify-content: center;
}
.tr-row {
display: flex;
flex-direction: row;
border-bottom: 1px solid #f0f0f0;
position: relative;
overflow: visible;
&:hover { background-color: #fafafa; }
}
.td {
padding: 12px 8px;
font-size: 13px;
color: #606266;
display: flex;
align-items: center;
justify-content: center;
}
.col-check { width: 50px; }
.col-id { width: 80px; }
.col-img { width: 80px; }
.col-name { flex: 1; justify-content: flex-start; text-align: left; }
.col-activity { width: 140px; }
.col-type { width: 100px; }
.col-price { width: 100px; }
.col-sales { width: 80px; }
.col-stock { width: 80px; }
.col-sort { width: 80px; }
.col-status { width: 100px; }
.col-op { width: 150px; }
.op-group {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.more-hover-box {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 40px;
padding: 0 10px;
cursor: pointer;
}
.more-trigger-txt {
display: flex;
flex-direction: row;
align-items: center;
pointer-events: none; /* 让事件透传给 more-hover-box */
}
.more-link-text {
font-size: 13px;
color: #1890ff;
}
.more-arrow-icon {
font-size: 10px;
color: #1890ff;
margin-left: 2px;
}
.dropdown-list-container {
position: absolute;
top: 35px;
right: -10px;
width: 120px;
padding-top: 8px; /* 增加感应连续性 */
z-index: 9999;
}
.dropdown-triangle {
position: absolute;
top: 2px;
right: 25px;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #fff;
}
.dropdown-menu-body {
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
padding: 6px 0;
}
.menu-item {
padding: 8px 16px;
font-size: 14px;
color: #606266;
text-align: center;
line-height: 1.5;
&:hover {
background-color: #f5f7fa;
color: #1890ff;
}
}
.danger-item {
&:hover {
color: #ff4d4f;
}
}
.p-img { width: 40px; height: 40px; border-radius: 4px; }
.p-name-txt { font-size: 13px; line-height: 1.4; color: #333; }
.activity-tags {
.op-divider { color: #e8e8e8; font-size: 12px; margin: 0 4px; }
}
.more-dropdown {
display: flex;
flex-direction: row;
align-items: center;
.arrow { font-size: 10px; color: #1890ff; margin-left: 2px; }
}
/* 分页区域已迁至 CommonPagination 组件 */
</style>