feat:商品管理

This commit is contained in:
weiwei committed 2025-09-11 12:38:54 +08:00
1 parent f716bd685f
commit a64cda2312
9 files changed
+411 -91

No files matched your search

+7
View File
@@ -165,6 +165,13 @@
"navigationBarTitleText": "商品管理",
"disableScroll": true
}
},
{
"path": "goods-form",
"style": {
"navigationBarTitleText": "商品信息管理",
"disableScroll": true
}
}
]
},
+104
View File
@@ -0,0 +1,104 @@
<template>
<crud-layout title="" mainIsScroll bg="/static/page/blue-bg.png">
<view class="h-30rpx" />
<tm-form @submit="onsubmit" v-model="form" :rules="rules" direction="vertical">
<view class="card p-20rpx mb-20rpx">
<view class="prefix_title mb-2">商品基本信息</view>
<tm-form-item name="title" label="商品标题" direction="horizontal">
<tm-input v-model="form.title" type="text" :showClear="true" placeholder="请输入商品标题"></tm-input>
</tm-form-item>
<tm-form-item name="unit" label="计量单位" direction="horizontal">
<tm-input v-model="form.unit" type="text" :showClear="true" placeholder="请输入计量单位"></tm-input>
</tm-form-item>
<tm-form-item name="status" label="状态" direction="horizontal">
<tm-radio-group v-model="form.status" align="flex-end">
<tm-radio :value="index.toString()" :label="item" v-for="(item, index) in ['上架', '下架']" :key="index"></tm-radio>
</tm-radio-group>
</tm-form-item>
<tm-form-item name="saleStartTime" label="开售时间" direction="horizontal">
<tm-picker-date v-model="form.saleStartTime" v-model:modelStr="_saleStartTimeStr">
<tm-input
:modelValue="_saleStartTimeStr"
placeholder="请选择开售时间"
style="pointer-events: none"
disabled
align="right"
color="transparent"
>
<template v-slot:rightIcon>
<tm-icon name="arrow-right-s-line"></tm-icon>
</template>
</tm-input>
</tm-picker-date>
</tm-form-item>
<tm-form-item name="saleEndTime" label="停售时间" direction="horizontal">
<tm-picker-date v-model="form.saleEndTime" v-model:modelStr="_saleEndTimeStr">
<tm-input
:modelValue="_saleEndTimeStr"
placeholder="请选择停售时间"
style="pointer-events: none"
disabled
align="right"
color="transparent"
>
<template v-slot:rightIcon>
<tm-icon name="arrow-right-s-line"></tm-icon>
</template>
</tm-input>
</tm-picker-date>
</tm-form-item>
<tm-form-item name="coverImage" label="商品图">
<tm-upload-photo
v-model="form.coverImage"
:maxFileSize="1024 * 1024 * 5"
:url="'/api/admin/sys-file/upload'"
:header="hooks.useDefaultHeader()"
:column="2"
img-height="300"
:max-count="1"
/>
</tm-form-item>
<view class="sub-text-20 c-red! mb-2">注:图片大小不超过5M</view>
<tm-form-item name="description" label="详情描述">
<tm-input v-model="form.description" type="textarea" :showClear="false" placeholder="请输入详情描述"></tm-input>
</tm-form-item>
</view>
</tm-form>
<view class="card p-x-20rpx p-y-30rpx m-b-20rpx">
<tm-picker
rang-key="title"
rang-text="title"
v-model="applyForm.reason"
v-model:model-str="applyForm.reasonStr"
:list="state.selectList"
>
<view class="flex-row items-center justify-between">
<view class="sub-text-28">申请原因</view>
<view class="flex-row items-center second-text-24">
<text>{{ applyForm.reasonStr || "请选择申请原因" }} </text>
<text class="m-l-10rpx color-#b1b1b1 iconfont icon-xiangxiajiantouxiao" />
</view>
</view>
</tm-picker>
</view>
<view class="h-30rpx" />
<template #main-bottom>
<view class="bg-#0094ff py-20rpx text-center rounded-40rpx m-30rpx sub-text-30 color-white!" @tap="doSubmit"> 保存 </view>
</template>
</crud-layout>
</template>
<script setup lang="ts">
import { reactive, ref } from "vue"
import { hooks } from "@@/index"
const form = reactive<any>({})
const _saleStartTimeStr = ref("")
const _saleEndTimeStr = ref("")
// 保存
const doSubmit = async () => {}
</script>
+191 -15
View File
@@ -1,30 +1,206 @@
<template>
<crud-layout bg="/static/page/blue-bg.png">
<template #top> </template>
<crud-layout mainIsScroll bg="/static/page/blue-bg.png">
<z-paging
ref="pageRef"
height="100%"
:fixed="false"
v-model="pageHook.list"
:default-page-size="10"
safe-area-inset-bottom
:loading-more-enabled="true"
:show-scrollbar="false"
@query="query"
:auto-clean-list-when-reload="false"
:auto-scroll-to-top-when-reload="false"
@scroll="pageHook.scroll"
>
<template #top>
<view class="w-full mt-2 flex items-center gap-2">
<tm-button size="m" @click="addGoods">新 增</tm-button>
<tm-button size="m">分 类</tm-button>
<tm-input
class="flex-1"
v-model="state.params.keyword"
type="text"
height="72"
minHeight="72"
fontSize="28"
:showClear="true"
:focus="false"
placeholder="请输入商品名称"
style="border: 4rpx solid #0779fd; border-radius: 14rpx"
:focus-highlight="false"
color="#ECF4F9"
@clear="inputClear"
>
<template v-slot:rightIcon>
<tm-button class="mr-1 px-3 py-26rpx text-30rpx!" size="xs" @click="search">搜 索</tm-button>
</template>
</tm-input>
</view>
<scroll-view scroll-x>
<view class="w-full flex my-2">
<view
class="w-300rpx mr-2 sub-text-28 py-1 px-2 rounded-1 bg-#fff border border-solid border-transparent whitespace-nowrap"
:class="{ 'bg-#0779FD22! c-#0779FD! border-#0779FD33!': state.params.categoryId === v.id }"
v-for="(v, i) in state.category"
:key="i"
@tap="doClassify(v.id)"
>
{{ v.name }}
</view>
</view>
</scroll-view>
</template>
<view v-for="v in pageHook.list" :key="v.id" class="card mx-20rpx mb-2 px-4 py-2">
<view class="flex">
<image
:src="filePath(v.coverImage)"
class="size-180rpx rounded-12rpx bg-#eeeeee"
mode="aspectFill"
@click.stop="hooks.usePreviewImage(filePath(v.coverImage))"
/>
<view class="flex-1 ml-2 overflow-hidden">
<view class="truncate sub-text-34">{{ v.title }}</view>
<view class="sub-text-26 mt-4rpx">
<text class="c-#0094ff! px-2 bg-#0094ff22 rounded-2 mr-2">
{{ state.category.find((item) => item.id == v.categoryId)?.name || "未知分类" }}
</text>
<text class="c-#0094ff! px-2 bg-#0094ff22 rounded-2">{{ v.status == "0" ? "在售" : "未售" }}</text>
</view>
<view class="mt-4rpx">
<text class="second-text-28 mr-2">单位 {{ v.unit }}</text>
<text class="second-text-28">库存 {{ v.totalStock }}</text>
</view>
<view>
<text class="second-text-32 line-through mr-1">
<text class="text-30rpx">¥</text>
<text class="font-bold">{{ v.originalPrice.toFixed(2) }}</text>
</text>
<text class="color-red">
<text class="text-30rpx">¥</text>
<text class="text-46rpx font-bold">{{ v.spuPrice.toFixed(2).split(".")[0] }}.</text>
<text class="text-38rpx font-bold">{{ v.spuPrice.toFixed(2).split(".")[1] }}</text>
</text>
<text class="view-clip px-1 text-20rpx c-white bg-red ml-1 rounded-r-1 rounded-lt-1">到手价</text>
</view>
</view>
</view>
<view class="my-2 h-1rpx bg-#A0A0A044"></view>
<view class="flex gap-2 justify-end">
<tm-button class="px-2" size="s" :linear="['to right', '#FFC328', '#E2000099']" @click.stop="deleteGoods(v.id)">
删除商品
</tm-button>
<tm-button class="px-2" size="s" :linear="['to right', '#62abff', '#0779FD99']" @click.stop="editGoods(v.id)">
编辑商品
</tm-button>
<tm-button class="px-2" size="s" :linear="['to right', '#00B960', '#00552C99']" @click.stop="doGoodsDetail(v.id)">
查看详情
</tm-button>
</view>
</view>
</z-paging>
</crud-layout>
</template>
<script setup lang="ts">
import { reactive } from "vue"
import { reactive, ref } from "vue"
import { prosperityShopApi } from "@/sheep/api"
import { hooks } from "@/sheep"
import { filePath } from "@/sheep/utils/file"
import { onLoad } from "@dcloudio/uni-app"
const state = reactive({
goodsList: [],
const state = reactive<any>({
params: {
keyword: "",
categoryId: "",
},
category: [],
curCategoryIndex: 0,
})
const pageRef = ref<any>(null)
const pageHook = hooks.usePage(pageRef, prosperityShopApi.getGoodsList, null, "data.records")
const query = (page: number, pagesize: number) => {
pageHook.startPage(page, pagesize, false, state.params)
}
onLoad(async () => {
await getGoodsList()
await getGoodsCategory()
})
const getGoodsList = async () => {
const params = {
current: 1,
size: 99999,
// categoryId: state.category[state.curCategoryIndex].id,
}
const { code, data, msg } = await prosperityShopApi.getGoodsList(params)
if (code != 0) return uni.showToast({ title: msg || "商品列表获取失败", icon: "none" })
state.goodsList = data?.records ?? []
const getGoodsCategory = async () => {
const { code, data, msg } = await prosperityShopApi.getGoodsCategory()
if (code != 0) return uni.showToast({ title: msg || "商品分类获取失败", icon: "none" })
state.category = data ?? []
state.category.unshift({ id: "", name: "全部分类" })
}
// 切换分类
const doClassify = async (categoryId: string) => {
if (state.params.categoryId == categoryId) return
state.params.categoryId = categoryId
pageRef.value && pageRef.value.reload(false)
}
// 点击搜索
const search = () => {
pageRef.value && pageRef.value.reload(false)
}
const inputClear = () => {
pageRef.value && pageRef.value.reload(false)
}
const doGoodsDetail = (id: string) => {
if (!id) return uni.showToast({ title: "商品id不存在", icon: "none" })
uni.navigateTo({
url: `/pages/prosperity-shop/goods-detail?id=${id}`,
})
}
// 新增商品
const addGoods = () => {
uni.navigateTo({ url: `/pages/prosperity-shop/goods-form` })
}
// 编辑商品
const editGoods = (id: string) => {
if (!id) return uni.showToast({ title: "商品id不存在", icon: "none" })
uni.navigateTo({ url: `/pages/prosperity-shop/goods-form?id=${id}` })
}
// 删除商品
const deleteGoods = (id: string) => {
if (!id) return uni.showToast({ title: "商品id不存在", icon: "none" })
uni.showModal({
title: "确定删除商品吗?",
content: "",
success: async (res) => {
if (res.confirm) {
const { code } = await prosperityShopApi.deleteGoods(id)
if (code != 0) return uni.showToast({ title: "删除失败", icon: "error" })
uni.showToast({ title: "删除成功", icon: "none" })
pageRef.value && pageRef.value.reload(false)
}
},
})
}
</script>
<style scoped lang="scss">
.view-clip {
position: relative;
&::before {
content: "";
position: absolute;
left: -18rpx;
bottom: 0rpx;
width: 20rpx;
height: 20rpx;
background: radial-gradient(circle at 0 0, transparent 80%, red 0);
}
}
</style>
+1 -1
View File
@@ -94,7 +94,7 @@ onLoad((options: any) => {
// 点击搜索
const search = () => {
pageRef.value && pageRef.value.reload(true)
pageRef.value && pageRef.value.reload(false)
}
const doGoodsDetail = (id: string) => {
+3 -3
View File
@@ -108,7 +108,7 @@ onLoad(async (option: any) => {
uni.showLoading({ title: "加载中..." })
state.id = option.id
await getShopDetail(option.id)
await getGoodsCategory(option.id)
await getGoodsCategoryTree(option.id)
await getGoodsList()
uni.hideLoading()
})
@@ -119,8 +119,8 @@ const getShopDetail = async (id: string) => {
state.detail = data
}
const getGoodsCategory = async (id: string) => {
const { code, data, msg } = await prosperityShopApi.getGoodsCategory({ merchantId: id })
const getGoodsCategoryTree = async (id: string) => {
const { code, data, msg } = await prosperityShopApi.getGoodsCategoryTree({ merchantId: id })
if (code != 0) return uni.showToast({ title: msg || "商品分类获取失败", icon: "none" })
state.category = treeToList(data) ?? []
}
+4 -4
View File
@@ -216,7 +216,7 @@ const getClassify = async () => {
state.classifyList.unshift({ id: "", name: "全部分类" })
if (!isEmpty(state.classifyList)) {
state.params.categoryId = state.classifyList[0].id
pageRef.value && pageRef.value.reload(true)
pageRef.value && pageRef.value.reload(false)
}
}
}
@@ -234,20 +234,20 @@ const confirmRadioMerchant = async () => {
state.params.merchantId = state.curMerchant.id
cartStore.saveShopMerchantId(state.curMerchant.id)
await cartStore.getShopCart()
pageRef.value && pageRef.value.reload(true)
pageRef.value && pageRef.value.reload(false)
}
// 点击搜索
const searchProduct = () => {
state.params.categoryId = ""
pageRef.value && pageRef.value.reload(true)
pageRef.value && pageRef.value.reload(false)
}
// 切换分类
const doClassify = async (categoryId: string) => {
if (state.params.categoryId == categoryId) return
state.params.categoryId = categoryId
pageRef.value && pageRef.value.reload(true)
pageRef.value && pageRef.value.reload(false)
}
const pageHook = hooks.usePage(pageRef, onlineShopApi.getSpuList, null, "data.records")
+2 -2
View File
@@ -136,13 +136,13 @@ onLoad(async () => {
// const doClassify = async (categoryId: string) => {
// if (state.params.categoryId == categoryId) return
// state.params.categoryId = categoryId
// pageRef.value && pageRef.value.reload(true)
// pageRef.value && pageRef.value.reload(false)
// }
// 点击搜索
const search = () => {
// state.params.categoryId = ""
pageRef.value && pageRef.value.reload(true)
pageRef.value && pageRef.value.reload(false)
}
// 联系商家
+17 -9
View File
@@ -9,13 +9,14 @@ export default {
params: { productLimit: 5 },
})
},
// 获取超市商户分类列表
// getShopCategory: (): Promise<any> => {
// return request({
// url: "/supermarket/app/mall/merchant/category/list",
// method: "GET",
// })
// },
// 获取商品分类列表
getGoodsCategory: (params?: any): Promise<any> => {
return request({
url: "/supermarket/app/mall/merchant/category/list",
method: "GET",
params,
})
},
// 获取超市详情
getShopDetail: (id: string): Promise<any> => {
return request({
@@ -23,8 +24,8 @@ export default {
method: "GET",
})
},
// 获取超市商品分类列表
getGoodsCategory: (params: { merchantId: string }): Promise<any> => {
// 获取超市商品分类列表树
getGoodsCategoryTree: (params: { merchantId: string }): Promise<any> => {
return request({
url: `/supermarket/app/mall/category/tree`,
method: "GET",
@@ -46,4 +47,11 @@ export default {
method: "GET",
})
},
// 删除商品
deleteGoods: (id: string): Promise<any> => {
return request({
url: `/supermarket/app/mall/merchant/product/${id}`,
method: "DELETE",
})
},
}
@@ -1,54 +1,72 @@
<script lang="ts" setup>
import { ref, computed, onMounted, onBeforeUnmount, watch, type PropType, getCurrentInstance, type ComponentInstance, onUpdated, nextTick, VueElement, inject } from 'vue';
import { arrayNumberValid, arrayNumberValidByStyleMP, covetUniNumber, arrayNumberValidByStyleBorderColor, linearValid, getUnit, getUid } from "../../libs/tool";
import { getDefaultColor, getDefaultColorObj, getOutlineColorObj, getTextColorObj, getThinColorObj } from "../../libs/colors";
import { useTmConfig } from "../../libs/config";
import pickerItem from './picker-item.vue';
import {
ref,
computed,
onMounted,
onBeforeUnmount,
watch,
type PropType,
getCurrentInstance,
type ComponentInstance,
onUpdated,
nextTick,
VueElement,
inject,
} from "vue"
import {
arrayNumberValid,
arrayNumberValidByStyleMP,
covetUniNumber,
arrayNumberValidByStyleBorderColor,
linearValid,
getUnit,
getUid,
} from "../../libs/tool"
import { getDefaultColor, getDefaultColorObj, getOutlineColorObj, getTextColorObj, getThinColorObj } from "../../libs/colors"
import { useTmConfig } from "../../libs/config"
import pickerItem from "./picker-item.vue"
const { config } = useTmConfig()
type TM_PICKER_X_ITEM = Record<string, any>
const boxHeight = ref(0)
const id = ('tmPickerItem-' + getUid())
const id = "tmPickerItem-" + getUid()
const nowCurrentIndex = ref([0])
let tid = 0
let tid2 = 1
const emits = defineEmits(['changeDeep'])
const emits = defineEmits(["changeDeep"])
const props = defineProps({
list: {
type: Array as PropType<TM_PICKER_X_ITEM[][]>,
default: (): TM_PICKER_X_ITEM[] => [] as TM_PICKER_X_ITEM[]
default: (): TM_PICKER_X_ITEM[] => [] as TM_PICKER_X_ITEM[],
},
selectedIndex: {
type: Array as PropType<number[]>,
default: (): number[] => [] as number[]
default: (): number[] => [] as number[],
},
cellUnits: {
type: Array as PropType<string[]>,
default: (): string[] => [] as string[]
default: (): string[] => [] as string[],
},
fontSize: {
type: String,
default: "30"
default: "30",
},
/**
* 自定义标识id
*/
rangKey: {
type: String,
default: "id"
default: "id",
},
/**
* 自定义标识文本字段名
*/
rangText: {
type: String,
default: "title"
default: "title",
},
})
const _fontSize = computed((): string => {
@@ -62,34 +80,28 @@ const _fontSize = computed((): string => {
return (sizeNumber * config.fontSizeScale).toString() + getUnit(props.fontSize)
})
const refreshKey = ref(12)
const _cellUnits = computed((): string[] => props.cellUnits as string[])
const _list = computed((): TM_PICKER_X_ITEM[][] => props.list.slice(0))
const _isDark = computed((): boolean => config.mode == 'dark')
const _isDark = computed((): boolean => config.mode == "dark")
const _maskStyle = computed((): string => {
if (_isDark.value) {
return 'background-image:linear-gradient(180deg,rgba(0, 0, 0, 0),rgba(0, 0, 0, 0)),linear-gradient(0deg, rgba(0, 0, 0, 0),rgba(0, 0, 0, 0))'
return "background-image:linear-gradient(180deg,rgba(0, 0, 0, 0),rgba(0, 0, 0, 0)),linear-gradient(0deg, rgba(0, 0, 0, 0),rgba(0, 0, 0, 0))"
}
return 'background-image:linear-gradient(180deg,rgba(255,255,255,0),rgba(255,255,255,0)),linear-gradient(0deg, rgba(255,255,255,0),rgba(255,255,255,0))'
return "background-image:linear-gradient(180deg,rgba(255,255,255,0),rgba(255,255,255,0)),linear-gradient(0deg, rgba(255,255,255,0),rgba(255,255,255,0))"
})
const _selectedMaskStyle = computed((): string => {
return _isDark.value ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'
return _isDark.value ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.05)"
})
const onChange = (event: any) => {
if (event.detail.value.length == 0 || _list.value.length == 0) return;
let indexs = event.detail.value!;
if (indexs.join('') == nowCurrentIndex.value.join('')) return;
if (event.detail.value.length == 0 || _list.value.length == 0) return
let indexs = event.detail.value!
if (indexs.join("") == nowCurrentIndex.value.join("")) return
nowCurrentIndex.value = indexs
emits("changeDeep", indexs)
}
@@ -102,18 +114,21 @@ onBeforeUnmount(() => {
clearTimeout(tid2)
})
watch(() => props.selectedIndex, (newvalue: number[]) => {
if (newvalue.join('') == nowCurrentIndex.value.join('')) return;
nowCurrentIndex.value = newvalue.slice(0)
}, { deep: true })
watch(_list, (newvalue) => {
refreshKey.value += 1;
}, { deep: true })
watch(
() => props.selectedIndex,
(newvalue: number[]) => {
if (newvalue.join("") == nowCurrentIndex.value.join("")) return
nowCurrentIndex.value = newvalue.slice(0)
},
{ deep: true },
)
watch(
_list,
(newvalue) => {
refreshKey.value += 1
},
{ deep: true },
)
</script>
<script lang="ts">
export default {
@@ -123,29 +138,41 @@ export default {
addGlobalClass: true,
multipleSlots: true,
},
};
}
</script>
<template>
<view>
<view class="tmPickerViewUnit" v-if="_cellUnits.length > 0">
<view v-for="(item, index) in _cellUnits" :key="index" style="flex: 1;text-align: center;">
<view v-for="(item, index) in _cellUnits" :key="index" style="flex: 1; text-align: center">
<tm-text font-size="24" class="tmPickerViewUnitText">{{ item }}</tm-text>
</view>
</view>
<picker-view :key="refreshKey" :immediate-change="true" :value="nowCurrentIndex" @change="onChange"
:style="{ height: '250px', width: '100%' }" :mask-style="`${_maskStyle}`"
:indicator-style="`height:50px;border:none;border-radius:10px`">
<picker-view
:key="refreshKey"
:immediate-change="true"
:value="nowCurrentIndex"
@change="onChange"
:style="{ height: '250px', width: '100%' }"
:mask-style="`${_maskStyle}`"
:indicator-style="`height:50px;border:none;border-radius:10px`"
>
<picker-view-column v-for="(item2, index2) in _list" :key="index2">
<view
:style="{ background: nowCurrentIndex[index2] == index ? _selectedMaskStyle : 'none', margin: '0 4px' }"
v-for="(item, index) in item2" :key="index" class="tmPickerViewWrapCoumn">
<text class="tmPickerViewWrapCoumnText" :style="{
fontSize: _fontSize,
lineHeight: 1,
fontWeight: nowCurrentIndex[index2] == index ? 'bold' : 'inherit',
opacity: item.disabled ? 0.4 : (nowCurrentIndex[index2] == index ? 1 : 0.6),
color: _isDark ? 'rgba(255,255,255,0.8)' : '#000000'
}">
v-for="(item, index) in item2"
:key="index"
class="tmPickerViewWrapCoumn"
>
<text
class="tmPickerViewWrapCoumnText"
:style="{
fontSize: _fontSize,
lineHeight: 1,
fontWeight: nowCurrentIndex[index2] == index ? 'bold' : 'inherit',
opacity: item.disabled ? 0.4 : nowCurrentIndex[index2] == index ? 1 : 0.6,
color: _isDark ? 'rgba(255,255,255,0.8)' : '#000000',
}"
>
{{ item[rangText] || "" }}
</text>
</view>
@@ -183,7 +210,6 @@ export default {
text-overflow: ellipsis;
overflow: hidden;
word-break: break-all;
}
.tmPickerView {
@@ -221,7 +247,6 @@ export default {
border-radius: 10px;
margin: 0 3px;
flex: 1;
}
.tmPickerContent {
@@ -239,4 +264,4 @@ export default {
border-radius: 10px;
/* background-color: #f5f5f5; */
}
</style>
</style>