Merge remote-tracking branch 'origin/huangzhenbao-admin'
This commit is contained in:
728
pages/mall/admin/product/product-management/components/edit.uvue
Normal file
728
pages/mall/admin/product/product-management/components/edit.uvue
Normal file
@@ -0,0 +1,728 @@
|
||||
<template>
|
||||
<view class="product-edit-page">
|
||||
<view class="page-header">
|
||||
<view class="back-link" @click="goBack">
|
||||
<text class="arrow"><</text>
|
||||
<text class="back-txt">返回</text>
|
||||
</view>
|
||||
<text class="header-title">编辑商品</text>
|
||||
</view>
|
||||
|
||||
<!-- 步骤层 -->
|
||||
<view class="steps-card">
|
||||
<view class="step-items">
|
||||
<view v-for="(step, index) in steps" :key="index" class="step-item" :class="{ active: activeStep === index }">
|
||||
<text class="step-txt">{{ step }}</text>
|
||||
<view v-if="index < steps.length - 1" class="step-line"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 表单内容 -->
|
||||
<view class="form-card">
|
||||
<view class="form-item align-center">
|
||||
<view class="label"><text class="required">*</text><text>商品类型:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="radio-group">
|
||||
<view class="radio-item active">
|
||||
<text class="radio-circle on"></text>
|
||||
<view class="radio-txt">
|
||||
<text class="main">普通商品</text>
|
||||
<text class="sub">(物流发货)</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item align-center">
|
||||
<view class="label"><text class="required">*</text><text>商品名称:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="input-box">
|
||||
<input class="real-input" v-model="formData.name" placeholder="请输入商品名称" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item align-center">
|
||||
<view class="label"><text class="required">*</text><text>单位:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="input-box small">
|
||||
<input class="real-input" v-model="formData.attributes.unit" placeholder="例: 件" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item multi-line">
|
||||
<view class="label"><text class="required">*</text><text>商品轮播图:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="image-uploader">
|
||||
<view v-for="(img, i) in formData.image_urls" :key="i" class="img-item">
|
||||
<image :src="img" mode="aspectFill" />
|
||||
<view v-if="i === 0" class="main-tag">主图</view>
|
||||
<view class="img-close" @click="removeImage(i)">×</view>
|
||||
</view>
|
||||
<view class="upload-btn" @click="uploadImage" v-if="formData.image_urls.length < 10">
|
||||
<text class="icon">+</text>
|
||||
<text class="upload-txt">添加图片</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="tip">建议尺寸:800*800,默认首张图为主图,最多上传10张</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item multi-line">
|
||||
<view class="label"><text>添加视频:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="image-uploader">
|
||||
<view v-for="(vid, i) in formData.video_urls" :key="i" class="img-item video-item">
|
||||
<video :src="vid" style="width: 100%; height: 100%; border-radius: 4px;" :controls="false"></video>
|
||||
<view class="img-close" @click="removeVideo(i)">×</view>
|
||||
</view>
|
||||
<view class="upload-btn v-btn" @click="uploadVideo" v-if="formData.video_urls.length < 1">
|
||||
<text class="v-icon">📹</text>
|
||||
<text class="upload-txt">上传视频</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="tip">建议时长:9~30秒,视频宽高比16:9</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item align-center">
|
||||
<view class="label"><text class="required">*</text><text>商品分类:</text></view>
|
||||
<view class="input-wrap">
|
||||
<picker :range="categories" @change="onCategoryChange">
|
||||
<view class="mock-btn-select">
|
||||
<text>{{ categoryName || '请选择分类' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item align-center">
|
||||
<view class="label"><text class="required">*</text><text>商品售价:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="input-box small">
|
||||
<input class="real-input" type="number" v-model="formData.base_price" placeholder="请输入售价" />
|
||||
<text class="unit">元</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item align-center">
|
||||
<view class="label"><text class="required">*</text><text>商品库存:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="input-box small">
|
||||
<input class="real-input" type="number" v-model="formData.available_stock" placeholder="请输入库存" />
|
||||
<text class="unit">件</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item align-center">
|
||||
<view class="label"><text>商品标签:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="tag-selector">
|
||||
<view v-for="(tg, index) in formData.tags" :key="index" class="tag-item">
|
||||
<text>{{ tg }}</text>
|
||||
<text class="close" @click="removeTag(index)">×</text>
|
||||
</view>
|
||||
<text class="add-link" @click="addTag">+添加标签</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item align-center">
|
||||
<view class="label"><text>商品状态:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="radio-group-simple">
|
||||
<view class="radio-simple" :class="{ on: formData.status === 1 }" @click="formData.status = 1">
|
||||
<text class="dot"></text>
|
||||
<text>上架</text>
|
||||
</view>
|
||||
<view class="radio-simple" :class="{ on: formData.status === 2 }" @click="formData.status = 2">
|
||||
<text class="dot"></text>
|
||||
<text>下架</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer-btns">
|
||||
<button class="btn-save" @click="saveDraftProduct">草稿箱</button>
|
||||
<button class="btn-next" @click="saveAndNext">下一步</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
|
||||
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance'
|
||||
import { SUPA_URL } from '@/ak/config.uts'
|
||||
|
||||
const activeStep = ref(0)
|
||||
const steps = ['基础信息', '规格库存', '商品详情', '物流设置', '会员价/佣金', '营销设置', '其他设置']
|
||||
|
||||
interface CategoryOption {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
const categoryOptions = ref<CategoryOption[]>([])
|
||||
const categories = ref<string[]>([])
|
||||
const categoryName = ref('')
|
||||
|
||||
const formData = ref({
|
||||
id: '',
|
||||
merchant_id: '',
|
||||
category_id: '',
|
||||
name: '',
|
||||
base_price: 0,
|
||||
available_stock: 0,
|
||||
total_stock: 0,
|
||||
main_image_url: '',
|
||||
image_urls: [] as string[],
|
||||
video_urls: [] as string[],
|
||||
status: 1, // 1上架 2下架 3草稿 4删除
|
||||
tags: [] as string[],
|
||||
attributes: { unit: '件' } as Record<string, any>,
|
||||
published_at: null as string | null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await ensureSupabaseReady()
|
||||
const mId = supa.getSession().user?.getString('id') ?? null
|
||||
if (!mId) {
|
||||
uni.showToast({ title: '商家未获取到信息,请重新登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
formData.value.merchant_id = mId
|
||||
|
||||
// 加载真实分类
|
||||
await loadCategoryOptions()
|
||||
|
||||
const editId = uni.getStorageSync('edit_product_id') as string | null
|
||||
if (editId) {
|
||||
uni.removeStorageSync('edit_product_id')
|
||||
await fetchProductDetail(editId, mId)
|
||||
}
|
||||
})
|
||||
|
||||
async function loadCategoryOptions() {
|
||||
try {
|
||||
const res = await supa.from('ml_categories')
|
||||
.select('id, name')
|
||||
.eq('is_active', true)
|
||||
.order('sort_order', { ascending: true })
|
||||
.execute()
|
||||
|
||||
if (res.data != null) {
|
||||
const data = res.data as Array<UTSJSONObject>
|
||||
categoryOptions.value = data.map((item: UTSJSONObject): CategoryOption => ({
|
||||
id: item.get('id') as string,
|
||||
name: item.get('name') as string
|
||||
}))
|
||||
categories.value = categoryOptions.value.map((item: CategoryOption): string => item.name)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载分类失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProductDetail(id: string, mId: string) {
|
||||
try {
|
||||
const res = await supa
|
||||
.from('ml_products')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('merchant_id', mId)
|
||||
.single()
|
||||
.execute()
|
||||
|
||||
if (res.error != null) {
|
||||
console.error('[ProductEdit] 查询失败:', res.error)
|
||||
uni.showToast({ title: '获取商品信息失败,或者无权限编辑', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
// .single() 返回时 data 可能是 UTSJSONObject 或包含一条记录的数组,兼容处理
|
||||
let row : UTSJSONObject | null = null
|
||||
if (res.data != null) {
|
||||
const raw = res.data
|
||||
if (Array.isArray(raw)) {
|
||||
const arr = raw as Array<UTSJSONObject>
|
||||
if (arr.length > 0) row = arr[0] as UTSJSONObject
|
||||
} else {
|
||||
row = raw as UTSJSONObject
|
||||
}
|
||||
}
|
||||
|
||||
if (row == null) {
|
||||
uni.showToast({ title: '未找到该商品', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
formData.value.id = row.getString('id') ?? ''
|
||||
formData.value.name = row.getString('name') ?? ''
|
||||
formData.value.base_price = row.getNumber('base_price') ?? 0
|
||||
formData.value.available_stock = row.getNumber('available_stock') ?? 0
|
||||
formData.value.total_stock = row.getNumber('total_stock') ?? row.getNumber('available_stock') ?? 0
|
||||
formData.value.status = row.getNumber('status') ?? 1
|
||||
formData.value.main_image_url = row.getString('main_image_url') ?? ''
|
||||
formData.value.category_id = row.getString('category_id') ?? ''
|
||||
|
||||
// image_urls / video_urls / tags 是 JSONB 数组,用 .get() 取原始值再强转
|
||||
const imgRaw = row.get('image_urls')
|
||||
if (imgRaw != null && Array.isArray(imgRaw)) {
|
||||
formData.value.image_urls = imgRaw as string[]
|
||||
}
|
||||
const vidRaw = row.get('video_urls')
|
||||
if (vidRaw != null && Array.isArray(vidRaw)) {
|
||||
formData.value.video_urls = vidRaw as string[]
|
||||
}
|
||||
const tagsRaw = row.get('tags')
|
||||
if (tagsRaw != null && Array.isArray(tagsRaw)) {
|
||||
formData.value.tags = tagsRaw as string[]
|
||||
}
|
||||
|
||||
// attributes JSONB 是对象,用 getJSON 取
|
||||
const attrsRaw = row.getJSON('attributes')
|
||||
if (attrsRaw != null) {
|
||||
formData.value.attributes.unit = attrsRaw.getString('unit') ?? '件'
|
||||
}
|
||||
|
||||
// 同步分类名称
|
||||
if (formData.value.category_id) {
|
||||
const cat = categoryOptions.value.find((c: CategoryOption): boolean => c.id === formData.value.category_id)
|
||||
categoryName.value = cat ? cat.name : ''
|
||||
}
|
||||
|
||||
console.log('[ProductEdit] 加载成功,id=', formData.value.id, 'name=', formData.value.name)
|
||||
} catch (e : any) {
|
||||
console.error('[ProductEdit] 获取详情异常:', e)
|
||||
uni.showToast({ title: '获取商品信息失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function onCategoryChange(e: any) {
|
||||
const v = e.detail.value as number
|
||||
const selected = categoryOptions.value[v]
|
||||
categoryName.value = selected.name
|
||||
formData.value.category_id = selected.id
|
||||
}
|
||||
|
||||
function addTag() {
|
||||
uni.showModal({
|
||||
title: '添加标签',
|
||||
editable: true,
|
||||
success: (res) => {
|
||||
if (res.confirm && res.content) {
|
||||
formData.value.tags.push(res.content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function removeTag(index: number) {
|
||||
formData.value.tags.splice(index, 1)
|
||||
}
|
||||
|
||||
function uploadImage() {
|
||||
const maxCount = 10 - formData.value.image_urls.length
|
||||
if (maxCount <= 0) return
|
||||
uni.chooseImage({
|
||||
count: maxCount,
|
||||
success: (res) => {
|
||||
const paths = res.tempFilePaths as string[]
|
||||
formData.value.image_urls.push(...paths)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
formData.value.image_urls.splice(index, 1)
|
||||
}
|
||||
|
||||
function uploadVideo() {
|
||||
uni.chooseVideo({
|
||||
sourceType: ['camera', 'album'],
|
||||
success: (res) => {
|
||||
formData.value.video_urls.push(res.tempFilePath as string)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function removeVideo(index: number) {
|
||||
formData.value.video_urls.splice(index, 1)
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
openRoute('product_productList')
|
||||
}
|
||||
|
||||
// 通用上传方法
|
||||
async function uploadToSupabase(filePath: string): Promise<string> {
|
||||
if (filePath.startsWith('http') && !filePath.startsWith('blob:')) {
|
||||
return filePath
|
||||
}
|
||||
const extMatch = filePath.match(/\.(\w+)$/)
|
||||
const ext = extMatch ? extMatch[1] : 'png'
|
||||
const uuid = Math.random().toString(36).substring(2, 10)
|
||||
const remotePath = `products/${formData.value.merchant_id}_${Date.now()}_${uuid}.${ext}`
|
||||
|
||||
uni.showLoading({ title: '上传中...' })
|
||||
try {
|
||||
const res = await supa.storage.from('zhipao').upload(remotePath, filePath, {})
|
||||
if (res.error != null) {
|
||||
throw res.error
|
||||
}
|
||||
|
||||
return `${SUPA_URL}/storage/v1/object/public/zhipao/${remotePath}`
|
||||
|
||||
} catch (e: any) {
|
||||
console.error('上传文件失败:', e)
|
||||
throw new Error(e.message || '上传异常')
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAllCurrentFiles() {
|
||||
const newImages = [] as string[]
|
||||
for (const p of formData.value.image_urls) {
|
||||
const u = await uploadToSupabase(p)
|
||||
if(u) newImages.push(u)
|
||||
}
|
||||
formData.value.image_urls = newImages
|
||||
|
||||
const newVideos = [] as string[]
|
||||
for (const p of formData.value.video_urls) {
|
||||
const u = await uploadToSupabase(p)
|
||||
if(u) newVideos.push(u)
|
||||
}
|
||||
formData.value.video_urls = newVideos
|
||||
}
|
||||
|
||||
async function doSaveProduct(targetStatus: number) {
|
||||
if (!formData.value.name) return uni.showToast({ title: '请输入商品名称', icon: 'none' })
|
||||
if (!formData.value.category_id) return uni.showToast({ title: '请选择商品分类', icon: 'none' })
|
||||
if (formData.value.image_urls.length === 0) return uni.showToast({ title: '请至少上传一张商品图', icon: 'none' })
|
||||
|
||||
// 必须获取正确的 merchant_id,并且存在
|
||||
if (!formData.value.merchant_id || formData.value.merchant_id.startsWith('merchant_123')) {
|
||||
const mId = supa.getSession().user?.getString('id') ?? null
|
||||
if (!mId) {
|
||||
uni.showToast({ title: '未获取到商家信息,请重新登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
formData.value.merchant_id = mId
|
||||
}
|
||||
|
||||
uni.showLoading({ title: '保存中...' })
|
||||
|
||||
try {
|
||||
// 1. 上传文件拿到正式 URL
|
||||
await uploadAllCurrentFiles()
|
||||
|
||||
// 2. 组装 Payload
|
||||
// 根据 CRMEB: 1:上架 2:下架 3:草稿 4:删除
|
||||
formData.value.status = targetStatus
|
||||
if (targetStatus === 1) {
|
||||
formData.value.published_at = new Date().toISOString()
|
||||
} else if (targetStatus === 3) {
|
||||
formData.value.published_at = null
|
||||
}
|
||||
|
||||
const payload : UTSJSONObject = {
|
||||
merchant_id: formData.value.merchant_id,
|
||||
name: formData.value.name,
|
||||
category_id: formData.value.category_id,
|
||||
base_price: Number(formData.value.base_price),
|
||||
main_image_url: formData.value.image_urls.length > 0 ? formData.value.image_urls[0] : '', // 首图同步
|
||||
image_urls: formData.value.image_urls,
|
||||
video_urls: formData.value.video_urls,
|
||||
available_stock: Number(formData.value.available_stock),
|
||||
total_stock: Number(formData.value.total_stock) || Number(formData.value.available_stock),
|
||||
status: formData.value.status,
|
||||
tags: formData.value.tags,
|
||||
attributes: formData.value.attributes,
|
||||
published_at: formData.value.published_at
|
||||
}
|
||||
// 新建商品时自动生成唯一的 product_code
|
||||
if (!formData.value.id) {
|
||||
const uid = Math.random().toString(36).substring(2, 10).toUpperCase()
|
||||
payload['product_code'] = `P${Date.now()}${uid}`
|
||||
}
|
||||
|
||||
let result;
|
||||
if (formData.value.id) {
|
||||
// 必须加上 merchant_id 检查,防止越权串数据
|
||||
result = await supa.from('ml_products')
|
||||
.update(payload)
|
||||
.eq('id', formData.value.id)
|
||||
.eq('merchant_id', formData.value.merchant_id)
|
||||
.execute()
|
||||
} else {
|
||||
result = await supa.from('ml_products').insert([payload]).execute()
|
||||
}
|
||||
|
||||
uni.hideLoading()
|
||||
|
||||
if (result && result.error) {
|
||||
console.error('Save Product DB Error:', result.error)
|
||||
uni.showToast({ title: '保存失败: ' + result.error.message, icon: 'none' })
|
||||
} else {
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
uni.$emit('REFRESH_PRODUCT_LIST')
|
||||
setTimeout(() => goBack(), 1000)
|
||||
}
|
||||
} catch (error: any) {
|
||||
uni.hideLoading()
|
||||
console.error('保存报错:', error)
|
||||
uni.showToast({ title: '发生错误: ' + (error.message || ''), icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
// 草稿箱
|
||||
function saveDraftProduct() {
|
||||
doSaveProduct(3) // 3 表示草稿
|
||||
}
|
||||
|
||||
// 下一步/发布
|
||||
function saveAndNext() {
|
||||
// 这里如果原本要求点击下一步是发布上架,则传入 1
|
||||
doSaveProduct(1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.product-edit-page {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
.back-link {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
.arrow { font-size: 14px; }
|
||||
.back-txt { font-size: 14px; }
|
||||
}
|
||||
.header-title { font-size: 16px; font-weight: bold; color: #333; }
|
||||
}
|
||||
|
||||
.steps-card {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.step-items {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
.step-txt {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
padding: 0 16px;
|
||||
}
|
||||
&.active .step-txt {
|
||||
color: #1890ff;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid #1890ff;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.step-line {
|
||||
width: 20px;
|
||||
height: 1px;
|
||||
background: #e8e8e8;
|
||||
}
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: #fff;
|
||||
padding: 40px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-bottom: 30px;
|
||||
|
||||
&.align-center {
|
||||
align-items: center;
|
||||
}
|
||||
&.multi-line {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.label {
|
||||
width: 120px;
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-right: 20px;
|
||||
line-height: 20px;
|
||||
.required { color: #f5222d; margin-right: 4px; }
|
||||
}
|
||||
|
||||
&.multi-line .label {
|
||||
padding-top: 8px; /* 顶部对齐缓冲 */
|
||||
}
|
||||
|
||||
.input-wrap { flex: 1; }
|
||||
}
|
||||
|
||||
.radio-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid #1890ff;
|
||||
border-radius: 4px;
|
||||
padding: 10px 16px;
|
||||
width: 160px;
|
||||
position: relative;
|
||||
.radio-circle {
|
||||
width: 14px; height: 14px; border: 1px solid #d9d9d9; border-radius: 50%;
|
||||
&.on { border-color: #1890ff; background: #1890ff; }
|
||||
}
|
||||
.radio-txt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.main { font-size: 14px; color: #333; }
|
||||
.sub { font-size: 12px; color: #999; }
|
||||
}
|
||||
&::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
right: 0; bottom: 0;
|
||||
background: #1890ff; color: #fff; font-size: 10px; padding: 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-box {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
width: 400px;
|
||||
height: 36px;
|
||||
&.small { width: 150px; }
|
||||
.real-input { flex: 1; font-size: 14px; color: #333; }
|
||||
.count, .unit { font-size: 14px; color: #666; margin-left: 8px; }
|
||||
}
|
||||
|
||||
.image-uploader {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
.img-item {
|
||||
width: 100px; height: 100px; position: relative; border-radius: 4px; overflow: visible;
|
||||
border: 1px solid #e8e8e8;
|
||||
background-color: #f9f9f9;
|
||||
image { width: 100%; height: 100%; border-radius: 4px; }
|
||||
.img-close {
|
||||
position: absolute; right: -8px; top: -8px; width: 20px; height: 20px;
|
||||
background: rgba(0,0,0,0.5); color: #fff; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 12px;
|
||||
cursor: pointer; z-index: 2;
|
||||
}
|
||||
.main-tag {
|
||||
position: absolute; left: 0; bottom: 0; background: rgba(24,144,255,0.8);
|
||||
color: #fff; font-size: 12px; padding: 2px 6px; border-top-right-radius: 4px; border-bottom-left-radius: 4px;
|
||||
}
|
||||
}
|
||||
.video-item {
|
||||
width: 150px; height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
width: 100px; height: 100px; border: 1px dashed #d9d9d9; border-radius: 4px;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; cursor: pointer;
|
||||
background-color: #fafafa;
|
||||
&:hover { border-color: #1890ff; }
|
||||
.icon { font-size: 28px; color: #999; margin-bottom: 4px; }
|
||||
.upload-txt { font-size: 12px; color: #666; }
|
||||
&.v-btn {
|
||||
width: 150px; height: 100px;
|
||||
.v-icon { font-size: 24px; color: #999; margin-bottom: 4px; }
|
||||
}
|
||||
}
|
||||
|
||||
.tip { font-size: 12px; color: #999; }
|
||||
|
||||
.tag-selector {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
.tag-item {
|
||||
background: #f5f5f5; border: 1px solid #d9d9d9; padding: 4px 10px; border-radius: 4px;
|
||||
display: flex; flex-direction: row; align-items: center; gap: 6px;
|
||||
font-size: 14px; color: #666;
|
||||
.close { color: #999; cursor: pointer; font-size: 16px; margin-left: 2px; }
|
||||
}
|
||||
.add-link { font-size: 14px; color: #1890ff; cursor: pointer; padding: 4px 0; }
|
||||
}
|
||||
|
||||
.mock-btn-select {
|
||||
border: 1px solid #d9d9d9; border-radius: 4px; padding: 0 16px; height: 36px;
|
||||
font-size: 14px; color: #333; display: flex; align-items: center; cursor: pointer;
|
||||
}
|
||||
|
||||
.radio-group-simple {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
.radio-simple {
|
||||
display: flex; flex-direction: row; align-items: center; gap: 6px; font-size: 14px; color: #666; cursor: pointer;
|
||||
.dot { width: 14px; height: 14px; border: 1px solid #d9d9d9; border-radius: 50%; position: relative; }
|
||||
&.on {
|
||||
color: #1890ff;
|
||||
.dot { border-color: #1890ff; }
|
||||
.dot::after {
|
||||
content: ''; position: absolute; left: 3px; top: 3px; width: 6px; height: 6px;
|
||||
background: #1890ff; border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-btns {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding-bottom: 40px;
|
||||
.btn-next { background: #1890ff; color: #fff; border: none; padding: 0 24px; height: 40px; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
||||
.btn-save { background: #fff; color: #333; border: 1px solid #d9d9d9; padding: 0 24px; height: 40px; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
||||
.btn-save:hover { color: #1890ff; border-color: #1890ff; }
|
||||
}
|
||||
</style>
|
||||
@@ -180,7 +180,7 @@ function goBack() {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.member-price-page { padding: 20px; background: #f5f7f9; min-height: 100vh; }
|
||||
.member-price-page { padding: 0; background: transparent; min-height: auto; }
|
||||
.page-header { display: flex; flex-direction: row; align-items: center; gap: 16px; margin-bottom: 20px;
|
||||
.back-link { display: flex; flex-direction: row; align-items: center; gap: 4px; color: #666; cursor: pointer; }
|
||||
.header-title { font-size: 16px; font-weight: bold; color: #333; }
|
||||
@@ -1,11 +1,25 @@
|
||||
<template>
|
||||
<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" v-model="searchName" @confirm="handleSearch" />
|
||||
<input class="mock-input" placeholder="请输入商品名称/关键字/ID" />
|
||||
</view>
|
||||
<view class="search-item">
|
||||
<text class="label">商品类型:</text>
|
||||
@@ -16,19 +30,14 @@
|
||||
</view>
|
||||
<view class="search-item">
|
||||
<text class="label">商品分类:</text>
|
||||
<picker :value="categoryIndex" :range="categoryOptions" range-key="label" @change="e => {
|
||||
categoryIndex = e.detail.value;
|
||||
selectedCategoryId = categoryOptions[categoryIndex].value;
|
||||
}">
|
||||
<view class="mock-select">
|
||||
<text>{{ categoryOptions[categoryIndex].label }}</text>
|
||||
<text class="arrow">▼</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="mock-select">
|
||||
<text>请选择</text>
|
||||
<text class="arrow">▼</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="search-btns">
|
||||
<button class="btn-primary" @click="handleSearch">查询</button>
|
||||
<button class="btn-reset" @click="handleReset">重置</button>
|
||||
<button class="btn-primary">查询</button>
|
||||
<button class="btn-reset">重置</button>
|
||||
<view class="expand-control">
|
||||
<text class="expand-txt">展开</text>
|
||||
<text class="expand-arrow">▼</text>
|
||||
@@ -54,7 +63,7 @@
|
||||
:key="index"
|
||||
class="tab-item"
|
||||
:class="{ active: activeStatus === tab.key }"
|
||||
@click="changeStatus(tab.key)"
|
||||
@click="activeStatus = tab.key"
|
||||
>
|
||||
<text>{{ tab.label }}({{ tab.count }})</text>
|
||||
</view>
|
||||
@@ -120,10 +129,12 @@
|
||||
<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">
|
||||
<view class="mock-switch" :class="{ on: item.status === 1 }" @click="toggleStatus(item)">
|
||||
<text class="switch-txt">{{ item.status === 1 ? '上架' : '下架' }}</text>
|
||||
<view class="switch-dot"></view>
|
||||
</view>
|
||||
<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>
|
||||
@@ -142,7 +153,7 @@
|
||||
<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)">{{ activeStatus === 4 ? '恢复商品' : '移到回收站' }}</text>
|
||||
<text class="menu-item danger-item" @click.stop="moveToRecycle(item.id)">{{ activeStatus === 'recycle' ? '恢复商品' : '移到回收站' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -151,129 +162,313 @@
|
||||
</view>
|
||||
|
||||
<!-- 5. 分页 -->
|
||||
<view class="pagination-row">
|
||||
<text class="total">共 {{ total }} 条</text>
|
||||
<view class="page-ctrl">
|
||||
<text class="page-btn" :class="{ disabled: page <= 1 }" @click="page > 1 && (page--, loadData())">{"<"}</text>
|
||||
<text class="page-num active">{{ page }}</text>
|
||||
<text class="page-btn" :class="{ disabled: productList.length < pageSize }" @click="productList.length == pageSize && (page++, loadData())">{">"}</text>
|
||||
</view>
|
||||
</view>
|
||||
<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 } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
|
||||
import { fetchAdminProductPage, updateAdminProductStatus, fetchAdminProductCountStats, type AdminProduct } from '@/services/admin/productService.uts'
|
||||
import { fetchAdminCategoryList, type AdminCategory } from '@/services/admin/productCategoryService.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 page = ref(1)
|
||||
|
||||
// 分页状态适配层
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const activeStatus = ref<number>(1) // 1:出售中
|
||||
const activeDropdownId = ref<string | null>(null)
|
||||
const productList = ref<Array<AdminProduct>>([])
|
||||
const searchName = ref('')
|
||||
const selectedCategoryId = ref<string | null>(null)
|
||||
const categoryOptions = ref<Array<{label: string, value: string | null}>>([
|
||||
{ label: '全部', value: null }
|
||||
])
|
||||
const categoryIndex = ref(0)
|
||||
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: 1, label: '出售中的商品', count: 0 },
|
||||
{ key: 2, label: '仓库中的商品', count: 0 },
|
||||
{ key: 3, label: '草稿箱', count: 0 },
|
||||
{ key: 4, label: '回收站', count: 0 },
|
||||
{ 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 },
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
loadCounts()
|
||||
loadCategories()
|
||||
loadData()
|
||||
const productList = ref<any[]>([])
|
||||
|
||||
// 监听 activeStatus 变化
|
||||
watch(activeStatus, () => {
|
||||
fetchProducts()
|
||||
})
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const categories = await fetchAdminCategoryList({ isActive: true })
|
||||
categories.forEach(item => {
|
||||
categoryOptions.value.push({
|
||||
label: item.name,
|
||||
value: item.id
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('加载分类失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCounts() {
|
||||
const stats = await fetchAdminProductCountStats()
|
||||
if (stats != null) {
|
||||
statusTabs.value[0].count = parseInt(String(stats['selling'] ?? '0'))
|
||||
statusTabs.value[1].count = parseInt(String(stats['warehouse'] ?? '0'))
|
||||
statusTabs.value[2].count = parseInt(String(stats['draft'] ?? '0'))
|
||||
statusTabs.value[3].count = parseInt(String(stats['recycle'] ?? '0'))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const res = await fetchAdminProductPage(page.value, pageSize.value, {
|
||||
name: searchName.value,
|
||||
status: activeStatus.value,
|
||||
categoryId: selectedCategoryId.value ?? undefined
|
||||
// 商品模块店铺门禁
|
||||
onMounted(async () => {
|
||||
await checkShop()
|
||||
uni.$on('REFRESH_PRODUCT_LIST', () => {
|
||||
fetchProducts()
|
||||
})
|
||||
productList.value = res.items
|
||||
total.value = res.total
|
||||
})
|
||||
|
||||
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 handleSearch() {
|
||||
page.value = 1
|
||||
loadData()
|
||||
loadCounts()
|
||||
function goCreateShop() {
|
||||
openRoute('shop_manage')
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchName.value = ''
|
||||
selectedCategoryId.value = null
|
||||
page.value = 1
|
||||
loadData()
|
||||
loadCounts()
|
||||
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 changeStatus(key: number) {
|
||||
activeStatus.value = key
|
||||
page.value = 1
|
||||
loadData()
|
||||
function getActivityName(tag: string): string {
|
||||
if (tag === 'kj') return '砍价'
|
||||
if (tag === 'pt') return '拼团'
|
||||
if (tag === 'ms') return '秒杀'
|
||||
return tag
|
||||
}
|
||||
|
||||
function goEdit(id: string | null) {
|
||||
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: string) {
|
||||
function goReviews(id: number) {
|
||||
openRoute('product_productReply')
|
||||
}
|
||||
|
||||
function goMemberPrice(id: string) {
|
||||
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 {
|
||||
padding: 20px;
|
||||
background-color: #f5f7f9;
|
||||
min-height: 100vh;
|
||||
/* 使用 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: 24px;
|
||||
padding: var(--admin-card-padding);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: var(--admin-section-gap);
|
||||
}
|
||||
|
||||
.search-row {
|
||||
@@ -537,62 +732,8 @@ function goMemberPrice(id: string) {
|
||||
.p-name-txt { font-size: 13px; line-height: 1.4; color: #333; }
|
||||
|
||||
.activity-tags {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
.tag {
|
||||
padding: 2px 4px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
border-radius: 2px;
|
||||
&.kj { background: #1890ff; }
|
||||
&.pt { background: #52c41a; }
|
||||
&.ms { background: #f5222d; }
|
||||
.op-divider { color: #e8e8e8; font-size: 12px; margin: 0 4px; }
|
||||
}
|
||||
}
|
||||
|
||||
.mock-switch {
|
||||
width: 50px;
|
||||
height: 20px;
|
||||
background: #dbdbdb;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
&.on {
|
||||
background: #1890ff;
|
||||
.switch-dot { left: 32px; }
|
||||
.switch-txt { left: 6px; }
|
||||
}
|
||||
&:not(.on) {
|
||||
.switch-txt { right: 6px; }
|
||||
.switch-dot { left: 2px; }
|
||||
}
|
||||
.switch-txt {
|
||||
position: absolute;
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
}
|
||||
.switch-dot {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: left 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
.op-link {
|
||||
font-size: 13px;
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
&.danger { color: #ff4d4f; }
|
||||
}
|
||||
.op-divider { color: #e8e8e8; font-size: 12px; margin: 0 4px; }
|
||||
|
||||
.more-dropdown {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -600,32 +741,8 @@ function goMemberPrice(id: string) {
|
||||
.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; }
|
||||
}
|
||||
/* 分页区域已迁至 CommonPagination 组件 */
|
||||
|
||||
.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>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user