Преглед изворни кода

Merge branch 'master' of https://gitee.com/yudaocode/yudao-ui-admin-vue3 into feature/bpm

YunaiV пре 1 година
родитељ
комит
28f768f3b2
88 измењених фајлова са 5523 додато и 441 уклоњено
  1. 0 2
      .env.dev
  2. 0 2
      .env.local
  3. 0 2
      .env.prod
  4. 0 2
      .env.stage
  5. 0 2
      .env.test
  6. 1 1
      .vscode/settings.json
  7. 1 1
      package.json
  8. 74 0
      src/api/iot/device/index.ts
  9. 62 0
      src/api/iot/product/index.ts
  10. 55 0
      src/api/iot/thinkmodelfunction/index.ts
  11. 6 1
      src/api/login/index.ts
  12. 7 0
      src/api/login/types.ts
  13. 91 0
      src/api/mall/promotion/point/index.ts
  14. 6 1
      src/api/mall/promotion/reward/rewardActivity.ts
  15. 6 0
      src/api/mall/promotion/seckill/seckillActivity.ts
  16. 8 2
      src/api/pay/order/index.ts
  17. 8 0
      src/components/AppLinkInput/data.ts
  18. 1 1
      src/components/ContentWrap/src/ContentWrap.vue
  19. 1 1
      src/components/DiyEditor/components/mobile/FloatingActionButton/index.vue
  20. 1 1
      src/components/DiyEditor/components/mobile/ProductCard/index.vue
  21. 1 1
      src/components/DiyEditor/components/mobile/ProductList/index.vue
  22. 96 0
      src/components/DiyEditor/components/mobile/PromotionPoint/config.ts
  23. 202 0
      src/components/DiyEditor/components/mobile/PromotionPoint/index.vue
  24. 154 0
      src/components/DiyEditor/components/mobile/PromotionPoint/property.vue
  25. 37 5
      src/components/DiyEditor/components/mobile/PromotionSeckill/config.ts
  26. 161 95
      src/components/DiyEditor/components/mobile/PromotionSeckill/index.vue
  27. 68 16
      src/components/DiyEditor/components/mobile/PromotionSeckill/property.vue
  28. 4 1
      src/components/DiyEditor/components/mobile/TabBar/property.vue
  29. 3 2
      src/components/Editor/src/Editor.vue
  30. 1 1
      src/components/FormCreate/src/config/useDictSelectRule.ts
  31. 9 14
      src/components/UploadFile/src/UploadImgs.vue
  32. 20 11
      src/components/UploadFile/src/useUpload.ts
  33. 1 1
      src/router/index.ts
  34. 32 0
      src/router/modules/remaining.ts
  35. 14 2
      src/utils/dict.ts
  36. 2 1
      src/utils/routerHelper.ts
  37. 251 114
      src/views/Login/components/RegisterForm.vue
  38. 4 4
      src/views/bpm/processInstance/index.vue
  39. 1 1
      src/views/crm/contract/detail/index.vue
  40. 4 2
      src/views/infra/webSocket/index.vue
  41. 156 0
      src/views/iot/device/DeviceForm.vue
  42. 76 0
      src/views/iot/device/detail/DeviceDetailsHeader.vue
  43. 123 0
      src/views/iot/device/detail/DeviceDetailsInfo.vue
  44. 66 0
      src/views/iot/device/detail/index.vue
  45. 267 0
      src/views/iot/device/index.vue
  46. 204 0
      src/views/iot/product/ProductForm.vue
  47. 103 0
      src/views/iot/product/detail/ProductDetailsHeader.vue
  48. 44 0
      src/views/iot/product/detail/ProductDetailsInfo.vue
  49. 243 0
      src/views/iot/product/detail/ProductTopic.vue
  50. 154 0
      src/views/iot/product/detail/ThinkModelFunction.vue
  51. 229 0
      src/views/iot/product/detail/ThinkModelFunctionForm.vue
  52. 80 0
      src/views/iot/product/detail/index.vue
  53. 191 0
      src/views/iot/product/index.vue
  54. 151 0
      src/views/knowledge/dataset-form/form-step1.vue
  55. 168 0
      src/views/knowledge/dataset-form/form-step2.vue
  56. 152 0
      src/views/knowledge/dataset.vue
  57. 1 1
      src/views/mall/product/property/value/index.vue
  58. 5 5
      src/views/mall/product/spu/components/SkuList.vue
  59. 1 1
      src/views/mall/product/spu/form/InfoForm.vue
  60. 44 40
      src/views/mall/promotion/combination/activity/index.vue
  61. 9 9
      src/views/mall/promotion/components/SpuAndSkuList.vue
  62. 3 2
      src/views/mall/promotion/coupon/components/CouponSelect.vue
  63. 18 3
      src/views/mall/promotion/coupon/formatter.ts
  64. 4 1
      src/views/mall/promotion/coupon/template/CouponTemplateForm.vue
  65. 7 1
      src/views/mall/promotion/coupon/template/index.vue
  66. 69 33
      src/views/mall/promotion/discountActivity/DiscountActivityForm.vue
  67. 0 2
      src/views/mall/promotion/discountActivity/discountActivity.data.ts
  68. 6 4
      src/views/mall/promotion/kefu/components/KeFuConversationList.vue
  69. 3 3
      src/views/mall/promotion/kefu/components/KeFuMessageList.vue
  70. 15 3
      src/views/mall/promotion/kefu/components/message/OrderItem.vue
  71. 5 2
      src/views/mall/promotion/kefu/components/tools/emoji.ts
  72. 4 2
      src/views/mall/promotion/kefu/index.vue
  73. 227 0
      src/views/mall/promotion/point/activity/PointActivityForm.vue
  74. 219 0
      src/views/mall/promotion/point/activity/index.vue
  75. 55 0
      src/views/mall/promotion/point/activity/pointActivity.data.ts
  76. 154 0
      src/views/mall/promotion/point/components/PointShowcase.vue
  77. 300 0
      src/views/mall/promotion/point/components/PointTableSelect.vue
  78. 8 2
      src/views/mall/promotion/rewardActivity/RewardForm.vue
  79. 12 1
      src/views/mall/promotion/rewardActivity/components/RewardRule.vue
  80. 2 2
      src/views/mall/promotion/rewardActivity/components/RewardRuleCouponSelect.vue
  81. 32 5
      src/views/mall/promotion/rewardActivity/index.vue
  82. 156 0
      src/views/mall/promotion/seckill/components/SeckillShowcase.vue
  83. 343 0
      src/views/mall/promotion/seckill/components/SeckillTableSelect.vue
  84. 11 0
      src/views/mp/components/wx-account-select/main.vue
  85. 6 25
      src/views/mp/statistics/index.vue
  86. 1 1
      src/views/pay/cashier/index.vue
  87. 3 2
      src/views/report/jmreport/index.vue
  88. 0 1
      types/env.d.ts

+ 0 - 2
.env.dev

@@ -8,8 +8,6 @@ VITE_BASE_URL='http://api-dashboard.yudao.iocoder.cn'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
 VITE_UPLOAD_TYPE=server
-# 上传路径
-VITE_UPLOAD_URL='http://api-dashboard.yudao.iocoder.cn/admin-api/infra/file/upload'
 
 # 接口地址
 VITE_API_URL=/admin-api

+ 0 - 2
.env.local

@@ -8,8 +8,6 @@ VITE_BASE_URL='http://localhost:48080'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持 S3 服务
 VITE_UPLOAD_TYPE=server
-# 上传路径
-VITE_UPLOAD_URL='http://localhost:48080/admin-api/infra/file/upload'
 
 # 接口地址
 VITE_API_URL=/admin-api

+ 0 - 2
.env.prod

@@ -8,8 +8,6 @@ VITE_BASE_URL='http://localhost:48080'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
 VITE_UPLOAD_TYPE=server
-# 上传路径
-VITE_UPLOAD_URL='http://localhost:48080/admin-api/infra/file/upload'
 
 # 接口地址
 VITE_API_URL=/admin-api

+ 0 - 2
.env.stage

@@ -8,8 +8,6 @@ VITE_BASE_URL='http://api-dashboard.yudao.iocoder.cn'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
 VITE_UPLOAD_TYPE=server
-# 上传路径
-VITE_UPLOAD_URL='http://api-dashboard.yudao.iocoder.cn/admin-api/infra/file/upload'
 
 # 接口地址
 VITE_API_URL=/admin-api

+ 0 - 2
.env.test

@@ -8,8 +8,6 @@ VITE_BASE_URL='http://localhost:48080'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
 VITE_UPLOAD_TYPE=server
-# 上传路径
-VITE_UPLOAD_URL='http://localhost:48080/admin-api/infra/file/upload'
 
 # 接口地址
 VITE_API_URL=/admin-api

+ 1 - 1
.vscode/settings.json

@@ -87,7 +87,7 @@
     "source.fixAll.stylelint": "explicit"
   },
   "[vue]": {
-    "editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
   },
   "i18n-ally.localesPaths": ["src/locales"],
   "i18n-ally.keystyle": "nested",

+ 1 - 1
package.json

@@ -130,7 +130,7 @@
     "vite-plugin-progress": "^0.0.7",
     "vite-plugin-purge-icons": "^0.10.0",
     "vite-plugin-svg-icons": "^2.0.1",
-    "vite-plugin-top-level-await": "^1.3.1",
+    "vite-plugin-top-level-await": "^1.4.4",
     "vue-eslint-parser": "^9.3.2",
     "vue-tsc": "^1.8.27"
   },

+ 74 - 0
src/api/iot/device/index.ts

@@ -0,0 +1,74 @@
+import request from '@/config/axios'
+
+// IoT 设备 VO
+export interface DeviceVO {
+  id: number // 设备 ID,主键,自增
+  deviceKey: string // 设备唯一标识符
+  deviceName: string // 设备名称
+  productId: number // 产品编号
+  productKey: string // 产品标识
+  deviceType: number // 设备类型
+  nickname: string // 设备备注名称
+  gatewayId: number // 网关设备 ID
+  status: number // 设备状态
+  statusLastUpdateTime: Date // 设备状态最后更新时间
+  lastOnlineTime: Date // 最后上线时间
+  lastOfflineTime: Date // 最后离线时间
+  activeTime: Date // 设备激活时间
+  createTime: Date // 创建时间
+  ip: string // 设备的 IP 地址
+  firmwareVersion: string // 设备的固件版本
+  deviceSecret: string // 设备密钥,用于设备认证,需安全存储
+  mqttClientId: string // MQTT 客户端 ID
+  mqttUsername: string // MQTT 用户名
+  mqttPassword: string // MQTT 密码
+  authType: string // 认证类型
+  latitude: number // 设备位置的纬度
+  longitude: number // 设备位置的经度
+  areaId: number // 地区编码
+  address: string // 设备详细地址
+  serialNumber: string // 设备序列号
+}
+
+export interface DeviceUpdateStatusVO {
+  id: number // 设备 ID,主键,自增
+  status: number // 设备状态
+}
+
+// 设备 API
+export const DeviceApi = {
+  // 查询设备分页
+  getDevicePage: async (params: any) => {
+    return await request.get({ url: `/iot/device/page`, params })
+  },
+
+  // 查询设备详情
+  getDevice: async (id: number) => {
+    return await request.get({ url: `/iot/device/get?id=` + id })
+  },
+
+  // 新增设备
+  createDevice: async (data: DeviceVO) => {
+    return await request.post({ url: `/iot/device/create`, data })
+  },
+
+  // 修改设备
+  updateDevice: async (data: DeviceVO) => {
+    return await request.put({ url: `/iot/device/update`, data })
+  },
+
+  // 修改设备状态
+  updateDeviceStatus: async (data: DeviceUpdateStatusVO) => {
+    return await request.put({ url: `/iot/device/update-status`, data })
+  },
+
+  // 删除设备
+  deleteDevice: async (id: number) => {
+    return await request.delete({ url: `/iot/device/delete?id=` + id })
+  },
+
+  // 获取设备数量
+  getDeviceCount: async (productId: number) => {
+    return await request.get({ url: `/iot/device/count?productId=` + productId })
+  }
+}

+ 62 - 0
src/api/iot/product/index.ts

@@ -0,0 +1,62 @@
+import request from '@/config/axios'
+
+// IoT 产品 VO
+export interface ProductVO {
+  id: number // 产品编号
+  name: string // 产品名称
+  productKey: string // 产品标识
+  protocolId: number // 协议编号
+  categoryId: number // 产品所属品类标识符
+  description: string // 产品描述
+  validateType: number // 数据校验级别
+  status: number // 产品状态
+  deviceType: number // 设备类型
+  netType: number // 联网方式
+  protocolType: number // 接入网关协议
+  dataFormat: number // 数据格式
+  deviceCount: number // 设备数量
+  createTime: Date // 创建时间
+}
+
+// IoT 产品 API
+export const ProductApi = {
+  // 查询产品分页
+  getProductPage: async (params: any) => {
+    return await request.get({ url: `/iot/product/page`, params })
+  },
+
+  // 查询产品详情
+  getProduct: async (id: number) => {
+    return await request.get({ url: `/iot/product/get?id=` + id })
+  },
+
+  // 新增产品
+  createProduct: async (data: ProductVO) => {
+    return await request.post({ url: `/iot/product/create`, data })
+  },
+
+  // 修改产品
+  updateProduct: async (data: ProductVO) => {
+    return await request.put({ url: `/iot/product/update`, data })
+  },
+
+  // 删除产品
+  deleteProduct: async (id: number) => {
+    return await request.delete({ url: `/iot/product/delete?id=` + id })
+  },
+
+  // 导出产品 Excel
+  exportProduct: async (params) => {
+    return await request.download({ url: `/iot/product/export-excel`, params })
+  },
+
+  // 更新产品状态
+  updateProductStatus: async (id: number, status: number) => {
+    return await request.put({ url: `/iot/product/update-status?id=` + id + `&status=` + status })
+  },
+
+  // 查询产品(精简)列表
+  getSimpleProductList() {
+    return request.get({ url: '/iot/product/list-all-simple' })
+  }
+}

+ 55 - 0
src/api/iot/thinkmodelfunction/index.ts

@@ -0,0 +1,55 @@
+import request from '@/config/axios'
+
+// IoT 产品物模型 VO
+export interface ThinkModelFunctionVO {
+  id: number // 物模型功能编号
+  identifier: string // 功能标识
+  name: string // 功能名称
+  description: string // 功能描述
+  productId: number // 产品编号
+  productKey: string // 产品标识
+  type: number // 功能类型
+  property: string // 属性
+  event: string // 事件
+  service: string // 服务
+}
+
+// IoT 产品物模型 API
+export const ThinkModelFunctionApi = {
+  // 查询产品物模型分页
+  getThinkModelFunctionPage: async (params: any) => {
+    return await request.get({ url: `/iot/think-model-function/page`, params })
+  },
+  // 获得产品物模型
+  getThinkModelFunctionListByProductId: async (params: any) => {
+    return await request.get({
+      url: `/iot/think-model-function/list-by-product-id`,
+      params
+    })
+  },
+
+  // 查询产品物模型详情
+  getThinkModelFunction: async (id: number) => {
+    return await request.get({ url: `/iot/think-model-function/get?id=` + id })
+  },
+
+  // 新增产品物模型
+  createThinkModelFunction: async (data: ThinkModelFunctionVO) => {
+    return await request.post({ url: `/iot/think-model-function/create`, data })
+  },
+
+  // 修改产品物模型
+  updateThinkModelFunction: async (data: ThinkModelFunctionVO) => {
+    return await request.put({ url: `/iot/think-model-function/update`, data })
+  },
+
+  // 删除产品物模型
+  deleteThinkModelFunction: async (id: number) => {
+    return await request.delete({ url: `/iot/think-model-function/delete?id=` + id })
+  },
+
+  // 导出产品物模型 Excel
+  exportThinkModelFunction: async (params) => {
+    return await request.download({ url: `/iot/think-model-function/export-excel`, params })
+  }
+}

+ 6 - 1
src/api/login/index.ts

@@ -1,6 +1,6 @@
 import request from '@/config/axios'
 import { getRefreshToken } from '@/utils/auth'
-import type { UserLoginVO } from './types'
+import type { RegisterVO, UserLoginVO } from './types'
 
 export interface SmsCodeVO {
   mobile: string
@@ -17,6 +17,11 @@ export const login = (data: UserLoginVO) => {
   return request.post({ url: '/system/auth/login', data })
 }
 
+// 注册
+export const register = (data: RegisterVO) => {
+  return request.post({ url: '/system/auth/register', data })
+}
+
 // 刷新访问令牌
 export const refreshToken = () => {
   return request.post({ url: '/system/auth/refresh-token?refreshToken=' + getRefreshToken() })

+ 7 - 0
src/api/login/types.ts

@@ -29,3 +29,10 @@ export type UserVO = {
   loginIp: string
   loginDate: string
 }
+
+export type RegisterVO = {
+  tenantName: string
+  username: string
+  password: string
+  captchaVerification: string
+}

+ 91 - 0
src/api/mall/promotion/point/index.ts

@@ -0,0 +1,91 @@
+import request from '@/config/axios'
+import { Sku, Spu } from '@/api/mall/product/spu' // 积分商城活动 VO
+
+// 积分商城活动 VO
+export interface PointActivityVO {
+  id: number // 积分商城活动编号
+  spuId: number // 积分商城活动商品
+  status: number // 活动状态
+  stock: number // 积分商城活动库存
+  totalStock: number // 积分商城活动总库存
+  remark?: string // 备注
+  sort: number // 排序
+  createTime: string // 创建时间
+  products: PointProductVO[] // 积分商城商品
+
+  // ========== 商品字段 ==========
+  spuName: string // 商品名称
+  picUrl: string // 商品主图
+  marketPrice: number // 商品市场价,单位:分
+
+  //======================= 显示所需兑换积分最少的 sku 信息 =======================
+  point: number // 兑换积分
+  price: number // 兑换金额,单位:分
+}
+
+// 秒杀活动所需属性
+export interface PointProductVO {
+  id?: number // 积分商城商品编号
+  activityId?: number // 积分商城活动 id
+  spuId?: number // 商品 SPU 编号
+  skuId: number // 商品 SKU 编号
+  count: number // 可兑换数量
+  point: number // 兑换积分
+  price: number // 兑换金额,单位:分
+  stock: number // 积分商城商品库存
+  activityStatus?: number // 积分商城商品状态
+}
+
+// 扩展 Sku 配置
+export type SkuExtension = Sku & {
+  productConfig: PointProductVO
+}
+
+export interface SpuExtension extends Spu {
+  skus: SkuExtension[] // 重写类型
+}
+
+export interface SpuExtension0 extends Spu {
+  pointStock: number // 积分商城活动库存
+  pointTotalStock: number // 积分商城活动总库存
+  point: number // 兑换积分
+  pointPrice: number // 兑换金额,单位:分
+}
+
+// 积分商城活动 API
+export const PointActivityApi = {
+  // 查询积分商城活动分页
+  getPointActivityPage: async (params: any) => {
+    return await request.get({ url: `/promotion/point-activity/page`, params })
+  },
+
+  // 查询积分商城活动详情
+  getPointActivity: async (id: number) => {
+    return await request.get({ url: `/promotion/point-activity/get?id=` + id })
+  },
+
+  // 查询积分商城活动列表,基于活动编号数组
+  getPointActivityListByIds: async (ids: number[]) => {
+    return request.get({ url: `/promotion/point-activity/list-by-ids?ids=${ids}` })
+  },
+
+  // 新增积分商城活动
+  createPointActivity: async (data: PointActivityVO) => {
+    return await request.post({ url: `/promotion/point-activity/create`, data })
+  },
+
+  // 修改积分商城活动
+  updatePointActivity: async (data: PointActivityVO) => {
+    return await request.put({ url: `/promotion/point-activity/update`, data })
+  },
+
+  // 删除积分商城活动
+  deletePointActivity: async (id: number) => {
+    return await request.delete({ url: `/promotion/point-activity/delete?id=` + id })
+  },
+
+  // 关闭秒杀活动
+  closePointActivity: async (id: number) => {
+    return await request.put({ url: '/promotion/point-activity/close?id=' + id })
+  }
+}

+ 6 - 1
src/api/mall/promotion/reward/rewardActivity.ts

@@ -47,7 +47,12 @@ export const getReward = async (id: number) => {
   return await request.get({ url: '/promotion/reward-activity/get?id=' + id })
 }
 
-// 删除限时折扣活动
+// 删除满减送活动
 export const deleteRewardActivity = async (id: number) => {
   return await request.delete({ url: '/promotion/reward-activity/delete?id=' + id })
 }
+
+// 关闭满减送活动
+export const closeRewardActivity = async (id: number) => {
+  return await request.put({ url: '/promotion/reward-activity/close?id=' + id })
+}

+ 6 - 0
src/api/mall/promotion/seckill/seckillActivity.ts

@@ -18,6 +18,7 @@ export interface SeckillActivityVO {
   singleLimitCount?: number
   stock?: number
   totalStock?: number
+  seckillPrice?: number
   products?: SeckillProductVO[]
 }
 
@@ -43,6 +44,11 @@ export const getSeckillActivityPage = async (params) => {
   return await request.get({ url: '/promotion/seckill-activity/page', params })
 }
 
+// 查询秒杀活动列表,基于活动编号数组
+export const getSeckillActivityListByIds = (ids: number[]) => {
+  return request.get({ url: `/promotion/seckill-activity/list-by-ids?ids=${ids}` })
+}
+
 // 查询秒杀活动详情
 export const getSeckillActivity = async (id: number) => {
   return await request.get({ url: '/promotion/seckill-activity/get?id=' + id })

+ 8 - 2
src/api/pay/order/index.ts

@@ -84,8 +84,14 @@ export const getOrderPage = async (params: OrderPageReqVO) => {
 }
 
 // 查询详情支付订单
-export const getOrder = async (id: number) => {
-  return await request.get({ url: '/pay/order/get?id=' + id })
+export const getOrder = async (id: number, sync?: boolean) => {
+  return await request.get({
+    url: '/pay/order/get',
+    params: {
+      id,
+      sync
+    }
+  })
 }
 
 // 获得支付订单的明细

+ 8 - 0
src/components/AppLinkInput/data.ts

@@ -5,6 +5,7 @@ export interface AppLinkGroup {
   // 链接列表
   links: AppLink[]
 }
+
 // APP 链接
 export interface AppLink {
   // 链接名称
@@ -21,6 +22,8 @@ export const enum APP_LINK_TYPE_ENUM {
   ACTIVITY_COMBINATION,
   // 秒杀活动
   ACTIVITY_SECKILL,
+  // 积分商城活动
+  ACTIVITY_POINT,
   // 文章详情
   ARTICLE_DETAIL,
   // 优惠券详情
@@ -131,6 +134,11 @@ export const APP_LINK_GROUP_LIST = [
         type: APP_LINK_TYPE_ENUM.ACTIVITY_SECKILL
       },
       {
+        name: '积分商城活动',
+        path: '/pages/activity/point/list',
+        type: APP_LINK_TYPE_ENUM.ACTIVITY_POINT
+      },
+      {
         name: '签到中心',
         path: '/pages/app/sign'
       },

+ 1 - 1
src/components/ContentWrap/src/ContentWrap.vue

@@ -11,7 +11,7 @@ const prefixCls = getPrefixCls('content-wrap')
 defineProps({
   title: propTypes.string.def(''),
   message: propTypes.string.def(''),
-  bodyStyle: propTypes.object.def({ padding: '20px' })
+  bodyStyle: propTypes.object.def({ padding: '10px' })
 })
 </script>
 

+ 1 - 1
src/components/DiyEditor/components/mobile/FloatingActionButton/index.vue

@@ -44,7 +44,7 @@ defineOptions({ name: 'FloatingActionButton' })
 defineProps<{ property: FloatingActionButtonProperty }>()
 
 // 是否展开
-const expanded = ref(true)
+const expanded = ref(false)
 // 处理展开/折叠
 const handleToggleFab = () => {
   expanded.value = !expanded.value

+ 1 - 1
src/components/DiyEditor/components/mobile/ProductCard/index.vue

@@ -67,7 +67,7 @@
             class="text-16px"
             :style="{ color: property.fields.price.color }"
           >
-            ¥{{ fenToYuan(spu.price) }}
+            ¥{{ fenToYuan(spu.price as any) }}
           </span>
           <!-- 市场价 -->
           <span

+ 1 - 1
src/components/DiyEditor/components/mobile/ProductList/index.vue

@@ -65,7 +65,7 @@
 <script setup lang="ts">
 import { ProductListProperty } from './config'
 import * as ProductSpuApi from '@/api/mall/product/spu'
-import { fenToYuan } from './index'
+import { fenToYuan } from '@/utils'
 
 /** 商品栏 */
 defineOptions({ name: 'ProductList' })

+ 96 - 0
src/components/DiyEditor/components/mobile/PromotionPoint/config.ts

@@ -0,0 +1,96 @@
+import {ComponentStyle, DiyComponent} from '@/components/DiyEditor/util'
+
+/** 积分商城属性 */
+export interface PromotionPointProperty {
+  // 布局类型:单列 | 三列
+  layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol'
+  // 商品字段
+  fields: {
+    // 商品名称
+    name: PromotionPointFieldProperty
+    // 商品简介
+    introduction: PromotionPointFieldProperty
+    // 商品价格
+    price: PromotionPointFieldProperty
+    // 市场价
+    marketPrice: PromotionPointFieldProperty
+    // 商品销量
+    salesCount: PromotionPointFieldProperty
+    // 商品库存
+    stock: PromotionPointFieldProperty
+  }
+  // 角标
+  badge: {
+    // 是否显示
+    show: boolean
+    // 角标图片
+    imgUrl: string
+  }
+  // 按钮
+  btnBuy: {
+    // 类型:文字 | 图片
+    type: 'text' | 'img'
+    // 文字
+    text: string
+    // 文字按钮:背景渐变起始颜色
+    bgBeginColor: string
+    // 文字按钮:背景渐变结束颜色
+    bgEndColor: string
+    // 图片按钮:图片地址
+    imgUrl: string
+  }
+  // 上圆角
+  borderRadiusTop: number
+  // 下圆角
+  borderRadiusBottom: number
+  // 间距
+  space: number
+  // 秒杀活动编号
+  activityIds: number[]
+  // 组件样式
+  style: ComponentStyle
+}
+
+// 商品字段
+export interface PromotionPointFieldProperty {
+  // 是否显示
+  show: boolean
+  // 颜色
+  color: string
+}
+
+// 定义组件
+export const component = {
+  id: 'PromotionPoint',
+  name: '积分商城',
+  icon: 'ep:present',
+  property: {
+    layoutType: 'oneColBigImg',
+    fields: {
+      name: { show: true, color: '#000' },
+      introduction: { show: true, color: '#999' },
+      price: { show: true, color: '#ff3000' },
+      marketPrice: { show: true, color: '#c4c4c4' },
+      salesCount: { show: true, color: '#c4c4c4' },
+      stock: { show: false, color: '#c4c4c4' }
+    },
+    badge: { show: false, imgUrl: '' },
+    btnBuy: {
+      type: 'text',
+      text: '立即兑换',
+      bgBeginColor: '#FF6000',
+      bgEndColor: '#FE832A',
+      imgUrl: ''
+    },
+    borderRadiusTop: 8,
+    borderRadiusBottom: 8,
+    space: 8,
+    style: {
+      bgType: 'color',
+      bgColor: '',
+      marginLeft: 8,
+      marginRight: 8,
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<PromotionPointProperty>

+ 202 - 0
src/components/DiyEditor/components/mobile/PromotionPoint/index.vue

@@ -0,0 +1,202 @@
+<template>
+  <div ref="containerRef" :class="`box-content min-h-30px w-full flex flex-row flex-wrap`">
+    <div
+      v-for="(spu, index) in spuList"
+      :key="index"
+      :style="{
+        ...calculateSpace(index),
+        ...calculateWidth(),
+        borderTopLeftRadius: `${property.borderRadiusTop}px`,
+        borderTopRightRadius: `${property.borderRadiusTop}px`,
+        borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
+        borderBottomRightRadius: `${property.borderRadiusBottom}px`
+      }"
+      class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white"
+    >
+      <!-- 角标 -->
+      <div v-if="property.badge.show" class="absolute left-0 top-0 z-1 items-center justify-center">
+        <el-image :src="property.badge.imgUrl" class="h-26px w-38px" fit="cover" />
+      </div>
+      <!-- 商品封面图 -->
+      <div
+        :class="[
+          'h-140px',
+          {
+            'w-full': property.layoutType !== 'oneColSmallImg',
+            'w-140px': property.layoutType === 'oneColSmallImg'
+          }
+        ]"
+      >
+        <el-image :src="spu.picUrl" class="h-full w-full" fit="cover" />
+      </div>
+      <div
+        :class="[
+          ' flex flex-col gap-8px p-8px box-border',
+          {
+            'w-full': property.layoutType !== 'oneColSmallImg',
+            'w-[calc(100%-140px-16px)]': property.layoutType === 'oneColSmallImg'
+          }
+        ]"
+      >
+        <!-- 商品名称 -->
+        <div
+          v-if="property.fields.name.show"
+          :class="[
+            'text-14px ',
+            {
+              truncate: property.layoutType !== 'oneColSmallImg',
+              'overflow-ellipsis line-clamp-2': property.layoutType === 'oneColSmallImg'
+            }
+          ]"
+          :style="{ color: property.fields.name.color }"
+        >
+          {{ spu.name }}
+        </div>
+        <!-- 商品简介 -->
+        <div
+          v-if="property.fields.introduction.show"
+          :style="{ color: property.fields.introduction.color }"
+          class="truncate text-12px"
+        >
+          {{ spu.introduction }}
+        </div>
+        <div>
+          <!-- 积分 -->
+          <span
+            v-if="property.fields.price.show"
+            :style="{ color: property.fields.price.color }"
+            class="text-16px"
+          >
+            {{ spu.point }}积分
+            {{ !spu.pointPrice || spu.pointPrice === 0 ? '' : `+${fenToYuan(spu.pointPrice)}元` }}
+          </span>
+          <!-- 市场价 -->
+          <span
+            v-if="property.fields.marketPrice.show && spu.marketPrice"
+            :style="{ color: property.fields.marketPrice.color }"
+            class="ml-4px text-10px line-through"
+          >
+            ¥{{ fenToYuan(spu.marketPrice) }}
+          </span>
+        </div>
+        <div class="text-12px">
+          <!-- 销量 -->
+          <span
+            v-if="property.fields.salesCount.show"
+            :style="{ color: property.fields.salesCount.color }"
+          >
+            已兑{{ (spu.pointTotalStock || 0) - (spu.pointStock || 0) }}件
+          </span>
+          <!-- 库存 -->
+          <span v-if="property.fields.stock.show" :style="{ color: property.fields.stock.color }">
+            库存{{ spu.pointTotalStock || 0 }}
+          </span>
+        </div>
+      </div>
+      <!-- 购买按钮 -->
+      <div class="absolute bottom-8px right-8px">
+        <!-- 文字按钮 -->
+        <span
+          v-if="property.btnBuy.type === 'text'"
+          :style="{
+            background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}`
+          }"
+          class="rounded-full p-x-12px p-y-4px text-12px text-white"
+        >
+          {{ property.btnBuy.text }}
+        </span>
+        <!-- 图片按钮 -->
+        <el-image
+          v-else
+          :src="property.btnBuy.imgUrl"
+          class="h-28px w-28px rounded-full"
+          fit="cover"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+<script lang="ts" setup>
+import { PromotionPointProperty } from './config'
+import * as ProductSpuApi from '@/api/mall/product/spu'
+import { PointActivityApi, PointActivityVO, SpuExtension0 } from '@/api/mall/promotion/point'
+import { fenToYuan } from '@/utils'
+
+/** 积分商城卡片 */
+defineOptions({ name: 'PromotionPoint' })
+// 定义属性
+const props = defineProps<{ property: PromotionPointProperty }>()
+// 商品列表
+const spuList = ref<SpuExtension0[]>([])
+const spuIdList = ref<number[]>([])
+const pointActivityList = ref<PointActivityVO[]>([])
+
+watch(
+  () => props.property.activityIds,
+  async () => {
+    try {
+      // 新添加的积分商城组件,是没有活动ID的
+      const activityIds = props.property.activityIds
+      // 检查活动ID的有效性
+      if (Array.isArray(activityIds) && activityIds.length > 0) {
+        // 获取积分商城活动详情列表
+        pointActivityList.value = await PointActivityApi.getPointActivityListByIds(activityIds)
+
+        // 获取积分商城活动的 SPU 详情列表
+        spuList.value = []
+        spuIdList.value = pointActivityList.value.map((activity) => activity.spuId)
+        if (spuIdList.value.length > 0) {
+          spuList.value = await ProductSpuApi.getSpuDetailList(spuIdList.value)
+        }
+
+        // 更新 SPU 的最低兑换积分和所需兑换金额
+        pointActivityList.value.forEach((activity) => {
+          // 匹配spuId
+          const spu = spuList.value.find((spu) => spu.id === activity.spuId)
+          if (spu) {
+            spu.pointStock = activity.stock
+            spu.pointTotalStock = activity.totalStock
+            spu.point = activity.point
+            spu.pointPrice = activity.price
+          }
+        })
+      }
+    } catch (error) {
+      console.error('获取积分商城活动细节或 SPU 细节时出错:', error)
+    }
+  },
+  {
+    immediate: true,
+    deep: true
+  }
+)
+
+/**
+ * 计算商品的间距
+ * @param index 商品索引
+ */
+const calculateSpace = (index: number) => {
+  // 商品的列数
+  const columns = props.property.layoutType === 'twoCol' ? 2 : 1
+  // 第一列没有左边距
+  const marginLeft = index % columns === 0 ? '0' : props.property.space + 'px'
+  // 第一行没有上边距
+  const marginTop = index < columns ? '0' : props.property.space + 'px'
+
+  return { marginLeft, marginTop }
+}
+
+// 容器
+const containerRef = ref()
+// 计算商品的宽度
+const calculateWidth = () => {
+  let width = '100%'
+  // 双列时每列的宽度为:(总宽度 - 间距)/ 2
+  if (props.property.layoutType === 'twoCol') {
+    width = `${(containerRef.value.offsetWidth - props.property.space) / 2}px`
+  }
+  return { width }
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 154 - 0
src/components/DiyEditor/components/mobile/PromotionPoint/property.vue

@@ -0,0 +1,154 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <el-form :model="formData" label-width="80px">
+      <el-card class="property-group" header="积分商城活动" shadow="never">
+        <PointShowcase v-model="formData.activityIds" />
+      </el-card>
+      <el-card class="property-group" header="商品样式" shadow="never">
+        <el-form-item label="布局" prop="type">
+          <el-radio-group v-model="formData.layoutType">
+            <el-tooltip class="item" content="单列大图" placement="bottom">
+              <el-radio-button value="oneColBigImg">
+                <Icon icon="fluent:text-column-one-24-filled" />
+              </el-radio-button>
+            </el-tooltip>
+            <el-tooltip class="item" content="单列小图" placement="bottom">
+              <el-radio-button value="oneColSmallImg">
+                <Icon icon="fluent:text-column-two-left-24-filled" />
+              </el-radio-button>
+            </el-tooltip>
+            <el-tooltip class="item" content="双列" placement="bottom">
+              <el-radio-button value="twoCol">
+                <Icon icon="fluent:text-column-two-24-filled" />
+              </el-radio-button>
+            </el-tooltip>
+            <!--<el-tooltip class="item" content="三列" placement="bottom">
+              <el-radio-button value="threeCol">
+                <Icon icon="fluent:text-column-three-24-filled" />
+              </el-radio-button>
+            </el-tooltip>-->
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="商品名称" prop="fields.name.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.name.color" />
+            <el-checkbox v-model="formData.fields.name.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品简介" prop="fields.introduction.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.introduction.color" />
+            <el-checkbox v-model="formData.fields.introduction.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品价格" prop="fields.price.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.price.color" />
+            <el-checkbox v-model="formData.fields.price.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="市场价" prop="fields.marketPrice.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.marketPrice.color" />
+            <el-checkbox v-model="formData.fields.marketPrice.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品销量" prop="fields.salesCount.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.salesCount.color" />
+            <el-checkbox v-model="formData.fields.salesCount.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品库存" prop="fields.stock.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.stock.color" />
+            <el-checkbox v-model="formData.fields.stock.show" />
+          </div>
+        </el-form-item>
+      </el-card>
+      <el-card class="property-group" header="角标" shadow="never">
+        <el-form-item label="角标" prop="badge.show">
+          <el-switch v-model="formData.badge.show" />
+        </el-form-item>
+        <el-form-item v-if="formData.badge.show" label="角标" prop="badge.imgUrl">
+          <UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px">
+            <template #tip> 建议尺寸:36 * 22</template>
+          </UploadImg>
+        </el-form-item>
+      </el-card>
+      <el-card class="property-group" header="按钮" shadow="never">
+        <el-form-item label="按钮类型" prop="btnBuy.type">
+          <el-radio-group v-model="formData.btnBuy.type">
+            <el-radio-button value="text">文字</el-radio-button>
+            <el-radio-button value="img">图片</el-radio-button>
+          </el-radio-group>
+        </el-form-item>
+        <template v-if="formData.btnBuy.type === 'text'">
+          <el-form-item label="按钮文字" prop="btnBuy.text">
+            <el-input v-model="formData.btnBuy.text" />
+          </el-form-item>
+          <el-form-item label="左侧背景" prop="btnBuy.bgBeginColor">
+            <ColorInput v-model="formData.btnBuy.bgBeginColor" />
+          </el-form-item>
+          <el-form-item label="右侧背景" prop="btnBuy.bgEndColor">
+            <ColorInput v-model="formData.btnBuy.bgEndColor" />
+          </el-form-item>
+        </template>
+        <template v-else>
+          <el-form-item label="图片" prop="btnBuy.imgUrl">
+            <UploadImg v-model="formData.btnBuy.imgUrl" height="56px" width="56px">
+              <template #tip> 建议尺寸:56 * 56</template>
+            </UploadImg>
+          </el-form-item>
+        </template>
+      </el-card>
+      <el-card class="property-group" header="商品样式" shadow="never">
+        <el-form-item label="上圆角" prop="borderRadiusTop">
+          <el-slider
+            v-model="formData.borderRadiusTop"
+            :max="100"
+            :min="0"
+            :show-input-controls="false"
+            input-size="small"
+            show-input
+          />
+        </el-form-item>
+        <el-form-item label="下圆角" prop="borderRadiusBottom">
+          <el-slider
+            v-model="formData.borderRadiusBottom"
+            :max="100"
+            :min="0"
+            :show-input-controls="false"
+            input-size="small"
+            show-input
+          />
+        </el-form-item>
+        <el-form-item label="间隔" prop="space">
+          <el-slider
+            v-model="formData.space"
+            :max="100"
+            :min="0"
+            :show-input-controls="false"
+            input-size="small"
+            show-input
+          />
+        </el-form-item>
+      </el-card>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script lang="ts" setup>
+import { PromotionPointProperty } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+import PointShowcase from '@/views/mall/promotion/point/components/PointShowcase.vue'
+
+// 秒杀属性面板
+defineOptions({ name: 'PromotionPointProperty' })
+
+const props = defineProps<{ modelValue: PromotionPointProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+</script>
+
+<style lang="scss" scoped></style>

+ 37 - 5
src/components/DiyEditor/components/mobile/PromotionSeckill/config.ts

@@ -3,13 +3,21 @@ import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
 /** 秒杀属性 */
 export interface PromotionSeckillProperty {
   // 布局类型:单列 | 三列
-  layoutType: 'oneCol' | 'threeCol'
+  layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol'
   // 商品字段
   fields: {
     // 商品名称
     name: PromotionSeckillFieldProperty
+    // 商品简介
+    introduction: PromotionSeckillFieldProperty
     // 商品价格
     price: PromotionSeckillFieldProperty
+    // 市场价
+    marketPrice: PromotionSeckillFieldProperty
+    // 商品销量
+    salesCount: PromotionSeckillFieldProperty
+    // 商品库存
+    stock: PromotionSeckillFieldProperty
   }
   // 角标
   badge: {
@@ -18,6 +26,19 @@ export interface PromotionSeckillProperty {
     // 角标图片
     imgUrl: string
   }
+  // 按钮
+  btnBuy: {
+    // 类型:文字 | 图片
+    type: 'text' | 'img'
+    // 文字
+    text: string
+    // 文字按钮:背景渐变起始颜色
+    bgBeginColor: string
+    // 文字按钮:背景渐变结束颜色
+    bgEndColor: string
+    // 图片按钮:图片地址
+    imgUrl: string
+  }
   // 上圆角
   borderRadiusTop: number
   // 下圆角
@@ -25,10 +46,11 @@ export interface PromotionSeckillProperty {
   // 间距
   space: number
   // 秒杀活动编号
-  activityId: number
+  activityIds: number[]
   // 组件样式
   style: ComponentStyle
 }
+
 // 商品字段
 export interface PromotionSeckillFieldProperty {
   // 是否显示
@@ -43,13 +65,23 @@ export const component = {
   name: '秒杀',
   icon: 'mdi:calendar-time',
   property: {
-    activityId: undefined,
-    layoutType: 'oneCol',
+    layoutType: 'oneColBigImg',
     fields: {
       name: { show: true, color: '#000' },
-      price: { show: true, color: '#ff3000' }
+      introduction: { show: true, color: '#999' },
+      price: { show: true, color: '#ff3000' },
+      marketPrice: { show: true, color: '#c4c4c4' },
+      salesCount: { show: true, color: '#c4c4c4' },
+      stock: { show: false, color: '#c4c4c4' }
     },
     badge: { show: false, imgUrl: '' },
+    btnBuy: {
+      type: 'text',
+      text: '立即秒杀',
+      bgBeginColor: '#FF6000',
+      bgEndColor: '#FE832A',
+      imgUrl: ''
+    },
     borderRadiusTop: 8,
     borderRadiusBottom: 8,
     space: 8,

+ 161 - 95
src/components/DiyEditor/components/mobile/PromotionSeckill/index.vue

@@ -1,135 +1,201 @@
 <template>
-  <el-scrollbar ref="containerRef" class="z-1 min-h-30px" wrap-class="w-full">
-    <!-- 商品网格 -->
+  <div :class="`box-content min-h-30px w-full flex flex-row flex-wrap`" ref="containerRef">
     <div
+      class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white"
       :style="{
-        gridGap: `${property.space}px`,
-        gridTemplateColumns,
-        width: scrollbarWidth
+        ...calculateSpace(index),
+        ...calculateWidth(),
+        borderTopLeftRadius: `${property.borderRadiusTop}px`,
+        borderTopRightRadius: `${property.borderRadiusTop}px`,
+        borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
+        borderBottomRightRadius: `${property.borderRadiusBottom}px`
       }"
-      class="grid overflow-x-auto"
+      v-for="(spu, index) in spuList"
+      :key="index"
     >
-      <!-- 商品 -->
+      <!-- 角标 -->
+      <div v-if="property.badge.show" class="absolute left-0 top-0 z-1 items-center justify-center">
+        <el-image fit="cover" :src="property.badge.imgUrl" class="h-26px w-38px" />
+      </div>
+      <!-- 商品封面图 -->
       <div
-        v-for="(spu, index) in spuList"
-        :key="index"
-        :style="{
-          borderTopLeftRadius: `${property.borderRadiusTop}px`,
-          borderTopRightRadius: `${property.borderRadiusTop}px`,
-          borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
-          borderBottomRightRadius: `${property.borderRadiusBottom}px`
-        }"
-        class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white"
+        :class="[
+          'h-140px',
+          {
+            'w-full': property.layoutType !== 'oneColSmallImg',
+            'w-140px': property.layoutType === 'oneColSmallImg'
+          }
+        ]"
       >
-        <!-- 角标 -->
-        <div
-          v-if="property.badge.show"
-          class="absolute left-0 top-0 z-1 items-center justify-center"
-        >
-          <el-image :src="property.badge.imgUrl" class="h-26px w-38px" fit="cover" />
-        </div>
-        <!-- 商品封面图 -->
-        <el-image :src="spu.picUrl" :style="{ width: imageSize, height: imageSize }" fit="cover" />
+        <el-image fit="cover" class="h-full w-full" :src="spu.picUrl" />
+      </div>
+      <div
+        :class="[
+          ' flex flex-col gap-8px p-8px box-border',
+          {
+            'w-full': property.layoutType !== 'oneColSmallImg',
+            'w-[calc(100%-140px-16px)]': property.layoutType === 'oneColSmallImg'
+          }
+        ]"
+      >
+        <!-- 商品名称 -->
         <div
+          v-if="property.fields.name.show"
           :class="[
-            'flex flex-col gap-8px p-8px box-border',
+            'text-14px ',
             {
-              'w-[calc(100%-64px)]': columns === 2,
-              'w-full': columns === 3
+              truncate: property.layoutType !== 'oneColSmallImg',
+              'overflow-ellipsis line-clamp-2': property.layoutType === 'oneColSmallImg'
             }
           ]"
+          :style="{ color: property.fields.name.color }"
         >
-          <!-- 商品名称 -->
-          <div
-            v-if="property.fields.name.show"
-            :style="{ color: property.fields.name.color }"
-            class="truncate text-12px"
+          {{ spu.name }}
+        </div>
+        <!-- 商品简介 -->
+        <div
+          v-if="property.fields.introduction.show"
+          class="truncate text-12px"
+          :style="{ color: property.fields.introduction.color }"
+        >
+          {{ spu.introduction }}
+        </div>
+        <div>
+          <!-- 价格 -->
+          <span
+            v-if="property.fields.price.show"
+            class="text-16px"
+            :style="{ color: property.fields.price.color }"
+          >
+            ¥{{ fenToYuan(spu.price || Infinity) }}
+          </span>
+          <!-- 市场价 -->
+          <span
+            v-if="property.fields.marketPrice.show && spu.marketPrice"
+            class="ml-4px text-10px line-through"
+            :style="{ color: property.fields.marketPrice.color }"
+            >¥{{ fenToYuan(spu.marketPrice) }}</span
+          >
+        </div>
+        <div class="text-12px">
+          <!-- 销量 -->
+          <span
+            v-if="property.fields.salesCount.show"
+            :style="{ color: property.fields.salesCount.color }"
           >
-            {{ spu.name }}
-          </div>
-          <div>
-            <!-- 商品价格 -->
-            <span
-              v-if="property.fields.price.show"
-              :style="{ color: property.fields.price.color }"
-              class="text-12px"
-            >
-              ¥{{ fenToYuan(spu.seckillPrice || spu.price || 0) }}
-            </span>
-          </div>
+            已售{{ (spu.salesCount || 0) + (spu.virtualSalesCount || 0) }}件
+          </span>
+          <!-- 库存 -->
+          <span v-if="property.fields.stock.show" :style="{ color: property.fields.stock.color }">
+            库存{{ spu.stock || 0 }}
+          </span>
         </div>
       </div>
+      <!-- 购买按钮 -->
+      <div class="absolute bottom-8px right-8px">
+        <!-- 文字按钮 -->
+        <span
+          v-if="property.btnBuy.type === 'text'"
+          class="rounded-full p-x-12px p-y-4px text-12px text-white"
+          :style="{
+            background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}`
+          }"
+        >
+          {{ property.btnBuy.text }}
+        </span>
+        <!-- 图片按钮 -->
+        <el-image
+          v-else
+          class="h-28px w-28px rounded-full"
+          fit="cover"
+          :src="property.btnBuy.imgUrl"
+        />
+      </div>
     </div>
-  </el-scrollbar>
+  </div>
 </template>
-<script lang="ts" setup>
+<script setup lang="ts">
 import { PromotionSeckillProperty } from './config'
 import * as ProductSpuApi from '@/api/mall/product/spu'
-import { Spu } from '@/api/mall/product/spu'
 import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity'
-import { SeckillProductVO } from '@/api/mall/promotion/seckill/seckillActivity'
 import { fenToYuan } from '@/utils'
 
-/** 秒杀 */
+/** 秒杀卡片 */
 defineOptions({ name: 'PromotionSeckill' })
 // 定义属性
 const props = defineProps<{ property: PromotionSeckillProperty }>()
 // 商品列表
 const spuList = ref<ProductSpuApi.Spu[]>([])
+const spuIdList = ref<number[]>([])
+const seckillActivityList = ref<SeckillActivityApi.SeckillActivityVO[]>([])
+
 watch(
-  () => props.property.activityId,
+  () => props.property.activityIds,
   async () => {
-    if (!props.property.activityId) return
-    const activity = await SeckillActivityApi.getSeckillActivity(props.property.activityId)
-    if (!activity?.spuId) return
-    spuList.value = [await ProductSpuApi.getSpu(activity.spuId)]
-    spuList.value = [await ProductSpuApi.getSpu(activity.spuId)]
-    // 循环活动信息,赋值秒杀最低价格
-    activity.products.forEach((product: SeckillProductVO) => {
-      spuList.value.forEach((spu: Spu) => {
-        spu.seckillPrice = Math.min(spu.seckillPrice || Infinity, product.seckillPrice) // 设置 SPU 的最低价格
-      })
-    })
+    try {
+      // 新添加的秒杀组件,是没有活动ID的
+      const activityIds = props.property.activityIds
+      // 检查活动ID的有效性
+      if (Array.isArray(activityIds) && activityIds.length > 0) {
+        // 获取秒杀活动详情列表
+        seckillActivityList.value =
+          await SeckillActivityApi.getSeckillActivityListByIds(activityIds)
+
+        // 获取秒杀活动的 SPU 详情列表
+        spuList.value = []
+        spuIdList.value = seckillActivityList.value
+          .map((activity) => activity.spuId)
+          .filter((spuId): spuId is number => typeof spuId === 'number')
+        if (spuIdList.value.length > 0) {
+          spuList.value = await ProductSpuApi.getSpuDetailList(spuIdList.value)
+        }
+
+        // 更新 SPU 的最低价格
+        seckillActivityList.value.forEach((activity) => {
+          // 匹配spuId
+          const spu = spuList.value.find((spu) => spu.id === activity.spuId)
+          if (spu) {
+            // 赋值活动价格,哪个最便宜就赋值哪个
+            spu.price = Math.min(activity.seckillPrice || Infinity, spu.price || Infinity)
+          }
+        })
+      }
+    } catch (error) {
+      console.error('获取秒杀活动细节或 SPU 细节时出错:', error)
+    }
   },
   {
     immediate: true,
     deep: true
   }
 )
-// 手机宽度
-const phoneWidth = ref(375)
+
+/**
+ * 计算商品的间距
+ * @param index 商品索引
+ */
+const calculateSpace = (index: number) => {
+  // 商品的列数
+  const columns = props.property.layoutType === 'twoCol' ? 2 : 1
+  // 第一列没有左边距
+  const marginLeft = index % columns === 0 ? '0' : props.property.space + 'px'
+  // 第一行没有上边距
+  const marginTop = index < columns ? '0' : props.property.space + 'px'
+
+  return { marginLeft, marginTop }
+}
+
 // 容器
 const containerRef = ref()
-// 商品的列数
-const columns = ref(2)
-// 滚动条宽度
-const scrollbarWidth = ref('100%')
-// 商品图大小
-const imageSize = ref('0')
-// 商品网络列数
-const gridTemplateColumns = ref('')
-// 计算布局参数
-watch(
-  () => [props.property, phoneWidth, spuList.value.length],
-  () => {
-    // 计算列数
-    columns.value = props.property.layoutType === 'oneCol' ? 1 : 3
-    // 每列的宽度为:(总宽度 - 间距 * (列数 - 1))/ 列数
-    const productWidth =
-      (phoneWidth.value - props.property.space * (columns.value - 1)) / columns.value
-    // 商品图布局:2列时,左右布局 3列时,上下布局
-    imageSize.value = columns.value === 2 ? '64px' : `${productWidth}px`
-    // 指定列数
-    gridTemplateColumns.value = `repeat(${columns.value}, auto)`
-    // 不滚动
-    scrollbarWidth.value = '100%'
-  },
-  { immediate: true, deep: true }
-)
-onMounted(() => {
-  // 提取手机宽度
-  phoneWidth.value = containerRef.value?.wrapRef?.offsetWidth || 375
-})
+// 计算商品的宽度
+const calculateWidth = () => {
+  let width = '100%'
+  // 双列时每列的宽度为:(总宽度 - 间距)/ 2
+  if (props.property.layoutType === 'twoCol') {
+    width = `${(containerRef.value.offsetWidth - props.property.space) / 2}px`
+  }
+  return { width }
+}
 </script>
 
-<style lang="scss" scoped></style>
+<style scoped lang="scss"></style>

+ 68 - 16
src/components/DiyEditor/components/mobile/PromotionSeckill/property.vue

@@ -2,30 +2,31 @@
   <ComponentContainerProperty v-model="formData.style">
     <el-form label-width="80px" :model="formData">
       <el-card header="秒杀活动" class="property-group" shadow="never">
-        <el-form-item label="秒杀活动" prop="activityId">
-          <el-select v-model="formData.activityId">
-            <el-option
-              v-for="activity in activityList"
-              :key="activity.id"
-              :label="activity.name"
-              :value="activity.id"
-            />
-          </el-select>
-        </el-form-item>
+        <SeckillShowcase v-model="formData.activityIds" />
       </el-card>
       <el-card header="商品样式" class="property-group" shadow="never">
         <el-form-item label="布局" prop="type">
           <el-radio-group v-model="formData.layoutType">
-            <el-tooltip class="item" content="单列" placement="bottom">
-              <el-radio-button value="oneCol">
+            <el-tooltip class="item" content="单列大图" placement="bottom">
+              <el-radio-button value="oneColBigImg">
                 <Icon icon="fluent:text-column-one-24-filled" />
               </el-radio-button>
             </el-tooltip>
-            <el-tooltip class="item" content="三列" placement="bottom">
+            <el-tooltip class="item" content="单列小图" placement="bottom">
+              <el-radio-button value="oneColSmallImg">
+                <Icon icon="fluent:text-column-two-left-24-filled" />
+              </el-radio-button>
+            </el-tooltip>
+            <el-tooltip class="item" content="双列" placement="bottom">
+              <el-radio-button value="twoCol">
+                <Icon icon="fluent:text-column-two-24-filled" />
+              </el-radio-button>
+            </el-tooltip>
+            <!--<el-tooltip class="item" content="三列" placement="bottom">
               <el-radio-button value="threeCol">
                 <Icon icon="fluent:text-column-three-24-filled" />
               </el-radio-button>
-            </el-tooltip>
+            </el-tooltip>-->
           </el-radio-group>
         </el-form-item>
         <el-form-item label="商品名称" prop="fields.name.show">
@@ -34,12 +35,36 @@
             <el-checkbox v-model="formData.fields.name.show" />
           </div>
         </el-form-item>
+        <el-form-item label="商品简介" prop="fields.introduction.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.introduction.color" />
+            <el-checkbox v-model="formData.fields.introduction.show" />
+          </div>
+        </el-form-item>
         <el-form-item label="商品价格" prop="fields.price.show">
           <div class="flex gap-8px">
             <ColorInput v-model="formData.fields.price.color" />
             <el-checkbox v-model="formData.fields.price.show" />
           </div>
         </el-form-item>
+        <el-form-item label="市场价" prop="fields.marketPrice.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.marketPrice.color" />
+            <el-checkbox v-model="formData.fields.marketPrice.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品销量" prop="fields.salesCount.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.salesCount.color" />
+            <el-checkbox v-model="formData.fields.salesCount.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品库存" prop="fields.stock.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.stock.color" />
+            <el-checkbox v-model="formData.fields.stock.show" />
+          </div>
+        </el-form-item>
       </el-card>
       <el-card header="角标" class="property-group" shadow="never">
         <el-form-item label="角标" prop="badge.show">
@@ -47,10 +72,36 @@
         </el-form-item>
         <el-form-item label="角标" prop="badge.imgUrl" v-if="formData.badge.show">
           <UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px">
-            <template #tip> 建议尺寸:36 * 22 </template>
+            <template #tip> 建议尺寸:36 * 22</template>
           </UploadImg>
         </el-form-item>
       </el-card>
+      <el-card header="按钮" class="property-group" shadow="never">
+        <el-form-item label="按钮类型" prop="btnBuy.type">
+          <el-radio-group v-model="formData.btnBuy.type">
+            <el-radio-button value="text">文字</el-radio-button>
+            <el-radio-button value="img">图片</el-radio-button>
+          </el-radio-group>
+        </el-form-item>
+        <template v-if="formData.btnBuy.type === 'text'">
+          <el-form-item label="按钮文字" prop="btnBuy.text">
+            <el-input v-model="formData.btnBuy.text" />
+          </el-form-item>
+          <el-form-item label="左侧背景" prop="btnBuy.bgBeginColor">
+            <ColorInput v-model="formData.btnBuy.bgBeginColor" />
+          </el-form-item>
+          <el-form-item label="右侧背景" prop="btnBuy.bgEndColor">
+            <ColorInput v-model="formData.btnBuy.bgEndColor" />
+          </el-form-item>
+        </template>
+        <template v-else>
+          <el-form-item label="图片" prop="btnBuy.imgUrl">
+            <UploadImg v-model="formData.btnBuy.imgUrl" height="56px" width="56px">
+              <template #tip> 建议尺寸:56 * 56</template>
+            </UploadImg>
+          </el-form-item>
+        </template>
+      </el-card>
       <el-card header="商品样式" class="property-group" shadow="never">
         <el-form-item label="上圆角" prop="borderRadiusTop">
           <el-slider
@@ -92,6 +143,7 @@ import { PromotionSeckillProperty } from './config'
 import { usePropertyForm } from '@/components/DiyEditor/util'
 import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity'
 import { CommonStatusEnum } from '@/utils/constants'
+import SeckillShowcase from '@/views/mall/promotion/seckill/components/SeckillShowcase.vue'
 
 // 秒杀属性面板
 defineOptions({ name: 'PromotionSeckillProperty' })
@@ -100,7 +152,7 @@ const props = defineProps<{ modelValue: PromotionSeckillProperty }>()
 const emit = defineEmits(['update:modelValue'])
 const { formData } = usePropertyForm(props.modelValue, emit)
 // 活动列表
-const activityList = ref<SeckillActivityApi.SeckillActivityVO>([])
+const activityList = ref<SeckillActivityApi.SeckillActivityVO[]>([])
 onMounted(async () => {
   const { list } = await SeckillActivityApi.getSeckillActivityPage({
     status: CommonStatusEnum.ENABLE

+ 4 - 1
src/components/DiyEditor/components/mobile/TabBar/property.vue

@@ -79,7 +79,7 @@
 </template>
 
 <script setup lang="ts">
-import { TabBarProperty, THEME_LIST } from './config'
+import { TabBarProperty, component, THEME_LIST } from './config'
 import { usePropertyForm } from '@/components/DiyEditor/util'
 // 底部导航栏
 defineOptions({ name: 'TabBarProperty' })
@@ -88,6 +88,9 @@ const props = defineProps<{ modelValue: TabBarProperty }>()
 const emit = defineEmits(['update:modelValue'])
 const { formData } = usePropertyForm(props.modelValue, emit)
 
+// 将数据库的值更新到右侧属性栏
+component.property.items = formData.value.items
+
 // 要的主题
 const handleThemeChange = () => {
   const theme = THEME_LIST.find((theme) => theme.id === formData.value.theme)

+ 3 - 2
src/components/Editor/src/Editor.vue

@@ -7,6 +7,7 @@ import { isNumber } from '@/utils/is'
 import { ElMessage } from 'element-plus'
 import { useLocaleStore } from '@/store/modules/locale'
 import { getAccessToken, getTenantId } from '@/utils/auth'
+import { getUploadUrl } from '@/components/UploadFile/src/useUpload'
 
 defineOptions({ name: 'Editor' })
 
@@ -88,7 +89,7 @@ const editorConfig = computed((): IEditorConfig => {
       scroll: true,
       MENU_CONF: {
         ['uploadImage']: {
-          server: import.meta.env.VITE_UPLOAD_URL,
+          server: getUploadUrl(),
           // 单个文件的最大体积限制,默认为 2M
           maxFileSize: 5 * 1024 * 1024,
           // 最多可上传几个文件,默认为 100
@@ -136,7 +137,7 @@ const editorConfig = computed((): IEditorConfig => {
           }
         },
         ['uploadVideo']: {
-          server: import.meta.env.VITE_UPLOAD_URL,
+          server: getUploadUrl(),
           // 单个文件的最大体积限制,默认为 10M
           maxFileSize: 10 * 1024 * 1024,
           // 最多可上传几个文件,默认为 100

+ 1 - 1
src/components/FormCreate/src/config/useDictSelectRule.ts

@@ -48,7 +48,7 @@ export const useDictSelectRule = () => {
         },
         {
           type: 'select',
-          field: 'dictValueType',
+          field: 'valueType',
           title: '字典值类型',
           value: 'str',
           options: [

+ 9 - 14
src/components/UploadFile/src/UploadImgs.vue

@@ -25,7 +25,7 @@
       <template #file="{ file }">
         <img :src="file.url" class="upload-image" />
         <div class="upload-handle" @click.stop>
-          <div class="handle-icon" @click="handlePictureCardPreview(file)">
+          <div class="handle-icon" @click="imagePreview(file.url!)">
             <Icon icon="ep:zoom-in" />
             <span>查看</span>
           </div>
@@ -39,16 +39,12 @@
     <div class="el-upload__tip">
       <slot name="tip"></slot>
     </div>
-    <el-image-viewer
-      v-if="imgViewVisible"
-      :url-list="[viewImageUrl]"
-      @close="imgViewVisible = false"
-    />
   </div>
 </template>
 <script lang="ts" setup>
 import type { UploadFile, UploadProps, UploadUserFile } from 'element-plus'
 import { ElNotification } from 'element-plus'
+import { createImageViewer } from '@/components/ImageViewer'
 
 import { propTypes } from '@/utils/propTypes'
 import { useUpload } from '@/components/UploadFile/src/useUpload'
@@ -56,6 +52,13 @@ import { useUpload } from '@/components/UploadFile/src/useUpload'
 defineOptions({ name: 'UploadImgs' })
 
 const message = useMessage() // 消息弹窗
+// 查看图片
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    zIndex: 9999999,
+    urlList: [imgUrl]
+  })
+}
 
 type FileTypes =
   | 'image/apng'
@@ -178,14 +181,6 @@ const handleExceed = () => {
     type: 'warning'
   })
 }
-
-// 图片预览
-const viewImageUrl = ref('')
-const imgViewVisible = ref(false)
-const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => {
-  viewImageUrl.value = uploadFile.url!
-  imgViewVisible.value = true
-}
 </script>
 
 <style lang="scss" scoped>

+ 20 - 11
src/components/UploadFile/src/useUpload.ts

@@ -3,9 +3,16 @@ import CryptoJS from 'crypto-js'
 import { UploadRawFile, UploadRequestOptions } from 'element-plus/es/components/upload/src/upload'
 import axios from 'axios'
 
+/**
+ * 获得上传 URL
+ */
+export const getUploadUrl = (): string => {
+  return import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL + '/infra/file/upload'
+}
+
 export const useUpload = () => {
   // 后端上传地址
-  const uploadUrl = import.meta.env.VITE_UPLOAD_URL
+  const uploadUrl = getUploadUrl()
   // 是否使用前端直连上传
   const isClientUpload = UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE
   // 重写ElUpload上传方法
@@ -17,16 +24,18 @@ export const useUpload = () => {
       // 1.2 获取文件预签名地址
       const presignedInfo = await FileApi.getFilePresignedUrl(fileName)
       // 1.3 上传文件(不能使用 ElUpload 的 ajaxUpload 方法的原因:其使用的是 FormData 上传,Minio 不支持)
-      return axios.put(presignedInfo.uploadUrl, options.file, {
-        headers: {
-          'Content-Type': options.file.type,
-        }
-      }).then(() => {
-        // 1.4. 记录文件信息到后端(异步)
-        createFile(presignedInfo, fileName, options.file)
-        // 通知成功,数据格式保持与后端上传的返回结果一致
-        return { data: presignedInfo.url }
-      })
+      return axios
+        .put(presignedInfo.uploadUrl, options.file, {
+          headers: {
+            'Content-Type': options.file.type
+          }
+        })
+        .then(() => {
+          // 1.4. 记录文件信息到后端(异步)
+          createFile(presignedInfo, fileName, options.file)
+          // 通知成功,数据格式保持与后端上传的返回结果一致
+          return { data: presignedInfo.url }
+        })
     } else {
       // 模式二:后端上传
       // 重写 el-upload httpRequest 文件上传成功会走成功的钩子,失败走失败的钩子

+ 1 - 1
src/router/index.ts

@@ -5,7 +5,7 @@ import remainingRouter from './modules/remaining'
 
 // 创建路由实例
 const router = createRouter({
-  history: createWebHistory(), // createWebHashHistory URL带#,createWebHistory URL不带#
+  history: createWebHistory(import.meta.env.VITE_BASE_PATH), // createWebHashHistory URL带#,createWebHistory URL不带#
   strict: true,
   routes: remainingRouter as RouteRecordRaw[],
   scrollBehavior: () => ({ left: 0, top: 0 })

+ 32 - 0
src/router/modules/remaining.ts

@@ -610,6 +610,38 @@ const remainingRouter: AppRouteRecordRaw[] = [
       hidden: true,
       breadcrumb: false
     }
+  },
+  {
+    path: '/iot',
+    component: Layout,
+    name: 'IOT',
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: 'product/detail/:id',
+        name: 'IoTProductDetail',
+        meta: {
+          title: '产品详情',
+          noCache: true,
+          hidden: true,
+          activeMenu: '/iot/product'
+        },
+        component: () => import('@/views/iot/product/detail/index.vue')
+      },
+      {
+        path: 'device/detail/:id',
+        name: 'IoTDeviceDetail',
+        meta: {
+          title: '设备详情',
+          noCache: true,
+          hidden: true,
+          activeMenu: '/iot/device'
+        },
+        component: () => import('@/views/iot/device/detail/index.vue')
+      }
+    ]
   }
 ]
 

+ 14 - 2
src/utils/dict.ts

@@ -195,7 +195,6 @@ export enum DICT_TYPE {
   PROMOTION_COUPON_TEMPLATE_VALIDITY_TYPE = 'promotion_coupon_template_validity_type', // 优惠劵模板的有限期类型
   PROMOTION_COUPON_STATUS = 'promotion_coupon_status', // 优惠劵的状态
   PROMOTION_COUPON_TAKE_TYPE = 'promotion_coupon_take_type', // 优惠劵的领取方式
-  PROMOTION_ACTIVITY_STATUS = 'promotion_activity_status', // 优惠活动的状态
   PROMOTION_CONDITION_TYPE = 'promotion_condition_type', // 营销的条件类型枚举
   PROMOTION_BARGAIN_RECORD_STATUS = 'promotion_bargain_record_status', // 砍价记录的状态
   PROMOTION_COMBINATION_RECORD_STATUS = 'promotion_combination_record_status', // 拼团记录的状态
@@ -227,5 +226,18 @@ export enum DICT_TYPE {
   AI_WRITE_LENGTH = 'ai_write_length', // AI 写作长度
   AI_WRITE_FORMAT = 'ai_write_format', // AI 写作格式
   AI_WRITE_TONE = 'ai_write_tone', // AI 写作语气
-  AI_WRITE_LANGUAGE = 'ai_write_language' // AI 写作语言
+  AI_WRITE_LANGUAGE = 'ai_write_language', // AI 写作语言
+
+  // ========== IOT - 物联网模块  ==========
+  IOT_NET_TYPE = 'iot_net_type', // IOT 联网方式
+  IOT_VALIDATE_TYPE = 'iot_validate_type', // IOT 数据校验级别
+  IOT_PRODUCT_STATUS = 'iot_product_status', // IOT 产品状态
+  IOT_PRODUCT_DEVICE_TYPE = 'iot_product_device_type', // IOT 产品设备类型
+  IOT_DATA_FORMAT = 'iot_data_format', // IOT 数据格式
+  IOT_PROTOCOL_TYPE = 'iot_protocol_type', // IOT 接入网关协议
+  IOT_DEVICE_STATUS = 'iot_device_status', // IOT 设备状态
+  IOT_PRODUCT_FUNCTION_TYPE = 'iot_product_function_type', // IOT 产品功能类型
+  IOT_DATA_TYPE = 'iot_data_type', // IOT 数据类型
+  IOT_UNIT_TYPE = 'iot_unit_type', // IOT 单位类型
+  IOT_RW_TYPE = 'iot_rw_type' // IOT 读写类型
 }

+ 2 - 1
src/utils/routerHelper.ts

@@ -88,7 +88,8 @@ export const generateRoute = (routes: AppCustomRouteRecordRaw[]): AppRouteRecord
     // 2. 生成 data(AppRouteRecordRaw)
     // 路由地址转首字母大写驼峰,作为路由名称,适配keepAlive
     let data: AppRouteRecordRaw = {
-      path: route.path.indexOf('?') > -1 ? route.path.split('?')[0] : route.path,
+      path:
+        route.path.indexOf('?') > -1 && !isUrl(route.path) ? route.path.split('?')[0] : route.path, // 注意,需要排除 http 这种 url,避免它带 ? 参数被截取掉
       name:
         route.componentName && route.componentName.length > 0
           ? route.componentName

+ 251 - 114
src/views/Login/components/RegisterForm.vue

@@ -1,142 +1,279 @@
 <template>
-  <Form
+  <el-form
     v-show="getShow"
-    :rules="rules"
-    :schema="schema"
-    class="w-[100%] dark:(border-1 border-[var(--el-border-color)] border-solid)"
-    hide-required-asterisk
+    ref="formLogin"
+    :model="registerData.registerForm"
+    :rules="registerRules"
+    class="login-form"
     label-position="top"
+    label-width="120px"
     size="large"
-    @register="register"
   >
-    <template #title>
-      <LoginFormTitle style="width: 100%" />
-    </template>
-
-    <template #code="form">
-      <div class="w-[100%] flex">
-        <el-input v-model="form['code']" :placeholder="t('login.codePlaceholder')" />
-      </div>
-    </template>
-
-    <template #register>
-      <div class="w-[100%]">
-        <XButton
-          :loading="loading"
-          :title="t('login.register')"
-          class="w-[100%]"
-          type="primary"
-          @click="loginRegister()"
-        />
-      </div>
-      <div class="mt-15px w-[100%]">
-        <XButton :title="t('login.hasUser')" class="w-[100%]" @click="handleBackLogin()" />
-      </div>
-    </template>
-  </Form>
+    <el-row style="margin-right: -10px; margin-left: -10px">
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item>
+          <LoginFormTitle style="width: 100%" />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item v-if="registerData.tenantEnable === 'true'" prop="tenantName">
+          <el-input
+            v-model="registerData.registerForm.tenantName"
+            :placeholder="t('login.tenantname')"
+            :prefix-icon="iconHouse"
+            link
+            type="primary"
+            size="large"
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item prop="username">
+          <el-input
+            v-model="registerData.registerForm.username"
+            :placeholder="t('login.username')"
+            size="large"
+            :prefix-icon="iconAvatar"
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item prop="username">
+          <el-input
+            v-model="registerData.registerForm.nickname"
+            placeholder="昵称"
+            size="large"
+            :prefix-icon="iconAvatar"
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item prop="password">
+          <el-input
+            v-model="registerData.registerForm.password"
+            type="password"
+            auto-complete="off"
+            :placeholder="t('login.password')"
+            size="large"
+            :prefix-icon="iconLock"
+            show-password
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item prop="confirmPassword">
+          <el-input
+            v-model="registerData.registerForm.confirmPassword"
+            type="password"
+            size="large"
+            auto-complete="off"
+            :placeholder="t('login.checkPassword')"
+            :prefix-icon="iconLock"
+            show-password
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item>
+          <XButton
+            :loading="loginLoading"
+            :title="t('login.register')"
+            class="w-[100%]"
+            type="primary"
+            @click="getCode()"
+          />
+        </el-form-item>
+      </el-col>
+      <Verify
+        ref="verify"
+        :captchaType="captchaType"
+        :imgSize="{ width: '400px', height: '200px' }"
+        mode="pop"
+        @success="handleRegister"
+      />
+    </el-row>
+    <XButton :title="t('login.hasUser')" class="w-[100%]" @click="handleBackLogin()" />
+  </el-form>
 </template>
 <script lang="ts" setup>
-import type { FormRules } from 'element-plus'
-
-import { useForm } from '@/hooks/web/useForm'
-import { useValidator } from '@/hooks/web/useValidator'
+import { ElLoading } from 'element-plus'
 import LoginFormTitle from './LoginFormTitle.vue'
+import type { RouteLocationNormalizedLoaded } from 'vue-router'
+import { useIcon } from '@/hooks/web/useIcon'
+import * as authUtil from '@/utils/auth'
+import { usePermissionStore } from '@/store/modules/permission'
+import * as LoginApi from '@/api/login'
 import { LoginStateEnum, useLoginState } from './useLogin'
-import { FormSchema } from '@/types/form'
 
 defineOptions({ name: 'RegisterForm' })
 
 const { t } = useI18n()
-const { required } = useValidator()
-const { register, elFormRef } = useForm()
+const iconHouse = useIcon({ icon: 'ep:house' })
+const iconAvatar = useIcon({ icon: 'ep:avatar' })
+const iconLock = useIcon({ icon: 'ep:lock' })
+const formLogin = ref()
 const { handleBackLogin, getLoginState } = useLoginState()
+const { currentRoute, push } = useRouter()
+const permissionStore = usePermissionStore()
+const redirect = ref<string>('')
+const loginLoading = ref(false)
+const verify = ref()
+const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字
+
 const getShow = computed(() => unref(getLoginState) === LoginStateEnum.REGISTER)
 
-const schema = reactive<FormSchema[]>([
-  {
-    field: 'title',
-    colProps: {
-      span: 24
+const equalToPassword = (rule, value, callback) => {
+  if (registerData.registerForm.password !== value) {
+    callback(new Error('两次输入的密码不一致'))
+  } else {
+    callback()
+  }
+}
+
+const registerRules = {
+  tenantName: [
+    { required: true, trigger: 'blur', message: '请输入您所属的租户' },
+    { min: 2, max: 20, message: '租户账号长度必须介于 2 和 20 之间', trigger: 'blur' }
+  ],
+  username: [
+    { required: true, trigger: 'blur', message: '请输入您的账号' },
+    { min: 4, max: 30, message: '用户账号长度必须介于 4 和 30 之间', trigger: 'blur' }
+  ],
+  nickname: [
+    { required: true, trigger: 'blur', message: '请输入您的昵称' },
+    { min: 0, max: 30, message: '昵称长度必须介于 0 和 30 之间', trigger: 'blur' }
+  ],
+  password: [
+    { required: true, trigger: 'blur', message: '请输入您的密码' },
+    { min: 5, max: 20, message: '用户密码长度必须介于 5 和 20 之间', trigger: 'blur' },
+    { pattern: /^[^<>"'|\\]+$/, message: '不能包含非法字符:< > " \' \\\ |', trigger: 'blur' }
+  ],
+  confirmPassword: [
+    { required: true, trigger: 'blur', message: '请再次输入您的密码' },
+    { required: true, validator: equalToPassword, trigger: 'blur' }
+  ]
+}
+
+const registerData = reactive({
+  isShowPassword: false,
+  captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE,
+  tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE,
+  registerForm: {
+    tenantName: import.meta.env.VITE_APP_DEFAULT_LOGIN_TENANT || '',
+    nickname: '',
+    tenantId: 0,
+    username: '',
+    password: '',
+    confirmPassword: '',
+    captchaVerification: ''
+  }
+})
+
+// 提交注册
+const handleRegister = async (params: any) => {
+  loading.value = true
+  try {
+    if (registerData.tenantEnable) {
+      await getTenantId()
+      registerData.registerForm.tenantId = authUtil.getTenantId()
     }
-  },
-  {
-    field: 'username',
-    label: t('login.username'),
-    value: '',
-    component: 'Input',
-    colProps: {
-      span: 24
-    },
-    componentProps: {
-      placeholder: t('login.usernamePlaceholder')
+
+    if (registerData.captchaEnable) {
+      registerData.registerForm.captchaVerification = params.captchaVerification
     }
-  },
-  {
-    field: 'password',
-    label: t('login.password'),
-    value: '',
-    component: 'InputPassword',
-    colProps: {
-      span: 24
-    },
-    componentProps: {
-      style: {
-        width: '100%'
-      },
-      strength: true,
-      placeholder: t('login.passwordPlaceholder')
+
+    const res = await LoginApi.register(registerData.registerForm)
+    if (!res) {
+      return
     }
-  },
-  {
-    field: 'check_password',
-    label: t('login.checkPassword'),
-    value: '',
-    component: 'InputPassword',
-    colProps: {
-      span: 24
-    },
-    componentProps: {
-      style: {
-        width: '100%'
-      },
-      strength: true,
-      placeholder: t('login.passwordPlaceholder')
+    loading.value = ElLoading.service({
+      lock: true,
+      text: '正在加载系统中...',
+      background: 'rgba(0, 0, 0, 0.7)'
+    })
+
+    authUtil.removeLoginForm()
+
+    authUtil.setToken(res)
+    if (!redirect.value) {
+      redirect.value = '/'
     }
-  },
-  {
-    field: 'code',
-    label: t('login.code'),
-    colProps: {
-      span: 24
+    // 判断是否为SSO登录
+    if (redirect.value.indexOf('sso') !== -1) {
+      window.location.href = window.location.href.replace('/login?redirect=', '')
+    } else {
+      push({ path: redirect.value || permissionStore.addRouters[0].path })
     }
+  } finally {
+    loginLoading.value = false
+    loading.value.close()
+  }
+}
+
+// 获取验证码
+const getCode = async () => {
+  // 情况一,未开启:则直接注册
+  if (registerData.captchaEnable === 'false') {
+    await handleRegister({})
+  } else {
+    // 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行注册
+    // 弹出验证码
+    verify.value.show()
+  }
+}
+
+// 获取租户 ID
+const getTenantId = async () => {
+  if (registerData.tenantEnable === 'true') {
+    const res = await LoginApi.getTenantIdByName(registerData.registerForm.tenantName)
+    authUtil.setTenantId(res)
+  }
+}
+
+// 根据域名,获得租户信息
+const getTenantByWebsite = async () => {
+  const website = location.host
+  const res = await LoginApi.getTenantByWebsite(website)
+  if (res) {
+    registerData.registerForm.tenantName = res.name
+    authUtil.setTenantId(res.id)
+  }
+}
+const loading = ref() // ElLoading.service 返回的实例
+
+watch(
+  () => currentRoute.value,
+  (route: RouteLocationNormalizedLoaded) => {
+    redirect.value = route?.query?.redirect as string
   },
   {
-    field: 'register',
-    colProps: {
-      span: 24
-    }
+    immediate: true
   }
-])
+)
+onMounted(() => {
+  // getCookie()
+  getTenantByWebsite()
+})
+</script>
 
-const rules: FormRules = {
-  username: [required()],
-  password: [required()],
-  check_password: [required()],
-  code: [required()]
+<style lang="scss" scoped>
+:deep(.anticon) {
+  &:hover {
+    color: var(--el-color-primary) !important;
+  }
 }
 
-const loading = ref(false)
-
-const loginRegister = async () => {
-  const formRef = unref(elFormRef)
-  formRef?.validate(async (valid) => {
-    if (valid) {
-      try {
-        loading.value = true
-      } finally {
-        loading.value = false
-      }
-    }
-  })
+.login-code {
+  float: right;
+  width: 100%;
+  height: 38px;
+
+  img {
+    width: 100%;
+    height: auto;
+    max-width: 100px;
+    vertical-align: middle;
+    cursor: pointer;
+  }
 }
-</script>
+</style>

+ 4 - 4
src/views/bpm/processInstance/index.vue

@@ -19,10 +19,10 @@
           class="!w-240px"
         />
       </el-form-item>
-      <el-form-item label="所属流程" prop="processDefinitionId">
+      <el-form-item label="所属流程" prop="processDefinitionKey">
         <el-input
-          v-model="queryParams.processDefinitionId"
-          placeholder="请输入流程定义的编号"
+          v-model="queryParams.processDefinitionKey"
+          placeholder="请输入流程定义的标识"
           clearable
           @keyup.enter="handleQuery"
           class="!w-240px"
@@ -183,7 +183,7 @@ const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
   name: '',
-  processDefinitionId: undefined,
+  processDefinitionKey: undefined,
   category: undefined,
   status: undefined,
   createTime: []

+ 1 - 1
src/views/crm/contract/detail/index.vue

@@ -36,7 +36,7 @@
           ref="permissionListRef"
           :biz-id="contract.id!"
           :biz-type="BizTypeEnum.CRM_CONTRACT"
-          :show-action="!permissionListRef?.isPool || false"
+          :show-action="true"
           @quit-team="close"
         />
       </el-tab-pane>

+ 4 - 2
src/views/infra/webSocket/index.vue

@@ -71,7 +71,7 @@
 <script lang="ts" setup>
 import { formatDate } from '@/utils/formatTime'
 import { useWebSocket } from '@vueuse/core'
-import { getAccessToken } from '@/utils/auth'
+import { getRefreshToken } from '@/utils/auth'
 import * as UserApi from '@/api/system/user'
 
 defineOptions({ name: 'InfraWebSocket' })
@@ -79,7 +79,9 @@ defineOptions({ name: 'InfraWebSocket' })
 const message = useMessage() // 消息弹窗
 
 const server = ref(
-  (import.meta.env.VITE_BASE_URL + '/infra/ws').replace('http', 'ws') + '?token=' + getAccessToken()
+  (import.meta.env.VITE_BASE_URL + '/infra/ws').replace('http', 'ws') +
+    '?token=' +
+    getRefreshToken() // 使用 getRefreshToken() 方法,而不使用 getAccessToken() 方法的原因:WebSocket 无法方便的刷新访问令牌
 ) // WebSocket 服务地址
 const getIsOpen = computed(() => status.value === 'OPEN') // WebSocket 连接是否打开
 const getTagColor = computed(() => (getIsOpen.value ? 'success' : 'red')) // WebSocket 连接的展示颜色

+ 156 - 0
src/views/iot/device/DeviceForm.vue

@@ -0,0 +1,156 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="产品" prop="productId">
+        <el-select
+          v-model="formData.productId"
+          placeholder="请选择产品"
+          :disabled="formType === 'update'"
+          clearable
+        >
+          <el-option
+            v-for="product in products"
+            :key="product.id"
+            :label="product.name"
+            :value="product.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="DeviceName" prop="deviceName">
+        <el-input
+          v-model="formData.deviceName"
+          placeholder="请输入 DeviceName"
+          :disabled="formType === 'update'"
+        />
+      </el-form-item>
+      <el-form-item label="备注名称" prop="nickname">
+        <el-input v-model="formData.nickname" placeholder="请输入备注名称" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { DeviceApi, DeviceVO } from '@/api/iot/device'
+import { ProductApi } from '@/api/iot/product'
+
+/** IoT 设备 表单 */
+defineOptions({ name: 'IoTDeviceForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  productId: undefined,
+  deviceName: undefined,
+  nickname: undefined
+})
+const formRules = reactive({
+  productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }],
+  deviceName: [
+    {
+      pattern: /^[a-zA-Z0-9_.\-:@]{4,32}$/,
+      message:
+        '支持英文字母、数字、下划线(_)、中划线(-)、点号(.)、半角冒号(:)和特殊字符@,长度限制为 4~32 个字符',
+      trigger: 'blur'
+    }
+  ],
+  nickname: [
+    {
+      validator: (rule, value, callback) => {
+        if (value === undefined || value === null) {
+          callback()
+          return
+        }
+        const length = value.replace(/[\u4e00-\u9fa5\u3040-\u30ff]/g, 'aa').length
+        if (length < 4 || length > 64) {
+          callback(new Error('备注名称长度限制为 4~64 个字符,中文及日文算 2 个字符'))
+        } else if (!/^[\u4e00-\u9fa5\u3040-\u30ff_a-zA-Z0-9]+$/.test(value)) {
+          callback(new Error('备注名称只能包含中文、英文字母、日文、数字和下划线(_)'))
+        } else {
+          callback()
+        }
+      },
+      trigger: 'blur'
+    }
+  ]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await DeviceApi.getDevice(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as DeviceVO
+    if (formType.value === 'create') {
+      await DeviceApi.createDevice(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await DeviceApi.updateDevice(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    productId: undefined,
+    deviceName: undefined,
+    nickname: undefined
+  }
+  formRef.value?.resetFields()
+}
+
+/** 查询字典下拉列表 */
+const products = ref()
+const getProducts = async () => {
+  products.value = await ProductApi.getSimpleProductList()
+}
+
+onMounted(() => {
+  getProducts()
+})
+</script>

+ 76 - 0
src/views/iot/device/detail/DeviceDetailsHeader.vue

@@ -0,0 +1,76 @@
+<template>
+  <div>
+    <div class="flex items-start justify-between">
+      <div>
+        <el-col>
+          <el-row>
+            <span class="text-xl font-bold">{{ device.deviceName }}</span>
+          </el-row>
+        </el-col>
+      </div>
+      <div>
+        <!-- 右上:按钮 -->
+        <el-button
+          @click="openForm('update', device.id)"
+          v-hasPermi="['iot:device:update']"
+          v-if="product.status === 0"
+        >
+          编辑
+        </el-button>
+      </div>
+    </div>
+  </div>
+  <ContentWrap class="mt-10px">
+    <el-descriptions :column="5" direction="horizontal">
+      <el-descriptions-item label="产品">
+        <el-link @click="goToProductDetail(product.id)">{{ product.name }}</el-link>
+      </el-descriptions-item>
+      <el-descriptions-item label="ProductKey">
+        {{ product.productKey }}
+        <el-button @click="copyToClipboard(product.productKey)">复制</el-button>
+      </el-descriptions-item>
+    </el-descriptions>
+  </ContentWrap>
+  <!-- 表单弹窗:添加/修改 -->
+  <DeviceForm ref="formRef" @success="emit('refresh')" />
+</template>
+<script setup lang="ts">
+import { ref } from 'vue'
+import DeviceForm from '@/views/iot/device/DeviceForm.vue'
+import { ProductVO } from '@/api/iot/product'
+import { DeviceVO } from '@/api/iot/device'
+import { useRouter } from 'vue-router'
+
+const message = useMessage()
+const router = useRouter()
+
+// 操作修改
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+const { product, device } = defineProps<{ product: ProductVO; device: DeviceVO }>()
+const emit = defineEmits(['refresh'])
+
+/**
+ * 将文本复制到剪贴板
+ *
+ * @param text 需要复制的文本
+ */
+const copyToClipboard = (text: string) => {
+  // TODO @haohao:可以考虑用 await 异步转同步哈
+  navigator.clipboard.writeText(text).then(() => {
+    message.success('复制成功')
+  })
+}
+
+/**
+ * 跳转到产品详情页面
+ *
+ * @param productId 产品 ID
+ */
+const goToProductDetail = (productId: number) => {
+  router.push({ name: 'IoTProductDetail', params: { id: productId } })
+}
+</script>

+ 123 - 0
src/views/iot/device/detail/DeviceDetailsInfo.vue

@@ -0,0 +1,123 @@
+<template>
+  <ContentWrap>
+    <el-collapse v-model="activeNames">
+      <el-descriptions :column="3" title="设备信息">
+        <el-descriptions-item label="产品名称">{{ product.name }}</el-descriptions-item>
+        <el-descriptions-item label="ProductKey">
+          {{ product.productKey }}
+          <el-button @click="copyToClipboard(product.productKey)">复制</el-button>
+        </el-descriptions-item>
+        <el-descriptions-item label="设备类型">
+          <dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="product.deviceType" />
+        </el-descriptions-item>
+        <el-descriptions-item label="DeviceName">
+          {{ device.deviceName }}
+          <el-button @click="copyToClipboard(device.deviceName)">复制</el-button>
+        </el-descriptions-item>
+        <el-descriptions-item label="备注名称">{{ device.nickname }}</el-descriptions-item>
+        <el-descriptions-item label="创建时间">
+          {{ formatDate(device.createTime) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="激活时间">
+          {{ formatDate(device.activeTime) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="最后上线时间">
+          {{ formatDate(device.lastOnlineTime) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="当前状态">
+          <dict-tag :type="DICT_TYPE.IOT_DEVICE_STATUS" :value="device.status" />
+        </el-descriptions-item>
+        <el-descriptions-item label="最后离线时间" :span="3">
+          {{ formatDate(device.lastOfflineTime) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="MQTT 连接参数">
+          <el-button type="primary" @click="openMqttParams">查看</el-button>
+        </el-descriptions-item>
+      </el-descriptions>
+    </el-collapse>
+
+    <!-- MQTT 连接参数弹框 -->
+    <Dialog
+      title="MQTT 连接参数"
+      v-model="mqttDialogVisible"
+      width="50%"
+      :before-close="handleCloseMqttDialog"
+    >
+      <el-form :model="mqttParams" label-width="120px">
+        <el-form-item label="clientId">
+          <el-input v-model="mqttParams.mqttClientId" readonly>
+            <template #append>
+              <el-button @click="copyToClipboard(mqttParams.mqttClientId)" type="primary">
+                <Icon icon="ph:copy" />
+              </el-button>
+            </template>
+          </el-input>
+        </el-form-item>
+        <el-form-item label="username">
+          <el-input v-model="mqttParams.mqttUsername" readonly>
+            <template #append>
+              <el-button @click="copyToClipboard(mqttParams.mqttUsername)" type="primary">
+                <Icon icon="ph:copy" />
+              </el-button>
+            </template>
+          </el-input>
+        </el-form-item>
+        <el-form-item label="passwd">
+          <el-input v-model="mqttParams.mqttPassword" readonly type="password">
+            <template #append>
+              <el-button @click="copyToClipboard(mqttParams.mqttPassword)" type="primary">
+                <Icon icon="ph:copy" />
+              </el-button>
+            </template>
+          </el-input>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="mqttDialogVisible = false">关闭</el-button>
+      </template>
+    </Dialog>
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import { ref } from 'vue'
+import { DICT_TYPE } from '@/utils/dict'
+import { ProductVO } from '@/api/iot/product'
+import { formatDate } from '@/utils/formatTime'
+import { DeviceVO } from '@/api/iot/device'
+
+const message = useMessage() // 消息提示
+
+const { product, device } = defineProps<{ product: ProductVO; device: DeviceVO }>() // 定义 Props
+
+const emit = defineEmits(['refresh']) // 定义 Emits
+
+const activeNames = ref(['basicInfo']) // 展示的折叠面板
+const mqttDialogVisible = ref(false) // 定义 MQTT 弹框的可见性
+const mqttParams = ref({
+  mqttClientId: '',
+  mqttUsername: '',
+  mqttPassword: ''
+}) // 定义 MQTT 参数对象
+
+/** 复制到剪贴板方法 */
+const copyToClipboard = (text: string) => {
+  navigator.clipboard.writeText(text).then(() => {
+    message.success('复制成功')
+  })
+}
+
+/** 打开 MQTT 参数弹框的方法 */
+const openMqttParams = () => {
+  mqttParams.value = {
+    mqttClientId: device.mqttClientId || 'N/A',
+    mqttUsername: device.mqttUsername || 'N/A',
+    mqttPassword: device.mqttPassword || 'N/A'
+  }
+  mqttDialogVisible.value = true
+}
+
+/** 关闭 MQTT 弹框的方法 */
+const handleCloseMqttDialog = () => {
+  mqttDialogVisible.value = false
+}
+</script>

+ 66 - 0
src/views/iot/device/detail/index.vue

@@ -0,0 +1,66 @@
+<template>
+  <DeviceDetailsHeader
+    :loading="loading"
+    :product="product"
+    :device="device"
+    @refresh="getDeviceData(id)"
+  />
+  <el-col>
+    <el-tabs>
+      <el-tab-pane label="设备信息">
+        <DeviceDetailsInfo :product="product" :device="device" />
+      </el-tab-pane>
+      <el-tab-pane label="Topic 列表" />
+      <el-tab-pane label="物模型数据" />
+      <el-tab-pane label="子设备管理" />
+    </el-tabs>
+  </el-col>
+</template>
+<script lang="ts" setup>
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import { DeviceApi, DeviceVO } from '@/api/iot/device'
+import { ProductApi, ProductVO } from '@/api/iot/product'
+import DeviceDetailsHeader from '@/views/iot/device/detail/DeviceDetailsHeader.vue'
+import DeviceDetailsInfo from '@/views/iot/device/detail/DeviceDetailsInfo.vue'
+
+defineOptions({ name: 'IoTDeviceDetail' })
+
+const route = useRoute()
+const message = useMessage()
+const id = Number(route.params.id) // 编号
+const loading = ref(true) // 加载中
+const product = ref<ProductVO>({} as ProductVO) // 产品详情
+const device = ref<DeviceVO>({} as DeviceVO) // 设备详情
+
+/** 获取设备详情 */
+const getDeviceData = async (id: number) => {
+  loading.value = true
+  try {
+    device.value = await DeviceApi.getDevice(id)
+    console.log(product.value)
+    await getProductData(device.value.productId)
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 获取产品详情 */
+const getProductData = async (id: number) => {
+  product.value = await ProductApi.getProduct(id)
+  console.log(product.value)
+}
+
+/** 获取物模型 */
+
+/** 初始化 */
+const { delView } = useTagsViewStore() // 视图操作
+const { currentRoute } = useRouter() // 路由
+onMounted(async () => {
+  if (!id) {
+    message.warning('参数错误,产品不能为空!')
+    delView(unref(currentRoute))
+    return
+  }
+  await getDeviceData(id)
+})
+</script>

+ 267 - 0
src/views/iot/device/index.vue

@@ -0,0 +1,267 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="产品" prop="productId">
+        <el-select
+          v-model="queryParams.productId"
+          placeholder="请选择产品"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="product in products"
+            :key="product.id"
+            :label="product.name"
+            :value="product.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="DeviceName" prop="deviceName">
+        <el-input
+          v-model="queryParams.deviceName"
+          placeholder="请输入 DeviceName"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="备注名称" prop="nickname">
+        <el-input
+          v-model="queryParams.nickname"
+          placeholder="请输入备注名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="设备类型" prop="deviceType">
+        <el-select
+          v-model="queryParams.deviceType"
+          placeholder="请选择设备类型"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="设备状态" prop="status">
+        <el-select
+          v-model="queryParams.status"
+          placeholder="请选择设备状态"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </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="['iot:device:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" />
+          新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="DeviceName" align="center" prop="deviceName">
+        <template #default="scope">
+          <el-link @click="openDetail(scope.row.id)">{{ scope.row.deviceName }}</el-link>
+        </template>
+      </el-table-column>
+      <el-table-column label="备注名称" align="center" prop="nickname" />
+      <el-table-column label="设备所属产品" align="center" prop="productId">
+        <template #default="scope">
+          {{ productMap[scope.row.productId] }}
+        </template>
+      </el-table-column>
+      <el-table-column label="设备类型" align="center" prop="deviceType">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="scope.row.deviceType" />
+        </template>
+      </el-table-column>
+      <el-table-column label="设备状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.IOT_DEVICE_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="最后上线时间"
+        align="center"
+        prop="lastOnlineTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center" min-width="120px">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openDetail(scope.row.id)"
+            v-hasPermi="['iot:product:query']"
+          >
+            查看
+          </el-button>
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['iot:device:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['iot:device:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <DeviceForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { DeviceApi, DeviceVO } from '@/api/iot/device'
+import DeviceForm from './DeviceForm.vue'
+import { ProductApi } from '@/api/iot/product'
+
+/** IoT 设备 列表 */
+defineOptions({ name: 'IoTDevice' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<DeviceVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  deviceName: undefined,
+  productId: undefined,
+  deviceType: undefined,
+  nickname: undefined,
+  status: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 产品标号和名称的映射 */
+const productMap = reactive({})
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await DeviceApi.getDevicePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+    // 获取产品ID列表
+    const productIds = [...new Set(data.list.map((device) => device.productId))]
+    // 获取产品名称
+    // TODO @haohao:最好后端拼接哈
+    const products = await Promise.all(productIds.map((id) => ProductApi.getProduct(id)))
+    products.forEach((product) => {
+      productMap[product.id] = product.name
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 打开详情 */
+const { push } = useRouter()
+const openDetail = (id: number) => {
+  push({ name: 'IoTDeviceDetail', params: { id } })
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await DeviceApi.deleteDevice(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 查询字典下拉列表 */
+const products = ref()
+const getProducts = async () => {
+  products.value = await ProductApi.getSimpleProductList()
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+  getProducts()
+})
+</script>

+ 204 - 0
src/views/iot/product/ProductForm.vue

@@ -0,0 +1,204 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="产品名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入产品名称" />
+      </el-form-item>
+
+      <el-form-item label="设备类型" prop="deviceType">
+        <el-select
+          v-model="formData.deviceType"
+          placeholder="请选择设备类型"
+          :disabled="formType === 'update'"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item
+        v-if="formData.deviceType === 0 || formData.deviceType === 2"
+        label="联网方式"
+        prop="netType"
+      >
+        <el-select
+          v-model="formData.netType"
+          placeholder="请选择联网方式"
+          :disabled="formType === 'update'"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_NET_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item v-if="formData.deviceType === 1" label="接入网关协议" prop="protocolType">
+        <el-select
+          v-model="formData.protocolType"
+          placeholder="请选择接入网关协议"
+          :disabled="formType === 'update'"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PROTOCOL_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="数据格式" prop="dataFormat">
+        <el-select
+          v-model="formData.dataFormat"
+          placeholder="请选择接数据格式"
+          :disabled="formType === 'update'"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DATA_FORMAT)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="数据校验级别" prop="validateType">
+        <el-radio-group v-model="formData.validateType" :disabled="formType === 'update'">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_VALIDATE_TYPE)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+
+      <el-form-item label="产品描述" prop="description">
+        <el-input type="textarea" v-model="formData.description" placeholder="请输入产品描述" />
+      </el-form-item>
+    </el-form>
+
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { ProductApi, ProductVO } from '@/api/iot/product'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+
+defineOptions({ name: 'IoTProductForm' })
+
+const { t } = useI18n()
+const message = useMessage()
+
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const formLoading = ref(false)
+const formType = ref('')
+const formData = ref({
+  name: undefined,
+  id: undefined,
+  productKey: undefined,
+  protocolId: undefined,
+  categoryId: undefined,
+  description: undefined,
+  validateType: undefined,
+  status: undefined,
+  deviceType: undefined,
+  netType: undefined,
+  protocolType: undefined,
+  dataFormat: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }],
+  deviceType: [{ required: true, message: '设备类型不能为空', trigger: 'change' }],
+  netType: [
+    {
+      // TODO @haohao:0、1、/2 最好前端也枚举下;另外,这里的 required 可以直接设置为 true。然后表单那些 v-if。只要不存在,它自动就不校验了哈
+      required: formData.deviceType === 0 || formData.deviceType === 2,
+      message: '联网方式不能为空',
+      trigger: 'change'
+    }
+  ],
+  protocolType: [
+    { required: formData.deviceType === 1, message: '接入网关协议不能为空', trigger: 'change' }
+  ],
+  dataFormat: [{ required: true, message: '数据格式不能为空', trigger: 'change' }],
+  validateType: [{ required: true, message: '数据校验级别不能为空', trigger: 'change' }]
+})
+const formRef = ref()
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await ProductApi.getProduct(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open, close: () => (dialogVisible.value = false) })
+
+/** 提交表单 */
+const emit = defineEmits(['success'])
+const submitForm = async () => {
+  await formRef.value.validate()
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as ProductVO
+    if (formType.value === 'create') {
+      await ProductApi.createProduct(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ProductApi.updateProduct(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false // 确保关闭弹框
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    name: undefined,
+    id: undefined,
+    productKey: undefined,
+    protocolId: undefined,
+    categoryId: undefined,
+    description: undefined,
+    validateType: undefined,
+    status: undefined,
+    deviceType: undefined,
+    netType: undefined,
+    protocolType: undefined,
+    dataFormat: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 103 - 0
src/views/iot/product/detail/ProductDetailsHeader.vue

@@ -0,0 +1,103 @@
+<template>
+  <div>
+    <div class="flex items-start justify-between">
+      <div>
+        <el-col>
+          <el-row>
+            <span class="text-xl font-bold">{{ product.name }}</span>
+          </el-row>
+        </el-col>
+      </div>
+      <div>
+        <!-- 右上:按钮 -->
+        <el-button
+          @click="openForm('update', product.id)"
+          v-hasPermi="['iot:product:update']"
+          v-if="product.status === 0"
+        >
+          编辑
+        </el-button>
+        <el-button
+          type="primary"
+          @click="confirmPublish(product.id)"
+          v-hasPermi="['iot:product:update']"
+          v-if="product.status === 0"
+        >
+          发布
+        </el-button>
+        <el-button
+          type="danger"
+          @click="confirmUnpublish(product.id)"
+          v-hasPermi="['iot:product:update']"
+          v-if="product.status === 1"
+        >
+          撤销发布
+        </el-button>
+      </div>
+    </div>
+  </div>
+  <ContentWrap class="mt-10px">
+    <el-descriptions :column="5" direction="horizontal">
+      <el-descriptions-item label="ProductKey">
+        {{ product.productKey }}
+        <el-button @click="copyToClipboard(product.productKey)">复制</el-button>
+      </el-descriptions-item>
+    </el-descriptions>
+    <el-descriptions :column="5" direction="horizontal">
+      <el-descriptions-item label="设备数">
+        {{ product.deviceCount }}
+        <el-button @click="goToManagement(product.id)">前往管理</el-button>
+      </el-descriptions-item>
+    </el-descriptions>
+  </ContentWrap>
+  <!-- 表单弹窗:添加/修改 -->
+  <ProductForm ref="formRef" @success="emit('refresh')" />
+</template>
+<script setup lang="ts">
+import ProductForm from '@/views/iot/product/ProductForm.vue'
+import { ProductApi, ProductVO } from '@/api/iot/product'
+
+const message = useMessage()
+
+const { product } = defineProps<{ product: ProductVO }>() // 定义 Props
+
+/** 处理复制 */
+const copyToClipboard = (text: string) => {
+  navigator.clipboard.writeText(text).then(() => {
+    message.success('复制成功')
+  })
+}
+
+/** 路由跳转到设备管理 */
+const { push } = useRouter()
+const goToManagement = (productId: string) => {
+  push({ name: 'IoTDevice', query: { productId } })
+}
+
+/** 操作修改 */
+const emit = defineEmits(['refresh']) // 定义 Emits
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+const confirmPublish = async (id: number) => {
+  try {
+    await ProductApi.updateProductStatus(id, 1)
+    message.success('发布成功')
+    formRef.value.close() // 关闭弹框
+    emit('refresh')
+  } catch (error) {
+    message.error('发布失败')
+  }
+}
+const confirmUnpublish = async (id: number) => {
+  try {
+    await ProductApi.updateProductStatus(id, 0)
+    message.success('撤销发布成功')
+    formRef.value.close() // 关闭弹框
+    emit('refresh')
+  } catch (error) {
+    message.error('撤销发布失败')
+  }
+}
+</script>

+ 44 - 0
src/views/iot/product/detail/ProductDetailsInfo.vue

@@ -0,0 +1,44 @@
+<template>
+  <ContentWrap>
+    <el-collapse v-model="activeNames">
+      <el-descriptions :column="3" title="产品信息">
+        <el-descriptions-item label="产品名称">{{ product.name }}</el-descriptions-item>
+        <el-descriptions-item label="设备类型">
+          <dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="product.deviceType" />
+        </el-descriptions-item>
+        <el-descriptions-item label="创建时间">
+          {{ formatDate(product.createTime) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="数据格式">
+          <dict-tag :type="DICT_TYPE.IOT_DATA_FORMAT" :value="product.dataFormat" />
+        </el-descriptions-item>
+        <el-descriptions-item label="数据校验级别">
+          <dict-tag :type="DICT_TYPE.IOT_VALIDATE_TYPE" :value="product.validateType" />
+        </el-descriptions-item>
+        <el-descriptions-item label="产品状态">
+          <dict-tag :type="DICT_TYPE.IOT_PRODUCT_STATUS" :value="product.status" />
+        </el-descriptions-item>
+        <el-descriptions-item
+          label="联网方式"
+          v-if="product.deviceType === 0 || product.deviceType === 2"
+        >
+          <dict-tag :type="DICT_TYPE.IOT_NET_TYPE" :value="product.netType" />
+        </el-descriptions-item>
+        <el-descriptions-item label="接入网关协议" v-if="product.deviceType === 1">
+          <dict-tag :type="DICT_TYPE.IOT_PROTOCOL_TYPE" :value="product.protocolType" />
+        </el-descriptions-item>
+        <el-descriptions-item label="产品描述">{{ product.description }}</el-descriptions-item>
+      </el-descriptions>
+    </el-collapse>
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import { DICT_TYPE } from '@/utils/dict'
+import { ProductVO } from '@/api/iot/product'
+import { formatDate } from '@/utils/formatTime'
+
+const { product } = defineProps<{ product: ProductVO }>()
+
+// 展示的折叠面板
+const activeNames = ref(['basicInfo'])
+</script>

+ 243 - 0
src/views/iot/product/detail/ProductTopic.vue

@@ -0,0 +1,243 @@
+<template>
+  <ContentWrap>
+    <el-tabs>
+      <el-tab-pane label="基础通信 Topic">
+        <Table
+          :columns="columns1"
+          :data="data1"
+          :span-method="createSpanMethod(data1)"
+          align="left"
+          headerAlign="left"
+          border="true"
+        />
+      </el-tab-pane>
+      <el-tab-pane label="物模型通信 Topic">
+        <Table
+          :columns="columns2"
+          :data="data2"
+          :span-method="createSpanMethod(data2)"
+          align="left"
+          headerAlign="left"
+          border="true"
+        />
+      </el-tab-pane>
+    </el-tabs>
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import { ProductVO } from '@/api/iot/product'
+
+const props = defineProps<{ product: ProductVO }>()
+
+// 定义列
+const columns1 = reactive([
+  { label: '功能', field: 'function', width: 150 },
+  { label: 'Topic 类', field: 'topicClass', width: 800 },
+  { label: '操作权限', field: 'operationPermission', width: 100 },
+  { label: '描述', field: 'description' }
+])
+
+const columns2 = reactive([
+  { label: '功能', field: 'function', width: 150 },
+  { label: 'Topic 类', field: 'topicClass', width: 800 },
+  { label: '操作权限', field: 'operationPermission', width: 100 },
+  { label: '描述', field: 'description' }
+])
+
+// TODO @haohao:这个,有没可能写到一个枚举里,方便后续维护? /Users/yunai/Java/yudao-ui-admin-vue3/src/views/ai/utils/constants.ts
+const data1 = computed(() => {
+  if (!props.product || !props.product.productKey) return []
+  return [
+    {
+      function: 'OTA 升级',
+      topicClass: `/ota/device/inform/${props.product.productKey}/\${deviceName}`,
+      operationPermission: '发布',
+      description: '设备上报固件升级信息'
+    },
+    {
+      function: 'OTA 升级',
+      topicClass: `/ota/device/upgrade/${props.product.productKey}/\${deviceName}`,
+      operationPermission: '订阅',
+      description: '固件升级信息下行'
+    },
+    {
+      function: 'OTA 升级',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/ota/firmware/get`,
+      operationPermission: '发布',
+      description: '设备上报固件升级进度'
+    },
+    {
+      function: 'OTA 升级',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/ota/firmware/get`,
+      operationPermission: '发布',
+      description: '设备主动拉取固件升级信息'
+    },
+    {
+      function: '设备标签',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/deviceinfo/update`,
+      operationPermission: '发布',
+      description: '设备上报标签数据'
+    },
+    {
+      function: '设备标签',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/deviceinfo/update_reply`,
+      operationPermission: '订阅',
+      description: '云端响应标签上报'
+    },
+    {
+      function: '设备标签',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/deviceinfo/delete`,
+      operationPermission: '订阅',
+      description: '设备删除标签信息'
+    },
+    {
+      function: '设备标签',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/deviceinfo/delete_reply`,
+      operationPermission: '订阅',
+      description: '云端响应标签删除'
+    },
+    {
+      function: '时钟同步',
+      topicClass: `/ext/ntp/${props.product.productKey}/\${deviceName}/request`,
+      operationPermission: '发布',
+      description: 'NTP 时钟同步请求'
+    },
+    {
+      function: '时钟同步',
+      topicClass: `/ext/ntp/${props.product.productKey}/\${deviceName}/response`,
+      operationPermission: '订阅',
+      description: 'NTP 时钟同步响应'
+    },
+    {
+      function: '设备影子',
+      topicClass: `/shadow/update/${props.product.productKey}/\${deviceName}`,
+      operationPermission: '发布',
+      description: '设备影子发布'
+    },
+    {
+      function: '设备影子',
+      topicClass: `/shadow/get/${props.product.productKey}/\${deviceName}`,
+      operationPermission: '订阅',
+      description: '设备接收影子变更'
+    },
+    {
+      function: '配置更新',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/config/push`,
+      operationPermission: '订阅',
+      description: '云端主动下推配置信息'
+    },
+    {
+      function: '配置更新',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/config/get`,
+      operationPermission: '发布',
+      description: '设备端查询配置信息'
+    },
+    {
+      function: '配置更新',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/config/get_reply`,
+      operationPermission: '订阅',
+      description: '云端响应配置信息'
+    },
+    {
+      function: '广播',
+      topicClass: `/broadcast/${props.product.productKey}/\${identifier}`,
+      operationPermission: '订阅',
+      description: '广播 Topic,identifier 为用户自定义字符串'
+    }
+  ]
+})
+
+const data2 = computed(() => {
+  if (!props.product || !props.product.productKey) return []
+  return [
+    {
+      function: '属性上报',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/event/property/post`,
+      operationPermission: '发布',
+      description: '设备属性上报'
+    },
+    {
+      function: '属性上报',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/event/property/post_reply`,
+      operationPermission: '订阅',
+      description: '云端响应属性上报'
+    },
+    {
+      function: '属性设置',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/service/property/set`,
+      operationPermission: '订阅',
+      description: '设备属性设置'
+    },
+    {
+      function: '事件上报',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/event/\${tsl.event.identifier}/post`,
+      operationPermission: '发布',
+      description: '设备事件上报'
+    },
+    {
+      function: '事件上报',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/event/\${tsl.event.identifier}/post_reply`,
+      operationPermission: '订阅',
+      description: '云端响应事件上报'
+    },
+    {
+      function: '服务调用',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/service/\${tsl.service.identifier}`,
+      operationPermission: '订阅',
+      description: '设备服务调用'
+    },
+    {
+      function: '服务调用',
+      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/service/\${tsl.service.identifier}_reply`,
+      operationPermission: '发布',
+      description: '设备端响应服务调用'
+    }
+  ]
+})
+
+// 通用的单元格合并方法生成器
+const createSpanMethod = (data: any[]) => {
+  // 预处理,计算每个功能的合并行数
+  const rowspanMap: Record<number, number> = {}
+  let currentFunction = ''
+  let startIndex = 0
+  let count = 0
+
+  data.forEach((item, index) => {
+    if (item.function !== currentFunction) {
+      if (count > 0) {
+        rowspanMap[startIndex] = count
+      }
+      currentFunction = item.function
+      startIndex = index
+      count = 1
+    } else {
+      count++
+    }
+  })
+
+  // 处理最后一组
+  if (count > 0) {
+    rowspanMap[startIndex] = count
+  }
+
+  // 返回 span 方法
+  return ({ row, column, rowIndex, columnIndex }: SpanMethodProps) => {
+    if (columnIndex === 0) {
+      // 仅对“功能”列进行合并
+      const rowspan = rowspanMap[rowIndex] || 0
+      if (rowspan > 0) {
+        return {
+          rowspan,
+          colspan: 1
+        }
+      } else {
+        return {
+          rowspan: 0,
+          colspan: 0
+        }
+      }
+    }
+  }
+}
+</script>

+ 154 - 0
src/views/iot/product/detail/ThinkModelFunction.vue

@@ -0,0 +1,154 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="功能类型" prop="name">
+        <el-select
+          v-model="queryParams.type"
+          placeholder="请选择功能类型"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_FUNCTION_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </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="['iot:think-model-function:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 添加功能
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+  <ContentWrap>
+    <el-tabs>
+      <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+        <el-table-column label="功能类型" align="center" prop="type">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.IOT_PRODUCT_FUNCTION_TYPE" :value="scope.row.type" />
+          </template>
+        </el-table-column>
+        <el-table-column label="功能名称" align="center" prop="name" />
+        <el-table-column label="标识符" align="center" prop="identifier" />
+        <el-table-column label="操作" align="center">
+          <template #default="scope">
+            <el-button
+              link
+              type="primary"
+              @click="openForm('update', scope.row.id)"
+              v-hasPermi="[`iot:think-model-function:update`]"
+            >
+              编辑
+            </el-button>
+            <el-button
+              link
+              type="danger"
+              @click="handleDelete(scope.row.id)"
+              v-hasPermi="['iot:think-model-function:delete']"
+            >
+              删除
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <!-- 分页 -->
+      <Pagination
+        :total="total"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getList"
+      />
+    </el-tabs>
+  </ContentWrap>
+  <!-- 表单弹窗:添加/修改 -->
+  <ThinkModelFunctionForm ref="formRef" :product="product" @success="getList" />
+</template>
+<script setup lang="ts">
+import { ProductVO } from '@/api/iot/product'
+import { ThinkModelFunctionApi, ThinkModelFunctionVO } from '@/api/iot/thinkmodelfunction'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import ThinkModelFunctionForm from '@/views/iot/product/detail/ThinkModelFunctionForm.vue'
+
+const props = defineProps<{ product: ProductVO }>()
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const loading = ref(true) // 列表的加载中
+const list = ref<ThinkModelFunctionVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  type: undefined,
+  productId: -1
+})
+
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    queryParams.productId = props.product.id
+    const data = await ThinkModelFunctionApi.getThinkModelFunctionPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  queryParams.type = undefined
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ThinkModelFunctionApi.deleteThinkModelFunction(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 229 - 0
src/views/iot/product/detail/ThinkModelFunctionForm.vue

@@ -0,0 +1,229 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="功能类型" prop="type">
+        <el-radio-group v-model="formData.type">
+          <el-radio-button value="1"> 属性 </el-radio-button>
+          <el-radio-button value="2"> 服务 </el-radio-button>
+          <el-radio-button value="3"> 事件 </el-radio-button>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="功能名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入功能名称" />
+      </el-form-item>
+      <el-form-item label="标识符" prop="identifier">
+        <el-input
+          v-model="formData.identifier"
+          placeholder="请输入标识符"
+          :disabled="formType === 'update'"
+        />
+      </el-form-item>
+      <el-form-item label="数据类型" prop="type">
+        <el-select
+          v-model="formData.property.dataType.type"
+          placeholder="请选择数据类型"
+          :disabled="formType === 'update'"
+        >
+          <el-option key="int" label="int32 (整数型)" value="int" />
+          <el-option key="float" label="float (单精度浮点型)" value="float" />
+          <el-option key="double" label="double (双精度浮点型)" value="double" />
+          <!--          <el-option key="text" label="text (文本型)" value="text" />-->
+          <!--          <el-option key="date" label="date (日期型)" value="date" />-->
+          <!--          <el-option key="bool" label="bool (布尔型)" value="bool" />-->
+          <!--          <el-option key="enum" label="enum (枚举型)" value="enum" />-->
+          <!--          <el-option key="struct" label="struct (结构体)" value="struct" />-->
+          <!--          <el-option key="array" label="array (数组)" value="array" />-->
+        </el-select>
+      </el-form-item>
+      <el-form-item label="取值范围" prop="max">
+        <el-input v-model="formData.property.dataType.specs.min" placeholder="请输入最小值" />
+        <span class="mx-2">~</span>
+        <el-input v-model="formData.property.dataType.specs.max" placeholder="请输入最大值" />
+      </el-form-item>
+      <el-form-item label="步长" prop="step">
+        <el-input v-model="formData.property.dataType.specs.step" placeholder="请输入步长" />
+      </el-form-item>
+      <el-form-item label="单位" prop="unit">
+        <el-input v-model="formData.property.dataType.specs.unit" placeholder="请输入单位" />
+      </el-form-item>
+      <el-form-item label="读写类型" prop="accessMode">
+        <el-radio-group v-model="formData.property.accessMode">
+          <el-radio label="rw">读写</el-radio>
+          <el-radio label="r">只读</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="属性描述" prop="property.description">
+        <el-input
+          type="textarea"
+          v-model="formData.property.description"
+          placeholder="请输入属性描述"
+        />
+      </el-form-item>
+    </el-form>
+
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { ProductVO } from '@/api/iot/product'
+import { ThinkModelFunctionApi, ThinkModelFunctionVO } from '@/api/iot/thinkmodelfunction'
+
+const props = defineProps<{ product: ProductVO }>()
+
+defineOptions({ name: 'ThinkModelFunctionForm' })
+
+const { t } = useI18n()
+const message = useMessage()
+
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const formLoading = ref(false)
+const formType = ref('')
+const formData = ref({
+  id: undefined,
+  productId: undefined,
+  productKey: undefined,
+  identifier: undefined,
+  name: undefined,
+  description: undefined,
+  type: '1',
+  property: {
+    identifier: undefined,
+    name: undefined,
+    accessMode: 'rw',
+    required: true,
+    dataType: {
+      type: undefined,
+      specs: {
+        min: undefined,
+        max: undefined,
+        step: undefined,
+        unit: undefined
+      }
+    },
+    description: undefined // 添加这一行
+  }
+})
+const formRules = reactive({
+  name: [
+    { required: true, message: '功能名称不能为空', trigger: 'blur' },
+    {
+      pattern: /^[\u4e00-\u9fa5a-zA-Z0-9][\u4e00-\u9fa5a-zA-Z0-9\-_/\.]{0,29}$/,
+      message:
+        '支持中文、大小写字母、日文、数字、短划线、下划线、斜杠和小数点,必须以中文、英文或数字开头,不超过 30 个字符',
+      trigger: 'blur'
+    }
+  ],
+  type: [{ required: true, message: '功能类型不能为空', trigger: 'blur' }],
+  identifier: [
+    { required: true, message: '标识符不能为空', trigger: 'blur' },
+    {
+      pattern: /^[a-zA-Z0-9_]{1,50}$/,
+      message: '支持大小写字母、数字和下划线,不超过 50 个字符',
+      trigger: 'blur'
+    },
+    {
+      validator: (rule, value, callback) => {
+        const reservedKeywords = ['set', 'get', 'post', 'property', 'event', 'time', 'value']
+        if (reservedKeywords.includes(value)) {
+          callback(
+            new Error(
+              'set, get, post, property, event, time, value 是系统保留字段,不能用于标识符定义'
+            )
+          )
+        } else {
+          callback()
+        }
+      },
+      trigger: 'blur'
+    }
+  ],
+  property: {
+    dataType: {
+      type: [{ required: true, message: '数据类型不能为空', trigger: 'blur' }]
+    },
+    accessMode: [{ required: true, message: '读写类型不能为空', trigger: 'blur' }]
+  }
+})
+const formRef = ref()
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await ThinkModelFunctionApi.getThinkModelFunction(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open, close: () => (dialogVisible.value = false) })
+
+/** 提交表单 */
+const emit = defineEmits(['success'])
+const submitForm = async () => {
+  await formRef.value.validate()
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as ThinkModelFunctionVO
+    data.productId = props.product.id
+    data.productKey = props.product.productKey
+    if (formType.value === 'create') {
+      await ThinkModelFunctionApi.createThinkModelFunction(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ThinkModelFunctionApi.updateThinkModelFunction(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false // 确保关闭弹框
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    productId: undefined,
+    productKey: undefined,
+    identifier: undefined,
+    name: undefined,
+    description: undefined,
+    type: '1', // todo @HAOHAO:看看枚举下
+    property: {
+      identifier: undefined,
+      name: undefined,
+      accessMode: 'rw',
+      required: true,
+      dataType: {
+        type: undefined,
+        specs: {
+          min: undefined,
+          max: undefined,
+          step: undefined,
+          unit: undefined
+        }
+      },
+      description: undefined // 确保重置 description 字段
+    }
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 80 - 0
src/views/iot/product/detail/index.vue

@@ -0,0 +1,80 @@
+<template>
+  <ProductDetailsHeader :loading="loading" :product="product" @refresh="() => getProductData(id)" />
+  <el-col>
+    <el-tabs v-model="activeTab">
+      <el-tab-pane label="产品信息" name="info">
+        <ProductDetailsInfo v-if="activeTab === 'info'" :product="product" />
+      </el-tab-pane>
+      <el-tab-pane label="Topic 类列表" name="topic">
+        <ProductTopic v-if="activeTab === 'topic'" :product="product" />
+      </el-tab-pane>
+      <el-tab-pane label="功能定义" name="function">
+        <ThinkModelFunction v-if="activeTab === 'function'" :product="product" />
+      </el-tab-pane>
+      <el-tab-pane label="消息解析" name="message" />
+      <el-tab-pane label="服务端订阅" name="subscription" />
+    </el-tabs>
+  </el-col>
+</template>
+<script lang="ts" setup>
+import { ProductApi, ProductVO } from '@/api/iot/product'
+import { DeviceApi } from '@/api/iot/device'
+import ProductDetailsHeader from '@/views/iot/product/detail/ProductDetailsHeader.vue'
+import ProductDetailsInfo from '@/views/iot/product/detail/ProductDetailsInfo.vue'
+import ProductTopic from '@/views/iot/product/detail/ProductTopic.vue'
+import ThinkModelFunction from '@/views/iot/product/detail/ThinkModelFunction.vue'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import { useRouter } from 'vue-router'
+
+defineOptions({ name: 'IoTProductDetail' })
+
+const { delView } = useTagsViewStore() // 视图操作
+const { currentRoute } = useRouter()
+
+const route = useRoute()
+const message = useMessage()
+const id = Number(route.params.id) // 编号
+const loading = ref(true) // 加载中
+const product = ref<ProductVO>({} as ProductVO) // 详情
+const activeTab = ref('info') // 默认激活的标签页
+
+/** 获取详情 */
+const getProductData = async (id: number) => {
+  loading.value = true
+  try {
+    product.value = await ProductApi.getProduct(id)
+    console.log('Product data:', product.value)
+  } finally {
+    loading.value = false
+  }
+}
+
+// 查询设备数量
+const getDeviceCount = async (productId: number) => {
+  try {
+    const count = await DeviceApi.getDeviceCount(productId)
+    console.log('Device count response:', count)
+    return count
+  } catch (error) {
+    console.error('Error fetching device count:', error)
+    return 0
+  }
+}
+
+/** 初始化 */
+onMounted(async () => {
+  if (!id) {
+    message.warning('参数错误,产品不能为空!')
+    delView(unref(currentRoute))
+    return
+  }
+  await getProductData(id)
+  // 查询设备数量
+  if (product.value.id) {
+    product.value.deviceCount = await getDeviceCount(product.value.id)
+    console.log('Device count:', product.value.deviceCount)
+  } else {
+    console.error('Product ID is undefined')
+  }
+})
+</script>

+ 191 - 0
src/views/iot/product/index.vue

@@ -0,0 +1,191 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="产品名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入产品名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="ProductKey" prop="productKey">
+        <el-input
+          v-model="queryParams.productKey"
+          placeholder="请输入产品标识"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </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="['iot:product:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="产品名称" align="center" prop="name">
+        <template #default="scope">
+          <el-link @click="openDetail(scope.row.id)">{{ scope.row.name }}</el-link>
+        </template>
+      </el-table-column>
+      <el-table-column label="ProductKey" align="center" prop="productKey" />
+      <el-table-column label="设备类型" align="center" prop="deviceType">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="scope.row.deviceType" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="产品状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.IOT_PRODUCT_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openDetail(scope.row.id)"
+            v-hasPermi="['iot:product:query']"
+          >
+            查看
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['iot:product:delete']"
+            :disabled="scope.row.status === 1"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <ProductForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import { ProductApi, ProductVO } from '@/api/iot/product'
+import ProductForm from './ProductForm.vue'
+import { DICT_TYPE } from '@/utils/dict'
+
+/** iot 产品 列表 */
+defineOptions({ name: 'IoTProduct' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<ProductVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  createTime: [],
+  productKey: undefined,
+  protocolId: undefined,
+  categoryId: undefined,
+  description: undefined,
+  validateType: undefined,
+  status: undefined,
+  deviceType: undefined,
+  netType: undefined,
+  protocolType: undefined,
+  dataFormat: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ProductApi.getProductPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 打开详情 */
+const { push } = useRouter()
+const openDetail = (id: number) => {
+  push({ name: 'IoTProductDetail', params: { id } })
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ProductApi.deleteProduct(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 151 - 0
src/views/knowledge/dataset-form/form-step1.vue

@@ -0,0 +1,151 @@
+<template>
+  <div class="upload-container">
+    <!-- 标题 -->
+    <div class="title">
+      <div>选择数据源</div>
+    </div>
+
+    <!-- 数据源选择 -->
+    <div class="resource-btn" >导入已有文本</div>
+
+    <!-- 上传文件区域 -->
+    <el-form>
+      <div class="upload-section">
+        <div class="upload-label">上传文本文件</div>
+        <el-upload
+          class="upload-area"
+          action="#"
+          :file-list="fileList"
+          :on-remove="handleRemove"
+          :before-upload="beforeUpload"
+          list-type="text"
+          drag
+        >
+          <i class="el-icon-upload"></i>
+          <div class="el-upload__text">拖拽文件至此,或者 <em>选择文件</em></div>
+          <div class="el-upload__tip">
+            已支持 TXT、MARKDOWN、PDF、HTML、XLSX、XLS、DOCX、CSV、EML、MSG、PPTX、PPT、XML、EPUB,每个文件不超过 15MB。
+          </div>
+        </el-upload>
+      </div>
+
+      <!-- 下一步按钮 -->
+      <div class="next-button">
+        <el-button type="primary" :disabled="!fileList.length">下一步</el-button>
+      </div>
+    </el-form>
+
+    <!-- 知识库创建 -->
+    <div class="create-knowledge">
+      <el-link type="primary" underline>创建一个空知识库</el-link>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+
+const fileList = ref([])
+
+const handleRemove = (file, fileList) => {
+  console.log(file, fileList)
+}
+
+const beforeUpload = (file) => {
+  fileList.value.push(file)
+  return false
+}
+</script>
+
+<style scoped lang="scss">
+.upload-container {
+  width: 600px;
+  margin: 0 auto;
+  padding: 20px;
+  background-color: #fff;
+  border-radius: 8px;
+  border: 1px solid #ebebeb;
+}
+
+.title {
+  font-size: 22px;
+  font-weight: bold;
+}
+
+.resource-btn {
+  margin-top: 20px;
+  border-radius: 10px;
+  cursor: pointer;
+  width: 150px;
+  border: 1.5px solid #528bff;
+  padding: 10px;
+  text-align: center;
+  font-weight: 500;
+  font-size: 14px;
+  line-height: 30px;
+  color: #101828;
+}
+
+.upload-section {
+  margin: 20px 0;
+  padding-top: 10px;
+}
+
+.upload-label {
+  font-size: 16px;
+  font-weight: bold;
+  margin-bottom: 10px;
+  color: #303133;
+}
+
+.upload-area {
+  margin-top: 10px;
+  border: 1px dashed #d9d9d9;
+  padding: 40px;
+  text-align: center;
+  background-color: #f5f7fa;
+  border-radius: 8px;
+}
+
+.el-upload__text em {
+  color: #409eff;
+  cursor: pointer;
+}
+
+.el-upload__tip {
+  margin-top: 10px;
+  font-size: 12px;
+  color: #909399;
+}
+
+.next-button {
+  text-align: left;
+  margin-top: 20px;
+}
+
+.create-knowledge {
+  text-align: left;
+  margin-top: 20px;
+}
+
+.el-form-item {
+  margin-bottom: 0;
+}
+
+.source-radio-group {
+  display: flex;
+  justify-content: space-between;
+}
+
+.el-radio-button {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 14px;
+  padding: 10px 20px;
+}
+
+.el-radio-button .el-icon {
+  margin-right: 8px;
+}
+</style>

Разлика између датотеке није приказан због своје велике величине
+ 168 - 0
src/views/knowledge/dataset-form/form-step2.vue


+ 152 - 0
src/views/knowledge/dataset.vue

@@ -0,0 +1,152 @@
+<template>
+  <div class="knowledge-base-container">
+    <div class="card-container">
+      <el-card class="create-card" shadow="hover">
+        <div class="create-content">
+          <el-icon class="create-icon"><Plus /></el-icon>
+          <span class="create-text">创建知识库</span>
+        </div>
+        <div class="create-footer">
+          导入您自己的文本数据或通过 Webhook 实时写入数据以增强 LLM 的上下文。
+        </div>
+      </el-card>
+
+      <el-card class="document-card" shadow="hover" v-for="index in 4" :key="index">
+        <div class="document-header">
+          <el-icon><Folder /></el-icon>
+          <span>接口鉴权示例代码.md</span>
+        </div>
+        <div class="document-info">
+          <el-tag size="small">1 文档</el-tag>
+          <el-tag size="small" type="info">5 千字符</el-tag>
+          <el-tag size="small" type="warning">0 关联应用</el-tag>
+        </div>
+        <p class="document-description">
+          useful for when you want to answer queries about the 接口鉴权示例代码.md
+        </p>
+      </el-card>
+    </div>
+
+    <div class="pagination-container">
+      <el-pagination
+        v-model:current-page="currentPage"
+        v-model:page-size="pageSize"
+        :page-sizes="[10, 20, 30, 40]"
+        :small="false"
+        :disabled="false"
+        :background="true"
+        layout="total, sizes, prev, pager, next, jumper"
+        :total="total"
+        @size-change="handleSizeChange"
+        @current-change="handleCurrentChange"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { Folder, Plus } from '@element-plus/icons-vue'
+
+const currentPage = ref(1)
+const pageSize = ref(10)
+const total = ref(100) // 假设总共有100条数据
+
+const handleSizeChange = (val) => {
+  console.log(`每页 ${val} 条`)
+}
+
+const handleCurrentChange = (val) => {
+  console.log(`当前页: ${val}`)
+}
+</script>
+
+<style scoped>
+.knowledge-base-container {
+  font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
+  position: absolute;
+  padding: 20px;
+  margin: 0 auto;
+  display: flex;
+  flex-direction: column;
+  top: 0;
+  bottom: 40px;
+  width: 100%;
+}
+
+.card-container {
+  display: flex;
+  flex-wrap: wrap; /* Enable wrapping */
+  gap: 20px;
+  margin-bottom: auto; /* Pushes pagination to the bottom */
+}
+
+.create-card, .document-card {
+  flex: 1 1 360px; /* Allow cards to grow and shrink */
+  min-width: 0;
+  max-width: 400px;
+  border-radius: 10px;
+  cursor: pointer;
+}
+
+.create-card {
+  background-color: rgba(168, 168, 168, 0.22);
+}
+.create-card:hover {
+  background-color: #fff;
+}
+
+.create-content {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 15px;
+}
+
+.create-icon {
+  font-size: 24px;
+  color: #409EFF;
+}
+
+.create-text {
+  font-size: 18px;
+  font-weight: bold;
+  color: #303133;
+}
+
+.create-footer {
+  font-size: 14px;
+  color: #909399;
+  line-height: 1.5;
+}
+
+.document-header {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  font-size: 16px;
+  font-weight: bold;
+  margin-bottom: 15px;
+}
+
+.document-info {
+  display: flex;
+  gap: 10px;
+  margin-bottom: 15px;
+}
+
+.document-description {
+  color: #606266;
+  font-size: 14px;
+  line-height: 1.5;
+}
+
+.pagination-container {
+  position: absolute;
+  width: 100%;
+  bottom: 0;
+  display: flex;
+  justify-content: center;
+  margin-top: 20px;
+}
+</style>

+ 1 - 1
src/views/mall/product/property/value/index.vue

@@ -105,7 +105,7 @@ const list = ref([]) // 列表的数据
 const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  propertyId: Number(params.propertyId),
+  propertyId: params.propertyId,
   name: undefined
 })
 const queryFormRef = ref() // 搜索的表单

+ 5 - 5
src/views/mall/product/spu/components/SkuList.vue

@@ -180,17 +180,17 @@
     </el-table-column>
     <el-table-column align="center" label="销售价(元)" min-width="80">
       <template #default="{ row }">
-        {{ formatToFraction(row.price) }}
+        {{ row.price }}
       </template>
     </el-table-column>
     <el-table-column align="center" label="市场价(元)" min-width="80">
       <template #default="{ row }">
-        {{ formatToFraction(row.marketPrice) }}
+        {{ row.marketPrice }}
       </template>
     </el-table-column>
     <el-table-column align="center" label="成本价(元)" min-width="80">
       <template #default="{ row }">
-        {{ formatToFraction(row.costPrice) }}
+        {{ row.costPrice }}
       </template>
     </el-table-column>
     <el-table-column align="center" label="库存" min-width="80">
@@ -211,12 +211,12 @@
     <template v-if="formData!.subCommissionType">
       <el-table-column align="center" label="一级返佣(元)" min-width="80">
         <template #default="{ row }">
-          {{ formatToFraction(row.firstBrokeragePrice) }}
+          {{ row.firstBrokeragePrice }}
         </template>
       </el-table-column>
       <el-table-column align="center" label="二级返佣(元)" min-width="80">
         <template #default="{ row }">
-          {{ formatToFraction(row.secondBrokeragePrice) }}
+          {{ row.secondBrokeragePrice }}
         </template>
       </el-table-column>
     </template>

+ 1 - 1
src/views/mall/product/spu/form/InfoForm.vue

@@ -45,7 +45,7 @@
         :show-word-limit="true"
         class="w-80!"
         maxlength="128"
-        placeholder="请输入商品名称"
+        placeholder="请输入商品简介"
         type="textarea"
       />
     </el-form-item>

+ 44 - 40
src/views/mall/promotion/combination/activity/index.vue

@@ -4,27 +4,27 @@
   <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="请输入活动名称"
+          class="!w-240px"
           clearable
+          placeholder="请输入活动名称"
           @keyup.enter="handleQuery"
-          class="!w-240px"
         />
       </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)"
@@ -35,15 +35,22 @@
         </el-select>
       </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
-          type="primary"
+          v-hasPermi="['promotion:combination-activity:create']"
           plain
+          type="primary"
           @click="openForm('create')"
-          v-hasPermi="['promotion:combination-activity:create']"
         >
-          <Icon icon="ep:plus" class="mr-5px" /> 新增
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
         </el-button>
       </el-form-item>
     </el-form>
@@ -51,77 +58,77 @@
 
   <!-- 列表 -->
   <ContentWrap>
-    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
-      <el-table-column label="活动编号" prop="id" min-width="80" />
-      <el-table-column label="活动名称" prop="name" min-width="140" />
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column label="活动编号" min-width="80" prop="id" />
+      <el-table-column label="活动名称" min-width="140" prop="name" />
       <el-table-column label="活动时间" min-width="210">
         <template #default="scope">
           {{ formatDate(scope.row.startTime, 'YYYY-MM-DD') }}
           ~ {{ formatDate(scope.row.endTime, 'YYYY-MM-DD') }}
         </template>
       </el-table-column>
-      <el-table-column label="商品图片" prop="spuName" min-width="80">
+      <el-table-column label="商品图片" min-width="80" prop="spuName">
         <template #default="scope">
           <el-image
+            :preview-src-list="[scope.row.picUrl]"
             :src="scope.row.picUrl"
             class="h-40px w-40px"
-            :preview-src-list="[scope.row.picUrl]"
             preview-teleported
           />
         </template>
       </el-table-column>
-      <el-table-column label="商品标题" prop="spuName" min-width="300" />
+      <el-table-column label="商品标题" min-width="300" prop="spuName" />
       <el-table-column
+        :formatter="fenToYuanFormat"
         label="原价"
-        prop="marketPrice"
         min-width="100"
-        :formatter="fenToYuanFormat"
+        prop="marketPrice"
       />
-      <el-table-column label="拼团价" prop="seckillPrice" min-width="100">
+      <el-table-column label="拼团价" min-width="100" prop="seckillPrice">
         <template #default="scope">
           {{ formatCombinationPrice(scope.row.products) }}
         </template>
       </el-table-column>
-      <el-table-column label="开团组数" prop="groupCount" min-width="100" />
-      <el-table-column label="成团组数" prop="groupSuccessCount" min-width="100" />
-      <el-table-column label="购买次数" prop="recordCount" min-width="100" />
-      <el-table-column label="活动状态" align="center" prop="status" min-width="100">
+      <el-table-column label="开团组数" min-width="100" prop="groupCount" />
+      <el-table-column label="成团组数" min-width="100" prop="groupSuccessCount" />
+      <el-table-column label="购买次数" min-width="100" prop="recordCount" />
+      <el-table-column align="center" label="活动状态" min-width="100" prop="status">
         <template #default="scope">
           <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
         </template>
       </el-table-column>
       <el-table-column
-        label="创建时间"
+        :formatter="dateFormatter"
         align="center"
+        label="创建时间"
         prop="createTime"
-        :formatter="dateFormatter"
         width="180px"
       />
-      <el-table-column label="操作" align="center" width="150px" fixed="right">
+      <el-table-column align="center" fixed="right" label="操作" width="150px">
         <template #default="scope">
           <el-button
+            v-hasPermi="['promotion:combination-activity:update']"
             link
             type="primary"
             @click="openForm('update', scope.row.id)"
-            v-hasPermi="['promotion:combination-activity:update']"
           >
             编辑
           </el-button>
           <el-button
+            v-if="scope.row.status === 0"
+            v-hasPermi="['promotion:combination-activity:close']"
             link
             type="danger"
             @click="handleClose(scope.row.id)"
-            v-if="scope.row.status === 0"
-            v-hasPermi="['promotion:combination-activity:close']"
           >
             关闭
           </el-button>
           <el-button
+            v-else
+            v-hasPermi="['promotion:combination-activity:delete']"
             link
             type="danger"
             @click="handleDelete(scope.row.id)"
-            v-else
-            v-hasPermi="['promotion:combination-activity:delete']"
           >
             删除
           </el-button>
@@ -130,9 +137,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>
@@ -141,12 +148,11 @@
   <CombinationActivityForm ref="formRef" @success="getList" />
 </template>
 
-<script setup lang="ts">
+<script lang="ts" setup>
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
-import { dateFormatter } from '@/utils/formatTime'
+import { dateFormatter, formatDate } from '@/utils/formatTime'
 import * as CombinationActivityApi from '@/api/mall/promotion/combination/combinationActivity'
 import CombinationActivityForm from './CombinationActivityForm.vue'
-import { formatDate } from '@/utils/formatTime'
 import { fenToYuanFormat } from '@/utils/formatter'
 import { fenToYuan } from '@/utils'
 
@@ -165,7 +171,6 @@ const queryParams = reactive({
   status: null
 })
 const queryFormRef = ref() // 搜索的表单
-const exportLoading = ref(false) // 导出的加载中
 
 /** 查询列表 */
 const getList = async () => {
@@ -197,12 +202,11 @@ const openForm = (type: string, id?: number) => {
   formRef.value.open(type, id)
 }
 
-// TODO 芋艿:这里要改下
 /** 关闭按钮操作 */
 const handleClose = async (id: number) => {
   try {
     // 关闭的二次确认
-    await message.confirm('确认关闭该秒杀活动吗?')
+    await message.confirm('确认关闭该拼团活动吗?')
     // 发起关闭
     await CombinationActivityApi.closeCombinationActivity(id)
     message.success('关闭成功')

+ 9 - 9
src/views/mall/promotion/components/SpuAndSkuList.vue

@@ -30,13 +30,13 @@
     <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
-      v-if="spuData.length > 1 && isDelete"
+      v-if="spuData.length > 1 && deletable"
       align="center"
       label="操作"
       min-width="90"
     >
       <template #default="scope">
-        <el-button type="primary" link @click="deleteSpu(scope.row.id)"> 删除 </el-button>
+        <el-button link type="primary" @click="deleteSpu(scope.row.id)"> 删除</el-button>
       </template>
     </el-table-column>
   </el-table>
@@ -56,13 +56,13 @@ const props = defineProps<{
   spuList: T[]
   ruleConfig: RuleConfig[]
   spuPropertyListP: SpuProperty<T>[]
-  isDelete?: boolean // SPU 是否可删除;TODO deletable 换成这个名字好点。
+  deletable?: boolean // SPU 是否可删除;
 }>()
 
 const spuData = ref<Spu[]>([]) // spu 详情数据列表
 const skuListRef = ref() // 商品属性列表Ref
 const spuPropertyList = ref<SpuProperty<T>[]>([]) // spuId 对应的 sku 的属性列表
-const expandRowKeys = ref<number[]>() // 控制展开行需要设置 row-key 属性才能使用,该属性为展开行的 keys 数组。
+const expandRowKeys = ref<string[]>([]) // 控制展开行需要设置 row-key 属性才能使用,该属性为展开行的 keys 数组。
 
 /**
  * 获取所有 sku 活动配置
@@ -71,10 +71,10 @@ const expandRowKeys = ref<number[]>() // 控制展开行需要设置 row-key 属
  */
 const getSkuConfigs = (extendedAttribute: string) => {
   skuListRef.value.validateSku()
-  const seckillProducts = []
+  const seckillProducts: any[] = []
   spuPropertyList.value.forEach((item) => {
-    item.spuDetail.skus.forEach((sku) => {
-      seckillProducts.push(sku[extendedAttribute])
+    item.spuDetail.skus?.forEach((sku: any) => {
+      seckillProducts.push(sku[extendedAttribute] as any)
     })
   })
   return seckillProducts
@@ -124,10 +124,10 @@ watch(
   () => props.spuPropertyListP,
   (data) => {
     if (!data) return
-    spuPropertyList.value = data as SpuProperty<T>[]
+    spuPropertyList.value = data as SpuProperty<T>[] as any
     // 解决如果之前选择的是单规格 spu 的话后面选择多规格 sku 多规格属性信息不展示的问题。解决方法:让 SkuList 组件重新渲染(行折叠会干掉包含的组件展开时会重新加载)
     setTimeout(() => {
-      expandRowKeys.value = data.map((item) => item.spuId)
+      expandRowKeys.value = data.map((item) => item.spuId + '')
     }, 200)
   },
   {

+ 3 - 2
src/views/mall/promotion/coupon/components/CouponSelect.vue

@@ -116,6 +116,7 @@ import {
   validityTypeFormat
 } from '@/views/mall/promotion/coupon/formatter'
 import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate'
+import { CouponTemplateTakeTypeEnum } from '@/utils/constants'
 
 defineOptions({ name: 'CouponSelect' })
 
@@ -128,7 +129,7 @@ const emit = defineEmits<{
   (e: 'change', v: CouponTemplateApi.CouponTemplateVO[]): void
 }>()
 const dialogVisible = ref(false) // 弹窗的是否展示
-const dialogTitle = ref('选择优惠') // 弹窗的标题
+const dialogTitle = ref('选择优惠') // 弹窗的标题
 const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
 const loading = ref(true) // 列表的加载中
 const total = ref(0) // 列表的总页数
@@ -138,7 +139,7 @@ const queryParams = reactive({
   pageSize: 10,
   name: null,
   discountType: null,
-  canTakeTypes: null
+  canTakeTypes: [CouponTemplateTakeTypeEnum.USER.type] // 只获得直接领取的券
 })
 const queryFormRef = ref() // 搜索的表单
 const selectedCouponList = ref<CouponTemplateApi.CouponTemplateVO[]>([]) // 选择的数据

+ 18 - 3
src/views/mall/promotion/coupon/formatter.ts

@@ -16,10 +16,14 @@ export const discountFormat = (row: CouponTemplateVO) => {
 
 // 格式化【领取上限】
 export const takeLimitCountFormat = (row: CouponTemplateVO) => {
-  if (row.takeLimitCount === -1) {
-    return '无领取限制'
+  if (row.takeLimitCount) {
+    if (row.takeLimitCount === -1) {
+      return '无领取限制'
+    }
+    return `${row.takeLimitCount} 张/人`
+  } else {
+    return ' '
   }
-  return `${row.takeLimitCount} 张/人`
 }
 
 // 格式化【有效期限】
@@ -33,8 +37,19 @@ export const validityTypeFormat = (row: CouponTemplateVO) => {
   return '未知【' + row.validityType + '】'
 }
 
+// 格式化【totalCount】
+export const totalCountFormat = (row: CouponTemplateVO) => {
+  if (row.totalCount === -1) {
+    return '不限制'
+  }
+  return row.totalCount
+}
+
 // 格式化【剩余数量】
 export const remainedCountFormat = (row: CouponTemplateVO) => {
+  if (row.totalCount === -1) {
+    return '不限制'
+  }
   return row.totalCount - row.takeCount
 }
 

+ 4 - 1
src/views/mall/promotion/coupon/template/CouponTemplateForm.vue

@@ -115,6 +115,7 @@
         <el-radio-group v-model="formData.takeType">
           <el-radio :key="1" :value="1">直接领取</el-radio>
           <el-radio :key="2" :value="2">指定发放</el-radio>
+          <el-radio :key="2" :value="3">新人劵</el-radio>
         </el-radio-group>
       </el-form-item>
       <el-form-item v-if="formData.takeType === 1" label="发放数量" prop="totalCount">
@@ -309,7 +310,9 @@ const submitForm = async () => {
       validEndTime:
         formData.value.validTimes && formData.value.validTimes.length === 2
           ? formData.value.validTimes[1]
-          : undefined
+          : undefined,
+      totalCount: formData.value.takeType === 1 ? formData.value.totalCount : -1,
+      takeLimitCount: formData.value.takeType === 1 ? formData.value.takeLimitCount : -1
     } as unknown as CouponTemplateApi.CouponTemplateVO
 
     // 设置商品范围

+ 7 - 1
src/views/mall/promotion/coupon/template/index.vue

@@ -109,7 +109,12 @@
         prop="validityType"
         width="185"
       />
-      <el-table-column align="center" label="发放数量" prop="totalCount" />
+      <el-table-column
+        :formatter="totalCountFormat"
+        align="center"
+        label="发放数量"
+        prop="totalCount"
+      />
       <el-table-column
         :formatter="remainedCountFormat"
         align="center"
@@ -189,6 +194,7 @@ import {
   discountFormat,
   remainedCountFormat,
   takeLimitCountFormat,
+  totalCountFormat,
   validityTypeFormat
 } from '@/views/mall/promotion/coupon/formatter'
 

+ 69 - 33
src/views/mall/promotion/discountActivity/DiscountActivityForm.vue

@@ -8,28 +8,40 @@
       :schema="allSchemas.formSchema"
     >
       <!-- 先选择 -->
-      <!-- TODO @zhangshuai:商品允许选择多个 -->
-      <!-- TODO @zhangshuai:选择后的 SKU,需要后面加个【删除】按钮 -->
-      <!-- TODO @zhangshuai:展示的金额,貌似不对,大了 100 倍,需要看下 -->
-      <!-- TODO @zhangshuai:“优惠类型”,是每个 SKU 可以自定义已设置哈。因为每个商品 SKU 的折扣和减少价格,可能不同。具体交互,可以注册一个 youzan.com 看看;它的交互方式是,如果设置了“优惠金额”,则算“减价”;如果再次设置了“折扣百分比”,就算“打折”;这样形成一个互斥的优惠类型 -->
       <template #spuId>
         <el-button @click="spuSelectRef.open()">选择商品</el-button>
         <SpuAndSkuList
           ref="spuAndSkuListRef"
+          :deletable="true"
           :rule-config="ruleConfig"
           :spu-list="spuList"
           :spu-property-list-p="spuPropertyList"
-          :isDelete="true"
           @delete="deleteSpu"
         >
           <el-table-column align="center" label="优惠金额" min-width="168">
-            <template #default="{ row: sku }">
-              <el-input-number v-model="sku.productConfig.discountPrice" :min="0" class="w-100%" />
+            <template #default="{ row }">
+              <el-input-number
+                v-model="row.productConfig.discountPrice"
+                :max="parseFloat(fenToYuan(row.price))"
+                :min="0"
+                :precision="2"
+                :step="0.1"
+                class="w-100%"
+                @change="handleSkuDiscountPriceChange(row)"
+              />
             </template>
           </el-table-column>
           <el-table-column align="center" label="折扣百分比(%)" min-width="168">
-            <template #default="{ row: sku }">
-              <el-input-number v-model="sku.productConfig.discountPercent" class="w-100%" />
+            <template #default="{ row }">
+              <el-input-number
+                v-model="row.productConfig.discountPercent"
+                :max="100"
+                :min="0"
+                :precision="2"
+                :step="0.1"
+                class="w-100%"
+                @change="handleSkuDiscountPercentChange(row)"
+              />
             </template>
           </el-table-column>
         </SpuAndSkuList>
@@ -45,11 +57,12 @@
 <script lang="ts" setup>
 import { SpuAndSkuList, SpuProperty, SpuSelect } from '../components'
 import { allSchemas, rules } from './discountActivity.data'
-import { cloneDeep } from 'lodash-es'
+import { cloneDeep, debounce } from 'lodash-es'
 import * as DiscountActivityApi from '@/api/mall/promotion/discount/discountActivity'
 import * as ProductSpuApi from '@/api/mall/product/spu'
 import { getPropertyList, RuleConfig } from '@/views/mall/product/spu/components'
-import { formatToFraction } from '@/utils'
+import { convertToInteger, erpCalculatePercentage, fenToYuan, yuanToFen } from '@/utils'
+import { PromotionDiscountTypeEnum } from '@/utils/constants'
 
 defineOptions({ name: 'PromotionDiscountActivityForm' })
 
@@ -65,7 +78,13 @@ const formRef = ref() // 表单 Ref
 
 const spuSelectRef = ref() // 商品和属性选择 Ref
 const spuAndSkuListRef = ref() // sku 限时折扣  配置组件Ref
-const ruleConfig: RuleConfig[] = []
+const ruleConfig: RuleConfig[] = [
+  {
+    name: 'productConfig.discountPrice',
+    rule: (arg) => arg > 0,
+    message: '商品优惠金额不能为 0 !!!'
+  }
+]
 const spuList = ref<DiscountActivityApi.SpuExtension[]>([]) // 选择的 spu
 const spuPropertyList = ref<SpuProperty<DiscountActivityApi.SpuExtension>[]>([])
 const spuIds = ref<number[]>([])
@@ -101,21 +120,20 @@ const getSpuDetails = async (
   selectSkus?.forEach((sku) => {
     let config: DiscountActivityApi.DiscountProductVO = {
       skuId: sku.id!,
-      spuId: spu.id,
+      spuId: spu.id!,
       discountType: 1,
       discountPercent: 0,
       discountPrice: 0
     }
     if (typeof products !== 'undefined') {
       const product = products.find((item) => item.skuId === sku.id)
+      if (product) {
+        product.discountPercent = fenToYuan(product.discountPercent) as any
+        product.discountPrice = fenToYuan(product.discountPrice) as any
+      }
       config = product || config
     }
     sku.productConfig = config
-    sku.price = formatToFraction(sku.price)
-    sku.marketPrice = formatToFraction(sku.marketPrice)
-    sku.costPrice = formatToFraction(sku.costPrice)
-    sku.firstBrokeragePrice = formatToFraction(sku.firstBrokeragePrice)
-    sku.secondBrokeragePrice = formatToFraction(sku.secondBrokeragePrice)
   })
   spu.skus = selectSkus as DiscountActivityApi.SkuExtension[]
   spuPropertyList.value.push({
@@ -168,25 +186,13 @@ const submitForm = async () => {
   // 提交请求
   formLoading.value = true
   try {
-    const data = formRef.value.formModel as DiscountActivityApi.DiscountActivityVO
     // 获取折扣商品配置
     const products = cloneDeep(spuAndSkuListRef.value.getSkuConfigs('productConfig'))
-    // 校验优惠金额、折扣百分比,是否正确
-    // TODO @puhui999:这个交互,可以参考下 youzan 的
-    let discountInvalid = false
     products.forEach((item: DiscountActivityApi.DiscountProductVO) => {
-      if (item.discountPrice != null && item.discountPrice > 0) {
-        item.discountType = 1
-      } else if (item.discountPercent != null && item.discountPercent > 0) {
-        item.discountType = 2
-      } else {
-        discountInvalid = true
-      }
+      item.discountPercent = convertToInteger(item.discountPercent)
+      item.discountPrice = convertToInteger(item.discountPrice)
     })
-    if (discountInvalid) {
-      message.error('优惠金额和折扣百分比需要填写一个')
-      return
-    }
+    const data = cloneDeep(formRef.value.formModel) as DiscountActivityApi.DiscountActivityVO
     data.products = products
     // 真正提交
     if (formType.value === 'create') {
@@ -204,6 +210,36 @@ const submitForm = async () => {
   }
 }
 
+/** 处理 sku 优惠金额变动 */
+const handleSkuDiscountPriceChange = debounce((row: any) => {
+  // 校验边界
+  if (row.productConfig.discountPrice <= 0) {
+    return
+  }
+
+  // 设置优惠类型:满减
+  row.productConfig.discountType = PromotionDiscountTypeEnum.PRICE.type
+  // 设置折扣
+  row.productConfig.discountPercent = erpCalculatePercentage(
+    row.price - yuanToFen(row.productConfig.discountPrice),
+    row.price
+  )
+}, 200)
+/** 处理 sku 优惠折扣变动 */
+const handleSkuDiscountPercentChange = debounce((row: any) => {
+  // 校验边界
+  if (row.productConfig.discountPercent <= 0 || row.productConfig.discountPercent >= 100) {
+    return
+  }
+
+  // 设置优惠类型:折扣
+  row.productConfig.discountType = PromotionDiscountTypeEnum.PERCENT.type
+  // 设置满减金额
+  row.productConfig.discountPrice = fenToYuan(
+    row.price - row.price * (row.productConfig.discountPercent / 100.0 || 0)
+  )
+}, 200)
+
 /** 重置表单 */
 const resetForm = async () => {
   spuList.value = []

+ 0 - 2
src/views/mall/promotion/discountActivity/discountActivity.data.ts

@@ -1,10 +1,8 @@
 import type { CrudSchema } from '@/hooks/web/useCrudSchemas'
 import { dateFormatter2 } from '@/utils/formatTime'
 
-// TODO @zhangshai:
 // 表单校验
 export const rules = reactive({
-  spuId: [required],
   name: [required],
   startTime: [required],
   endTime: [required],

+ 6 - 4
src/views/mall/promotion/kefu/components/KeFuConversationList.vue

@@ -22,13 +22,15 @@
         <div class="ml-10px w-100%">
           <div class="flex justify-between items-center w-100%">
             <span class="username">{{ item.userNickname }}</span>
-            <span class="color-[var(--left-menu-text-color)]" style="font-size: 13px;">
+            <span class="color-[var(--left-menu-text-color)]" style="font-size: 13px">
               {{ formatPast(item.lastMessageTime, 'YYYY-MM-DD') }}
             </span>
           </div>
           <!-- 最后聊天内容 -->
           <div
-            v-dompurify-html="getConversationDisplayText(item.lastMessageContentType, item.lastMessageContent)"
+            v-dompurify-html="
+              getConversationDisplayText(item.lastMessageContentType, item.lastMessageContent)
+            "
             class="last-message flex items-center color-[var(--left-menu-text-color)]"
           >
           </div>
@@ -205,7 +207,7 @@ watch(showRightMenu, (val) => {
 
   .active {
     border-left: 5px #3271ff solid;
-    background-color: var(--left-menu-bg-active-color);
+    background-color: var(--login-bg-color);
   }
 
   .pinned {
@@ -215,7 +217,7 @@ watch(showRightMenu, (val) => {
   .right-menu-ul {
     position: absolute;
     background-color: var(--app-content-bg-color);
-    padding: 10px;
+    padding: 5px;
     margin: 0;
     list-style-type: none; /* 移除默认的项目符号 */
     border-radius: 12px;

+ 3 - 3
src/views/mall/promotion/kefu/components/KeFuMessageList.vue

@@ -86,7 +86,7 @@
                   <OrderItem
                     v-if="KeFuMessageContentTypeEnum.ORDER === item.contentType"
                     :message="item"
-                    class="max-w-70%"
+                    class="max-w-100%"
                   />
                 </MessageItem>
               </div>
@@ -423,9 +423,9 @@ const showTime = computed(() => (item: KeFuMessageRespVO, index: number) => {
 
     // 消息气泡
     .kefu-message {
-      color: #A9A9A9;
+      color: #a9a9a9;
       border-radius: 5px;
-      box-shadow: 3px 3px 5px rgba(220,220,220, 0.1);
+      box-shadow: 3px 3px 5px rgba(220, 220, 220, 0.1);
       padding: 5px 10px;
       width: auto;
       max-width: 50%;

+ 15 - 3
src/views/mall/promotion/kefu/components/message/OrderItem.vue

@@ -1,8 +1,13 @@
 <template>
-  <div v-if="isObject(getMessageContent)" @click="openDetail(getMessageContent.id)" style="cursor: pointer;">
+  <div v-if="isObject(getMessageContent)">
     <div :key="getMessageContent.id" class="order-list-card-box mt-14px">
       <div class="order-card-header flex items-center justify-between p-x-5px">
-        <div class="order-no">订单号:{{ getMessageContent.no }}</div>
+        <div class="order-no">
+          订单号:
+          <span style="cursor: pointer" @click="openDetail(getMessageContent.id)">
+            {{ getMessageContent.no }}
+          </span>
+        </div>
         <div :class="formatOrderColor(getMessageContent)" class="order-state font-16">
           {{ formatOrderStatus(getMessageContent) }}
         </div>
@@ -113,8 +118,15 @@ function formatOrderStatus(order: any) {
     height: 28px;
 
     .order-no {
-      font-size: 10px;
+      font-size: 12px;
       font-weight: 500;
+
+      span {
+        &:hover {
+          text-decoration: underline;
+          color: var(--left-menu-bg-active-color);
+        }
+      }
     }
   }
 

+ 5 - 2
src/views/mall/promotion/kefu/components/tools/emoji.ts

@@ -66,7 +66,7 @@ export const useEmoji = () => {
     )
     for (const path in pathList) {
       const imageModule: any = await pathList[path]()
-      emojiPathList.value.push(imageModule.default)
+      emojiPathList.value.push({ path: path, src: imageModule.default })
     }
   }
 
@@ -116,7 +116,10 @@ export const useEmoji = () => {
   function getEmojiFileByName(name: string) {
     for (const emoji of emojiList) {
       if (emoji.name === name) {
-        return emojiPathList.value.find((item: string) => item.indexOf(emoji.file) > -1)
+        const emojiPath = emojiPathList.value.find(
+          (item: { path: string; src: string }) => item.path.indexOf(emoji.file) > -1
+        )
+        return emojiPath ? emojiPath.src : undefined
       }
     }
     return false

+ 4 - 2
src/views/mall/promotion/kefu/index.vue

@@ -25,7 +25,7 @@
 import { KeFuConversationList, KeFuMessageList, MemberBrowsingHistory } from './components'
 import { WebSocketMessageTypeConstants } from './components/tools/constants'
 import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
-import { getAccessToken } from '@/utils/auth'
+import { getRefreshToken } from '@/utils/auth'
 import { useWebSocket } from '@vueuse/core'
 
 defineOptions({ name: 'KeFu' })
@@ -34,7 +34,9 @@ const message = useMessage() // 消息弹窗
 
 // ======================= WebSocket start =======================
 const server = ref(
-  (import.meta.env.VITE_BASE_URL + '/infra/ws').replace('http', 'ws') + '?token=' + getAccessToken()
+  (import.meta.env.VITE_BASE_URL + '/infra/ws').replace('http', 'ws') +
+    '?token=' +
+    getRefreshToken() // 使用 getRefreshToken() 方法,而不使用 getAccessToken() 方法的原因:WebSocket 无法方便的刷新访问令牌
 ) // WebSocket 服务地址
 
 /** 发起 WebSocket 连接 */

+ 227 - 0
src/views/mall/promotion/point/activity/PointActivityForm.vue

@@ -0,0 +1,227 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle" width="65%">
+    <Form
+      ref="formRef"
+      v-loading="formLoading"
+      :isCol="true"
+      :rules="rules"
+      :schema="allSchemas.formSchema"
+    >
+      <!-- 先选择 -->
+      <template #spuId>
+        <el-button v-if="!isFormUpdate" @click="spuSelectRef.open()">选择商品</el-button>
+        <SpuAndSkuList
+          ref="spuAndSkuListRef"
+          :rule-config="ruleConfig"
+          :spu-list="spuList"
+          :spu-property-list-p="spuPropertyList"
+        >
+          <el-table-column align="center" label="可兑换库存" min-width="168">
+            <template #default="{ row: sku }">
+              <el-input-number
+                v-model="sku.productConfig.stock"
+                :max="sku.stock"
+                :min="0"
+                class="w-100%"
+              />
+            </template>
+          </el-table-column>
+          <el-table-column align="center" label="可兑换次数" min-width="168">
+            <template #default="{ row: sku }">
+              <el-input-number v-model="sku.productConfig.count" :min="0" class="w-100%" />
+            </template>
+          </el-table-column>
+          <el-table-column align="center" label="所需积分" min-width="168">
+            <template #default="{ row: sku }">
+              <el-input-number v-model="sku.productConfig.point" :min="0" class="w-100%" />
+            </template>
+          </el-table-column>
+          <el-table-column align="center" label="所需金额(元)" min-width="168">
+            <template #default="{ row: sku }">
+              <el-input-number
+                v-model="sku.productConfig.price"
+                :min="0"
+                :precision="2"
+                :step="0.1"
+                class="w-100%"
+              />
+            </template>
+          </el-table-column>
+        </SpuAndSkuList>
+      </template>
+    </Form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+  <SpuSelect ref="spuSelectRef" :isSelectSku="true" @confirm="selectSpu" />
+</template>
+<script lang="ts" setup>
+import { SpuAndSkuList, SpuProperty, SpuSelect } from '../../components'
+import { allSchemas, rules } from './pointActivity.data'
+import { cloneDeep } from 'lodash-es'
+import {
+  PointActivityApi,
+  PointActivityVO,
+  PointProductVO,
+  SkuExtension,
+  SpuExtension
+} from '@/api/mall/promotion/point'
+import * as ProductSpuApi from '@/api/mall/product/spu'
+import { getPropertyList, RuleConfig } from '@/views/mall/product/spu/components'
+import { convertToInteger, formatToFraction } from '@/utils'
+
+defineOptions({ name: 'PromotionSeckillActivityForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formRef = ref() // 表单 Ref
+const isFormUpdate = ref(false) // 是否更新表单
+
+// ================= 商品选择相关 =================
+
+const spuSelectRef = ref() // 商品和属性选择 Ref
+const spuAndSkuListRef = ref() // sku 积分商城商品配置组件Ref
+const ruleConfig: RuleConfig[] = [
+  {
+    name: 'productConfig.stock',
+    rule: (arg) => arg >= 1,
+    message: '商品可兑换库存必须大于等于 1 !!!'
+  },
+  {
+    name: 'productConfig.point',
+    rule: (arg) => arg >= 1,
+    message: '商品所需兑换积分必须大于等于 1 !!!'
+  },
+  {
+    name: 'productConfig.count',
+    rule: (arg) => arg >= 1,
+    message: '商品可兑换次数必须大于等于 1 !!!'
+  }
+]
+const spuList = ref<SpuExtension[]>([]) // 选择的 spu
+const spuPropertyList = ref<SpuProperty<SpuExtension>[]>([])
+const selectSpu = (spuId: number, skuIds: number[]) => {
+  formRef.value.setValues({ spuId })
+  getSpuDetails(spuId, skuIds)
+}
+/**
+ * 获取 SPU 详情
+ */
+const getSpuDetails = async (
+  spuId: number,
+  skuIds: number[] | undefined,
+  products?: PointProductVO[]
+) => {
+  const spuProperties: SpuProperty<SpuExtension>[] = []
+  const res = (await ProductSpuApi.getSpuDetailList([spuId])) as SpuExtension[]
+  if (res.length == 0) {
+    return
+  }
+  spuList.value = []
+  // 因为只能选择一个
+  const spu = res[0]
+  const selectSkus =
+    typeof skuIds === 'undefined' ? spu?.skus : spu?.skus?.filter((sku) => skuIds.includes(sku.id!))
+  selectSkus?.forEach((sku) => {
+    let config: PointProductVO = {
+      skuId: sku.id!,
+      stock: 0,
+      price: 0,
+      point: 0,
+      count: 0
+    }
+    if (typeof products !== 'undefined') {
+      const product = products.find((item) => item.skuId === sku.id)
+      if (product) {
+        product.price = formatToFraction(product.price) as any
+      }
+      config = product || config
+    }
+    sku.productConfig = config
+  })
+  spu.skus = selectSkus as SkuExtension[]
+  spuProperties.push({
+    spuId: spu.id!,
+    spuDetail: spu,
+    propertyList: getPropertyList(spu)
+  })
+  spuList.value.push(spu)
+  spuPropertyList.value = spuProperties
+}
+
+// ================= end =================
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  await resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      const data = (await PointActivityApi.getPointActivity(id)) as PointActivityVO
+      isFormUpdate.value = true
+      await getSpuDetails(
+        data.spuId!,
+        data.products?.map((sku) => sku.skuId),
+        data.products
+      )
+      formRef.value.setValues(data)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.getElFormRef().validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    // 获取秒杀商品配置
+    const products = cloneDeep(spuAndSkuListRef.value.getSkuConfigs('productConfig'))
+    products.forEach((item: PointProductVO) => {
+      item.price = convertToInteger(item.price)
+    })
+    const data = formRef.value.formModel as PointActivityVO
+    data.products = products
+    // 真正提交
+    if (formType.value === 'create') {
+      await PointActivityApi.createPointActivity(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await PointActivityApi.updatePointActivity(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = async () => {
+  spuList.value = []
+  spuPropertyList.value = []
+  isFormUpdate.value = false
+  await nextTick()
+  formRef.value.getElFormRef().resetFields()
+}
+</script>

+ 219 - 0
src/views/mall/promotion/point/activity/index.vue

@@ -0,0 +1,219 @@
+<template>
+  <doc-alert title="【营销】积分商城活动" url="https://doc.iocoder.cn/mall/promotion-point/" />
+
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="68px"
+    >
+      <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>
+        <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="['promotion:point-activity:create']"
+          plain
+          type="primary"
+          @click="openForm('create')"
+        >
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column label="活动编号" min-width="80" prop="id" />
+      <el-table-column label="商品图片" min-width="80" prop="spuName">
+        <template #default="scope">
+          <el-image
+            :preview-src-list="[scope.row.picUrl]"
+            :src="scope.row.picUrl"
+            class="h-40px w-40px"
+            preview-teleported
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="商品标题" min-width="300" prop="spuName" />
+      <el-table-column
+        :formatter="fenToYuanFormat"
+        label="原价"
+        min-width="100"
+        prop="marketPrice"
+      />
+      <el-table-column label="原价" min-width="100" prop="marketPrice" />
+      <el-table-column align="center" label="活动状态" min-width="100" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="库存" min-width="80" prop="stock" />
+      <el-table-column align="center" label="总库存" min-width="80" prop="totalStock" />
+      <el-table-column align="center" label="已兑换数量" min-width="100" prop="redeemedQuantity">
+        <template #default="{ row }">
+          {{ getRedeemedQuantity(row) }}
+        </template>
+      </el-table-column>
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="创建时间"
+        prop="createTime"
+        width="180px"
+      />
+      <el-table-column align="center" fixed="right" label="操作" width="150px">
+        <template #default="scope">
+          <el-button
+            v-hasPermi="['promotion:point-activity:update']"
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+          >
+            编辑
+          </el-button>
+          <el-button
+            v-if="scope.row.status === 0"
+            v-hasPermi="['promotion:point-activity:close']"
+            link
+            type="danger"
+            @click="handleClose(scope.row.id)"
+          >
+            关闭
+          </el-button>
+          <el-button
+            v-else
+            v-hasPermi="['promotion:point-activity:delete']"
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <PointActivityForm ref="formRef" @success="getList" />
+</template>
+
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import PointActivityForm from './PointActivityForm.vue'
+import { fenToYuanFormat } from '@/utils/formatter'
+import { PointActivityApi } from '@/api/mall/promotion/point'
+
+defineOptions({ name: 'PointActivity' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  status: null
+})
+const queryFormRef = ref() // 搜索的表单
+const getRedeemedQuantity = computed(() => (row: any) => (row.totalStock || 0) - (row.stock || 0)) // 获得商品已兑换数量
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await PointActivityApi.getPointActivityPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 关闭按钮操作 */
+const handleClose = async (id: number) => {
+  try {
+    // 关闭的二次确认
+    await message.confirm('确认关闭该积分商城活动吗?')
+    // 发起关闭
+    await PointActivityApi.closePointActivity(id)
+    message.success('关闭成功')
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await PointActivityApi.deletePointActivity(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  await getList()
+})
+</script>

+ 55 - 0
src/views/mall/promotion/point/activity/pointActivity.data.ts

@@ -0,0 +1,55 @@
+import type { CrudSchema } from '@/hooks/web/useCrudSchemas'
+
+// 表单校验
+export const rules = reactive({
+  spuId: [required],
+  sort: [required]
+})
+
+// CrudSchema https://doc.iocoder.cn/vue3/crud-schema/
+const crudSchemas = reactive<CrudSchema[]>([
+  {
+    label: '排序',
+    field: 'sort',
+    form: {
+      component: 'InputNumber',
+      value: 0
+    },
+    table: {
+      width: 80
+    }
+  },
+  {
+    label: '积分商城活动商品',
+    field: 'spuId',
+    isTable: true,
+    isSearch: false,
+    form: {
+      colProps: {
+        span: 24
+      }
+    },
+    table: {
+      width: 300
+    }
+  },
+  {
+    label: '备注',
+    field: 'remark',
+    isSearch: false,
+    form: {
+      component: 'Input',
+      componentProps: {
+        type: 'textarea',
+        rows: 4
+      },
+      colProps: {
+        span: 24
+      }
+    },
+    table: {
+      width: 300
+    }
+  }
+])
+export const { allSchemas } = useCrudSchemas(crudSchemas)

+ 154 - 0
src/views/mall/promotion/point/components/PointShowcase.vue

@@ -0,0 +1,154 @@
+<template>
+  <div class="flex flex-wrap items-center gap-8px">
+    <div
+      v-for="(pointActivity, index) in pointActivityList"
+      :key="pointActivity.id"
+      class="select-box spu-pic"
+    >
+      <el-tooltip :content="pointActivity.name">
+        <div class="relative h-full w-full">
+          <el-image :src="pointActivity.picUrl" class="h-full w-full" />
+          <Icon
+            v-show="!disabled"
+            class="del-icon"
+            icon="ep:circle-close-filled"
+            @click="handleRemoveActivity(index)"
+          />
+        </div>
+      </el-tooltip>
+    </div>
+    <el-tooltip v-if="canAdd" content="选择活动">
+      <div class="select-box" @click="openSeckillActivityTableSelect">
+        <Icon icon="ep:plus" />
+      </div>
+    </el-tooltip>
+  </div>
+  <!-- 拼团活动选择对话框(表格形式) -->
+  <PointTableSelect
+    ref="pointActivityTableSelectRef"
+    :multiple="limit != 1"
+    @change="handleActivitySelected"
+  />
+</template>
+<script lang="ts" setup>
+import PointTableSelect from './PointTableSelect.vue'
+import { PointActivityApi, PointActivityVO } from '@/api/mall/promotion/point'
+import { propTypes } from '@/utils/propTypes'
+import { oneOfType } from 'vue-types'
+import { isArray } from '@/utils/is'
+
+// 活动橱窗,一般用于装修时使用
+// 提供功能:展示活动列表、添加活动、删除活动
+defineOptions({ name: 'PointShowcase' })
+
+const props = defineProps({
+  modelValue: oneOfType<number | Array<number>>([Number, Array]).isRequired,
+  // 限制数量:默认不限制
+  limit: propTypes.number.def(Number.MAX_VALUE),
+  disabled: propTypes.bool.def(false)
+})
+
+// 计算是否可以添加
+const canAdd = computed(() => {
+  // 情况一:禁用时不可以添加
+  if (props.disabled) return false
+  // 情况二:未指定限制数量时,可以添加
+  if (!props.limit) return true
+  // 情况三:检查已添加数量是否小于限制数量
+  return pointActivityList.value.length < props.limit
+})
+
+// 拼团活动列表
+const pointActivityList = ref<PointActivityVO[]>([])
+
+watch(
+  () => props.modelValue,
+  async () => {
+    const ids = isArray(props.modelValue)
+      ? // 情况一:多选
+        props.modelValue
+      : // 情况二:单选
+        props.modelValue
+        ? [props.modelValue]
+        : []
+    // 不需要返显
+    if (ids.length === 0) {
+      pointActivityList.value = []
+      return
+    }
+    // 只有活动发生变化之后,才会查询活动
+    if (
+      pointActivityList.value.length === 0 ||
+      pointActivityList.value.some((pointActivity) => !ids.includes(pointActivity.id!))
+    ) {
+      pointActivityList.value = await PointActivityApi.getPointActivityListByIds(ids)
+    }
+  },
+  { immediate: true }
+)
+
+/** 活动表格选择对话框 */
+const pointActivityTableSelectRef = ref()
+// 打开对话框
+const openSeckillActivityTableSelect = () => {
+  pointActivityTableSelectRef.value.open(pointActivityList.value)
+}
+
+/**
+ * 选择活动后触发
+ * @param activityList 选中的活动列表
+ */
+const handleActivitySelected = (activityList: PointActivityVO | PointActivityVO[]) => {
+  pointActivityList.value = isArray(activityList) ? activityList : [activityList]
+  emitActivityChange()
+}
+
+/**
+ * 删除活动
+ * @param index 活动索引
+ */
+const handleRemoveActivity = (index: number) => {
+  pointActivityList.value.splice(index, 1)
+  emitActivityChange()
+}
+const emit = defineEmits(['update:modelValue', 'change'])
+const emitActivityChange = () => {
+  if (props.limit === 1) {
+    const pointActivity = pointActivityList.value.length > 0 ? pointActivityList.value[0] : null
+    emit('update:modelValue', pointActivity?.id || 0)
+    emit('change', pointActivity)
+  } else {
+    emit(
+      'update:modelValue',
+      pointActivityList.value.map((pointActivity) => pointActivity.id)
+    )
+    emit('change', pointActivityList.value)
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.select-box {
+  display: flex;
+  width: 60px;
+  height: 60px;
+  border: 1px dashed var(--el-border-color-darker);
+  border-radius: 8px;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+}
+
+.spu-pic {
+  position: relative;
+}
+
+.del-icon {
+  position: absolute;
+  top: -10px;
+  right: -10px;
+  z-index: 1;
+  width: 20px !important;
+  height: 20px !important;
+}
+</style>

+ 300 - 0
src/views/mall/promotion/point/components/PointTableSelect.vue

@@ -0,0 +1,300 @@
+<template>
+  <Dialog v-model="dialogVisible" :appendToBody="true" title="选择活动" width="70%">
+    <ContentWrap>
+      <!-- 搜索工作栏 -->
+      <el-form
+        ref="queryFormRef"
+        :inline="true"
+        :model="queryParams"
+        class="-mb-15px"
+        label-width="68px"
+      >
+        <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>
+          <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-form-item>
+      </el-form>
+      <el-table v-loading="loading" :data="list" show-overflow-tooltip>
+        <!-- 1. 多选模式(不能使用type="selection",Element会忽略Header插槽) -->
+        <el-table-column v-if="multiple" width="55">
+          <template #header>
+            <el-checkbox
+              v-model="isCheckAll"
+              :indeterminate="isIndeterminate"
+              @change="handleCheckAll"
+            />
+          </template>
+          <template #default="{ row }">
+            <el-checkbox
+              v-model="checkedStatus[row.id]"
+              @change="(checked: boolean) => handleCheckOne(checked, row, true)"
+            />
+          </template>
+        </el-table-column>
+        <!-- 2. 单选模式 -->
+        <el-table-column v-else label="#" width="55">
+          <template #default="{ row }">
+            <el-radio
+              v-model="selectedActivityId"
+              :value="row.id"
+              @change="handleSingleSelected(row)"
+            >
+              <!-- 空格不能省略,是为了让单选框不显示label,如果不指定label不会有选中的效果 -->
+              &nbsp;
+            </el-radio>
+          </template>
+        </el-table-column>
+        <el-table-column label="活动编号" min-width="80" prop="id" />
+        <el-table-column label="商品图片" min-width="80" prop="spuName">
+          <template #default="scope">
+            <el-image
+              :preview-src-list="[scope.row.picUrl]"
+              :src="scope.row.picUrl"
+              class="h-40px w-40px"
+              preview-teleported
+            />
+          </template>
+        </el-table-column>
+        <el-table-column label="商品标题" min-width="300" prop="spuName" />
+        <el-table-column
+          :formatter="fenToYuanFormat"
+          label="原价"
+          min-width="100"
+          prop="marketPrice"
+        />
+        <el-table-column label="原价" min-width="100" prop="marketPrice" />
+        <el-table-column align="center" label="活动状态" min-width="100" prop="status">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="库存" min-width="80" prop="stock" />
+        <el-table-column align="center" label="总库存" min-width="80" prop="totalStock" />
+        <el-table-column align="center" label="已兑换数量" min-width="100" prop="redeemedQuantity">
+          <template #default="{ row }">
+            {{ getRedeemedQuantity(row) }}
+          </template>
+        </el-table-column>
+        <el-table-column
+          :formatter="dateFormatter"
+          align="center"
+          label="创建时间"
+          prop="createTime"
+          width="180px"
+        />
+      </el-table>
+      <!-- 分页 -->
+      <Pagination
+        v-model:limit="queryParams.pageSize"
+        v-model:page="queryParams.pageNo"
+        :total="total"
+        @pagination="getList"
+      />
+    </ContentWrap>
+    <template v-if="multiple" #footer>
+      <el-button type="primary" @click="handleEmitChange">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import { propTypes } from '@/utils/propTypes'
+import { CHANGE_EVENT } from 'element-plus'
+import { PointActivityApi, PointActivityVO } from '@/api/mall/promotion/point'
+import { fenToYuanFormat } from '@/utils/formatter'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+
+/**
+ * 活动表格选择对话框
+ * 1. 单选模式:
+ *    1.1 点击表格左侧的单选框时,结束选择,并关闭对话框
+ *    1.2 再次打开时,保持选中状态
+ * 2. 多选模式:
+ *    2.1 点击表格左侧的多选框时,记录选中的活动
+ *    2.2 切换分页时,保持活动的选中状态
+ *    2.3 点击右下角的确定按钮时,结束选择,关闭对话框
+ *    2.4 再次打开时,保持选中状态
+ */
+defineOptions({ name: 'PointTableSelect' })
+
+defineProps({
+  // 多选模式
+  multiple: propTypes.bool.def(false)
+})
+
+// 列表的总页数
+const total = ref(0)
+// 列表的数据
+const list = ref<PointActivityVO[]>([])
+// 列表的加载中
+const loading = ref(false)
+// 弹窗的是否展示
+const dialogVisible = ref(false)
+// 查询参数
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  status: undefined
+})
+const getRedeemedQuantity = computed(() => (row: any) => (row.totalStock || 0) - (row.stock || 0)) // 获得商品已兑换数量
+/** 打开弹窗 */
+const open = (pointList?: PointActivityVO[]) => {
+  // 重置
+  checkedActivities.value = []
+  checkedStatus.value = {}
+  isCheckAll.value = false
+  isIndeterminate.value = false
+
+  // 处理已选中
+  if (pointList && pointList.length > 0) {
+    checkedActivities.value = [...pointList]
+    checkedStatus.value = Object.fromEntries(pointList.map((activityVO) => [activityVO.id, true]))
+  }
+
+  dialogVisible.value = true
+  resetQuery()
+}
+// 提供 open 方法,用于打开弹窗
+defineExpose({ open })
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await PointActivityApi.getPointActivityPage(queryParams.value)
+    list.value = data.list
+    total.value = data.total
+    // checkbox绑定undefined会有问题,需要给一个bool值
+    list.value.forEach(
+      (activityVO) =>
+        (checkedStatus.value[activityVO.id] = checkedStatus.value[activityVO.id] || false)
+    )
+    // 计算全选框状态
+    calculateIsCheckAll()
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.value.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryParams.value = {
+    pageNo: 1,
+    pageSize: 10,
+    name: null,
+    status: undefined
+  }
+  getList()
+}
+
+// 是否全选
+const isCheckAll = ref(false)
+// 全选框是否处于中间状态:不是全部选中 && 任意一个选中
+const isIndeterminate = ref(false)
+// 选中的活动
+const checkedActivities = ref<PointActivityVO[]>([])
+// 选中状态:key为活动ID,value为是否选中
+const checkedStatus = ref<Record<string, boolean>>({})
+
+// 选中的活动 activityId
+const selectedActivityId = ref()
+/** 单选中时触发 */
+const handleSingleSelected = (pointActivityVO: PointActivityVO) => {
+  emits(CHANGE_EVENT, pointActivityVO)
+  // 关闭弹窗
+  dialogVisible.value = false
+  // 记住上次选择的ID
+  selectedActivityId.value = pointActivityVO.id
+}
+
+/** 多选完成 */
+const handleEmitChange = () => {
+  // 关闭弹窗
+  dialogVisible.value = false
+  emits(CHANGE_EVENT, [...checkedActivities.value])
+}
+
+/** 确认选择时的触发事件 */
+const emits = defineEmits<{
+  (e: CHANGE_EVENT, v: PointActivityVO | PointActivityVO[] | any): void
+}>()
+
+/** 全选/全不选 */
+const handleCheckAll = (checked: boolean) => {
+  isCheckAll.value = checked
+  isIndeterminate.value = false
+
+  list.value.forEach((pointActivity) => handleCheckOne(checked, pointActivity, false))
+}
+
+/**
+ * 选中一行
+ * @param checked 是否选中
+ * @param pointActivity 活动
+ * @param isCalcCheckAll 是否计算全选
+ */
+const handleCheckOne = (
+  checked: boolean,
+  pointActivity: PointActivityVO,
+  isCalcCheckAll: boolean
+) => {
+  if (checked) {
+    checkedActivities.value.push(pointActivity)
+    checkedStatus.value[pointActivity.id] = true
+  } else {
+    const index = findCheckedIndex(pointActivity)
+    if (index > -1) {
+      checkedActivities.value.splice(index, 1)
+      checkedStatus.value[pointActivity.id] = false
+      isCheckAll.value = false
+    }
+  }
+
+  // 计算全选框状态
+  if (isCalcCheckAll) {
+    calculateIsCheckAll()
+  }
+}
+
+// 查找活动在已选中活动列表中的索引
+const findCheckedIndex = (activityVO: PointActivityVO) =>
+  checkedActivities.value.findIndex((item) => item.id === activityVO.id)
+
+// 计算全选框状态
+const calculateIsCheckAll = () => {
+  isCheckAll.value = list.value.every((activityVO) => checkedStatus.value[activityVO.id])
+  // 计算中间状态:不是全部选中 && 任意一个选中
+  isIndeterminate.value =
+    !isCheckAll.value && list.value.some((activityVO) => checkedStatus.value[activityVO.id])
+}
+</script>

+ 8 - 2
src/views/mall/promotion/rewardActivity/RewardForm.vue

@@ -56,7 +56,7 @@
         label="分类"
         prop="productCategoryIds"
       >
-        <ProductCategorySelect v-model="formData.productCategoryIds" />
+        <ProductCategorySelect v-model="formData.productCategoryIds" :multiple="true" />
       </el-form-item>
       <el-form-item label="备注" prop="remark">
         <el-input v-model="formData.remark" placeholder="请输入备注" />
@@ -119,6 +119,9 @@ const open = async (type: string, id?: number) => {
       // 规则分转元
       data.rules?.forEach((item: any) => {
         item.discountPrice = fenToYuan(item.discountPrice || 0)
+        if (data.conditionType === PromotionConditionTypeEnum.PRICE.type) {
+          item.limit = fenToYuan(item.limit || 0)
+        }
       })
       formData.value = data
       // 获得商品范围
@@ -151,6 +154,9 @@ const submitForm = async () => {
     // 规则元转分
     data.rules.forEach((item) => {
       item.discountPrice = yuanToFen(item.discountPrice || 0)
+      if (data.conditionType === PromotionConditionTypeEnum.PRICE.type) {
+        item.limit = yuanToFen(item.limit || 0)
+      }
     })
     // 设置商品范围
     setProductScopeValues(data)
@@ -188,7 +194,7 @@ const getProductScope = async () => {
     case PromotionProductScopeEnum.CATEGORY.scope:
       await nextTick()
       let productCategoryIds = formData.value.productScopeValues as any
-      if (Array.isArray(productCategoryIds) && productCategoryIds.length > 0) {
+      if (Array.isArray(productCategoryIds) && productCategoryIds.length === 1) {
         // 单选时使用数组不能反显
         productCategoryIds = productCategoryIds[0]
       }

+ 12 - 1
src/views/mall/promotion/rewardActivity/components/RewardRule.vue

@@ -10,14 +10,25 @@
         <el-form ref="formRef" :model="rule">
           <el-form-item label="优惠门槛:" label-width="100px" prop="limit">
+            <el-input-number
+              v-if="PromotionConditionTypeEnum.PRICE.type === formData.conditionType"
+              v-model="rule.limit"
+              :min="0"
+              :precision="2"
+              :step="0.1"
+              class="w-150px! p-x-20px!"
+              placeholder=""
+              type="number"
+              controls-position="right"
+            />
             <el-input
+              v-else
               v-model="rule.limit"
               :min="0"
               class="w-150px! p-x-20px!"
               placeholder=""
               type="number"
             />
-            <!-- TODO @puhui999:走字典数据? -->
             {{ PromotionConditionTypeEnum.PRICE.type === formData.conditionType ? '元' : '件' }}
           </el-form-item>
           <el-form-item label="优惠内容:" label-width="100px">

+ 2 - 2
src/views/mall/promotion/rewardActivity/components/RewardRuleCouponSelect.vue

@@ -1,5 +1,5 @@
 <template>
-  <el-button class="ml-10px" type="text" @click="selectCoupon">添加优惠</el-button>
+  <el-button class="ml-10px" type="text" @click="selectCoupon">添加优惠</el-button>
 
   <div
     v-for="(item, index) in list"
@@ -57,7 +57,7 @@ const emits = defineEmits<{
 const rewardRule = useVModel(props, 'modelValue', emits) // 赠送规则
 const list = ref<GiveCouponVO[]>([]) // 选择的优惠券列表
 
-/** 选择赠送的优惠类型拓展 */
+/** 选择赠送的优惠类型拓展 */
 interface GiveCouponVO extends CouponTemplateApi.CouponTemplateVO {
   giveCount?: number
 }

+ 32 - 5
src/views/mall/promotion/rewardActivity/index.vue

@@ -27,7 +27,7 @@
           placeholder="请选择活动状态"
         >
           <el-option
-            v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_ACTIVITY_STATUS)"
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
             :key="dict.value"
             :label="dict.label"
             :value="dict.value"
@@ -55,7 +55,7 @@
           重置
         </el-button>
         <el-button
-          v-hasPermi="['product:brand:create']"
+          v-hasPermi="['promotion:reward-activity:create']"
           plain
           type="primary"
           @click="openForm('create')"
@@ -71,6 +71,11 @@
   <ContentWrap>
     <el-table v-loading="loading" :data="list" default-expand-all row-key="id">
       <el-table-column label="活动名称" prop="name" />
+      <el-table-column label="活动范围" prop="productScope" >
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.PROMOTION_PRODUCT_SCOPE" :value="scope.row.productScope" />
+        </template>
+      </el-table-column>
       <el-table-column
         :formatter="dateFormatter"
         align="center"
@@ -85,7 +90,7 @@
       />
       <el-table-column align="center" label="状态" prop="status">
         <template #default="scope">
-          <dict-tag :type="DICT_TYPE.PROMOTION_ACTIVITY_STATUS" :value="scope.row.status" />
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
         </template>
       </el-table-column>
       <el-table-column
@@ -98,7 +103,7 @@
       <el-table-column align="center" label="操作">
         <template #default="scope">
           <el-button
-            v-hasPermi="['product:brand:update']"
+            v-hasPermi="['promotion:reward-activity:update']"
             link
             type="primary"
             @click="openForm('update', scope.row.id)"
@@ -106,7 +111,16 @@
             编辑
           </el-button>
           <el-button
-            v-hasPermi="['product:brand:delete']"
+            v-if="scope.row.status === 0"
+            v-hasPermi="['promotion:reward-activity:close']"
+            link
+            type="danger"
+            @click="handleClose(scope.row.id)"
+          >
+            关闭
+          </el-button>
+          <el-button
+            v-hasPermi="['promotion:reward-activity:delete']"
             link
             type="danger"
             @click="handleDelete(scope.row.id)"
@@ -180,6 +194,19 @@ const openForm = (type: string, id?: number) => {
   formRef.value?.open(type, id)
 }
 
+/** 关闭按钮操作 */
+const handleClose = async (id: number) => {
+  try {
+    // 关闭的二次确认
+    await message.confirm('确认关闭该满减活动吗?')
+    // 发起关闭
+    await RewardActivityApi.closeRewardActivity(id)
+    message.success('关闭成功')
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
 /** 删除按钮操作 */
 const handleDelete = async (id: number) => {
   try {

+ 156 - 0
src/views/mall/promotion/seckill/components/SeckillShowcase.vue

@@ -0,0 +1,156 @@
+<template>
+  <div class="flex flex-wrap items-center gap-8px">
+    <div
+      v-for="(seckillActivity, index) in Activitys"
+      :key="seckillActivity.id"
+      class="select-box spu-pic"
+    >
+      <el-tooltip :content="seckillActivity.name">
+        <div class="relative h-full w-full">
+          <el-image :src="seckillActivity.picUrl" class="h-full w-full" />
+          <Icon
+            v-show="!disabled"
+            class="del-icon"
+            icon="ep:circle-close-filled"
+            @click="handleRemoveActivity(index)"
+          />
+        </div>
+      </el-tooltip>
+    </div>
+    <el-tooltip content="选择活动" v-if="canAdd">
+      <div class="select-box" @click="openSeckillActivityTableSelect">
+        <Icon icon="ep:plus" />
+      </div>
+    </el-tooltip>
+  </div>
+  <!-- 拼团活动选择对话框(表格形式) -->
+  <SeckillTableSelect
+    ref="seckillActivityTableSelectRef"
+    :multiple="limit != 1"
+    @change="handleActivitySelected"
+  />
+</template>
+<script lang="ts" setup>
+import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity'
+import { propTypes } from '@/utils/propTypes'
+import { oneOfType } from 'vue-types'
+import { isArray } from '@/utils/is'
+import SeckillTableSelect from '@/views/mall/promotion/seckill/components/SeckillTableSelect.vue'
+
+// 活动橱窗,一般用于装修时使用
+// 提供功能:展示活动列表、添加活动、删除活动
+defineOptions({ name: 'SeckillShowcase' })
+
+const props = defineProps({
+  modelValue: oneOfType<number | Array<number>>([Number, Array]).isRequired,
+  // 限制数量:默认不限制
+  limit: propTypes.number.def(Number.MAX_VALUE),
+  disabled: propTypes.bool.def(false)
+})
+
+// 计算是否可以添加
+const canAdd = computed(() => {
+  // 情况一:禁用时不可以添加
+  if (props.disabled) return false
+  // 情况二:未指定限制数量时,可以添加
+  if (!props.limit) return true
+  // 情况三:检查已添加数量是否小于限制数量
+  return Activitys.value.length < props.limit
+})
+
+// 拼团活动列表
+const Activitys = ref<SeckillActivityApi.SeckillActivityVO[]>([])
+
+watch(
+  () => props.modelValue,
+  async () => {
+    const ids = isArray(props.modelValue)
+      ? // 情况一:多选
+        props.modelValue
+      : // 情况二:单选
+        props.modelValue
+        ? [props.modelValue]
+        : []
+    // 不需要返显
+    if (ids.length === 0) {
+      Activitys.value = []
+      return
+    }
+    // 只有活动发生变化之后,才会查询活动
+    if (
+      Activitys.value.length === 0 ||
+      Activitys.value.some((seckillActivity) => !ids.includes(seckillActivity.id!))
+    ) {
+      Activitys.value = await SeckillActivityApi.getSeckillActivityListByIds(ids)
+    }
+  },
+  { immediate: true }
+)
+
+/** 活动表格选择对话框 */
+const seckillActivityTableSelectRef = ref()
+// 打开对话框
+const openSeckillActivityTableSelect = () => {
+  seckillActivityTableSelectRef.value.open(Activitys.value)
+}
+
+/**
+ * 选择活动后触发
+ * @param activityVOs 选中的活动列表
+ */
+const handleActivitySelected = (
+  activityVOs: SeckillActivityApi.SeckillActivityVO | SeckillActivityApi.SeckillActivityVO[]
+) => {
+  Activitys.value = isArray(activityVOs) ? activityVOs : [activityVOs]
+  emitActivityChange()
+}
+
+/**
+ * 删除活动
+ * @param index 活动索引
+ */
+const handleRemoveActivity = (index: number) => {
+  Activitys.value.splice(index, 1)
+  emitActivityChange()
+}
+const emit = defineEmits(['update:modelValue', 'change'])
+const emitActivityChange = () => {
+  if (props.limit === 1) {
+    const seckillActivity = Activitys.value.length > 0 ? Activitys.value[0] : null
+    emit('update:modelValue', seckillActivity?.id || 0)
+    emit('change', seckillActivity)
+  } else {
+    emit(
+      'update:modelValue',
+      Activitys.value.map((seckillActivity) => seckillActivity.id)
+    )
+    emit('change', Activitys.value)
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.select-box {
+  display: flex;
+  width: 60px;
+  height: 60px;
+  border: 1px dashed var(--el-border-color-darker);
+  border-radius: 8px;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+}
+
+.spu-pic {
+  position: relative;
+}
+
+.del-icon {
+  position: absolute;
+  top: -10px;
+  right: -10px;
+  z-index: 1;
+  width: 20px !important;
+  height: 20px !important;
+}
+</style>

+ 343 - 0
src/views/mall/promotion/seckill/components/SeckillTableSelect.vue

@@ -0,0 +1,343 @@
+<template>
+  <Dialog v-model="dialogVisible" :appendToBody="true" title="选择活动" width="70%">
+    <ContentWrap>
+      <!-- 搜索工作栏 -->
+      <el-form
+        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"
+          />
+        </el-form-item>
+        <el-form-item label="活动状态" prop="status">
+          <el-select
+            v-model="queryParams.status"
+            placeholder="请选择活动状态"
+            clearable
+            class="!w-240px"
+          >
+            <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>
+          <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-form-item>
+      </el-form>
+      <el-table v-loading="loading" :data="list" show-overflow-tooltip>
+        <!-- 1. 多选模式(不能使用type="selection",Element会忽略Header插槽) -->
+        <el-table-column width="55" v-if="multiple">
+          <template #header>
+            <el-checkbox
+              v-model="isCheckAll"
+              :indeterminate="isIndeterminate"
+              @change="handleCheckAll"
+            />
+          </template>
+          <template #default="{ row }">
+            <el-checkbox
+              v-model="checkedStatus[row.id]"
+              @change="(checked: boolean) => handleCheckOne(checked, row, true)"
+            />
+          </template>
+        </el-table-column>
+        <!-- 2. 单选模式 -->
+        <el-table-column label="#" width="55" v-else>
+          <template #default="{ row }">
+            <el-radio
+              :value="row.id"
+              v-model="selectedActivityId"
+              @change="handleSingleSelected(row)"
+            >
+              <!-- 空格不能省略,是为了让单选框不显示label,如果不指定label不会有选中的效果 -->
+              &nbsp;
+            </el-radio>
+          </template>
+        </el-table-column>
+        <el-table-column label="活动编号" prop="id" min-width="80" />
+        <el-table-column label="活动名称" prop="name" min-width="140" />
+        <el-table-column label="活动时间" min-width="210">
+          <template #default="scope">
+            {{ formatDate(scope.row.startTime, 'YYYY-MM-DD') }}
+            ~ {{ formatDate(scope.row.endTime, 'YYYY-MM-DD') }}
+          </template>
+        </el-table-column>
+        <el-table-column label="商品图片" prop="spuName" min-width="80">
+          <template #default="scope">
+            <el-image
+              :src="scope.row.picUrl"
+              class="h-40px w-40px"
+              :preview-src-list="[scope.row.picUrl]"
+              preview-teleported
+            />
+          </template>
+        </el-table-column>
+        <el-table-column label="商品标题" prop="spuName" min-width="300" />
+        <el-table-column
+          label="原价"
+          prop="marketPrice"
+          min-width="100"
+          :formatter="fenToYuanFormat"
+        />
+        <el-table-column label="拼团价" prop="seckillPrice" min-width="100">
+          <template #default="scope">
+            {{ formatSeckillPrice(scope.row.products) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="开团组数" prop="groupCount" min-width="100" />
+        <el-table-column label="成团组数" prop="groupSuccessCount" min-width="100" />
+        <el-table-column label="购买次数" prop="recordCount" min-width="100" />
+        <el-table-column label="活动状态" align="center" prop="status" min-width="100">
+          <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="createTime"
+          :formatter="dateFormatter"
+          width="180px"
+        />
+      </el-table>
+      <!-- 分页 -->
+      <Pagination
+        v-model:limit="queryParams.pageSize"
+        v-model:page="queryParams.pageNo"
+        :total="total"
+        @pagination="getList"
+      />
+    </ContentWrap>
+    <template #footer v-if="multiple">
+      <el-button type="primary" @click="handleEmitChange">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import { handleTree } from '@/utils/tree'
+
+import * as ProductCategoryApi from '@/api/mall/product/category'
+import { propTypes } from '@/utils/propTypes'
+import { CHANGE_EVENT } from 'element-plus'
+import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity'
+import { fenToYuanFormat } from '@/utils/formatter'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter, formatDate } from '@/utils/formatTime'
+import { fenToYuan } from '@/utils'
+
+type SeckillActivityVO = Required<SeckillActivityApi.SeckillActivityVO>
+
+/**
+ * 活动表格选择对话框
+ * 1. 单选模式:
+ *    1.1 点击表格左侧的单选框时,结束选择,并关闭对话框
+ *    1.2 再次打开时,保持选中状态
+ * 2. 多选模式:
+ *    2.1 点击表格左侧的多选框时,记录选中的活动
+ *    2.2 切换分页时,保持活动的选中状态
+ *    2.3 点击右下角的确定按钮时,结束选择,关闭对话框
+ *    2.4 再次打开时,保持选中状态
+ */
+defineOptions({ name: 'SeckillTableSelect' })
+
+defineProps({
+  // 多选模式
+  multiple: propTypes.bool.def(false)
+})
+
+// 列表的总页数
+const total = ref(0)
+// 列表的数据
+const list = ref<SeckillActivityVO[]>([])
+// 列表的加载中
+const loading = ref(false)
+// 弹窗的是否展示
+const dialogVisible = ref(false)
+// 查询参数
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  status: undefined
+})
+
+/** 打开弹窗 */
+const open = (SeckillList?: SeckillActivityVO[]) => {
+  // 重置
+  checkedActivitys.value = []
+  checkedStatus.value = {}
+  isCheckAll.value = false
+  isIndeterminate.value = false
+
+  // 处理已选中
+  if (SeckillList && SeckillList.length > 0) {
+    checkedActivitys.value = [...SeckillList]
+    checkedStatus.value = Object.fromEntries(SeckillList.map((activityVO) => [activityVO.id, true]))
+  }
+
+  dialogVisible.value = true
+  resetQuery()
+}
+// 提供 open 方法,用于打开弹窗
+defineExpose({ open })
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await SeckillActivityApi.getSeckillActivityPage(queryParams.value)
+    list.value = data.list
+    total.value = data.total
+    // checkbox绑定undefined会有问题,需要给一个bool值
+    list.value.forEach(
+      (activityVO) =>
+        (checkedStatus.value[activityVO.id] = checkedStatus.value[activityVO.id] || false)
+    )
+    // 计算全选框状态
+    calculateIsCheckAll()
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.value.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryParams.value = {
+    pageNo: 1,
+    pageSize: 10,
+    name: undefined,
+    createTime: []
+  }
+  getList()
+}
+
+/**
+ * 格式化拼团价格
+ * @param products
+ */
+const formatSeckillPrice = (products) => {
+  const seckillPrice = Math.min(...products.map((item) => item.seckillPrice))
+  return `¥${fenToYuan(seckillPrice)}`
+}
+
+// 是否全选
+const isCheckAll = ref(false)
+// 全选框是否处于中间状态:不是全部选中 && 任意一个选中
+const isIndeterminate = ref(false)
+// 选中的活动
+const checkedActivitys = ref<SeckillActivityVO[]>([])
+// 选中状态:key为活动ID,value为是否选中
+const checkedStatus = ref<Record<string, boolean>>({})
+
+// 选中的活动 activityId
+const selectedActivityId = ref()
+/** 单选中时触发 */
+const handleSingleSelected = (seckillActivityVO: SeckillActivityVO) => {
+  emits(CHANGE_EVENT, seckillActivityVO)
+  // 关闭弹窗
+  dialogVisible.value = false
+  // 记住上次选择的ID
+  selectedActivityId.value = seckillActivityVO.id
+}
+
+/** 多选完成 */
+const handleEmitChange = () => {
+  // 关闭弹窗
+  dialogVisible.value = false
+  emits(CHANGE_EVENT, [...checkedActivitys.value])
+}
+
+/** 确认选择时的触发事件 */
+const emits = defineEmits<{
+  change: [SeckillActivityApi: SeckillActivityVO | SeckillActivityVO[] | any]
+}>()
+
+/** 全选/全不选 */
+const handleCheckAll = (checked: boolean) => {
+  isCheckAll.value = checked
+  isIndeterminate.value = false
+
+  list.value.forEach((seckillActivity) => handleCheckOne(checked, seckillActivity, false))
+}
+
+/**
+ * 选中一行
+ * @param checked 是否选中
+ * @param seckillActivity 活动
+ * @param isCalcCheckAll 是否计算全选
+ */
+const handleCheckOne = (
+  checked: boolean,
+  seckillActivity: SeckillActivityVO,
+  isCalcCheckAll: boolean
+) => {
+  if (checked) {
+    checkedActivitys.value.push(seckillActivity)
+    checkedStatus.value[seckillActivity.id] = true
+  } else {
+    const index = findCheckedIndex(seckillActivity)
+    if (index > -1) {
+      checkedActivitys.value.splice(index, 1)
+      checkedStatus.value[seckillActivity.id] = false
+      isCheckAll.value = false
+    }
+  }
+
+  // 计算全选框状态
+  if (isCalcCheckAll) {
+    calculateIsCheckAll()
+  }
+}
+
+// 查找活动在已选中活动列表中的索引
+const findCheckedIndex = (activityVO: SeckillActivityVO) =>
+  checkedActivitys.value.findIndex((item) => item.id === activityVO.id)
+
+// 计算全选框状态
+const calculateIsCheckAll = () => {
+  isCheckAll.value = list.value.every((activityVO) => checkedStatus.value[activityVO.id])
+  // 计算中间状态:不是全部选中 && 任意一个选中
+  isIndeterminate.value =
+    !isCheckAll.value && list.value.some((activityVO) => checkedStatus.value[activityVO.id])
+}
+
+// 分类列表
+const categoryList = ref()
+// 分类树
+const categoryTreeList = ref()
+/** 初始化 **/
+onMounted(async () => {
+  await getList()
+  // 获得分类树
+  categoryList.value = await ProductCategoryApi.getCategoryList({})
+  categoryTreeList.value = handleTree(categoryList.value, 'id', 'parentId')
+})
+</script>

+ 11 - 0
src/views/mp/components/wx-account-select/main.vue

@@ -6,6 +6,11 @@
 
 <script lang="ts" setup>
 import * as MpAccountApi from '@/api/mp/account'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+
+const message = useMessage() // 消息弹窗
+const { delView } = useTagsViewStore() // 视图操作
+const { push, currentRoute } = useRouter() // 路由
 
 defineOptions({ name: 'WxAccountSelect' })
 
@@ -22,6 +27,12 @@ const emit = defineEmits<{
 
 const handleQuery = async () => {
   accountList.value = await MpAccountApi.getSimpleAccountList()
+  if (accountList.value.length == 0) {
+    message.error('未配置公众号,请在【公众号管理 -> 账号管理】菜单,进行配置')
+    delView(unref(currentRoute))
+    await push({ name: 'MpAccount' })
+    return
+  }
   // 默认选中第一个
   if (accountList.value.length > 0) {
     account.id = accountList.value[0].id

+ 6 - 25
src/views/mp/statistics/index.vue

@@ -3,14 +3,7 @@
   <ContentWrap>
     <el-form class="-mb-15px" ref="queryForm" :inline="true" label-width="68px">
       <el-form-item label="公众号" prop="accountId">
-        <el-select v-model="accountId" @change="getSummary" class="!w-240px">
-          <el-option
-            v-for="item in accountList"
-            :key="item.id"
-            :label="item.name"
-            :value="item.id"
-          />
-        </el-select>
+        <WxAccountSelect @change="onAccountChanged" />
       </el-form-item>
       <el-form-item label="时间范围" prop="dateRange">
         <el-date-picker
@@ -76,7 +69,7 @@
 <script lang="ts" setup>
 import { formatDate, addTime, betweenDay, beginOfDay, endOfDay } from '@/utils/formatTime'
 import * as StatisticsApi from '@/api/mp/statistics'
-import * as MpAccountApi from '@/api/mp/account'
+import WxAccountSelect from '@/views/mp/components/wx-account-select'
 
 defineOptions({ name: 'MpStatistics' })
 
@@ -88,7 +81,6 @@ const dateRange = ref([
   endOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24))
 ])
 const accountId = ref(-1) // 选中的公众号编号
-const accountList = ref<MpAccountApi.AccountVO[]>([]) // 公众号账号列表
 
 const xAxisDate = ref([] as any[]) // X 轴的日期范围
 // 用户增减数据图表配置项
@@ -230,13 +222,10 @@ const interfaceSummaryOption = reactive({
   ]
 })
 
-/** 加载公众号账号的列表 */
-const getAccountList = async () => {
-  accountList.value = await MpAccountApi.getSimpleAccountList()
-  // 默认选中第一个
-  if (accountList.value.length > 0) {
-    accountId.value = accountList.value[0].id!
-  }
+/** 侦听公众号变化 **/
+const onAccountChanged = (id: number) => {
+  accountId.value = id
+  getSummary()
 }
 
 /** 加载数据 */
@@ -357,12 +346,4 @@ const interfaceSummaryChart = async () => {
     })
   } catch {}
 }
-
-/** 初始化 */
-onMounted(async () => {
-  // 获取公众号下拉列表
-  await getAccountList()
-  // 加载数据
-  getSummary()
-})
 </script>

+ 1 - 1
src/views/pay/cashier/index.vue

@@ -231,7 +231,7 @@ const getDetail = async () => {
     goReturnUrl('cancel')
     return
   }
-  const data = await PayOrderApi.getOrder(id.value)
+  const data = await PayOrderApi.getOrder(id.value, true)
   payOrder.value = data
   // 1.2 无法查询到支付信息
   if (!data) {

+ 3 - 2
src/views/report/jmreport/index.vue

@@ -6,9 +6,10 @@
   </ContentWrap>
 </template>
 <script lang="ts" setup>
-import { getAccessToken } from '@/utils/auth'
+import { getRefreshToken } from '@/utils/auth'
 
 defineOptions({ name: 'JimuReport' })
 
-const src = ref(import.meta.env.VITE_BASE_URL + '/jmreport/list?token=' + getAccessToken())
+// 使用 getRefreshToken() 方法,而不使用 getAccessToken() 方法的原因:积木报表无法方便的刷新访问令牌
+const src = ref(import.meta.env.VITE_BASE_URL + '/jmreport/list?token=' + getRefreshToken())
 </script>

+ 0 - 1
types/env.d.ts

@@ -19,7 +19,6 @@ interface ImportMetaEnv {
   readonly VITE_APP_DEFAULT_LOGIN_PASSWORD: string
   readonly VITE_APP_DOCALERT_ENABLE: string
   readonly VITE_BASE_URL: string
-  readonly VITE_UPLOAD_URL: string
   readonly VITE_API_URL: string
   readonly VITE_BASE_PATH: string
   readonly VITE_DROP_DEBUGGER: string