diff --git a/src/api/mall/product/category.ts b/src/api/mall/product/category.ts index 7ae81285..8158fc0f 100644 --- a/src/api/mall/product/category.ts +++ b/src/api/mall/product/category.ts @@ -17,17 +17,17 @@ export interface CategoryVO { */ name: string /** - * 分类图片 + * 移动端分类图 */ picUrl: string + /** + * PC 端分类图 + */ + bigPicUrl?: string /** * 分类排序 */ - sort?: number - /** - * 分类描述 - */ - description?: string + sort: number /** * 开启状态 */ diff --git a/src/api/mall/product/management/spu.ts b/src/api/mall/product/management/spu.ts new file mode 100644 index 00000000..07d7103e --- /dev/null +++ b/src/api/mall/product/management/spu.ts @@ -0,0 +1,39 @@ +import request from '@/config/axios' +import type { SpuType } from './type/spuType' // TODO @puhui999: type 和 api 一起放,简单一点哈~ + +// TODO @puhui999:中英文之间有空格 + +// 获得spu列表 TODO @puhui999:这个是 getSpuPage 哈 +export const getSpuList = (params: PageParam) => { + return request.get({ url: '/product/spu/page', params }) +} + +// 获得spu列表tabsCount +export const getTabsCount = () => { + return request.get({ url: '/product/spu/tabsCount' }) +} + +// 创建商品spu +export const createSpu = (data: SpuType) => { + return request.post({ url: '/product/spu/create', data }) +} + +// 更新商品spu +export const updateSpu = (data: SpuType) => { + return request.put({ url: '/product/spu/update', data }) +} + +// 更新商品spu status +export const updateStatus = (data: { id: number; status: number }) => { + return request.put({ url: '/product/spu/updateStatus', data }) +} + +// 获得商品 spu +export const getSpu = (id: number) => { + return request.get({ url: `/product/spu/get-detail?id=${id}` }) +} + +// 删除商品Spu +export const deleteSpu = (id: number) => { + return request.delete({ url: `/product/spu/delete?id=${id}` }) +} diff --git a/src/api/mall/product/management/type/skuType.ts b/src/api/mall/product/management/type/skuType.ts new file mode 100644 index 00000000..42889dc4 --- /dev/null +++ b/src/api/mall/product/management/type/skuType.ts @@ -0,0 +1,79 @@ +export interface Property { + /** + * 属性编号 + * + * 关联 {@link ProductPropertyDO#getId()} + */ + propertyId?: number + /** + * 属性值编号 + * + * 关联 {@link ProductPropertyValueDO#getId()} + */ + valueId?: number + /** + * 属性值名称 + */ + valueName?: string +} + +export interface SkuType { + /** + * 商品 SKU 编号,自增 + */ + id?: number + /** + * SPU 编号 + */ + spuId?: number + /** + * 属性数组,JSON 格式 + */ + properties?: Property[] + /** + * 商品价格,单位:分 + */ + price?: number + /** + * 市场价,单位:分 + */ + marketPrice?: number + /** + * 成本价,单位:分 + */ + costPrice?: number + /** + * 商品条码 + */ + barCode?: string + /** + * 图片地址 + */ + picUrl?: string + /** + * 库存 + */ + stock?: number + /** + * 商品重量,单位:kg 千克 + */ + weight?: number + /** + * 商品体积,单位:m^3 平米 + */ + volume?: number + + /** + * 一级分销的佣金,单位:分 + */ + subCommissionFirstPrice?: number + /** + * 二级分销的佣金,单位:分 + */ + subCommissionSecondPrice?: number + + /** + * 商品销量 + */ + salesCount?: number +} diff --git a/src/api/mall/product/management/type/spuType.ts b/src/api/mall/product/management/type/spuType.ts new file mode 100644 index 00000000..11c3c888 --- /dev/null +++ b/src/api/mall/product/management/type/spuType.ts @@ -0,0 +1,25 @@ +import { SkuType } from './skuType' + +export interface SpuType { + id?: number + name?: string // 商品名称 + categoryId?: number | null // 商品分类 + keyword?: string // 关键字 + unit?: number | null // 单位 + picUrl?: string // 商品封面图 + sliderPicUrls?: string[] // 商品轮播图 + introduction?: string // 商品简介 + deliveryTemplateId?: number // 运费模版 + specType?: boolean // 商品规格 + subCommissionType?: boolean // 分销类型 + skus: SkuType[] // sku数组 + description?: string // 商品详情 + sort?: string // 商品排序 + giveIntegral?: number // 赠送积分 + virtualSalesCount?: number // 虚拟销量 + recommendHot?: boolean // 是否热卖 + recommendBenefit?: boolean // 是否优惠 + recommendBest?: boolean // 是否精品 + recommendNew?: boolean // 是否新品 + recommendGood?: boolean // 是否优品 +} diff --git a/src/api/mall/product/property.ts b/src/api/mall/product/property.ts index 01c79f9f..ac8bac59 100644 --- a/src/api/mall/product/property.ts +++ b/src/api/mall/product/property.ts @@ -71,8 +71,8 @@ export const getPropertyList = (params: any) => { } // 获得属性项列表 -export const getPropertyListAndValue = (params: any) => { - return request.get({ url: '/product/property/get-value-list', params }) +export const getPropertyListAndValue = (data: any) => { + return request.post({ url: '/product/property/get-value-list', data }) } // ------------------------ 属性值 ------------------- diff --git a/src/api/mp/account/index.ts b/src/api/mp/account/index.ts index d641ef3c..e973cda3 100644 --- a/src/api/mp/account/index.ts +++ b/src/api/mp/account/index.ts @@ -1,7 +1,7 @@ import request from '@/config/axios' export interface AccountVO { - id?: number + id: number name: string } diff --git a/src/components/Form/src/helper.ts b/src/components/Form/src/helper.ts index 9cab8ff1..cdfc8caa 100644 --- a/src/components/Form/src/helper.ts +++ b/src/components/Form/src/helper.ts @@ -1,6 +1,6 @@ import type { Slots } from 'vue' import { getSlot } from '@/utils/tsxHelper' -import { PlaceholderMoel } from './types' +import { PlaceholderModel } from './types' import { FormSchema } from '@/types/form' import { ColProps } from '@/types/components' @@ -10,7 +10,7 @@ import { ColProps } from '@/types/components' * @returns 返回提示信息对象 * @description 用于自动设置placeholder */ -export const setTextPlaceholder = (schema: FormSchema): PlaceholderMoel => { +export const setTextPlaceholder = (schema: FormSchema): PlaceholderModel => { const { t } = useI18n() const textMap = ['Input', 'Autocomplete', 'InputNumber', 'InputPassword'] const selectMap = ['Select', 'SelectV2', 'TimePicker', 'DatePicker', 'TimeSelect', 'TimeSelect'] @@ -108,8 +108,8 @@ export const setItemComponentSlots = ( /** * * @param schema Form表单结构化数组 - * @param formModel FormMoel - * @returns FormMoel + * @param formModel FormModel + * @returns FormModel * @description 生成对应的formModel */ export const initModel = (schema: FormSchema[], formModel: Recordable) => { diff --git a/src/components/Form/src/types.ts b/src/components/Form/src/types.ts index 92a49d85..dcd01e78 100644 --- a/src/components/Form/src/types.ts +++ b/src/components/Form/src/types.ts @@ -1,6 +1,6 @@ import { FormSchema } from '@/types/form' -export interface PlaceholderMoel { +export interface PlaceholderModel { placeholder?: string startPlaceholder?: string endPlaceholder?: string diff --git a/src/router/modules/remaining.ts b/src/router/modules/remaining.ts index 55e933ed..4f5a16bd 100644 --- a/src/router/modules/remaining.ts +++ b/src/router/modules/remaining.ts @@ -2,9 +2,9 @@ import { Layout } from '@/utils/routerHelper' const { t } = useI18n() /** -* redirect: noredirect 当设置 noredirect 的时候该路由在面包屑导航中不可被点击 -* name:'router-name' 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题 -* meta : { + * redirect: noredirect 当设置 noredirect 的时候该路由在面包屑导航中不可被点击 + * name:'router-name' 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题 + * meta : { hidden: true 当设置 true 的时候该路由不会再侧边栏出现 如404,login等页面(默认 false) alwaysShow: true 当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式, @@ -31,7 +31,7 @@ const { t } = useI18n() canTo: true 设置为true即使hidden为true,也依然可以进行路由跳转(默认 false) } -**/ + **/ const remainingRouter: AppRouteRecordRaw[] = [ { path: '/redirect', @@ -345,6 +345,29 @@ const remainingRouter: AppRouteRecordRaw[] = [ meta: { title: '商品属性值', icon: '', activeMenu: '/product/property' } } ] + }, + { + path: '/product', + component: Layout, + name: 'ProductManagementEdit', + meta: { + hidden: true + }, + children: [ + { + path: 'productManagementAdd', // TODO @puhui999:最好拆成 add 和 edit 两个路由;添加商品;修改商品 + component: () => import('@/views/mall/product/spu/addForm.vue'), + name: 'ProductManagementAdd', + meta: { + noCache: true, + hidden: true, + canTo: true, + icon: 'ep:edit', + title: '添加商品', + activeMenu: '/product/product-management' + } + } + ] } ] diff --git a/src/styles/index.scss b/src/styles/index.scss index 2781c12e..33d29123 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -10,6 +10,12 @@ width: 100% !important; } +// 解决表格内容超过表格总宽度后,横向滚动条前端顶不到表格边缘的问题 +.el-scrollbar__bar { + display: flex; + justify-content: flex-start; +} + /* nprogress 适配 element-plus 的主题色 */ #nprogress { & .bar { diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 5cda391f..e37b6abc 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -220,3 +220,21 @@ export const PayRefundStatusEnum = { name: '退款关闭' } } + +/** + * 商品SPU枚举类 + */ +export const ProductSpuStatusEnum = { + RECYCLE: { + status: -1, + name: '回收站' + }, + DISABLE: { + status: 0, + name: '下架' + }, + ENABLE: { + status: 1, + name: '上架' + } +} diff --git a/src/utils/dict.ts b/src/utils/dict.ts index 03e17e75..d11debc9 100644 --- a/src/utils/dict.ts +++ b/src/utils/dict.ts @@ -144,5 +144,9 @@ export enum DICT_TYPE { // ========== MP 模块 ========== MP_AUTO_REPLY_REQUEST_MATCH = 'mp_auto_reply_request_match', // 自动回复请求匹配类型 - MP_MESSAGE_TYPE = 'mp_message_type' // 消息类型 + MP_MESSAGE_TYPE = 'mp_message_type', // 消息类型 + + // ========== MALL 模块 ========== + PRODUCT_UNIT = 'product_unit', // 商品单位 + PRODUCT_SPU_STATUS = 'product_spu_status' //商品状态 } diff --git a/src/utils/object.ts b/src/utils/object.ts new file mode 100644 index 00000000..6612da74 --- /dev/null +++ b/src/utils/object.ts @@ -0,0 +1,18 @@ +// TODO @puhui999:这个方法,可以考虑放到 index.js +/** + * 将值复制到目标对象,且以目标对象属性为准,例:target: {a:1} source:{a:2,b:3} 结果为:{a:2} + * @param target 目标对象 + * @param source 源对象 + */ +export const copyValueToTarget = (target, source) => { + const newObj = Object.assign({}, target, source) + // 删除多余属性 + Object.keys(newObj).forEach((key) => { + // 如果不是target中的属性则删除 + if (Object.keys(target).indexOf(key) === -1) { + delete newObj[key] + } + }) + // 更新目标对象值 + Object.assign(target, newObj) +} diff --git a/src/views/infra/redis/index.vue b/src/views/infra/redis/index.vue index 8315a207..011f8e59 100644 --- a/src/views/infra/redis/index.vue +++ b/src/views/infra/redis/index.vue @@ -1,7 +1,6 @@ <template> <doc-alert title="Redis 缓存" url="https://doc.iocoder.cn/redis-cache/" /> <doc-alert title="本地缓存" url="https://doc.iocoder.cn/local-cache/" /> - <el-scrollbar height="calc(100vh - 88px - 40px - 50px)"> <el-row> <!-- 基本信息 --> @@ -51,127 +50,224 @@ <!-- 命令统计 --> <el-col :span="12" class="mt-3"> <el-card :gutter="12" shadow="hover"> - <div ref="commandStatsRef" class="h-88"></div> + <Echart :options="commandStatsRefChika" :height="420" /> </el-card> </el-col> <!-- 内存使用量统计 --> <el-col :span="12" class="mt-3"> <el-card class="ml-3" :gutter="12" shadow="hover"> - <div ref="usedmemory" class="h-88"></div> + <Echart :options="usedmemoryEchartChika" :height="420" /> </el-card> </el-col> </el-row> </el-scrollbar> </template> -<script setup lang="ts" name="InfraRedis"> -import * as echarts from 'echarts' +<script setup lang="ts"> +import echarts from '@/plugins/echarts' +import { GaugeChart } from 'echarts/charts' +import { ToolboxComponent } from 'echarts/components' import * as RedisApi from '@/api/infra/redis' import { RedisMonitorInfoVO } from '@/api/infra/redis/types' - const cache = ref<RedisMonitorInfoVO>() // 基本信息 const readRedisInfo = async () => { const data = await RedisApi.getCache() cache.value = data - loadEchartOptions(data.commandStats) } -// 图表 -const commandStatsRef = ref<HTMLElement>() -const usedmemory = ref<HTMLDivElement>() -const loadEchartOptions = (stats) => { - const commandStats = [] as any[] - const nameList = [] as string[] - stats.forEach((row) => { - commandStats.push({ - name: row.command, - value: row.calls - }) - nameList.push(row.command) - }) - - const commandStatsInstance = echarts.init(commandStatsRef.value!, 'macarons') - - commandStatsInstance.setOption({ - title: { - text: '命令统计', - left: 'center' - }, - tooltip: { - trigger: 'item', - formatter: '{a} <br/>{b} : {c} ({d}%)' - }, - legend: { - type: 'scroll', - orient: 'vertical', - right: 30, - top: 10, - bottom: 20, - data: nameList, - textStyle: { - color: '#a1a1a1' +// 内存使用情况 +const usedmemoryEchartChika = reactive({ + title: { + // 仪表盘标题。 + text: '内存使用情况', + left: 'center', + show: true, // 是否显示标题,默认 true。 + offsetCenter: [0, '20%'], //相对于仪表盘中心的偏移位置,数组第一项是水平方向的偏移,第二项是垂直方向的偏移。可以是绝对的数值,也可以是相对于仪表盘半径的百分比。 + color: 'yellow', // 文字的颜色,默认 #333。 + fontSize: 20 // 文字的字体大小,默认 15。 + }, + toolbox: { + show: false, + feature: { + restore: { show: true }, + saveAsImage: { show: true } + } + }, + series: [ + { + name: '峰值', + type: 'gauge', + min: 0, + max: 50, + splitNumber: 10, + //这是指针的颜色 + color: '#F5C74E', + radius: '85%', + center: ['50%', '50%'], + startAngle: 225, + endAngle: -45, + axisLine: { + // 坐标轴线 + lineStyle: { + // 属性lineStyle控制线条样式 + color: [ + [0.2, '#7FFF00'], + [0.8, '#00FFFF'], + [1, '#FF0000'] + ], + //width: 6 外框的大小(环的宽度) + width: 10 + } + }, + axisTick: { + // 坐标轴小标记 + //里面的线长是5(短线) + length: 5, // 属性length控制线长 + lineStyle: { + // 属性lineStyle控制线条样式 + color: '#76D9D7' + } + }, + splitLine: { + // 分隔线 + length: 20, // 属性length控制线长 + lineStyle: { + // 属性lineStyle(详见lineStyle)控制线条样式 + color: '#76D9D7' + } + }, + axisLabel: { + color: '#76D9D7', + distance: 15, + fontSize: 15 + }, + pointer: { + // 指针的大小 + width: 7, + show: true + }, + detail: { + textStyle: { + fontWeight: 'normal', + // 里面文字下的数值大小(50) + fontSize: 15, + color: '#FFFFFF' + }, + valueAnimation: true + }, + progress: { + show: true } - }, - series: [ - { - name: '命令', - type: 'pie', - radius: [20, 120], - center: ['40%', '60%'], - data: commandStats, - roseType: 'radius', + } + ] +}) + +// 指令使用情况 +const commandStatsRefChika = reactive({ + title: { + text: '命令统计', + left: 'center' + }, + tooltip: { + trigger: 'item', + formatter: '{a} <br/>{b} : {c} ({d}%)' + }, + legend: { + type: 'scroll', + orient: 'vertical', + right: 30, + top: 10, + bottom: 20, + data: [] as any[], + textStyle: { + color: '#a1a1a1' + } + }, + series: [ + { + name: '命令', + type: 'pie', + radius: [20, 120], + center: ['40%', '60%'], + data: [] as any[], + roseType: 'radius', + label: { + show: true + }, + emphasis: { label: { show: true }, - emphasis: { - label: { - show: true - }, - itemStyle: { - shadowBlur: 10, - shadowOffsetX: 0, - shadowColor: 'rgba(0, 0, 0, 0.5)' - } + itemStyle: { + shadowBlur: 10, + shadowOffsetX: 0, + shadowColor: 'rgba(0, 0, 0, 0.5)' } } - ] - }) + } + ] +}) - const usedMemoryInstance = echarts.init(usedmemory.value!, 'macarons') - usedMemoryInstance.setOption({ - title: { - text: '内存使用情况', - left: 'center' - }, - tooltip: { - formatter: '{b} <br/>{a} : ' + cache.value!.info.used_memory_human - }, - series: [ - { - name: '峰值', - type: 'gauge', - min: 0, - max: 100, - progress: { - show: true - }, - detail: { - formatter: cache.value!.info.used_memory_human - }, - data: [ - { - value: parseFloat(cache.value!.info.used_memory_human), - name: '内存消耗' - } - ] - } - ] - }) +/** 加载数据 */ +const getSummary = () => { + // 初始化命令图表 + initCommandStatsChart() + usedMemoryInstance() } -onBeforeMount(() => { - // TODO @hiiwbs 微信,优化使用 Echart 组件 +/** 命令使用情况 */ +const initCommandStatsChart = async () => { + usedmemoryEchartChika.series[0].data = [] + // 发起请求 + try { + const data = await RedisApi.getCache() + cache.value = data + // 处理数据 + const commandStats = [] as any[] + const nameList = [] as string[] + data.commandStats.forEach((row) => { + commandStats.push({ + name: row.command, + value: row.calls + }) + nameList.push(row.command) + }) + commandStatsRefChika.legend.data = nameList + commandStatsRefChika.series[0].data = commandStats + } catch {} +} +const usedMemoryInstance = async () => { + try { + const data = await RedisApi.getCache() + cache.value = data + // 仪表盘详情,用于显示数据。 + usedmemoryEchartChika.series[0].detail = { + show: true, // 是否显示详情,默认 true。 + offsetCenter: [0, '50%'], // 相对于仪表盘中心的偏移位置,数组第一项是水平方向的偏移,第二项是垂直方向的偏移。可以是绝对的数值,也可以是相对于仪表盘半径的百分比。 + color: 'auto', // 文字的颜色,默认 auto。 + fontSize: 30, // 文字的字体大小,默认 15。 + formatter: cache.value!.info.used_memory_human // 格式化函数或者字符串 + } + + usedmemoryEchartChika.series[0].data[0] = { + value: cache.value!.info.used_memory_human, + name: '内存消耗' + } + console.log(cache.value!.info) + usedmemoryEchartChika.tooltip = { + formatter: '{b} <br/>{a} : ' + cache.value!.info.used_memory_human + } + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + echarts.use([ToolboxComponent]) + echarts.use([GaugeChart]) + // 读取 redis 信息 readRedisInfo() + // 加载数据 + getSummary() }) </script> diff --git a/src/views/mall/product/category/CategoryForm.vue b/src/views/mall/product/category/CategoryForm.vue index 19bce872..dfe81333 100644 --- a/src/views/mall/product/category/CategoryForm.vue +++ b/src/views/mall/product/category/CategoryForm.vue @@ -4,27 +4,30 @@ ref="formRef" :model="formData" :rules="formRules" - label-width="80px" + label-width="120px" v-loading="formLoading" > <el-form-item label="上级分类" prop="parentId"> - <el-tree-select - v-model="formData.parentId" - :data="categoryTree" - :props="{ label: 'name', value: 'id' }" - :render-after-expand="false" - placeholder="请选择上级分类" - check-strictly - default-expand-all - /> + <el-select v-model="formData.parentId" placeholder="请选择上级分类"> + <el-option :key="0" label="顶级分类" :value="0" /> + <el-option + v-for="item in categoryList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> </el-form-item> <el-form-item label="分类名称" prop="name"> <el-input v-model="formData.name" placeholder="请输入分类名称" /> </el-form-item> - <el-form-item label="分类图片" prop="picUrl"> + <el-form-item label="移动端分类图" prop="picUrl"> <UploadImg v-model="formData.picUrl" :limit="1" :is-show-tip="false" /> - <div v-if="formData.parentId === 0" style="font-size: 10px">推荐 200x100 图片分辨率</div> - <div v-else style="font-size: 10px">推荐 100x100 图片分辨率</div> + <div style="font-size: 10px" class="pl-10px">推荐 180x180 图片分辨率</div> + </el-form-item> + <el-form-item label="PC 端分类图" prop="bigPicUrl"> + <UploadImg v-model="formData.bigPicUrl" :limit="1" :is-show-tip="false" /> + <div style="font-size: 10px" class="pl-10px">推荐 468x340 图片分辨率</div> </el-form-item> <el-form-item label="分类排序" prop="sort"> <el-input-number v-model="formData.sort" controls-position="right" :min="0" /> @@ -40,9 +43,6 @@ </el-radio> </el-radio-group> </el-form-item> - <el-form-item label="分类描述"> - <el-input v-model="formData.description" type="textarea" placeholder="请输入分类描述" /> - </el-form-item> </el-form> <template #footer> <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> @@ -53,7 +53,6 @@ <script setup lang="ts" name="ProductCategory"> import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' import { CommonStatusEnum } from '@/utils/constants' -import { handleTree } from '@/utils/tree' import * as ProductCategoryApi from '@/api/mall/product/category' const { t } = useI18n() // 国际化 const message = useMessage() // 消息弹窗 @@ -66,8 +65,8 @@ const formData = ref({ id: undefined, name: '', picUrl: '', - status: CommonStatusEnum.ENABLE, - description: '' + bigPicUrl: '', + status: CommonStatusEnum.ENABLE }) const formRules = reactive({ parentId: [{ required: true, message: '请选择上级分类', trigger: 'blur' }], @@ -77,7 +76,7 @@ const formRules = reactive({ status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }] }) const formRef = ref() // 表单 Ref -const categoryTree = ref<any[]>([]) // 分类树 +const categoryList = ref<any[]>([]) // 分类树 /** 打开弹窗 */ const open = async (type: string, id?: number) => { @@ -95,7 +94,7 @@ const open = async (type: string, id?: number) => { } } // 获得分类树 - await getTree() + categoryList.value = await ProductCategoryApi.getCategoryList({ parentId: 0 }) } defineExpose({ open }) // 提供 open 方法,用于打开弹窗 @@ -131,17 +130,9 @@ const resetForm = () => { id: undefined, name: '', picUrl: '', - status: CommonStatusEnum.ENABLE, - description: '' + bigPicUrl: '', + status: CommonStatusEnum.ENABLE } formRef.value?.resetFields() } - -/** 获得分类树 */ -const getTree = async () => { - const data = await ProductCategoryApi.getCategoryList({}) - const tree = handleTree(data, 'id', 'parentId') - const menu = { id: 0, name: '顶级分类', children: tree } - categoryTree.value = [menu] -} </script> diff --git a/src/views/mall/product/category/index.vue b/src/views/mall/product/category/index.vue index f57e35f8..ebe1d63f 100644 --- a/src/views/mall/product/category/index.vue +++ b/src/views/mall/product/category/index.vue @@ -36,9 +36,9 @@ <ContentWrap> <el-table v-loading="loading" :data="list" row-key="id" default-expand-all> <el-table-column label="分类名称" prop="name" sortable /> - <el-table-column label="分类图片" align="center" prop="picUrl"> + <el-table-column label="移动端分类图" align="center" prop="picUrl"> <template #default="scope"> - <img v-if="scope.row.picUrl" :src="scope.row.picUrl" alt="分类图片" class="h-100px" /> + <img v-if="scope.row.picUrl" :src="scope.row.picUrl" alt="移动端分类图" class="h-100px" /> </template> </el-table-column> <el-table-column label="分类排序" align="center" prop="sort" /> diff --git a/src/views/mall/product/property/index.vue b/src/views/mall/product/property/index.vue index 102ee8a5..399633bd 100644 --- a/src/views/mall/product/property/index.vue +++ b/src/views/mall/product/property/index.vue @@ -2,42 +2,49 @@ <!-- 搜索工作栏 --> <ContentWrap> <el-form - class="-mb-15px" - :model="queryParams" ref="queryFormRef" :inline="true" + :model="queryParams" + class="-mb-15px" label-width="68px" > <el-form-item label="名称" prop="name"> <el-input v-model="queryParams.name" - placeholder="请输入名称" - clearable - @keyup.enter="handleQuery" class="!w-240px" + clearable + placeholder="请输入名称" + @keyup.enter="handleQuery" /> </el-form-item> <el-form-item label="创建时间" prop="createTime"> <el-date-picker v-model="queryParams.createTime" - value-format="YYYY-MM-DD HH:mm:ss" - type="daterange" - start-placeholder="开始日期" - end-placeholder="结束日期" :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" /> </el-form-item> <el-form-item> - <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> - <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> <el-button + v-hasPermi="['product:property:create']" plain type="primary" @click="openForm('create')" - v-hasPermi="['product:property:create']" > - <Icon icon="ep:plus" class="mr-5px" /> 新增 + <Icon class="mr-5px" icon="ep:plus" /> + 新增 </el-button> </el-form-item> </el-form> @@ -46,23 +53,23 @@ <!-- 列表 --> <ContentWrap> <el-table v-loading="loading" :data="list"> - <el-table-column label="编号" align="center" prop="id" /> - <el-table-column label="名称" align="center" /> - <el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" /> + <el-table-column align="center" label="编号" prop="id" /> + <el-table-column align="center" label="名称" prop="name" /> + <el-table-column :show-overflow-tooltip="true" align="center" label="备注" prop="remark" /> <el-table-column - label="创建时间" + :formatter="dateFormatter" align="center" + label="创建时间" prop="createTime" width="180" - :formatter="dateFormatter" /> - <el-table-column label="操作" align="center"> + <el-table-column align="center" label="操作"> <template #default="scope"> <el-button + v-hasPermi="['product:property:update']" link type="primary" @click="openForm('update', scope.row.id)" - v-hasPermi="['product:property:update']" > 编辑 </el-button> @@ -70,10 +77,10 @@ <router-link :to="'/property/value/' + scope.row.id">属性值</router-link> </el-button> <el-button + v-hasPermi="['product:property:delete']" link type="danger" @click="handleDelete(scope.row.id)" - v-hasPermi="['product:property:delete']" > 删除 </el-button> @@ -82,9 +89,9 @@ </el-table> <!-- 分页 --> <Pagination - :total="total" - v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" @pagination="getList" /> </ContentWrap> @@ -92,10 +99,11 @@ <!-- 表单弹窗:添加/修改 --> <PropertyForm ref="formRef" @success="getList" /> </template> -<script setup lang="ts" name="ProductProperty"> +<script lang="ts" name="ProductProperty" setup> import { dateFormatter } from '@/utils/formatTime' import * as PropertyApi from '@/api/mall/product/property' import PropertyForm from './PropertyForm.vue' + const message = useMessage() // 消息弹窗 const { t } = useI18n() // 国际化 diff --git a/src/views/mall/product/spu/addForm.vue b/src/views/mall/product/spu/addForm.vue new file mode 100644 index 00000000..28fc414d --- /dev/null +++ b/src/views/mall/product/spu/addForm.vue @@ -0,0 +1,240 @@ +<template> + <ContentWrap v-loading="formLoading"> + <el-tabs v-model="activeName"> + <el-tab-pane label="商品信息" name="basicInfo"> + <BasicInfoForm + ref="BasicInfoRef" + v-model:activeName="activeName" + :propFormData="formData" + /> + </el-tab-pane> + <el-tab-pane label="商品详情" name="description"> + <DescriptionForm + ref="DescriptionRef" + v-model:activeName="activeName" + :propFormData="formData" + /> + </el-tab-pane> + <el-tab-pane label="其他设置" name="otherSettings"> + <OtherSettingsForm + ref="OtherSettingsRef" + v-model:activeName="activeName" + :propFormData="formData" + /> + </el-tab-pane> + </el-tabs> + <el-form> + <el-form-item style="float: right"> + <el-button :loading="formLoading" type="primary" @click="submitForm">保存</el-button> + <el-button @click="close">返回</el-button> + </el-form-item> + </el-form> + </ContentWrap> +</template> +<script lang="ts" name="ProductManagementForm" setup> +import { useTagsViewStore } from '@/store/modules/tagsView' +import { BasicInfoForm, DescriptionForm, OtherSettingsForm } from './components' +import type { SpuType } from '@/api/mall/product/management/type/spuType' // 业务api +import * as managementApi from '@/api/mall/product/management/spu' +import * as PropertyApi from '@/api/mall/product/property' +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 +const { push, currentRoute } = useRouter() // 路由 +const { query } = useRoute() // 查询参数 +const { delView } = useTagsViewStore() // 视图操作 + +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const activeName = ref('basicInfo') // Tag 激活的窗口 +const BasicInfoRef = ref<ComponentRef<typeof BasicInfoForm>>() // 商品信息Ref +const DescriptionRef = ref<ComponentRef<typeof DescriptionForm>>() // 商品详情Ref +const OtherSettingsRef = ref<ComponentRef<typeof OtherSettingsForm>>() // 其他设置Ref +const formData = ref<SpuType>({ + name: '213', // 商品名称 + categoryId: null, // 商品分类 + keyword: '213', // 关键字 + unit: null, // 单位 + picUrl: + 'http://127.0.0.1:48080/admin-api/infra/file/4/get/6ffdf8f5dfc03f7ceec1ff1ebc138adb8b721a057d505ccfb0e61a8783af1371.png', // 商品封面图 + sliderPicUrls: [ + { + name: 'http://127.0.0.1:48080/admin-api/infra/file/4/get/6ffdf8f5dfc03f7ceec1ff1ebc138adb8b721a057d505ccfb0e61a8783af1371.png', + url: 'http://127.0.0.1:48080/admin-api/infra/file/4/get/6ffdf8f5dfc03f7ceec1ff1ebc138adb8b721a057d505ccfb0e61a8783af1371.png' + } + ], // 商品轮播图 + introduction: '213', // 商品简介 + deliveryTemplateId: 0, // 运费模版 + specType: false, // 商品规格 + subCommissionType: false, // 分销类型 + skus: [ + { + /** + * 商品价格,单位:分 TODO @puhui999:注释放在尾巴哈,简洁一点~ + */ + price: 0, + /** + * 市场价,单位:分 + */ + marketPrice: 0, + /** + * 成本价,单位:分 + */ + costPrice: 0, + /** + * 商品条码 + */ + barCode: '', + /** + * 图片地址 + */ + picUrl: '', + /** + * 库存 + */ + stock: 0, + /** + * 商品重量,单位:kg 千克 + */ + weight: 0, + /** + * 商品体积,单位:m^3 平米 + */ + volume: 0, + /** + * 一级分销的佣金,单位:分 + */ + subCommissionFirstPrice: 0, + /** + * 二级分销的佣金,单位:分 + */ + subCommissionSecondPrice: 0 + } + ], + description: '5425', // 商品详情 + sort: 1, // 商品排序 + giveIntegral: 1, // 赠送积分 + virtualSalesCount: 1, // 虚拟销量 + recommendHot: false, // 是否热卖 + recommendBenefit: false, // 是否优惠 + recommendBest: false, // 是否精品 + recommendNew: false, // 是否新品 + recommendGood: false // 是否优品 +}) + +/** 获得详情 */ +const getDetail = async () => { + const id = query.id as unknown as number + if (id) { + formLoading.value = true + try { + const res = (await managementApi.getSpu(id)) as SpuType + formData.value = res + // 直接取第一个值就能得到所有属性的id + // TODO @puhui999:可以直接拿 propertyName 拼接处规格 id + 属性,可以看下商品 uniapp 详情的做法 + const propertyIds = res.skus[0]?.properties.map((item) => item.propertyId) + const PropertyS = await PropertyApi.getPropertyListAndValue({ propertyIds }) + await nextTick() + // 回显商品属性 + BasicInfoRef.value.addAttribute(PropertyS) + } finally { + formLoading.value = false + } + } +} + +/** 提交按钮 */ +const submitForm = async () => { + // 提交请求 + formLoading.value = true + const newSkus = JSON.parse(JSON.stringify(formData.value.skus)) //深拷贝一份skus保存失败时使用 + // TODO 三个表单逐一校验,如果有一个表单校验不通过则切换到对应表单,如果有两个及以上的情况则切换到最前面的一个并弹出提示消息 + // 校验各表单 + try { + await unref(BasicInfoRef)?.validate() + await unref(DescriptionRef)?.validate() + await unref(OtherSettingsRef)?.validate() + // TODO @puhui:直接做深拷贝?这样最终 server 端不满足,不需要恢复 + // 处理掉一些无关数据 + formData.value.skus.forEach((item) => { + // 给sku name赋值 + item.name = formData.value.name + // 多规格情况移除skus相关属性值value + if (formData.value.specType) { + item.properties.forEach((item2) => { + delete item2.valueName + }) + } + }) + // 处理轮播图列表 + const newSliderPicUrls = [] + formData.value.sliderPicUrls.forEach((item) => { + // 如果是前端选的图 + // TODO @puhui999:疑问哈,为啥会是 object 呀? + if (typeof item === 'object') { + newSliderPicUrls.push(item.url) + } else { + newSliderPicUrls.push(item) + } + }) + formData.value.sliderPicUrls = newSliderPicUrls + // 校验都通过后提交表单 + const data = formData.value as SpuType + // 移除skus. + const id = query.id as unknown as number + if (!id) { + await managementApi.createSpu(data) + message.success(t('common.createSuccess')) + } else { + await managementApi.updateSpu(data) + message.success(t('common.updateSuccess')) + } + close() + } catch (e) { + // 如果是后端校验失败,恢复skus数据 + if (typeof e === 'string') { + formData.value.skus = newSkus + } + } finally { + formLoading.value = false + } +} + +/** + * 重置表单 + */ +const resetForm = async () => { + formData.value = { + name: '', // 商品名称 + categoryId: 0, // 商品分类 + keyword: '', // 关键字 + unit: '', // 单位 + picUrl: '', // 商品封面图 + sliderPicUrls: [], // 商品轮播图 + introduction: '', // 商品简介 + deliveryTemplateId: 0, // 运费模版 + selectRule: '', + specType: false, // 商品规格 + subCommissionType: false, // 分销类型 + description: '', // 商品详情 + sort: 1, // 商品排序 + giveIntegral: 1, // 赠送积分 + virtualSalesCount: 1, // 虚拟销量 + recommendHot: false, // 是否热卖 + recommendBenefit: false, // 是否优惠 + recommendBest: false, // 是否精品 + recommendNew: false, // 是否新品 + recommendGood: false // 是否优品 + } +} +/** 关闭按钮 */ +const close = () => { + // TODO @puhui999:是不是不用 reset 呀?close 默认销毁 + resetForm() + delView(unref(currentRoute)) + push('/product/product-management') +} + +/** 初始化 */ +onMounted(() => { + getDetail() +}) +</script> diff --git a/src/views/mall/product/spu/components/BasicInfoForm.vue b/src/views/mall/product/spu/components/BasicInfoForm.vue new file mode 100644 index 00000000..249a3830 --- /dev/null +++ b/src/views/mall/product/spu/components/BasicInfoForm.vue @@ -0,0 +1,238 @@ +<template> + <el-form ref="ProductManagementBasicInfoRef" :model="formData" :rules="rules" label-width="120px"> + <el-row> + <el-col :span="12"> + <el-form-item label="商品名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入商品名称" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <!-- TODO @puhui999:只能选根节点 --> + <el-form-item label="商品分类" prop="categoryId"> + <el-tree-select + v-model="formData.categoryId" + :data="categoryList" + :props="defaultProps" + check-strictly + node-key="id" + placeholder="请选择商品分类" + class="w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="商品关键字" prop="keyword"> + <el-input v-model="formData.keyword" placeholder="请输入商品关键字" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="单位" prop="unit"> + <el-select v-model="formData.unit" placeholder="请选择单位" class="w-1/1"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.PRODUCT_UNIT)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="商品简介" prop="introduction"> + <el-input + v-model="formData.introduction" + :rows="3" + placeholder="请输入商品简介" + type="textarea" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="商品封面图" prop="picUrl"> + <UploadImg v-model="formData.picUrl" height="80px" /> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="商品轮播图" prop="sliderPicUrls"> + <UploadImgs v-model="formData.sliderPicUrls" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="运费模板" prop="deliveryTemplateId"> + <el-select v-model="formData.deliveryTemplateId" placeholder="请选择" class="w-1/1"> + <el-option v-for="item in []" :key="item.id" :label="item.name" :value="item.id" /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-button class="ml-20px">运费模板</el-button> + </el-col> + <el-col :span="12"> + <el-form-item label="商品规格" props="specType"> + <el-radio-group v-model="formData.specType" @change="onChangeSpec"> + <el-radio :label="false" class="radio">单规格</el-radio> + <el-radio :label="true">多规格</el-radio> + </el-radio-group> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="分销类型" props="subCommissionType"> + <el-radio-group v-model="formData.subCommissionType" @change="changeSubCommissionType"> + <el-radio :label="false">默认设置</el-radio> + <el-radio :label="true" class="radio">自行设置</el-radio> + </el-radio-group> + </el-form-item> + </el-col> + <!-- 多规格添加--> + <el-col :span="24"> + <el-form-item v-if="formData.specType" label="商品属性"> + <!-- TODO @puhui999:参考 https://admin.java.crmeb.net/store/list/creatProduct 添加规格好做么?添加的时候,不用输入备注哈 --> + <el-button class="mr-15px mb-10px" @click="AttributesAddFormRef.open">添加规格</el-button> + <ProductAttributes :attribute-data="attributeList" /> + </el-form-item> + <template v-if="formData.specType && attributeList.length > 0"> + <el-form-item label="批量设置"> + <SkuList :attributeList="attributeList" :is-batch="true" :prop-form-data="formData" /> + </el-form-item> + <el-form-item label="属性列表"> + <SkuList :attributeList="attributeList" :prop-form-data="formData" /> + </el-form-item> + </template> + <el-form-item v-if="!formData.specType"> + <SkuList :attributeList="attributeList" :prop-form-data="formData" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <ProductAttributesAddForm ref="AttributesAddFormRef" @success="addAttribute" /> +</template> +<script lang="ts" name="ProductManagementBasicInfoForm" setup> +import { PropType } from 'vue' +import { defaultProps, handleTree } from '@/utils/tree' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import type { SpuType } from '@/api/mall/product/management/type/spuType' +import { UploadImg, UploadImgs } from '@/components/UploadFile' +import { copyValueToTarget } from '@/utils/object' +import { ProductAttributes, ProductAttributesAddForm, SkuList } from './index' +import * as ProductCategoryApi from '@/api/mall/product/category' +import { propTypes } from '@/utils/propTypes' +const message = useMessage() // 消息弹窗 + +const props = defineProps({ + propFormData: { + type: Object as PropType<SpuType>, + default: () => {} + }, + activeName: propTypes.string.def('') +}) +const AttributesAddFormRef = ref() // 添加商品属性表单 TODO @puhui999:小写开头哈 +const ProductManagementBasicInfoRef = ref() // 表单Ref TODO @puhui999:小写开头哈 +// TODO @puhui999:attributeList 改成 propertyList,会更统一一点 +const attributeList = ref([]) // 商品属性列表 +/** 添加商品属性 */ // TODO @puhui999:propFormData 算出来 +const addAttribute = (property: any) => { + if (Array.isArray(property)) { + attributeList.value = property + return + } + attributeList.value.push(property) +} +const formData = reactive<SpuType>({ + name: '', // 商品名称 + categoryId: undefined, // 商品分类 + keyword: '', // 关键字 + unit: '', // 单位 + picUrl: '', // 商品封面图 + sliderPicUrls: [], // 商品轮播图 + introduction: '', // 商品简介 + deliveryTemplateId: 1, // 运费模版 + specType: false, // 商品规格 + subCommissionType: false, // 分销类型 + skus: [] +}) +const rules = reactive({ + name: [required], + categoryId: [required], + keyword: [required], + unit: [required], + introduction: [required], + picUrl: [required], + sliderPicUrls: [required], + // deliveryTemplateId: [required], + specType: [required], + subCommissionType: [required] +}) + +/** + * 将传进来的值赋值给 formData + */ +watch( + () => props.propFormData, + (data) => { + if (!data) return + copyValueToTarget(formData, data) + }, + { + deep: true, + immediate: true + } +) + +/** + * 表单校验 + */ +const emit = defineEmits(['update:activeName']) +const validate = async () => { + // 校验表单 + if (!ProductManagementBasicInfoRef) return + return await unref(ProductManagementBasicInfoRef).validate((valid) => { + if (!valid) { + message.warning('商品信息未完善!!') + emit('update:activeName', 'basicInfo') + // 目的截断之后的校验 + throw new Error('商品信息未完善!!') + } else { + // 校验通过更新数据 + Object.assign(props.propFormData, formData) + } + }) +} +defineExpose({ validate, addAttribute }) + +/** 分销类型 */ +const changeSubCommissionType = () => { + // 默认为零,类型切换后也要重置为零 + for (const item of formData.skus) { + item.subCommissionFirstPrice = 0 + item.subCommissionSecondPrice = 0 + } +} + +/** 选择规格 */ +const onChangeSpec = () => { + // 重置商品属性列表 + attributeList.value = [] + // 重置sku列表 + formData.skus = [ + { + price: 0, + marketPrice: 0, + costPrice: 0, + barCode: '', + picUrl: '', + stock: 0, + weight: 0, + volume: 0, + subCommissionFirstPrice: 0, + subCommissionSecondPrice: 0 + } + ] +} + +const categoryList = ref() // 分类树 +onMounted(async () => { + // 获得分类树 + const data = await ProductCategoryApi.getCategoryList({}) + categoryList.value = handleTree(data, 'id', 'parentId') +}) +</script> diff --git a/src/views/mall/product/spu/components/DescriptionForm.vue b/src/views/mall/product/spu/components/DescriptionForm.vue new file mode 100644 index 00000000..0a7f522b --- /dev/null +++ b/src/views/mall/product/spu/components/DescriptionForm.vue @@ -0,0 +1,84 @@ +<template> + <el-form ref="DescriptionFormRef" :model="formData" :rules="rules" label-width="120px"> + <!--富文本编辑器组件--> + <el-form-item label="商品详情" prop="description"> + <Editor v-model:modelValue="formData.description" /> + </el-form-item> + </el-form> +</template> +<script lang="ts" name="DescriptionForm" setup> +import type { SpuType } from '@/api/mall/product/management/type/spuType' +import { Editor } from '@/components/Editor' +import { PropType } from 'vue' +import { copyValueToTarget } from '@/utils/object' +import { propTypes } from '@/utils/propTypes' + +const message = useMessage() // 消息弹窗 +const props = defineProps({ + propFormData: { + type: Object as PropType<SpuType>, + default: () => {} + }, + activeName: propTypes.string.def('') +}) +const DescriptionFormRef = ref() // 表单Ref +const formData = ref<SpuType>({ + description: '' // 商品详情 +}) +// 表单规则 +const rules = reactive({ + description: [required] +}) + +/** + * 富文本编辑器如果输入过再清空会有残留,需再重置一次 + */ +watch( + () => formData.value.description, + (newValue) => { + if ('<p><br></p>' === newValue) { + formData.value.description = '' + } + }, + { + deep: true, + immediate: true + } +) + +/** + * 将传进来的值赋值给formData + */ +watch( + () => props.propFormData, + (data) => { + if (!data) return + copyValueToTarget(formData.value, data) + }, + { + deep: true, + immediate: true + } +) + +/** + * 表单校验 + */ +const emit = defineEmits(['update:activeName']) +const validate = async () => { + // 校验表单 + if (!DescriptionFormRef) return + return unref(DescriptionFormRef).validate((valid) => { + if (!valid) { + message.warning('商品详情为完善!!') + emit('update:activeName', 'description') + // 目的截断之后的校验 + throw new Error('商品详情为完善!!') + } else { + // 校验通过更新数据 + Object.assign(props.propFormData, formData.value) + } + }) +} +defineExpose({ validate }) +</script> diff --git a/src/views/mall/product/spu/components/OtherSettingsForm.vue b/src/views/mall/product/spu/components/OtherSettingsForm.vue new file mode 100644 index 00000000..c0fc5122 --- /dev/null +++ b/src/views/mall/product/spu/components/OtherSettingsForm.vue @@ -0,0 +1,156 @@ +<template> + <el-form ref="OtherSettingsFormRef" :model="formData" :rules="rules" label-width="120px"> + <el-row> + <!-- TODO @puhui999:横着三个哈 --> + <el-col :span="24"> + <el-col :span="8"> + <el-form-item label="商品排序" prop="sort"> + <el-input-number v-model="formData.sort" :min="0" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="赠送积分" prop="giveIntegral"> + <el-input-number v-model="formData.giveIntegral" :min="0" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="虚拟销量" prop="virtualSalesCount"> + <el-input-number + v-model="formData.virtualSalesCount" + :min="0" + placeholder="请输入虚拟销量" + /> + </el-form-item> + </el-col> + </el-col> + <el-col :span="24"> + <el-form-item label="商品推荐"> + <el-checkbox-group v-model="checkboxGroup" @change="onChangeGroup"> + <el-checkbox v-for="(item, index) in recommend" :key="index" :label="item.value"> + {{ item.name }} + </el-checkbox> + </el-checkbox-group> + </el-form-item> + </el-col> + <el-col :span="24"> + <!-- TODO tag展示暂时不考虑排序 --> + <el-form-item label="活动优先级"> + <el-tag>默认</el-tag> + <el-tag class="ml-2" type="success">秒杀</el-tag> + <el-tag class="ml-2" type="info">砍价</el-tag> + <el-tag class="ml-2" type="warning">拼团</el-tag> + </el-form-item> + </el-col> + <!-- TODO @puhui999:等优惠劵 ok 在搞 --> + <el-col :span="24"> + <el-form-item label="赠送优惠劵"> + <el-button>选择优惠券</el-button> + </el-form-item> + </el-col> + </el-row> + </el-form> +</template> +<script lang="ts" name="OtherSettingsForm" setup> +import type { SpuType } from '@/api/mall/product/management/type/spuType' +import { PropType } from 'vue' +import { copyValueToTarget } from '@/utils/object' +import { propTypes } from '@/utils/propTypes' +const message = useMessage() // 消息弹窗 + +const props = defineProps({ + propFormData: { + type: Object as PropType<SpuType>, + default: () => {} + }, + activeName: propTypes.string.def('') +}) +// 商品推荐选项 TODO @puhui999:这种叫 recommendOptions 会更合适哈 +const recommend = [ + { name: '是否热卖', value: 'recommendHot' }, + { name: '是否优惠', value: 'recommendBenefit' }, + { name: '是否精品', value: 'recommendBest' }, + { name: '是否新品', value: 'recommendNew' }, + { name: '是否优品', value: 'recommendGood' } +] +const checkboxGroup = ref<string[]>(['recommendHot']) // 选中推荐选项 +/** 选择商品后赋值 */ +const onChangeGroup = () => { + // TODO @puhui999:是不是可以遍历 recommend,然后进行是否选中; + checkboxGroup.value.includes('recommendHot') + ? (formData.value.recommendHot = true) + : (formData.value.recommendHot = false) + checkboxGroup.value.includes('recommendBenefit') + ? (formData.value.recommendBenefit = true) + : (formData.value.recommendBenefit = false) + checkboxGroup.value.includes('recommendBest') + ? (formData.value.recommendBest = true) + : (formData.value.recommendBest = false) + checkboxGroup.value.includes('recommendNew') + ? (formData.value.recommendNew = true) + : (formData.value.recommendNew = false) + checkboxGroup.value.includes('recommendGood') + ? (formData.value.recommendGood = true) + : (formData.value.recommendGood = false) +} +const OtherSettingsFormRef = ref() // 表单Ref +// 表单数据 +const formData = ref<SpuType>({ + sort: 1, // 商品排序 + giveIntegral: 1, // 赠送积分 + virtualSalesCount: 1, // 虚拟销量 + recommendHot: false, // 是否热卖 + recommendBenefit: false, // 是否优惠 + recommendBest: false, // 是否精品 + recommendNew: false, // 是否新品 + recommendGood: false // 是否优品 +}) +// 表单规则 +const rules = reactive({ + sort: [required], + giveIntegral: [required], + virtualSalesCount: [required] +}) + +/** + * 将传进来的值赋值给formData + */ +watch( + () => props.propFormData, + (data) => { + if (!data) return + copyValueToTarget(formData.value, data) + // TODO 如果先修改其他设置的值,再改变商品详情或是商品信息会重置其他设置页面中的相关值 下一个版本修复 + checkboxGroup.value = [] + formData.value.recommendHot ? checkboxGroup.value.push('recommendHot') : '' + formData.value.recommendBenefit ? checkboxGroup.value.push('recommendBenefit') : '' + formData.value.recommendBest ? checkboxGroup.value.push('recommendBest') : '' + formData.value.recommendNew ? checkboxGroup.value.push('recommendNew') : '' + formData.value.recommendGood ? checkboxGroup.value.push('recommendGood') : '' + }, + { + deep: true, + immediate: true + } +) + +/** + * 表单校验 + */ +const emit = defineEmits(['update:activeName']) +const validate = async () => { + // 校验表单 + if (!OtherSettingsFormRef) return + return await unref(OtherSettingsFormRef).validate((valid) => { + if (!valid) { + message.warning('商品其他设置未完善!!') + emit('update:activeName', 'otherSettings') + // 目的截断之后的校验 + throw new Error('商品其他设置未完善!!') + } else { + // 校验通过更新数据 + Object.assign(props.propFormData, formData.value) + } + }) +} +defineExpose({ validate }) +</script> diff --git a/src/views/mall/product/spu/components/ProductAttributes.vue b/src/views/mall/product/spu/components/ProductAttributes.vue new file mode 100644 index 00000000..73e8c992 --- /dev/null +++ b/src/views/mall/product/spu/components/ProductAttributes.vue @@ -0,0 +1,102 @@ +<template> + <el-col v-for="(item, index) in attributeList" :key="index"> + <div> + <el-text class="mx-1">属性名:</el-text> + <el-text class="mx-1">{{ item.name }}</el-text> + </div> + <div> + <el-text class="mx-1">属性值:</el-text> + <el-tag + v-for="(value, valueIndex) in item.values" + :key="value.id" + :disable-transitions="false" + class="mx-1" + closable + @close="handleClose(index, valueIndex)" + > + {{ value.name }} + </el-tag> + <el-input + v-show="inputVisible(index)" + ref="InputRef" + v-model="inputValue" + class="!w-20" + size="small" + @blur="handleInputConfirm(index, item.id)" + @keyup.enter="handleInputConfirm(index, item.id)" + /> + <el-button + v-show="!inputVisible(index)" + class="button-new-tag ml-1" + size="small" + @click="showInput(index)" + > + + 添加 + </el-button> + </div> + <el-divider class="my-10px" /> + </el-col> +</template> + +<script lang="ts" name="ProductAttributes" setup> +import { ElInput } from 'element-plus' +import * as PropertyApi from '@/api/mall/product/property' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 +const inputValue = ref('') // 输入框值 +const attributeIndex = ref<number | null>(null) // 获取焦点时记录当前属性项的index +// 输入框显隐控制 +const inputVisible = computed(() => (index) => { + if (attributeIndex.value === null) return false + if (attributeIndex.value === index) return true +}) +const InputRef = ref() //标签输入框Ref +const attributeList = ref([]) // 商品属性列表 +const props = defineProps({ + attributeData: { + type: Array, + default: () => {} + } +}) + +watch( + () => props.attributeData, + (data) => { + if (!data) return + attributeList.value = data + }, + { + deep: true, + immediate: true + } +) + +/** 删除标签 tagValue 标签值*/ +const handleClose = (index, valueIndex) => { + attributeList.value[index].values?.splice(valueIndex, 1) +} + +/** 显示输入框并获取焦点 */ +const showInput = async (index) => { + attributeIndex.value = index + // 因为组件在ref中所以需要用索引获取对应的Ref + InputRef.value[index]!.input!.focus() +} + +/** 输入框失去焦点或点击回车时触发 */ +const handleInputConfirm = async (index, propertyId) => { + if (inputValue.value) { + // 保存属性值 + try { + const id = await PropertyApi.createPropertyValue({ propertyId, name: inputValue.value }) + attributeList.value[index].values.push({ id, name: inputValue.value }) + message.success(t('common.createSuccess')) + } catch { + message.error('添加失败,请重试') // TODO 缺少国际化 + } + } + attributeIndex.value = null + inputValue.value = '' +} +</script> diff --git a/src/views/mall/product/spu/components/ProductAttributesAddForm.vue b/src/views/mall/product/spu/components/ProductAttributesAddForm.vue new file mode 100644 index 00000000..bd715dde --- /dev/null +++ b/src/views/mall/product/spu/components/ProductAttributesAddForm.vue @@ -0,0 +1,85 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名称" /> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入内容" type="textarea" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" name="ProductPropertyForm" setup> +import * as PropertyApi from '@/api/mall/product/property' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('添加商品属性') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref({ + name: '', + remark: '' +}) +const formRules = reactive({ + name: [{ required: true, message: '名称不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async () => { + dialogVisible.value = true + resetForm() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as PropertyApi.PropertyVO + // 检查属性是否已存在,如果有则返回属性和其下属性值 + const res = await PropertyApi.getPropertyListAndValue({ name: data.name }) + if (res.length === 0) { + const propertyId = await PropertyApi.createProperty(data) + emit('success', { id: propertyId, ...formData.value, values: [] }) + } else { + if (res[0].values === null) { + res[0].values = [] + } + emit('success', res[0]) // 因为只用一个 + } + message.success(t('common.createSuccess')) + dialogVisible.value = false + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + name: '', + remark: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/product/spu/components/SkuList.vue b/src/views/mall/product/spu/components/SkuList.vue new file mode 100644 index 00000000..9e1c666f --- /dev/null +++ b/src/views/mall/product/spu/components/SkuList.vue @@ -0,0 +1,309 @@ +<template> + <el-table + :data="isBatch ? SkuData : formData.skus" + border + class="tabNumWidth" + max-height="500" + size="small" + > + <el-table-column align="center" fixed="left" label="图片" min-width="100"> + <template #default="{ row }"> + <UploadImg v-model="row.picUrl" height="80px" width="100%" /> + </template> + </el-table-column> + <template v-if="formData.specType && !isBatch"> + <!-- 根据商品属性动态添加 --> + <el-table-column + v-for="(item, index) in tableHeaderList" + :key="index" + :label="item.label" + align="center" + min-width="120" + > + <template #default="{ row }"> + {{ row.properties[index]?.valueName }} + </template> + </el-table-column> + </template> + <!-- TODO @puhui999: controls-position="right" 可以去掉哈,不然太长了,手动输入更方便 --> + <el-table-column align="center" label="商品条码" min-width="168"> + <template #default="{ row }"> + <el-input v-model="row.barCode" class="w-100%" /> + </template> + </el-table-column> + <!-- TODO @puhui999:用户输入的时候,是按照元;分主要是我们自己用; --> + <el-table-column align="center" label="销售价(分)" min-width="168"> + <template #default="{ row }"> + <el-input-number v-model="row.price" :min="0" class="w-100%" controls-position="right" /> + </template> + </el-table-column> + <el-table-column align="center" label="市场价(分)" min-width="168"> + <template #default="{ row }"> + <el-input-number + v-model="row.marketPrice" + :min="0" + class="w-100%" + controls-position="right" + /> + </template> + </el-table-column> + <el-table-column align="center" label="成本价(分)" min-width="168"> + <template #default="{ row }"> + <el-input-number + v-model="row.costPrice" + :min="0" + class="w-100%" + controls-position="right" + /> + </template> + </el-table-column> + <el-table-column align="center" label="库存" min-width="168"> + <template #default="{ row }"> + <el-input-number v-model="row.stock" :min="0" class="w-100%" controls-position="right" /> + </template> + </el-table-column> + <el-table-column align="center" label="重量(kg)" min-width="168"> + <template #default="{ row }"> + <el-input-number v-model="row.weight" :min="0" class="w-100%" controls-position="right" /> + </template> + </el-table-column> + <el-table-column align="center" label="体积(m^3)" min-width="168"> + <template #default="{ row }"> + <el-input-number v-model="row.volume" :min="0" class="w-100%" controls-position="right" /> + </template> + </el-table-column> + <template v-if="formData.subCommissionType"> + <el-table-column align="center" label="一级返佣(分)" min-width="168"> + <template #default="{ row }"> + <el-input-number + v-model="row.subCommissionFirstPrice" + :min="0" + class="w-100%" + controls-position="right" + /> + </template> + </el-table-column> + <el-table-column align="center" label="二级返佣(分)" min-width="168"> + <template #default="{ row }"> + <el-input-number + v-model="row.subCommissionSecondPrice" + :min="0" + class="w-100%" + controls-position="right" + /> + </template> + </el-table-column> + </template> + <el-table-column v-if="formData.specType" align="center" fixed="right" label="操作" width="80"> + <template #default> + <el-button v-if="isBatch" link size="small" type="primary" @click="batchAdd"> + 批量添加 + </el-button> + <el-button v-else link size="small" type="primary">删除</el-button> + </template> + </el-table-column> + </el-table> +</template> +<script lang="ts" name="SkuList" setup> +import { UploadImg } from '@/components/UploadFile' +import { PropType } from 'vue' +import { SpuType } from '@/api/mall/product/management/type/spuType' +import { propTypes } from '@/utils/propTypes' +import { SkuType } from '@/api/mall/product/management/type/skuType' +import { copyValueToTarget } from '@/utils/object' + +const props = defineProps({ + propFormData: { + type: Object as PropType<SpuType>, + default: () => {} + }, + attributeList: { + type: Array, + default: () => [] + }, + isBatch: propTypes.bool.def(false) // 是否批量操作 +}) +const formData = ref<SpuType>() // 表单数据 +// 批量添加时的零时数据 TODO @puhui999:小写开头哈;然后变量都尾注释 +const SkuData = ref<SkuType[]>([ + { + /** + * 商品价格,单位:分 + */ + price: 0, + /** + * 市场价,单位:分 + */ + marketPrice: 0, + /** + * 成本价,单位:分 + */ + costPrice: 0, + /** + * 商品条码 + */ + barCode: '', + /** + * 图片地址 + */ + picUrl: '', + /** + * 库存 + */ + stock: 0, + /** + * 商品重量,单位:kg 千克 + */ + weight: 0, + /** + * 商品体积,单位:m^3 平米 + */ + volume: 0, + /** + * 一级分销的佣金,单位:分 + */ + subCommissionFirstPrice: 0, + /** + * 二级分销的佣金,单位:分 + */ + subCommissionSecondPrice: 0 + } +]) + +/** 批量添加 */ +const batchAdd = () => { + formData.value.skus.forEach((item) => { + copyValueToTarget(item, SkuData.value[0]) + }) +} + +const tableHeaderList = ref<{ prop: string; label: string }[]>([]) + +/** + * 将传进来的值赋值给SkuData + */ +watch( + () => props.propFormData, + (data) => { + if (!data) return + formData.value = data + }, + { + deep: true, + immediate: true + } +) + +// TODO @芋艿:看看 chatgpt 可以进一步下面几个方法的实现不 +/** 生成表数据 */ +const generateTableData = (data: any[]) => { + // 构建数据结构 + const propertiesItemList = [] + for (const item of data) { + const objList = [] + for (const v of item.values) { + const obj = { propertyId: 0, valueId: 0, valueName: '' } + obj.propertyId = item.id + obj.valueId = v.id + obj.valueName = v.name + objList.push(obj) + } + propertiesItemList.push(objList) + } + const buildList = build(propertiesItemList) + // 如果构建后的组合数跟sku数量一样的话则不用处理,添加新属性没有属性值也不做处理 (解决编辑表单时或查看详情时数据回显问题) + if ( + buildList.length === formData.value.skus.length || + data.some((item) => item.values.length === 0) + ) { + return + } + // 重置表数据 + formData.value!.skus = [] + buildList.forEach((item) => { + const row = { + properties: [], + price: 0, + marketPrice: 0, + costPrice: 0, + barCode: '', + picUrl: '', + stock: 0, + weight: 0, + volume: 0, + subCommissionFirstPrice: 0, + subCommissionSecondPrice: 0 + } + // 判断是否是单一属性的情况 + if (Array.isArray(item)) { + row.properties = item + } else { + row.properties.push(item) + } + formData.value.skus.push(row) + }) +} + +/** 构建所有排列组合 */ +const build = (list: any[]) => { + if (list.length === 0) { + return [] + } else if (list.length === 1) { + return list[0] + } else { + const result = [] + const rest = build(list.slice(1)) + for (let i = 0; i < list[0].length; i++) { + for (let j = 0; j < rest.length; j++) { + // 第一次不是数组结构,后面的都是数组结构 + if (Array.isArray(rest[j])) { + result.push([list[0][i], ...rest[j]]) + } else { + result.push([list[0][i], rest[j]]) + } + } + } + return result + } +} + +/** 监听属性列表生成相关参数和表头 */ +watch( + () => props.attributeList, + (data) => { + // 如果不是多规格则结束 + if (!formData.value.specType) return + // 如果当前组件作为批量添加数据使用则重置表数据 + if (props.isBatch) { + SkuData.value = [ + { + price: 0, + marketPrice: 0, + costPrice: 0, + barCode: '', + picUrl: '', + stock: 0, + weight: 0, + volume: 0, + subCommissionFirstPrice: 0, + subCommissionSecondPrice: 0 + } + ] + } + // 判断代理对象是否为空 + if (JSON.stringify(data) === '[]') return + // 重置表头 + tableHeaderList.value = [] + // 生成表头 + data.forEach((item, index) => { + // name加属性项index区分属性值 + tableHeaderList.value.push({ prop: `name${index}`, label: item.name }) + }) + generateTableData(data) + }, + { + deep: true, + immediate: true + } +) +</script> diff --git a/src/views/mall/product/spu/components/index.ts b/src/views/mall/product/spu/components/index.ts new file mode 100644 index 00000000..f908e7d3 --- /dev/null +++ b/src/views/mall/product/spu/components/index.ts @@ -0,0 +1,15 @@ +import BasicInfoForm from './BasicInfoForm.vue' +import DescriptionForm from './DescriptionForm.vue' +import OtherSettingsForm from './OtherSettingsForm.vue' +import ProductAttributes from './ProductAttributes.vue' +import ProductAttributesAddForm from './ProductAttributesAddForm.vue' +import SkuList from './SkuList.vue' + +export { + BasicInfoForm, + DescriptionForm, + OtherSettingsForm, + ProductAttributes, + ProductAttributesAddForm, + SkuList +} diff --git a/src/views/mall/product/spu/index.vue b/src/views/mall/product/spu/index.vue new file mode 100644 index 00000000..b3a04c88 --- /dev/null +++ b/src/views/mall/product/spu/index.vue @@ -0,0 +1,388 @@ +<template> + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <!-- TODO @puhui999:https://admin.java.crmeb.net/store/index,参考,使用分类 + 标题搜索 --> + <el-form-item label="品牌名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入品牌名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="请选择状态"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button v-hasPermi="['product:brand:create']" plain type="primary" @click="openForm"> + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + <!-- TODO @puhui999:增加一个【导出】操作 --> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-tabs v-model="queryParams.tabType" @tab-click="handleClick"> + <el-tab-pane + v-for="item in tabsData" + :key="item.type" + :label="item.name + '(' + item.count + ')'" + :name="item.type" + /> + </el-tabs> + <el-table v-loading="loading" :data="list"> + <!-- TODO puhui999: ID 编号的展示 --> + <!-- TODO 暂时不做折叠数据 --> + <!-- <el-table-column type="expand">--> + <!-- <template #default="{ row }">--> + <!-- <el-form inline label-position="left">--> + <!-- <el-form-item label="市场价:">--> + <!-- <span>{{ row.marketPrice }}</span>--> + <!-- </el-form-item>--> + <!-- <el-form-item label="成本价:">--> + <!-- <span>{{ row.costPrice }}</span>--> + <!-- </el-form-item>--> + <!-- <el-form-item label="虚拟销量:">--> + <!-- <span>{{ row.virtualSalesCount }}</span>--> + <!-- </el-form-item>--> + <!-- </el-form>--> + <!-- </template>--> + <!-- </el-table-column>--> + <el-table-column label="商品图" min-width="80"> + <template #default="{ row }"> + <el-image + :src="row.picUrl" + style="width: 36px; height: 36px" + @click="imagePreview(row.picUrl)" + /> + </template> + </el-table-column> + <el-table-column :show-overflow-tooltip="true" label="商品名称" min-width="300" prop="name" /> + <!-- TODO 价格 / 100.0 --> + <el-table-column align="center" label="商品售价" min-width="90" prop="price" /> + <el-table-column align="center" label="销量" min-width="90" prop="salesCount" /> + <el-table-column align="center" label="库存" min-width="90" prop="stock" /> + <el-table-column align="center" label="排序" min-width="70" prop="sort" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180" + /> + <el-table-column fixed="right" label="状态" min-width="80"> + <template #default="{ row }"> + <!-- TODO @puhui:是不是不用 Number(row.status) 去比较哈,直接 row.status < 0 --> + <el-switch + v-model="row.status" + :active-value="1" + :disabled="Number(row.status) < 0" + :inactive-value="0" + active-text="上架" + inactive-text="下架" + inline-prompt + @change="changeStatus(row)" + /> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" min-width="150"> + <template #default="{ row }"> + <!-- TODO @puhui999:【详情】,可以后面点做哈 --> + <template v-if="queryParams.tabType === 4"> + <el-button + v-hasPermi="['product:spu:delete']" + link + type="danger" + @click="handleDelete(row.id)" + > + 删除 + </el-button> + <el-button + v-hasPermi="['product:spu:update']" + link + type="primary" + @click="addToTrash(row, ProductSpuStatusEnum.DISABLE.status)" + > + 恢复到仓库 + </el-button> + </template> + <template v-else> + <el-button + v-hasPermi="['product:spu:update']" + link + type="primary" + @click="openForm(row.id)" + > + 修改 + </el-button> + <el-button + v-hasPermi="['product:spu:update']" + link + type="primary" + @click="addToTrash(row, ProductSpuStatusEnum.RECYCLE.status)" + > + 加入回收站 + </el-button> + </template> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + <!-- https://kailong110120130.gitee.io/vue-element-plus-admin-doc/components/image-viewer.html,可以用这个么? --> + <!-- 必须在表格外面展示。不然单元格会遮挡图层 --> + <el-image-viewer + v-if="imgViewVisible" + :url-list="imageViewerList" + @close="imgViewVisible = false" + /> +</template> +<script lang="ts" name="ProductList" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +// TODO @puhui999:managementApi=》ProductSpuApi +import * as managementApi from '@/api/mall/product/management/spu' +import { ProductSpuStatusEnum } from '@/utils/constants' +import { TabsPaneContext } from 'element-plus' +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 +const { currentRoute, push } = useRouter() // 路由跳转 + +const loading = ref(false) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref<any[]>([]) // 列表的数据 +// tabs 数据 +const tabsData = ref([ + { + count: 0, + name: '出售中商品', + type: 0 + }, + { + count: 0, + name: '仓库中商品', + type: 1 + }, + { + count: 0, + name: '已经售空商品', + type: 2 + }, + { + count: 0, + name: '警戒库存', + type: 3 + }, + { + count: 0, + name: '商品回收站', + type: 4 + } +]) + +/** 获得每个 Tab 的数量 */ +const getTabsCount = async () => { + // TODO @puhui999:这里是不是可以不要 try catch 哈 + try { + const res = await managementApi.getTabsCount() + for (let objName in res) { + tabsData.value[Number(objName)].count = res[objName] + } + } catch {} +} + +const imgViewVisible = ref(false) // 商品图预览 +const imageViewerList = ref<string[]>([]) // 商品图预览列表 +const queryParams = ref({ + pageNo: 1, + pageSize: 10, + tabType: 0 +}) +const queryFormRef = ref() // 搜索的表单 + +// TODO @puhui999:可以改成 handleTabClick:更准确一点; +const handleClick = (tab: TabsPaneContext) => { + queryParams.value.tabType = tab.paneName + getList() +} + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await managementApi.getSpuList(queryParams.value) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +// TODO @puhui999:是不是 changeStatus 和 addToTrash 调用一个统一的方法,去更新状态。这样逻辑会更干净一些。 +/** + * 更改 SPU 状态 + * + * @param row + * @param status 更改前的值 + */ +const changeStatus = async (row, status?: number) => { + // TODO 测试过程中似乎有点问题,下一版修复 + try { + let text = '' + switch (row.status) { + case ProductSpuStatusEnum.DISABLE.status: + text = ProductSpuStatusEnum.DISABLE.name + break + case ProductSpuStatusEnum.ENABLE.status: + text = ProductSpuStatusEnum.ENABLE.name + break + case ProductSpuStatusEnum.RECYCLE.status: + text = `加入${ProductSpuStatusEnum.RECYCLE.name}` + break + } + await message.confirm( + row.status === -1 ? `确认要将[${row.name}]${text}吗?` : `确认要${text}[${row.name}]吗?` + ) + await managementApi.updateStatus({ id: row.id, status: row.status }) + message.success('更新状态成功') + // 刷新 tabs 数据 + await getTabsCount() + // 刷新列表 + await getList() + } catch { + // 取消加入回收站时回显数据 + if (typeof status !== 'undefined') { + row.status = status + return + } + // 取消更改状态时回显数据 + row.status = + row.status === ProductSpuStatusEnum.DISABLE.status + ? ProductSpuStatusEnum.ENABLE.status + : ProductSpuStatusEnum.DISABLE.status + } +} + +/** + * 加入回收站 + * + * @param row + * @param status + */ +const addToTrash = (row, status) => { + // 复制一份原值 + const num = Number(`${row.status}`) + row.status = status + changeStatus(row, num) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await managementApi.deleteSpu(id) + message.success(t('common.delSuccess')) + // 刷新tabs数据 + await getTabsCount() + // 刷新列表 + await getList() + } catch {} +} + +/** + * 商品图预览 + * @param imgUrl + */ +const imagePreview = (imgUrl: string) => { + imageViewerList.value = [imgUrl] + imgViewVisible.value = true +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** + * 新增或修改 + * + * @param id 商品 SPU 编号 + */ +const openForm = (id?: number) => { + // 修改 + if (typeof id === 'number') { + push('/product/productManagementAdd?id=' + id) + return + } + // 新增 + push('/product/productManagementAdd') +} + +// 监听路由变化更新列表 TODO @puhui999:这个是必须加的么? +watch( + () => currentRoute.value, + () => { + getList() + }, + { + immediate: true + } +) + +/** 初始化 **/ +onMounted(() => { + getTabsCount() + getList() +}) +</script> diff --git a/src/views/mp/autoReply/components/ReplyForm.vue b/src/views/mp/autoReply/components/ReplyForm.vue new file mode 100644 index 00000000..edcbc696 --- /dev/null +++ b/src/views/mp/autoReply/components/ReplyForm.vue @@ -0,0 +1,78 @@ +<template> + <div> + <el-form ref="formRef" :model="replyForm" :rules="rules" label-width="80px"> + <el-form-item label="消息类型" prop="requestMessageType" v-if="msgType === MsgType.Message"> + <el-select v-model="replyForm.requestMessageType" placeholder="请选择"> + <template v-for="dict in getDictOptions(DICT_TYPE.MP_MESSAGE_TYPE)" :key="dict.value"> + <el-option + v-if="RequestMessageTypes.includes(dict.value)" + :label="dict.label" + :value="dict.value" + /> + </template> + </el-select> + </el-form-item> + <el-form-item label="匹配类型" prop="requestMatch" v-if="msgType === MsgType.Keyword"> + <el-select v-model="replyForm.requestMatch" placeholder="请选择匹配类型" clearable> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.MP_AUTO_REPLY_REQUEST_MATCH)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="关键词" prop="requestKeyword" v-if="msgType === MsgType.Keyword"> + <el-input v-model="replyForm.requestKeyword" placeholder="请输入内容" clearable /> + </el-form-item> + <el-form-item label="回复消息"> + <WxReplySelect v-model="reply" /> + </el-form-item> + </el-form> + </div> +</template> + +<script setup lang="ts" name="ReplyForm"> +import WxReplySelect, { type Reply } from '@/views/mp/components/wx-reply' +import type { FormInstance } from 'element-plus' +import { MsgType } from './types' +import { DICT_TYPE, getDictOptions, getIntDictOptions } from '@/utils/dict' + +const props = defineProps<{ + modelValue: any + reply: Reply + msgType: MsgType +}>() + +const emit = defineEmits<{ + (e: 'update:reply', v: Reply) + (e: 'update:modelValue', v: any) +}>() + +const reply = computed<Reply>({ + get: () => props.reply, + set: (val) => emit('update:reply', val) +}) + +const replyForm = computed<any>({ + get: () => props.modelValue, + set: (val) => emit('update:modelValue', val) +}) + +const formRef = ref<FormInstance | null>(null) // 表单 ref + +const RequestMessageTypes = ['text', 'image', 'voice', 'video', 'shortvideo', 'location', 'link'] // 允许选择的请求消息类型 + +// 表单校验 +const rules = { + requestKeyword: [{ required: true, message: '请求的关键字不能为空', trigger: 'blur' }], + requestMatch: [{ required: true, message: '请求的关键字的匹配不能为空', trigger: 'blur' }] +} + +defineExpose({ + resetFields: () => formRef.value?.resetFields(), + validate: async () => formRef.value?.validate() +}) +</script> + +<style scoped></style> diff --git a/src/views/mp/autoReply/index.vue b/src/views/mp/autoReply/index.vue index 20a1e683..b3826de5 100644 --- a/src/views/mp/autoReply/index.vue +++ b/src/views/mp/autoReply/index.vue @@ -53,38 +53,13 @@ @on-delete="onDelete" /> - <!-- 添加或修改自动回复的对话框 --> - <!-- TODO @Dhb52 --> - <el-dialog :title="dialogTitle" v-model="showFormDialog" width="800px" destroy-on-close> - <el-form ref="formRef" :model="replyForm" :rules="rules" label-width="80px"> - <el-form-item label="消息类型" prop="requestMessageType" v-if="msgType === MsgType.Message"> - <el-select v-model="replyForm.requestMessageType" placeholder="请选择"> - <template v-for="dict in getDictOptions(DICT_TYPE.MP_MESSAGE_TYPE)" :key="dict.value"> - <el-option - v-if="RequestMessageTypes.includes(dict.value)" - :label="dict.label" - :value="dict.value" - /> - </template> - </el-select> - </el-form-item> - <el-form-item label="匹配类型" prop="requestMatch" v-if="msgType === MsgType.Keyword"> - <el-select v-model="replyForm.requestMatch" placeholder="请选择匹配类型" clearable> - <el-option - v-for="dict in getIntDictOptions(DICT_TYPE.MP_AUTO_REPLY_REQUEST_MATCH)" - :key="dict.value" - :label="dict.label" - :value="dict.value" - /> - </el-select> - </el-form-item> - <el-form-item label="关键词" prop="requestKeyword" v-if="msgType === MsgType.Keyword"> - <el-input v-model="replyForm.requestKeyword" placeholder="请输入内容" clearable /> - </el-form-item> - <el-form-item label="回复消息"> - <WxReplySelect v-model="reply" /> - </el-form-item> - </el-form> + <el-dialog + :title="isCreating ? '新增自动回复' : '修改自动回复'" + v-model="showDialog" + width="800px" + destroy-on-close + > + <ReplyForm v-model="replyForm" v-model:reply="reply" :msg-type="msgType" ref="formRef" /> <template #footer> <el-button @click="cancel">取 消</el-button> <el-button type="primary" @click="onSubmit">确 定</el-button> @@ -93,52 +68,43 @@ </ContentWrap> </template> <script setup lang="ts" name="MpAutoReply"> -import WxReplySelect, { type Reply, ReplyType } from '@/views/mp/components/wx-reply' +import ReplyForm from '@/views/mp/autoReply/components/ReplyForm.vue' +import { type Reply, ReplyType } from '@/views/mp/components/wx-reply' import WxAccountSelect from '@/views/mp/components/wx-account-select' import * as MpAutoReplyApi from '@/api/mp/autoReply' -import { DICT_TYPE, getDictOptions, getIntDictOptions } from '@/utils/dict' import { ContentWrap } from '@/components/ContentWrap' -import type { FormInstance, TabPaneName } from 'element-plus' +import type { TabPaneName } from 'element-plus' import ReplyTable from './components/ReplyTable.vue' import { MsgType } from './components/types' const message = useMessage() // 消息 +const accountId = ref(-1) // 公众号ID const msgType = ref<MsgType>(MsgType.Keyword) // 消息类型 -const RequestMessageTypes = ['text', 'image', 'voice', 'video', 'shortvideo', 'location', 'link'] // 允许选择的请求消息类型 const loading = ref(true) // 遮罩层 const total = ref(0) // 总条数 const list = ref<any[]>([]) // 自动回复列表 -const formRef = ref<FormInstance | null>(null) // 表单 ref +const formRef = ref<InstanceType<typeof ReplyForm> | null>(null) // 表单 ref // 查询参数 -interface QueryParams { - pageNo: number - pageSize: number - accountId: number -} -const queryParams: QueryParams = reactive({ +const queryParams = reactive({ pageNo: 1, pageSize: 10, - accountId: 0 + accountId: accountId }) -const dialogTitle = ref('') // 弹出层标题 -const showFormDialog = ref(false) // 是否显示弹出层 +const isCreating = ref(false) // 是否新建(否则编辑) +const showDialog = ref(false) // 是否显示弹出层 const replyForm = ref<any>({}) // 表单参数 // 回复消息 const reply = ref<Reply>({ type: ReplyType.Text, - accountId: 0 + accountId: -1 }) -// 表单校验 -const rules = { - requestKeyword: [{ required: true, message: '请求的关键字不能为空', trigger: 'blur' }], - requestMatch: [{ required: true, message: '请求的关键字的匹配不能为空', trigger: 'blur' }] -} /** 侦听账号变化 */ const onAccountChanged = (id: number) => { - queryParams.accountId = id + accountId.value = id reply.value.accountId = id + queryParams.pageNo = 1 getList() } @@ -177,8 +143,8 @@ const onCreate = () => { accountId: queryParams.accountId } - dialogTitle.value = '新增自动回复' - showFormDialog.value = true + isCreating.value = true + showDialog.value = true } /** 修改按钮操作 */ @@ -210,8 +176,8 @@ const onUpdate = async (id: number) => { } // 打开表单 - dialogTitle.value = '修改自动回复' - showFormDialog.value = true + isCreating.value = false + showDialog.value = true } /** 删除按钮操作 */ @@ -223,8 +189,7 @@ const onDelete = async (id: number) => { } const onSubmit = async () => { - const valid = await formRef.value?.validate() - if (!valid) return + await formRef.value?.validate() // 处理回复消息 const submitForm: any = { ...replyForm.value } @@ -248,7 +213,7 @@ const onSubmit = async () => { message.success('新增成功') } - showFormDialog.value = false + showDialog.value = false await getList() } @@ -267,7 +232,7 @@ const reset = () => { // 取消按钮 const cancel = () => { - showFormDialog.value = false + showDialog.value = false reset() } </script> diff --git a/src/views/mp/components/wx-account-select/main.vue b/src/views/mp/components/wx-account-select/main.vue index 8dbad499..e2501657 100644 --- a/src/views/mp/components/wx-account-select/main.vue +++ b/src/views/mp/components/wx-account-select/main.vue @@ -8,13 +8,14 @@ import * as MpAccountApi from '@/api/mp/account' const account: MpAccountApi.AccountVO = reactive({ - id: undefined, + id: -1, name: '' }) -const accountList: Ref<MpAccountApi.AccountVO[]> = ref([]) + +const accountList = ref<MpAccountApi.AccountVO[]>([]) const emit = defineEmits<{ - (e: 'change', id: number, name: string): void + (e: 'change', id: number, name: string) }>() const handleQuery = async () => { diff --git a/src/views/mp/components/wx-msg/components/Msg.vue b/src/views/mp/components/wx-msg/components/Msg.vue new file mode 100644 index 00000000..eff834c9 --- /dev/null +++ b/src/views/mp/components/wx-msg/components/Msg.vue @@ -0,0 +1,67 @@ +<template> + <div> + <MsgEvent v-if="item.type === MsgType.Event" :item="item" /> + + <div v-else-if="item.type === MsgType.Text">{{ item.content }}</div> + + <div v-else-if="item.type === MsgType.Voice"> + <WxVoicePlayer :url="item.mediaUrl" :content="item.recognition" /> + </div> + + <div v-else-if="item.type === MsgType.Image"> + <a target="_blank" :href="item.mediaUrl"> + <img :src="item.mediaUrl" style="width: 100px" /> + </a> + </div> + + <div + v-else-if="item.type === MsgType.Video || item.type === 'shortvideo'" + style="text-align: center" + > + <WxVideoPlayer :url="item.mediaUrl" /> + </div> + + <div v-else-if="item.type === MsgType.Link" class="avue-card__detail"> + <el-link type="success" :underline="false" target="_blank" :href="item.url"> + <div class="avue-card__title"><i class="el-icon-link"></i>{{ item.title }}</div> + </el-link> + <div class="avue-card__info" style="height: unset">{{ item.description }}</div> + </div> + + <div v-else-if="item.type === MsgType.Location"> + <WxLocation :label="item.label" :location-y="item.locationY" :location-x="item.locationX" /> + </div> + + <div v-else-if="item.type === MsgType.News" style="width: 300px"> + <WxNews :articles="item.articles" /> + </div> + + <div v-else-if="item.type === MsgType.Music"> + <WxMusic + :title="item.title" + :description="item.description" + :thumb-media-url="item.thumbMediaUrl" + :music-url="item.musicUrl" + :hq-music-url="item.hqMusicUrl" + /> + </div> + </div> +</template> + +<script setup lang="ts" name="Msg"> +import WxVideoPlayer from '@/views/mp/components/wx-video-play' +import WxVoicePlayer from '@/views/mp/components/wx-voice-play' +import WxNews from '@/views/mp/components/wx-news' +import WxLocation from '@/views/mp/components/wx-location' +import WxMusic from '@/views/mp/components/wx-music' +import MsgEvent from './MsgEvent.vue' +import { MsgType } from '../types' + +const props = defineProps<{ + item: any +}>() + +const item = ref<any>(props.item) +</script> + +<style scoped></style> diff --git a/src/views/mp/components/wx-msg/components/MsgEvent.vue b/src/views/mp/components/wx-msg/components/MsgEvent.vue new file mode 100644 index 00000000..d23c9366 --- /dev/null +++ b/src/views/mp/components/wx-msg/components/MsgEvent.vue @@ -0,0 +1,49 @@ +<template> + <div> + <div v-if="item.event === 'subscribe'"> + <el-tag type="success">关注</el-tag> + </div> + <div v-else-if="item.event === 'unsubscribe'"> + <el-tag type="danger">取消关注</el-tag> + </div> + <div v-else-if="item.event === 'CLICK'"> + <el-tag>点击菜单</el-tag> + 【{{ item.eventKey }}】 + </div> + <div v-else-if="item.event === 'VIEW'"> + <el-tag>点击菜单链接</el-tag> + 【{{ item.eventKey }}】 + </div> + <div v-else-if="item.event === 'scancode_waitmsg'"> + <el-tag>扫码结果</el-tag> + 【{{ item.eventKey }}】 + </div> + <div v-else-if="item.event === 'scancode_push'"> + <el-tag>扫码结果</el-tag> + 【{{ item.eventKey }}】 + </div> + <div v-else-if="item.event === 'pic_sysphoto'"> + <el-tag>系统拍照发图</el-tag> + </div> + <div v-else-if="item.event === 'pic_photo_or_album'"> + <el-tag>拍照或者相册</el-tag> + </div> + <div v-else-if="item.event === 'pic_weixin'"> + <el-tag>微信相册</el-tag> + </div> + <div v-else-if="item.event === 'location_select'"> + <el-tag>选择地理位置</el-tag> + </div> + <div v-else> + <el-tag type="danger">未知事件类型</el-tag> + </div> + </div> +</template> + +<script setup lang="ts"> +const props = defineProps<{ + item: any +}>() + +const item = ref(props.item) +</script> diff --git a/src/views/mp/components/wx-msg/components/MsgList.vue b/src/views/mp/components/wx-msg/components/MsgList.vue new file mode 100644 index 00000000..f759adda --- /dev/null +++ b/src/views/mp/components/wx-msg/components/MsgList.vue @@ -0,0 +1,60 @@ +<template> + <div class="execution" v-for="item in props.list" :key="item.id"> + <div + class="avue-comment" + :class="{ 'avue-comment--reverse': item.sendFrom === SendFrom.MpBot }" + > + <div class="avatar-div"> + <img :src="getAvatar(item.sendFrom)" class="avue-comment__avatar" /> + <div class="avue-comment__author"> + {{ getNickname(item.sendFrom) }} + </div> + </div> + <div class="avue-comment__main"> + <div class="avue-comment__header"> + <div class="avue-comment__create_time">{{ formatDate(item.createTime) }}</div> + </div> + <div + class="avue-comment__body" + :style="item.sendFrom === SendFrom.MpBot ? 'background: #6BED72;' : ''" + > + <Msg :item="item" /> + </div> + </div> + </div> + </div> +</template> +<script setup lang="ts" name="MsgList"> +import Msg from './Msg.vue' +import { formatDate } from '@/utils/formatTime' +import { User } from '../types' +import avatarWechat from '@/assets/imgs/wechat.png' + +const props = defineProps<{ + list: any[] + accountId: number + user: User +}>() + +enum SendFrom { + User = 1, + MpBot = 2 +} + +const getAvatar = (sendFrom: SendFrom) => + sendFrom === SendFrom.User ? props.user.avatar : avatarWechat + +const getNickname = (sendFrom: SendFrom) => + sendFrom === SendFrom.User ? props.user.nickname : '公众号' +</script> + +<style lang="scss" scoped> +/* 因为 joolun 实现依赖 avue 组件,该页面使用了 comment.scss、card.scc */ +@import '../comment.scss'; +@import '../card.scss'; + +.avatar-div { + text-align: center; + width: 80px; +} +</style> diff --git a/src/views/mp/components/wx-msg/main.vue b/src/views/mp/components/wx-msg/main.vue index 19763245..1eeab64a 100644 --- a/src/views/mp/components/wx-msg/main.vue +++ b/src/views/mp/components/wx-msg/main.vue @@ -7,123 +7,22 @@ --> <template> <ContentWrap> - <div class="msg-div" :id="'msg-div' + nowStr"> + <div class="msg-div" ref="msgDivRef"> <!-- 加载更多 --> <div v-loading="loading"></div> <div v-if="!loading"> - <div class="el-table__empty-block" v-if="loadMore" @click="loadingMore" + <div class="el-table__empty-block" v-if="hasMore" @click="loadMore" ><span class="el-table__empty-text">点击加载更多</span></div > - <div class="el-table__empty-block" v-if="!loadMore" + <div class="el-table__empty-block" v-if="!hasMore" ><span class="el-table__empty-text">没有更多了</span></div > </div> + <!-- 消息列表 --> - <div class="execution" v-for="item in list" :key="item.id"> - <div class="avue-comment" :class="item.sendFrom === 2 ? 'avue-comment--reverse' : ''"> - <div class="avatar-div"> - <img - :src="item.sendFrom === 1 ? user.avatar : mp.avatar" - class="avue-comment__avatar" - /> - <div class="avue-comment__author" - >{{ item.sendFrom === 1 ? user.nickname : mp.nickname }} - </div> - </div> - <div class="avue-comment__main"> - <div class="avue-comment__header"> - <div class="avue-comment__create_time">{{ formatDate(item.createTime) }}</div> - </div> - <div - class="avue-comment__body" - :style="item.sendFrom === 2 ? 'background: #6BED72;' : ''" - > - <!-- 【事件】区域 --> - <div v-if="item.type === MsgType.Event && item.event === 'subscribe'"> - <el-tag type="success">关注</el-tag> - </div> - <div v-else-if="item.type === MsgType.Event && item.event === 'unsubscribe'"> - <el-tag type="danger">取消关注</el-tag> - </div> - <div v-else-if="item.type === MsgType.Event && item.event === 'CLICK'"> - <el-tag>点击菜单</el-tag> - 【{{ item.eventKey }}】 - </div> - <div v-else-if="item.type === MsgType.Event && item.event === 'VIEW'"> - <el-tag>点击菜单链接</el-tag> - 【{{ item.eventKey }}】 - </div> - <div v-else-if="item.type === MsgType.Event && item.event === 'scancode_waitmsg'"> - <el-tag>扫码结果</el-tag> - 【{{ item.eventKey }}】 - </div> - <div v-else-if="item.type === MsgType.Event && item.event === 'scancode_push'"> - <el-tag>扫码结果</el-tag> - 【{{ item.eventKey }}】 - </div> - <div v-else-if="item.type === MsgType.Event && item.event === 'pic_sysphoto'"> - <el-tag>系统拍照发图</el-tag> - </div> - <div v-else-if="item.type === MsgType.Event && item.event === 'pic_photo_or_album'"> - <el-tag>拍照或者相册</el-tag> - </div> - <div v-else-if="item.type === MsgType.Event && item.event === 'pic_weixin'"> - <el-tag>微信相册</el-tag> - </div> - <div v-else-if="item.type === MsgType.Event && item.event === 'location_select'"> - <el-tag>选择地理位置</el-tag> - </div> - <div v-else-if="item.type === MsgType.Event"> - <el-tag type="danger">未知事件类型</el-tag> - </div> - <!-- 【消息】区域 --> - <div v-else-if="item.type === MsgType.Text">{{ item.content }}</div> - <div v-else-if="item.type === MsgType.Voice"> - <WxVoicePlayer :url="item.mediaUrl" :content="item.recognition" /> - </div> - <div v-else-if="item.type === MsgType.Image"> - <a target="_blank" :href="item.mediaUrl"> - <img :src="item.mediaUrl" style="width: 100px" /> - </a> - </div> - <div - v-else-if="item.type === MsgType.Video || item.type === 'shortvideo'" - style="text-align: center" - > - <WxVideoPlayer :url="item.mediaUrl" /> - </div> - <div v-else-if="item.type === MsgType.Link" class="avue-card__detail"> - <el-link type="success" :underline="false" target="_blank" :href="item.url"> - <div class="avue-card__title"><i class="el-icon-link"></i>{{ item.title }}</div> - </el-link> - <div class="avue-card__info" style="height: unset">{{ item.description }}</div> - </div> - <!-- TODO 芋艿:待完善 --> - <div v-else-if="item.type === MsgType.Location"> - <WxLocation - :label="item.label" - :location-y="item.locationY" - :location-x="item.locationX" - /> - </div> - <div v-else-if="item.type === MsgType.News" style="width: 300px"> - <!-- TODO 芋艿:待测试;详情页也存在类似的情况 --> - <WxNews :articles="item.articles" /> - </div> - <div v-else-if="item.type === MsgType.Music"> - <WxMusic - :title="item.title" - :description="item.description" - :thumb-media-url="item.thumbMediaUrl" - :music-url="item.musicUrl" - :hq-music-url="item.hqMusicUrl" - /> - </div> - </div> - </div> - </div> - </div> + <MsgList :list="list" :account-id="accountId" :user="user" /> </div> + <div class="msg-send" v-loading="sendLoading"> <WxReplySelect ref="replySelectRef" v-model="reply" /> <el-button type="success" class="send-but" @click="sendMsg">发送(S)</el-button> @@ -132,18 +31,12 @@ </template> <script setup lang="ts" name="WxMsg"> -import WxReplySelect from '@/views/mp/components/wx-reply' -import WxVideoPlayer from '@/views/mp/components/wx-video-play' -import WxVoicePlayer from '@/views/mp/components/wx-voice-play' -import WxNews from '@/views/mp/components/wx-news' -import WxLocation from '@/views/mp/components/wx-location' -import WxMusic from '@/views/mp/components/wx-music' +import WxReplySelect, { Reply, ReplyType } from '@/views/mp/components/wx-reply' +import MsgList from './components/MsgList.vue' import { getMessagePage, sendMessage } from '@/api/mp/message' import { getUser } from '@/api/mp/user' -import { formatDate } from '@/utils/formatTime' import profile from '@/assets/imgs/profile.jpg' -import wechat from '@/assets/imgs/wechat.png' -import { MsgType } from './types' +import { User } from './types' const message = useMessage() // 消息弹窗 @@ -154,61 +47,41 @@ const props = defineProps({ } }) -const nowStr = ref(new Date().getTime()) // 当前的时间戳,用于每次消息加载后,回到原位置;具体见 :id="'msg-div' + nowStr" 处 +const accountId = ref(-1) // 公众号ID,需要通过userId初始化 const loading = ref(false) // 消息列表是否正在加载中 -const loadMore = ref(true) // 是否可以加载更多 +const hasMore = ref(true) // 是否可以加载更多 const list = ref<any[]>([]) // 消息列表 const queryParams = reactive({ pageNo: 1, // 当前页数 pageSize: 14, // 每页显示多少条 - accountId: undefined + accountId: accountId }) -interface User { - nickname: string - avatar: string - accountId: number -} // 由于微信不再提供昵称,直接使用“用户”展示 const user: User = reactive({ nickname: '用户', avatar: profile, - accountId: 0 // 公众号账号编号 -}) - -interface Mp { - nickname: string - avatar: string -} -const mp: Mp = reactive({ - nickname: '公众号', - avatar: wechat + accountId: accountId // 公众号账号编号 }) // ========= 消息发送 ========= const sendLoading = ref(false) // 发送消息是否加载中 -interface Reply { - type: MsgType - accountId: number | null - articles: any[] -} - // 微信发送消息 const reply = ref<Reply>({ - type: MsgType.Text, - accountId: null, + type: ReplyType.Text, + accountId: -1, articles: [] }) -const replySelectRef = ref<InstanceType<typeof WxReplySelect> | null>(null) +const replySelectRef = ref<InstanceType<typeof WxReplySelect> | null>(null) // WxReplySelect组件ref,用于消息发送成功后清除内容 +const msgDivRef = ref<HTMLDivElement | null>(null) // 消息显示窗口ref,用于滚动到底部 /** 完成加载 */ onMounted(async () => { const data = await getUser(props.userId) user.nickname = data.nickname?.length > 0 ? data.nickname : user.nickname user.avatar = user.avatar?.length > 0 ? data.avatar : user.avatar - user.accountId = data.accountId - queryParams.accountId = data.accountId + accountId.value = data.accountId reply.value.accountId = data.accountId refreshChange() @@ -216,11 +89,15 @@ onMounted(async () => { // 执行发送 const sendMsg = async () => { - if (!reply) { + if (!unref(reply)) { return } // 公众号限制:客服消息,公众号只允许发送一条 - if (reply.value.type === MsgType.News && reply.value.articles.length > 1) { + if ( + reply.value.type === ReplyType.News && + reply.value.articles && + reply.value.articles.length > 1 + ) { reply.value.articles = [reply.value.articles[0]] message.success('图文消息条数限制在 1 条以内,已默认发送第一条') } @@ -229,18 +106,18 @@ const sendMsg = async () => { sendLoading.value = false list.value = [...list.value, ...[data]] - scrollToBottom() + await scrollToBottom() // 发送后清空数据 replySelectRef.value?.clear() } -const loadingMore = () => { +const loadMore = () => { queryParams.pageNo++ getPage(queryParams, null) } -const getPage = async (page, params) => { +const getPage = async (page: any, params: any = null) => { loading.value = true let dataTemp = await getMessagePage( Object.assign( @@ -254,62 +131,45 @@ const getPage = async (page, params) => { ) ) - const msgDiv = document.getElementById('msg-div' + nowStr.value) - let scrollHeight = 0 - if (msgDiv) { - scrollHeight = msgDiv.scrollHeight - } + const scrollHeight = msgDivRef.value?.scrollHeight ?? 0 // 处理数据 const data = dataTemp.list.reverse() list.value = [...data, ...list.value] loading.value = false if (data.length < queryParams.pageSize || data.length === 0) { - loadMore.value = false + hasMore.value = false } queryParams.pageNo = page.pageNo queryParams.pageSize = page.pageSize // 滚动到原来的位置 if (queryParams.pageNo === 1) { // 定位到消息底部 - scrollToBottom() + await scrollToBottom() } else if (data.length !== 0) { // 定位滚动条 - await nextTick(() => { - if (scrollHeight !== 0) { - let div = document.getElementById('msg-div' + nowStr.value) - if (div && msgDiv) { - msgDiv.scrollTop = div.scrollHeight - scrollHeight - 100 - } + await nextTick() + if (scrollHeight !== 0) { + if (msgDivRef.value) { + msgDivRef.value.scrollTop = msgDivRef.value.scrollHeight - scrollHeight - 100 } - }) + } } } const refreshChange = () => { - getPage(queryParams, null) + getPage(queryParams) } /** 定位到消息底部 */ -const scrollToBottom = () => { - nextTick(() => { - let div = document.getElementById('msg-div' + nowStr.value) - if (div) { - div.scrollTop = div.scrollHeight - } - }) +const scrollToBottom = async () => { + await nextTick() + if (msgDivRef.value) { + msgDivRef.value.scrollTop = msgDivRef.value.scrollHeight + } } </script> <style lang="scss" scoped> -/* 因为 joolun 实现依赖 avue 组件,该页面使用了 comment.scss、card.scc */ -@import './comment.scss'; -@import './card.scss'; - -.msg-main { - margin-top: -30px; - padding: 10px; -} - .msg-div { height: 50vh; overflow: auto; @@ -322,11 +182,6 @@ const scrollToBottom = () => { padding: 10px; } -.avatar-div { - text-align: center; - width: 80px; -} - .send-but { float: right; margin-top: 8px; diff --git a/src/views/mp/components/wx-msg/types.ts b/src/views/mp/components/wx-msg/types.ts index b1989ea7..38a0ff86 100644 --- a/src/views/mp/components/wx-msg/types.ts +++ b/src/views/mp/components/wx-msg/types.ts @@ -9,3 +9,9 @@ export enum MsgType { Music = 'music', News = 'news' } + +export interface User { + nickname: string + avatar: string + accountId: number +} diff --git a/src/views/mp/components/wx-music/main.vue b/src/views/mp/components/wx-music/main.vue index 70f4c58b..f528359d 100644 --- a/src/views/mp/components/wx-music/main.vue +++ b/src/views/mp/components/wx-music/main.vue @@ -55,6 +55,6 @@ defineExpose({ </script> <style lang="scss" scoped> -/* 因为 joolun 实现依赖 avue 组件,该页面使用了 card.scc */ -@import url('../wx-msg/card.scss'); +/* 因为 joolun 实现依赖 avue 组件,该页面使用了 card.scss */ +@import '../wx-msg/card.scss'; </style> diff --git a/src/views/mp/draft/components/CoverSelect.vue b/src/views/mp/draft/components/CoverSelect.vue index 944b7d96..bbb2b44c 100644 --- a/src/views/mp/draft/components/CoverSelect.vue +++ b/src/views/mp/draft/components/CoverSelect.vue @@ -51,7 +51,7 @@ > <WxMaterialSelect type="image" - :account-id="accountId" + :account-id="accountId!" @select-material="onMaterialSelected" /> </el-dialog> @@ -93,11 +93,11 @@ const showImageDialog = ref(false) const fileList = ref<UploadFiles>([]) interface UploadData { type: UploadType - accountId: number | undefined + accountId: number } const uploadData: UploadData = reactive({ type: UploadType.Image, - accountId: accountId + accountId: accountId! }) /** 素材选择完成事件*/ diff --git a/src/views/mp/draft/components/NewsForm.vue b/src/views/mp/draft/components/NewsForm.vue index a2b88a5b..97166a4b 100644 --- a/src/views/mp/draft/components/NewsForm.vue +++ b/src/views/mp/draft/components/NewsForm.vue @@ -125,7 +125,7 @@ </el-container> </template> -<script setup lang="ts"> +<script setup lang="ts" name="NewsForm"> import { Editor } from '@/components/Editor' import { createEditorConfig } from '../editor-config' import CoverSelect from './CoverSelect.vue' diff --git a/src/views/mp/draft/index.vue b/src/views/mp/draft/index.vue index d8e771a0..a916ed39 100644 --- a/src/views/mp/draft/index.vue +++ b/src/views/mp/draft/index.vue @@ -76,30 +76,17 @@ import { const message = useMessage() // 消息 -const accountId = ref<number>(0) +const accountId = ref(-1) provide('accountId', accountId) const loading = ref(true) // 列表的加载中 const list = ref<any[]>([]) // 列表的数据 const total = ref(0) // 列表的总页数 -interface QueryParams { - pageNo: number - pageSize: number - accountId: number -} -const queryParams: QueryParams = reactive({ + +const queryParams = reactive({ pageNo: 1, pageSize: 10, - accountId: 0 -}) - -interface UploadData { - type: 'image' | 'video' | 'audio' - accountId: number -} -const uploadData: UploadData = reactive({ - type: 'image', - accountId: 0 + accountId: accountId }) // ========== 草稿新建 or 修改 ========== @@ -111,7 +98,8 @@ const isSubmitting = ref(false) /** 侦听公众号变化 **/ const onAccountChanged = (id: number) => { - setAccountId(id) + accountId.value = id + queryParams.pageNo = 1 getList() } @@ -124,12 +112,6 @@ const onBeforeDialogClose = async (onDone: () => {}) => { } // ======================== 列表查询 ======================== -/** 设置账号编号 */ -const setAccountId = (id: number) => { - queryParams.accountId = id - uploadData.accountId = id -} - /** 查询列表 */ const getList = async () => { loading.value = true @@ -170,10 +152,10 @@ const onSubmitNewsItem = async () => { isSubmitting.value = true try { if (isCreating.value) { - await MpDraftApi.createDraft(queryParams.accountId, newsList.value) + await MpDraftApi.createDraft(accountId.value, newsList.value) message.notifySuccess('新增成功') } else { - await MpDraftApi.updateDraft(queryParams.accountId, mediaId.value, newsList.value) + await MpDraftApi.updateDraft(accountId.value, mediaId.value, newsList.value) message.notifySuccess('更新成功') } } finally { @@ -185,7 +167,6 @@ const onSubmitNewsItem = async () => { // ======================== 草稿箱发布 ======================== const onPublish = async (item: Article) => { - const accountId = queryParams.accountId const mediaId = item.mediaId const content = '你正在通过发布的方式发表内容。 发布不占用群发次数,一天可多次发布。' + @@ -193,7 +174,7 @@ const onPublish = async (item: Article) => { '发布后,你可以前往发表记录获取链接,也可以将发布内容添加到自定义菜单、自动回复、话题和页面模板中。' try { await message.confirm(content) - await MpFreePublishApi.submitFreePublish(accountId, mediaId) + await MpFreePublishApi.submitFreePublish(accountId.value, mediaId) message.notifySuccess('发布成功') await getList() } catch {} @@ -201,11 +182,10 @@ const onPublish = async (item: Article) => { /** 删除按钮操作 */ const onDelete = async (item: Article) => { - const accountId = queryParams.accountId const mediaId = item.mediaId try { await message.confirm('此操作将永久删除该草稿, 是否继续?') - await MpDraftApi.deleteDraft(accountId, mediaId) + await MpDraftApi.deleteDraft(accountId.value, mediaId) message.notifySuccess('删除成功') await getList() } catch {} diff --git a/src/views/mp/freePublish/index.vue b/src/views/mp/freePublish/index.vue index 08a202c2..62ca1999 100644 --- a/src/views/mp/freePublish/index.vue +++ b/src/views/mp/freePublish/index.vue @@ -59,20 +59,16 @@ const loading = ref(true) // 列表的加载中 const total = ref(0) // 列表的总页数 const list = ref<any[]>([]) // 列表的数据 -interface QueryParams { - pageNo: number - pageSize: number - accountId: number -} -const queryParams: QueryParams = reactive({ +const queryParams = reactive({ pageNo: 1, pageSize: 10, - accountId: 0 + accountId: -1 }) /** 侦听公众号变化 **/ const onAccountChanged = (id: number) => { queryParams.accountId = id + queryParams.pageNo = 1 getList() } diff --git a/src/views/mp/material/index.vue b/src/views/mp/material/index.vue index 0e2a87d6..b72c9ad6 100644 --- a/src/views/mp/material/index.vue +++ b/src/views/mp/material/index.vue @@ -100,16 +100,10 @@ const loading = ref(false) // 遮罩层 const list = ref<any[]>([]) // 总条数 const total = ref(0) // 数据列表 // 查询参数 -interface QueryParams { - pageNo: number - pageSize: number - accountId: number - permanent: boolean -} -const queryParams: QueryParams = reactive({ +const queryParams = reactive({ pageNo: 1, pageSize: 10, - accountId: 0, + accountId: -1, permanent: true }) const showCreateVideo = ref(false) // 是否新建视频的弹窗 @@ -117,6 +111,7 @@ const showCreateVideo = ref(false) // 是否新建视频的弹窗 /** 侦听公众号变化 **/ const onAccountChanged = (id: number) => { queryParams.accountId = id + queryParams.pageNo = 1 getList() } diff --git a/src/views/mp/menu/components/MenuPreviewer.vue b/src/views/mp/menu/components/MenuPreviewer.vue index 3066e590..d2626320 100644 --- a/src/views/mp/menu/components/MenuPreviewer.vue +++ b/src/views/mp/menu/components/MenuPreviewer.vue @@ -4,7 +4,7 @@ item-key="id" ghost-class="draggable-ghost" :animation="400" - @end="onDragEnd" + @end="onParentDragEnd" > <template #item="{ element: parent, index: x }"> <div class="menu_bottom"> @@ -23,6 +23,7 @@ item-key="id" ghost-class="draggable-ghost" :animation="400" + @end="onChildDragEnd" > <template #item="{ element: child, index: y }"> <div class="subtitle menu_bottom"> @@ -118,42 +119,49 @@ const subMenuClicked = (child: Menu, x: number, y: number) => { } /** - * 处理一级菜单展开后被拖动 + * 处理一级菜单展开后被拖动,激活(展开)原来活动的一级菜单 * * @param oldIndex: 一级菜单拖动前的位置 * @param newIndex: 一级菜单拖动后的位置 */ -const onDragEnd = ({ oldIndex, newIndex }) => { +const onParentDragEnd = ({ oldIndex, newIndex }) => { // 二级菜单没有展开,直接返回 if (props.activeIndex === '__MENU_NOT_SELECTED__') { return } - let newParent = props.parentIndex - if (props.parentIndex === oldIndex) { - newParent = newIndex - } else if (props.parentIndex === newIndex) { - newParent = oldIndex - } else { - // 如果展开的二级菜单下标`props.parentIndex`不是被移动的菜单的前后下标。 - // 那么使用一个辅助素组来模拟菜单移动,然后找到展开的二级菜单的新下标`newParent` - let positions = new Array<boolean>(menuList.value.length).fill(false) - positions[props.parentIndex] = true - positions.splice(oldIndex, 1) - positions.splice(newIndex, 0, true) - newParent = positions.indexOf(true) - } + // 使用一个辅助数组来模拟菜单移动,然后找到展开的二级菜单的新下标`newParent` + let positions = new Array<boolean>(menuList.value.length).fill(false) + positions[props.parentIndex] = true + const [out] = positions.splice(oldIndex, 1) // 移出菜单,保存到变量out + positions.splice(newIndex, 0, out) // 把out变量插入被移出的菜单 + const newParentIndex = positions.indexOf(true) // 找到菜单元素,触发一级菜单点击 - const parent = menuList.value[newParent] - emit('menu-clicked', parent, newParent) + const parent = menuList.value[newParentIndex] + emit('menu-clicked', parent, newParentIndex) +} + +/** + * 处理二级菜单展开后被拖动,激活被拖动的菜单 + * + * @param newIndex 二级菜单拖动后的位置 + */ +const onChildDragEnd = ({ newIndex }) => { + const x = props.parentIndex + const y = newIndex + const children = menuList.value[x]?.children + if (children && children?.length > 0) { + const child = children[y] + emit('submenu-clicked', child, x, y) + } } </script> <style lang="scss" scoped> .menu_bottom { position: relative; - display: inline-block; + display: block; float: left; width: 85.5px; text-align: center; diff --git a/src/views/mp/menu/index.vue b/src/views/mp/menu/index.vue index 442e33b5..0b02cc16 100644 --- a/src/views/mp/menu/index.vue +++ b/src/views/mp/menu/index.vue @@ -65,7 +65,7 @@ const MENU_NOT_SELECTED = '__MENU_NOT_SELECTED__' // ======================== 列表查询 ======================== const loading = ref(false) // 遮罩层 -const accountId = ref<number>(0) +const accountId = ref(-1) const accountName = ref<string>('') const menuList = ref<Menu[]>([]) @@ -339,7 +339,7 @@ div { .left { position: relative; - display: inline-block; + display: block; float: left; width: 350px; height: 715px; diff --git a/src/views/mp/message/index.vue b/src/views/mp/message/index.vue index 85048f38..db92cc0f 100644 --- a/src/views/mp/message/index.vue +++ b/src/views/mp/message/index.vue @@ -93,20 +93,12 @@ const total = ref(0) // 数据的总页数 const list = ref<any[]>([]) // 当前页的列表数据 // 搜索参数 -interface QueryParams { - pageNo: number - pageSize: number - openid: string | undefined - accountId: number - type: MsgType | undefined - createTime: string[] | [] -} -const queryParams: QueryParams = reactive({ +const queryParams = reactive({ pageNo: 1, pageSize: 10, - openid: undefined, - accountId: 0, - type: undefined, + openid: '', + accountId: -1, + type: MsgType.Text, createTime: [] }) const queryFormRef = ref<FormInstance | null>(null) // 搜索的表单 @@ -120,6 +112,7 @@ const messageBox = reactive({ /** 侦听accountId */ const onAccountChanged = (id: number) => { queryParams.accountId = id + queryParams.pageNo = 1 handleQuery() } diff --git a/src/views/mp/statistics/index.vue b/src/views/mp/statistics/index.vue index cef8e079..4e2dbfcc 100644 --- a/src/views/mp/statistics/index.vue +++ b/src/views/mp/statistics/index.vue @@ -84,7 +84,7 @@ const dateRange = ref([ beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7)), endOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24)) ]) -const accountId = ref() // 选中的公众号编号 +const accountId = ref(-1) // 选中的公众号编号 const accountList = ref<MpAccountApi.AccountVO[]>([]) // 公众号账号列表 const xAxisDate = ref([] as any[]) // X 轴的日期范围 @@ -232,7 +232,7 @@ const getAccountList = async () => { accountList.value = await MpAccountApi.getSimpleAccountList() // 默认选中第一个 if (accountList.value.length > 0) { - accountId.value = accountList.value[0].id + accountId.value = accountList.value[0].id! } } diff --git a/src/views/mp/tag/index.vue b/src/views/mp/tag/index.vue index a92d9127..8d452a5d 100644 --- a/src/views/mp/tag/index.vue +++ b/src/views/mp/tag/index.vue @@ -95,23 +95,18 @@ const loading = ref(true) // 列表的加载中 const total = ref(0) // 列表的总页数 const list = ref<any[]>([]) // 列表的数据 -interface QueryParams { - pageNo: number - pageSize: number - accountId: number -} -const queryParams: QueryParams = reactive({ +const queryParams = reactive({ pageNo: 1, pageSize: 10, - accountId: 0 + accountId: -1 }) const formRef = ref<InstanceType<typeof TagForm> | null>(null) /** 侦听公众号变化 **/ const onAccountChanged = (id: number) => { - queryParams.pageNo = 1 queryParams.accountId = id + queryParams.pageNo = 1 getList() } diff --git a/src/views/mp/user/index.vue b/src/views/mp/user/index.vue index 03e58a7f..422e219b 100644 --- a/src/views/mp/user/index.vue +++ b/src/views/mp/user/index.vue @@ -113,27 +113,20 @@ const loading = ref(true) // 列表的加载中 const total = ref(0) // 列表的总页数 const list = ref<any[]>([]) // 列表的数据 -interface QueryParams { - pageNo: number - pageSize: number - accountId: number - openid: string | null - nickname: string | null -} -const queryParams: QueryParams = reactive({ +const queryParams = reactive({ pageNo: 1, pageSize: 10, - accountId: 0, - openid: null, - nickname: null + accountId: -1, + openid: '', + nickname: '' }) const queryFormRef = ref<FormInstance | null>(null) // 搜索的表单 const tagList = ref<any[]>([]) // 公众号标签列表 /** 侦听公众号变化 **/ const onAccountChanged = (id: number) => { - queryParams.pageNo = 1 queryParams.accountId = id + queryParams.pageNo = 1 getList() } diff --git a/src/views/system/dict/index.vue b/src/views/system/dict/index.vue index bbcd8a2c..755b9415 100644 --- a/src/views/system/dict/index.vue +++ b/src/views/system/dict/index.vue @@ -2,36 +2,36 @@ <!-- 搜索工作栏 --> <ContentWrap> <el-form - class="-mb-15px" - :model="queryParams" ref="queryFormRef" :inline="true" + :model="queryParams" + class="-mb-15px" label-width="68px" > <el-form-item label="字典名称" prop="name"> <el-input v-model="queryParams.name" - placeholder="请输入字典名称" - clearable - @keyup.enter="handleQuery" class="!w-240px" + clearable + placeholder="请输入字典名称" + @keyup.enter="handleQuery" /> </el-form-item> <el-form-item label="字典类型" prop="type"> <el-input v-model="queryParams.type" - placeholder="请输入字典类型" - clearable - @keyup.enter="handleQuery" class="!w-240px" + clearable + placeholder="请输入字典类型" + @keyup.enter="handleQuery" /> </el-form-item> <el-form-item label="状态" prop="status"> <el-select v-model="queryParams.status" - placeholder="请选择字典状态" - clearable class="!w-240px" + clearable + placeholder="请选择字典状态" > <el-option v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" @@ -44,33 +44,41 @@ <el-form-item label="创建时间" prop="createTime"> <el-date-picker v-model="queryParams.createTime" - value-format="yyyy-MM-dd HH:mm:ss" - type="daterange" - start-placeholder="开始日期" - end-placeholder="结束日期" :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="yyyy-MM-dd HH:mm:ss" /> </el-form-item> <el-form-item> - <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> - <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> - <el-button - type="primary" - plain - @click="openForm('create')" - v-hasPermi="['system:dict:create']" - > - <Icon icon="ep:plus" class="mr-5px" /> 新增 + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 </el-button> <el-button - type="success" + v-hasPermi="['system:dict:create']" plain - @click="handleExport" - :loading="exportLoading" - v-hasPermi="['system:dict:export']" + type="primary" + @click="openForm('create')" > - <Icon icon="ep:download" class="mr-5px" /> 导出 + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + <el-button + v-hasPermi="['system:dict:export']" + :loading="exportLoading" + plain + type="success" + @click="handleExport" + > + <Icon class="mr-5px" icon="ep:download" /> + 导出 </el-button> </el-form-item> </el-form> @@ -79,29 +87,29 @@ <!-- 列表 --> <ContentWrap> <el-table v-loading="loading" :data="list"> - <el-table-column label="字典编号" align="center" prop="id" /> - <el-table-column label="字典名称" align="center" prop="name" show-overflow-tooltip /> - <el-table-column label="字典类型" align="center" prop="type" width="300" /> - <el-table-column label="状态" align="center" prop="status"> + <el-table-column align="center" label="字典编号" prop="id" /> + <el-table-column align="center" label="字典名称" prop="name" show-overflow-tooltip /> + <el-table-column align="center" label="字典类型" prop="type" width="300" /> + <el-table-column align="center" label="状态" prop="status"> <template #default="scope"> <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> </template> </el-table-column> - <el-table-column label="备注" align="center" prop="remark" /> + <el-table-column align="center" label="备注" prop="remark" /> <el-table-column - label="创建时间" :formatter="dateFormatter" align="center" + label="创建时间" prop="createTime" width="180" /> - <el-table-column label="操作" align="center"> + <el-table-column align="center" label="操作"> <template #default="scope"> <el-button + v-hasPermi="['system:dict:update']" link type="primary" @click="openForm('update', scope.row.id)" - v-hasPermi="['system:dict:update']" > 修改 </el-button> @@ -109,10 +117,10 @@ <el-button link type="primary">数据</el-button> </router-link> <el-button + v-hasPermi="['system:dict:delete']" link type="danger" @click="handleDelete(scope.row.id)" - v-hasPermi="['system:dict:delete']" > 删除 </el-button> @@ -121,9 +129,9 @@ </el-table> <!-- 分页 --> <Pagination - :total="total" - v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" @pagination="getList" /> </ContentWrap> @@ -132,12 +140,13 @@ <DictTypeForm ref="formRef" @success="getList" /> </template> -<script setup lang="ts" name="SystemDictType"> -import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +<script lang="ts" name="SystemDictType" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' import { dateFormatter } from '@/utils/formatTime' import * as DictTypeApi from '@/api/system/dict/dict.type' import DictTypeForm from './DictTypeForm.vue' import download from '@/utils/download' + const message = useMessage() // 消息弹窗 const { t } = useI18n() // 国际化