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

593 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="product-list-page">
<!-- 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. 分页 -->
<view class="pagination-row">
<text class="total">共 {{ total }} 条</text>
<view class="page-ctrl">
<text class="page-btn disabled">{"<"}</text>
<text class="page-num active">1</text>
<text class="page-num">2</text>
<text class="page-btn">{">"}</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, onUnmounted } from 'vue'
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
import StatusSwitch from '@/components/StatusSwitch.uvue'
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance'
const total = ref(0)
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[]>([])
onMounted(() => {
fetchProducts()
uni.$on('REFRESH_PRODUCT_LIST', () => {
fetchProducts()
})
})
onUnmounted(() => {
uni.$off('REFRESH_PRODUCT_LIST')
})
// 从 ml_products 表中获取商品数据
async function fetchProducts() {
await ensureSupabaseReady()
// 从本地缓存获取 current merchant_id
const currentMerchantId = supa.getSession().user?.id as string | null
if (!currentMerchantId) {
uni.showToast({ title: '未获取到商家信息,请重新登录', icon: 'none' })
return
}
try {
const { data, error, count } = await supa
.from('ml_products')
.select('id, name, main_image_url, base_price, available_stock, status, created_at', { count: 'exact' })
.eq('merchant_id', currentMerchantId)
.order('created_at', { ascending: false })
if (error) {
console.error('Fetch products error:', error)
return
}
if (data) {
productList.value = data.map((item: any) => {
return {
id: item.id,
image: item.main_image_url || '',
name: item.name || '未命名商品',
activities: [],
typeName: '普通商品',
price: item.base_price !== null ? Number(item.base_price).toFixed(2) : '0.00',
sales: 0,
stock: item.available_stock || 0,
sort: 0,
status: item.status || 0
}
})
total.value = count || data.length
}
} catch (err) {
console.error('获取商品列表失败:', err)
uni.showToast({ title: '加载失败', 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;
}
.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; }
}
.pagination-row {
padding: 24px;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: 16px;
.total { font-size: 13px; color: #606266; }
}
.page-ctrl {
display: flex;
flex-direction: row;
gap: 8px;
.page-num, .page-btn {
width: 32px;
height: 32px;
border: 1px solid #dcdfe6;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: #606266;
&.active { background: #1890ff; color: #fff; border-color: #1890ff; }
&.disabled { color: #c0c4cc; background: #f5f7fa; }
}
}
</style>