feat(商品管理): 添加商品管理模块及相关功能

- 新增商品管理页面及API接口
- 扩展用户余额信息展示,增加已用借呗和总额显示
- 添加商品管理路由入口和权限控制
- 移除商品列表页未使用的滚动定位代码
- 新增通用类型定义文件
- 添加余额刷新功能

商品管理模块包含商品列表展示、添加、编辑和删除功能,同时完善了用户余额信息的展示,包括剩余借呗、已用借呗和总额。移除了商品列表页中未使用的滚动定位相关代码以简化逻辑。
This commit is contained in:
dzq 2025-06-02 10:24:50 +08:00
parent 5bf5fd6b0e
commit 8cfa252d9a
9 changed files with 465 additions and 27 deletions

View File

@ -0,0 +1,96 @@
import { request } from "@/http/axios"
import { PageDTO, ResponseData, BasePageQuery } from "../type"
export interface ShopGoodsDTO {
goodsId?: number
goodsName?: string
categoryId?: number
categoryName?: string
price?: number
stock?: number
status?: number
autoApproval?: number
coverImg?: string
creatorId?: number
creatorName?: string
createTime?: string
remark?: string
cabinetName?: string
cellNo?: number
cellNoStr?: string
totalStock?: number
usageInstruction?: string
}
export interface SearchShopGoodsQuery extends BasePageQuery {
goodsName?: string
categoryId?: number
status?: number
autoApproval?: number
minPrice?: number
maxPrice?: number
}
/** 获取商品列表 */
export function getGoodsList(query: SearchShopGoodsQuery) {
return request<ResponseData<PageDTO<ShopGoodsDTO>>>({
url: "manage/goods/list",
method: "get",
params: query
})
}
/** 新增商品 */
export function addGoods(data: {
goodsName: string
categoryId: number
price: number
stock: number
status: number
autoApproval: number
coverImg: string
goodsDetail?: string
usageInstruction?: string
}) {
return request<ResponseData<void>>({
url: "manage/goods",
method: "post",
data
})
}
/** 删除商品 */
export function deleteGoods(goodsIds: number[]) {
return request<ResponseData<void>>({
url: `manage/goods/${goodsIds.join(',')}`,
method: "delete"
})
}
/** 修改商品 */
export function updateGoods(goodsId: number, data: {
goodsName?: string
categoryId?: number
price?: number
stock?: number
status?: number
autoApproval?: number
coverImg?: string
goodsDetail?: string
usageInstruction?: string
}) {
return request<ResponseData<void>>({
url: `manage/goods/${goodsId}`,
method: "put",
data
})
}
/** 获取单个商品信息 */
export function getGoodsInfo(goodsId: number) {
return request<ResponseData<ShopGoodsDTO>>({
url: "manage/goods/getGoodsInfo",
method: "get",
params: { goodsId }
})
}

View File

@ -101,7 +101,12 @@ export interface GetOrdersByOpenIdDTO {
export interface GetBalanceResponse {
userid: string
corpid: string
/** 剩余借呗 */
balance: number
/** 已用借呗 */
useBalance: number
/** 借呗总额 */
balanceLimit: number
}
export interface QyLoginDTO {

24
src/common/apis/type.ts Normal file
View File

@ -0,0 +1,24 @@
export type ResponseData<T> = {
code: number;
msg: string;
data: T;
};
export type PageDTO<T> = {
total: number;
rows: Array<T>;
};
export interface BasePageQuery extends BaseQuery {
pageNum?: number;
pageSize?: number;
}
export interface BaseQuery {
beginTime?: string;
endTime?: string;
orderColumn?: string;
orderDirection?: string;
timeRangeColumn?: string;
}

View File

@ -0,0 +1,261 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { showConfirmDialog, showDialog, showSuccessToast } from 'vant';
import {
getGoodsList,
addGoods,
deleteGoods,
updateGoods,
type ShopGoodsDTO,
type SearchShopGoodsQuery
} from '@/common/apis/manage/goods';
//
const goodsList = ref<ShopGoodsDTO[]>([]);
const loading = ref(false);
const finished = ref(false);
//
const searchParams = reactive<SearchShopGoodsQuery>({
pageNum: 1,
pageSize: 10,
goodsName: '',
status: undefined
});
//
const showEditDialog = ref(false);
const currentGoods = ref<Partial<ShopGoodsDTO>>({});
const isEditMode = ref(false);
//
const fetchGoodsList = async () => {
try {
const res = await getGoodsList(searchParams);
goodsList.value = res.data.rows;
} catch (e) {
showDialog({ message: '加载失败,请重试' });
}
};
//
const onLoad = async () => {
try {
const res = await getGoodsList(searchParams);
goodsList.value.push(...res.data.rows);
loading.value = false;
if (goodsList.value.length >= res.data.total) {
finished.value = true;
} else {
if (searchParams.pageNum) {
searchParams.pageNum++;
} else {
searchParams.pageNum = 1;
}
}
} catch (error) {
console.error('获取商品列表失败', error);
finished.value = true;
}
};
//
const handleDelete = async (ids: number[]) => {
await showConfirmDialog({ message: '确认删除选中商品?' });
await deleteGoods(ids);
showSuccessToast('删除成功');
await fetchGoodsList();
};
//
const submitForm = async () => {
try {
if (isEditMode.value) {
await updateGoods(currentGoods.value.goodsId!, currentGoods.value);
} else {
await addGoods({
goodsName: currentGoods.value.goodsName || '',
categoryId: currentGoods.value.categoryId || 0,
price: currentGoods.value.price || 0,
stock: currentGoods.value.stock || 0,
status: currentGoods.value.status || 1,
autoApproval: currentGoods.value.autoApproval || 0,
coverImg: currentGoods.value.coverImg || '',
usageInstruction: currentGoods.value.usageInstruction
});
}
showSuccessToast('操作成功');
showEditDialog.value = false;
await fetchGoodsList();
} catch (e) {
showDialog({ message: '操作失败' });
}
};
//
const openEdit = (goods?: ShopGoodsDTO) => {
currentGoods.value = goods ? { ...goods } : { status: 1 };
isEditMode.value = !!goods;
showEditDialog.value = true;
};
onMounted(fetchGoodsList);
</script>
<template>
<div class="goods-manage">
<!-- 搜索栏和操作按钮 -->
<div class="search-action-bar">
<van-search v-model="searchParams.goodsName" placeholder="输入商品名称搜索" @search="fetchGoodsList" />
<van-button type="primary" @click="openEdit">添加商品</van-button>
</div>
<!-- 商品表格 -->
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
<div class="goods-grid">
<van-cell v-for="item in goodsList" :key="item.goodsId" class="goods-card">
<template #icon>
<van-image :src="item.coverImg" width="80" height="80" class="goods-image">
<div v-if="item.stock === 0" class="sold-out-overlay">
<span class="sold-out-text">已售罄</span>
</div>
<template #error>
<div class="custom-error">
图片加载失败
</div>
</template>
</van-image>
</template>
<div class="goods-info">
<div class="goods-name van-ellipsis">{{ item.goodsName }}</div>
<div class="goods-price">¥{{ item.price?.toFixed(2) }}</div>
<div class="action-row">
<span v-if="item.stock! > 0" class="stock-count">库存: {{ item.stock }}</span>
<div class="goods-actions">
<van-button size="mini" @click="openEdit(item)">编辑</van-button>
</div>
</div>
</div>
</van-cell>
</div>
</van-list>
<!-- 编辑弹窗 -->
<van-dialog v-model:show="showEditDialog" :title="isEditMode ? '编辑商品' : '新增商品'">
<van-form @submit="submitForm">
<van-cell-group inset>
<van-field v-model="currentGoods.goodsName" label="商品名称" placeholder="请输入"
:rules="[{ required: true }]" />
<van-field v-model="currentGoods.price" label="价格" type="number" placeholder="请输入" />
<van-field v-model="currentGoods.stock" label="库存" type="number" placeholder="请输入" />
</van-cell-group>
<div style="padding: 16px">
<van-button block type="primary" native-type="submit">提交</van-button>
</div>
</van-form>
</van-dialog>
</div>
</template>
<style scoped>
.goods-manage {
padding: 4px;
}
.search-action-bar {
margin: 0;
display: flex;
align-items: center;
gap: 4px;
}
.search-action-bar .van-search {
flex: 1;
}
.goods-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 4px;
margin: 4px 0;
}
.goods-card {
margin-bottom: 10px;
padding: min(2.667vw, 20px) 0;
}
.goods-image {
margin-right: 12px;
border-radius: 4px;
overflow: hidden;
}
.goods-info {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 80px;
position: relative;
}
.goods-name {
font-size: 14px;
color: #333;
line-height: 1.4;
text-align: left;
}
.goods-price {
font-size: 16px;
color: #e95d5d;
font-weight: bold;
text-align: left;
}
.action-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: auto;
}
.stock-count {
font-size: 11px;
color: #bbbbbb;
margin-right: 8px;
display: flex;
align-items: flex-end;
height: 100%;
line-height: 1;
}
.sold-out-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
}
.sold-out-text {
color: #999;
font-size: 14px;
transform: rotate(-15deg);
border: 1px solid #eee;
padding: 2px 8px;
border-radius: 4px;
}
.custom-error {
color: #999;
font-size: 12px;
text-align: center;
padding: 10px;
}
</style>

View File

@ -1,16 +1,17 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { useWxStore } from '@/pinia/stores/wx'
import { useAb98UserStore } from '@/pinia/stores/ab98-user'
import { storeToRefs } from 'pinia'
import { publicPath } from "@/common/utils/path"
import { showConfirmDialog } from 'vant';
const router = useRouter()
const wxStore = useWxStore()
const ab98UserStore = useAb98UserStore()
const router = useRouter();
const route = useRoute();
const wxStore = useWxStore();
const ab98UserStore = useAb98UserStore();
const { balance, name: qyName } = storeToRefs(wxStore);
const { balance, useBalance, balanceLimit, name: qyName } = storeToRefs(wxStore);
const { name: userName, sex: userSex, face_img } = storeToRefs(ab98UserStore);
const name = computed(() => {
@ -29,7 +30,9 @@ const handleLogout = () => {
}).catch(() => {
//
});
}
};
wxStore.refreshBalance();
</script>
<template>
@ -61,9 +64,17 @@ const handleLogout = () => {
<!-- 余额区域 -->
<div class="balance-card flex flex-wrap justify-between items-center p-4 bg-white rounded-lg shadow-sm un-mt-16px">
<van-icon name="gold-coin" size="28px" class="un-mr-8px un-color-#ffb300!" />
<div class="flex-1 ml-2">
<div class="text-sm text-gray-700">余额</div>
<van-icon name="gold-coin" size="28px" class="un-mr-8px un-color-#ffb300! mr-2" />
<div class="flex-1 ml-1">
<div class="text-sm text-gray-700">借呗总额</div>
<div class="text-lg font-bold text-primary">{{ balanceLimit }}</div>
</div>
<div class="flex-1 ml-1">
<div class="text-sm text-gray-700">未还借呗</div>
<div class="text-lg font-bold text-primary">{{ useBalance }}</div>
</div>
<div class="flex-1 ml-1">
<div class="text-sm text-gray-700">剩余借呗</div>
<div class="text-lg font-bold text-primary">{{ balance }}</div>
</div>
</div>
@ -73,19 +84,25 @@ const handleLogout = () => {
<van-col span="24">
<div class="section-title text-sm font-bold pb-2">个人中心</div>
</van-col>
<van-col span="8">
<van-col span="6">
<div class="custom-btn" @click="router.push('/order-list')">
<van-icon name="orders-o" size="20px" />
<span>订单列表</span>
</div>
</van-col>
<van-col span="8">
<van-col span="6">
<div v-if="wxStore.isCabinetAdmin" class="custom-btn" @click="router.push('/manage/goods')">
<van-icon name="comment-o" size="20px" />
<span>商品管理</span>
</div>
</van-col>
<van-col span="6">
<div v-if="wxStore.isCabinetAdmin" class="custom-btn" @click="router.push('/cabinet')">
<van-icon name="manager-o" size="20px" />
<span>柜机管理</span>
</div>
</van-col>
<van-col span="8">
<van-col span="6">
<div v-if="wxStore.isCabinetAdmin" class="custom-btn" @click="router.push('/approval/list')">
<van-icon name="comment-o" size="20px" />
<span>审批中心</span>

View File

@ -21,8 +21,6 @@ const { labels, categories } = storeToRefs(productStore)
//
const activeCategory = ref(0)
// DOM
const categoryRefs = ref<HTMLElement[]>([])
//
const scrollContainer = ref<HTMLElement>()
//
@ -47,14 +45,10 @@ const searchQuery = ref('')
//
function handleCategoryClick(index: number) {
activeCategory.value = index
categoryRefs.value[index].scrollIntoView({
behavior: "smooth",
block: "start"
})
}
//
const throttledUpdate = throttle(() => {
/* const throttledUpdate = throttle(() => {
if (!scrollContainer.value || !categoryRefs.value.length) return
//
@ -73,7 +67,7 @@ const throttledUpdate = throttle(() => {
}
}
// activeCategory.value = activeIndex
}, 100)
}, 100) */
//
const throttledScroll = throttle(() => {
@ -117,7 +111,7 @@ const currentProducts = computed(() => {
onMounted(() => {
productStore.getGoods();
scrollListener.push(scrollContainer.value?.addEventListener("scroll", throttledScroll))
scrollListener.push(scrollContainer.value?.addEventListener("scroll", throttledUpdate))
// scrollListener.push(scrollContainer.value?.addEventListener("scroll", throttledUpdate))
})
//
@ -159,7 +153,7 @@ watch(() => route.path, (newPath) => {
shape="round"
class="search-box"
/>
<div :ref="el => categoryRefs[0] = el as HTMLElement" class="category-section">
<div class="category-section">
<van-cell v-for="product in currentProducts" :key="product.id" class="product-item">
<template #icon>
<van-image :src="product.image" width="80" height="80" @click.stop="showProductDetail(product.id)"

View File

@ -1,5 +1,6 @@
import { pinia } from "@/pinia"
import { getOpenIdApi, getBalanceApi, qyLogin, getBalanceByQyUserid } from "@/common/apis/shop"
import { GetBalanceResponse } from "@/common/apis/shop/type"
export const useWxStore = defineStore("wx", () => {
@ -11,8 +12,12 @@ export const useWxStore = defineStore("wx", () => {
const openid = ref<string>("")
// 用户 userid
const userid = ref<string>("");
// 用户余额
// 剩余借呗
const balance = ref<number>(0);
// 已用借呗
const useBalance = ref<number>(0);
// 借呗总额
const balanceLimit = ref<number>(0);
// 企业id
const corpid = ref<string>("");
// 是否企业微信登录
@ -34,6 +39,17 @@ export const useWxStore = defineStore("wx", () => {
const setIsCabinetAdmin = (isAdmin: boolean) => {
isCabinetAdmin.value = isAdmin;
}
const refreshBalance = async () => {
if (corpid.value && userid.value) {
const res = await getBalanceByQyUserid(corpid.value, userid.value);
if (res && res.code == 0) {
balance.value = res.data.balance;
useBalance.value = res.data.useBalance;
balanceLimit.value = res.data.balanceLimit;
}
}
}
const handleWxCallback = async (params: { corpid?: string; code?: string; state?: string; }) => {
console.log('handleWxCallback:', params)
@ -65,7 +81,7 @@ export const useWxStore = defineStore("wx", () => {
if (openid.value) {
// 获取用户余额
let balanceRes = null;
let balanceRes: ApiResponseData<GetBalanceResponse> | null = null;
if(corpid.value) {
balanceRes = await getBalanceByQyUserid(corpid.value, userid.value);
@ -74,7 +90,9 @@ export const useWxStore = defineStore("wx", () => {
}
console.log('获取余额成功:', balanceRes)
if (balanceRes && balanceRes.code == 0) {
balance.value = balanceRes.data.balance
balance.value = balanceRes.data.balance;
useBalance.value = balanceRes.data.useBalance;
balanceLimit.value = balanceRes.data.balanceLimit;
if (!userid.value) {
userid.value = balanceRes.data.userid;
}
@ -90,8 +108,8 @@ export const useWxStore = defineStore("wx", () => {
}
}
return { code, state, openid, corpid, userid, balance, isCabinetAdmin, corpidLogin, name,
setOpenid, setBalance, handleWxCallback, setIsCabinetAdmin }
return { code, state, openid, corpid, userid, balance, useBalance, balanceLimit, isCabinetAdmin, corpidLogin, name,
setOpenid, setBalance, handleWxCallback, setIsCabinetAdmin, refreshBalance }
})
/**

View File

@ -25,6 +25,10 @@ export function registerNavigationGuard(router: Router) {
if (corpid) {
return true;
}
/* const isAdmin = urlParams.get('isAdmin') || undefined;
if (isAdmin) {
return true;
} */
// useAb98UserStore位置不能放在外面否则会导致路由守卫无法正常工作
const ab98UserStore = useAb98UserStore();

View File

@ -111,6 +111,25 @@ export const routes: RouteRecordRaw[] = [
}
}
},
/* {
path: '/manage/goods',
component: () => import('@/pages/manage/goods/goodsList.vue'),
name: "ManageGoods",
meta: {
title: '商品管理',
keepAlive: false,
layout: {
navBar: {
showNavBar: true,
showLeftArrow: true
},
tabbar: {
showTabbar: false,
icon: "home-o"
}
}
}
}, */
{
path: "/",
component: () => import("@/pages/product/ProductList.vue"),