ydmyzx 5 hónapja
szülő
commit
b886277ac5
25 módosított fájl, 2894 hozzáadás és 0 törlés
  1. 112 0
      build/vite/optimize.ts
  2. 105 0
      src/components/OperateLogV2/src/OperateLogV2.vue
  3. 297 0
      src/components/SimpleProcessDesigner/src/nodeWrap.vue
  4. 92 0
      src/views/mall/home/components/OperationDataCard.vue
  5. 91 0
      src/views/mall/product/spu/form/OtherForm.vue
  6. BIN
      src/views/mall/promotion/kefu/components/asserts/nanguo.png
  7. 182 0
      src/views/mall/promotion/kefu/components/message/OrderMessageItem.vue
  8. 263 0
      src/views/mall/trade/order/components/OrderTableColumn.vue
  9. 99 0
      src/views/mall/trade/order/form/OrderDeliveryForm.vue
  10. 108 0
      src/views/mall/trade/order/form/OrderPickUpForm.vue
  11. 98 0
      src/views/mall/trade/order/form/OrderUpdateAddressForm.vue
  12. 95 0
      src/views/mall/trade/order/form/OrderUpdatePriceForm.vue
  13. 70 0
      src/views/mall/trade/order/form/OrderUpdateRemarkForm.vue
  14. 69 0
      src/views/mp/components/wx-msg/components/Msg.vue
  15. 49 0
      src/views/mp/components/wx-msg/components/MsgEvent.vue
  16. 62 0
      src/views/mp/components/wx-msg/components/MsgList.vue
  17. 304 0
      src/views/mp/draft/components/NewsForm.vue
  18. 86 0
      src/views/pay/notify/NotifyDetail.vue
  19. 111 0
      src/views/pay/order/OrderDetail.vue
  20. 132 0
      src/views/system/notice/NoticeForm.vue
  21. 66 0
      src/views/system/notify/message/NotifyMessageDetail.vue
  22. 48 0
      src/views/system/notify/my/MyNotifyMessageDetail.vue
  23. 141 0
      src/views/system/notify/template/NotifyTemplateForm.vue
  24. 146 0
      src/views/system/notify/template/NotifyTemplateSendForm.vue
  25. 68 0
      src/views/system/operatelog/OperateLogDetail.vue

+ 112 - 0
build/vite/optimize.ts

@@ -0,0 +1,112 @@
+const include = [
+  'qs',
+  'url',
+  'vue',
+  'sass',
+  'mitt',
+  'axios',
+  'pinia',
+  'dayjs',
+  'qrcode',
+  'unocss',
+  'vue-router',
+  'vue-types',
+  'vue-i18n',
+  'crypto-js',
+  'cropperjs',
+  'lodash-es',
+  'nprogress',
+  'web-storage-cache',
+  '@iconify/iconify',
+  '@vueuse/core',
+  '@zxcvbn-ts/core',
+  'echarts/core',
+  'echarts/charts',
+  'echarts/components',
+  'echarts/renderers',
+  'echarts-wordcloud',
+  '@wangeditor/editor',
+  '@wangeditor/editor-for-vue',
+  'element-plus',
+  'element-plus/es',
+  'element-plus/es/locale/lang/zh-cn',
+  'element-plus/es/locale/lang/en',
+  'element-plus/es/components/avatar/style/css',
+  'element-plus/es/components/space/style/css',
+  'element-plus/es/components/backtop/style/css',
+  'element-plus/es/components/form/style/css',
+  'element-plus/es/components/radio-group/style/css',
+  'element-plus/es/components/radio/style/css',
+  'element-plus/es/components/checkbox/style/css',
+  'element-plus/es/components/checkbox-group/style/css',
+  'element-plus/es/components/switch/style/css',
+  'element-plus/es/components/time-picker/style/css',
+  'element-plus/es/components/date-picker/style/css',
+  'element-plus/es/components/descriptions/style/css',
+  'element-plus/es/components/descriptions-item/style/css',
+  'element-plus/es/components/link/style/css',
+  'element-plus/es/components/tooltip/style/css',
+  'element-plus/es/components/drawer/style/css',
+  'element-plus/es/components/dialog/style/css',
+  'element-plus/es/components/checkbox-button/style/css',
+  'element-plus/es/components/option-group/style/css',
+  'element-plus/es/components/radio-button/style/css',
+  'element-plus/es/components/cascader/style/css',
+  'element-plus/es/components/color-picker/style/css',
+  'element-plus/es/components/input-number/style/css',
+  'element-plus/es/components/rate/style/css',
+  'element-plus/es/components/select-v2/style/css',
+  'element-plus/es/components/tree-select/style/css',
+  'element-plus/es/components/slider/style/css',
+  'element-plus/es/components/time-select/style/css',
+  'element-plus/es/components/autocomplete/style/css',
+  'element-plus/es/components/image-viewer/style/css',
+  'element-plus/es/components/upload/style/css',
+  'element-plus/es/components/col/style/css',
+  'element-plus/es/components/form-item/style/css',
+  'element-plus/es/components/alert/style/css',
+  'element-plus/es/components/breadcrumb/style/css',
+  'element-plus/es/components/select/style/css',
+  'element-plus/es/components/input/style/css',
+  'element-plus/es/components/breadcrumb-item/style/css',
+  'element-plus/es/components/tag/style/css',
+  'element-plus/es/components/pagination/style/css',
+  'element-plus/es/components/table/style/css',
+  'element-plus/es/components/table-v2/style/css',
+  'element-plus/es/components/table-column/style/css',
+  'element-plus/es/components/card/style/css',
+  'element-plus/es/components/row/style/css',
+  'element-plus/es/components/button/style/css',
+  'element-plus/es/components/menu/style/css',
+  'element-plus/es/components/sub-menu/style/css',
+  'element-plus/es/components/menu-item/style/css',
+  'element-plus/es/components/option/style/css',
+  'element-plus/es/components/dropdown/style/css',
+  'element-plus/es/components/dropdown-menu/style/css',
+  'element-plus/es/components/dropdown-item/style/css',
+  'element-plus/es/components/skeleton/style/css',
+  'element-plus/es/components/skeleton/style/css',
+  'element-plus/es/components/backtop/style/css',
+  'element-plus/es/components/menu/style/css',
+  'element-plus/es/components/sub-menu/style/css',
+  'element-plus/es/components/menu-item/style/css',
+  'element-plus/es/components/dropdown/style/css',
+  'element-plus/es/components/tree/style/css',
+  'element-plus/es/components/dropdown-menu/style/css',
+  'element-plus/es/components/dropdown-item/style/css',
+  'element-plus/es/components/badge/style/css',
+  'element-plus/es/components/breadcrumb/style/css',
+  'element-plus/es/components/breadcrumb-item/style/css',
+  'element-plus/es/components/image/style/css',
+  'element-plus/es/components/collapse-transition/style/css',
+  'element-plus/es/components/timeline/style/css',
+  'element-plus/es/components/timeline-item/style/css',
+  'element-plus/es/components/collapse/style/css',
+  'element-plus/es/components/collapse-item/style/css',
+  'element-plus/es/components/button-group/style/css',
+  'element-plus/es/components/text/style/css'
+]
+
+const exclude = ['@iconify/json']
+
+export { include, exclude }

+ 105 - 0
src/components/OperateLogV2/src/OperateLogV2.vue

@@ -0,0 +1,105 @@
+<!-- 某个记录的操作日志列表,目前主要用于 CRM 客户、商机等详情界面 -->
+<template>
+  <div class="pt-20px">
+    <el-timeline>
+      <el-timeline-item
+        v-for="(log, index) in logList"
+        :key="index"
+        :timestamp="formatDate(log.createTime)"
+        placement="top"
+      >
+        <div class="el-timeline-right-content">
+          <el-tag class="mr-10px" type="success">{{ log.userName }}</el-tag>
+          {{ log.action }}
+        </div>
+        <template #dot>
+          <span :style="{ backgroundColor: getUserTypeColor(log.userType) }" class="dot-node-style">
+            {{ getDictLabel(DICT_TYPE.USER_TYPE, log.userType)[0] }}
+          </span>
+        </template>
+      </el-timeline-item>
+    </el-timeline>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { OperateLogVO } from '@/api/system/operatelog'
+import { formatDate } from '@/utils/formatTime'
+import { DICT_TYPE, getDictLabel, getDictObj } from '@/utils/dict'
+import { ElTag } from 'element-plus'
+
+defineOptions({ name: 'OperateLogV2' })
+
+interface Props {
+  logList: OperateLogVO[] // 操作日志列表
+}
+
+withDefaults(defineProps<Props>(), {
+  logList: () => []
+})
+
+/** 获得 userType 颜色 */
+const getUserTypeColor = (type: number) => {
+  const dict = getDictObj(DICT_TYPE.USER_TYPE, type)
+  switch (dict?.colorType) {
+    case 'success':
+      return '#67C23A'
+    case 'info':
+      return '#909399'
+    case 'warning':
+      return '#E6A23C'
+    case 'danger':
+      return '#F56C6C'
+  }
+  return '#409EFF'
+}
+</script>
+
+<style lang="scss" scoped>
+// 时间线样式调整
+:deep(.el-timeline) {
+  margin: 10px 0 0 110px;
+
+  .el-timeline-item__wrapper {
+    position: relative;
+    top: -20px;
+
+    .el-timeline-item__timestamp {
+      position: absolute !important;
+      top: 10px;
+      left: -150px;
+    }
+  }
+
+  .el-timeline-right-content {
+    display: flex;
+    align-items: center;
+    min-height: 30px;
+    padding: 10px;
+    background-color: #fff;
+
+    &::before {
+      position: absolute;
+      top: 10px;
+      left: 13px; /* 将伪元素水平居中 */
+      border-color: transparent #fff transparent transparent; /* 尖角颜色,左侧朝向 */
+      border-style: solid;
+      border-width: 8px; /* 调整尖角大小 */
+      content: ''; /* 必须设置 content 属性 */
+    }
+  }
+}
+
+.dot-node-style {
+  position: absolute;
+  left: -5px;
+  display: flex;
+  width: 20px;
+  height: 20px;
+  font-size: 10px;
+  color: #fff;
+  border-radius: 50%;
+  justify-content: center;
+  align-items: center;
+}
+</style>

+ 297 - 0
src/components/SimpleProcessDesigner/src/nodeWrap.vue

@@ -0,0 +1,297 @@
+<!-- eslint-disable vue/no-mutating-props -->
+<!--
+ * @Date: 2022-09-21 14:41:53
+ * @LastEditors: StavinLi 495727881@qq.com
+ * @LastEditTime: 2023-05-24 15:20:24
+ * @FilePath: /Workflow-Vue3/src/components/nodeWrap.vue
+-->
+<template>
+     <div class="node-wrap" v-if="nodeConfig.type < 3">
+      <div class="node-wrap-box" :class="(nodeConfig.type == 0 ? 'start-node ' : '') +(isTried && nodeConfig.error ? 'active error' : '')">
+          <div class="title" :style="`background: rgb(${bgColors[nodeConfig.type]});`">
+            <span v-if="nodeConfig.type == 0">{{ nodeConfig.nodeName }}</span>
+            <template v-else>
+              <span class="iconfont">{{nodeConfig.type == 1?'':''}}</span>
+              <input
+                v-if="isInput"
+                type="text"
+                class="ant-input editable-title-input"
+                @blur="blurEvent()"
+                @focus="$event.currentTarget.select()"
+                v-focus
+                v-model="nodeConfig.nodeName"
+                :placeholder="defaultText"
+              />
+              <span v-else class="editable-title" @click="clickEvent()">{{ nodeConfig.nodeName }}</span>
+              <i class="anticon anticon-close close" @click="delNode"></i>
+            </template>
+          </div>
+          <div class="content" @click="setPerson">
+            <div class="text">
+                <span class="placeholder" v-if="!showText">请选择{{defaultText}}</span>
+                {{showText}}
+            </div>
+            <i class="anticon anticon-right arrow"></i>
+          </div>
+          <div class="error_tip" v-if="isTried && nodeConfig.error">
+            <i class="anticon anticon-exclamation-circle"></i>
+          </div>
+      </div>
+      <addNode v-model:childNodeP="nodeConfig.childNode" />
+    </div>
+    <div class="branch-wrap" v-if="nodeConfig.type == 4">
+    <div class="branch-box-wrap">
+      <div class="branch-box">
+        <button class="add-branch" @click="addTerm">添加条件</button>
+        <div class="col-box" v-for="(item, index) in nodeConfig.conditionNodes" :key="index">
+          <div class="condition-node">
+            <div class="condition-node-box">
+              <div class="auto-judge" :class="isTried && item.error ? 'error active' : ''">
+                <div class="sort-left" v-if="index != 0" @click="arrTransfer(index, -1)">&lt;</div>
+                <div class="title-wrapper">
+                  <input
+                    v-if="isInputList[index]"
+                    type="text"
+                    class="ant-input editable-title-input"
+                    @blur="blurEvent(index)"
+                    @focus="$event.currentTarget.select()"
+                    v-model="item.nodeName"
+                  />
+                  <span v-else class="editable-title" @click="clickEvent(index)">{{ item.nodeName }}</span>
+                  <span class="priority-title" @click="setPerson(item.priorityLevel)">优先级{{ item.priorityLevel }}</span>
+                  <i class="anticon anticon-close close" @click="delTerm(index)"></i>
+                </div>
+                <div class="sort-right" v-if="index != nodeConfig.conditionNodes.length - 1" @click="arrTransfer(index)">&gt;</div>
+                <div class="content" @click="setPerson(item.priorityLevel)">{{ conditionStr(nodeConfig, index) }}</div>
+                <div class="error_tip" v-if="isTried && item.error">
+                    <i class="anticon anticon-exclamation-circle"></i>
+                </div>
+              </div>
+              <addNode v-model:childNodeP="item.childNode" />
+            </div>
+          </div>
+          <nodeWrap v-if="item.childNode" v-model:nodeConfig="item.childNode" />
+          <template v-if="index == 0">
+            <div class="top-left-cover-line"></div>
+            <div class="bottom-left-cover-line"></div>
+          </template>
+          <template v-if="index == nodeConfig.conditionNodes.length - 1">
+            <div class="top-right-cover-line"></div>
+            <div class="bottom-right-cover-line"></div>
+          </template>
+        </div>
+      </div>
+      <addNode v-model:childNodeP="nodeConfig.childNode" />
+    </div>
+  </div>
+    <nodeWrap v-if="nodeConfig.childNode" v-model:nodeConfig="nodeConfig.childNode" />
+</template>
+<script  setup>
+import addNode from './addNode.vue'
+import { onMounted, ref, watch, getCurrentInstance, computed } from 'vue'
+import {
+  arrToStr,
+  conditionStr,
+  setApproverStr,
+  copyerStr,
+  bgColors,
+  placeholderList
+} from './util'
+import { useWorkFlowStoreWithOut } from '@/store/modules/simpleWorkflow'
+let _uid = getCurrentInstance().uid
+
+let props = defineProps({
+  nodeConfig: {
+    type: Object,
+    default: () => ({})
+  },
+  flowPermission: {
+    type: Object,
+    // eslint-disable-next-line vue/require-valid-default-prop
+    default: () => []
+  }
+})
+
+let defaultText = computed(() => {
+  return placeholderList[props.nodeConfig.type]
+})
+let showText = computed(() => {
+  if (props.nodeConfig.type == 0) return arrToStr(props.flowPermission) || '所有人'
+  if (props.nodeConfig.type == 1) return setApproverStr(props.nodeConfig)
+  return copyerStr(props.nodeConfig)
+})
+
+let isInputList = ref([])
+let isInput = ref(false)
+const resetConditionNodesErr = () => {
+  for (var i = 0; i < props.nodeConfig.conditionNodes.length; i++) {
+    // eslint-disable-next-line vue/no-mutating-props
+    props.nodeConfig.conditionNodes[i].error =
+      conditionStr(props.nodeConfig, i) == '请设置条件' &&
+      i != props.nodeConfig.conditionNodes.length - 1
+  }
+}
+onMounted(() => {
+  if (props.nodeConfig.type == 1) {
+    // eslint-disable-next-line vue/no-mutating-props
+    props.nodeConfig.error = !setApproverStr(props.nodeConfig)
+  } else if (props.nodeConfig.type == 2) {
+    // eslint-disable-next-line vue/no-mutating-props
+    props.nodeConfig.error = !copyerStr(props.nodeConfig)
+  } else if (props.nodeConfig.type == 4) {
+    resetConditionNodesErr()
+  }
+})
+let emits = defineEmits(['update:flowPermission', 'update:nodeConfig'])
+let store = useWorkFlowStoreWithOut()
+let {
+  setPromoter,
+  setApprover,
+  setCopyer,
+  setCondition,
+  setFlowPermission,
+  setApproverConfig,
+  setCopyerConfig,
+  setConditionsConfig
+} = store
+let isTried = computed(() => store.isTried)
+let flowPermission1 = computed(() => store.flowPermission1)
+let approverConfig1 = computed(() => store.approverConfig1)
+let copyerConfig1 = computed(() => store.copyerConfig1)
+let conditionsConfig1 = computed(() => store.conditionsConfig1)
+watch(flowPermission1, (flow) => {
+  if (flow.flag && flow.id === _uid) {
+    emits('update:flowPermission', flow.value)
+  }
+})
+watch(approverConfig1, (approver) => {
+  if (approver.flag && approver.id === _uid) {
+    emits('update:nodeConfig', approver.value)
+  }
+})
+watch(copyerConfig1, (copyer) => {
+  if (copyer.flag && copyer.id === _uid) {
+    emits('update:nodeConfig', copyer.value)
+  }
+})
+watch(conditionsConfig1, (condition) => {
+  if (condition.flag && condition.id === _uid) {
+    emits('update:nodeConfig', condition.value)
+  }
+})
+
+const clickEvent = (index) => {
+  if (index || index === 0) {
+    isInputList.value[index] = true
+  } else {
+    isInput.value = true
+  }
+}
+const blurEvent = (index) => {
+  if (index || index === 0) {
+    isInputList.value[index] = false
+    // eslint-disable-next-line vue/no-mutating-props
+    props.nodeConfig.conditionNodes[index].nodeName =
+      props.nodeConfig.conditionNodes[index].nodeName || '条件'
+  } else {
+    isInput.value = false
+    // eslint-disable-next-line vue/no-mutating-props
+    props.nodeConfig.nodeName = props.nodeConfig.nodeName || defaultText
+  }
+}
+const delNode = () => {
+  emits('update:nodeConfig', props.nodeConfig.childNode)
+}
+const addTerm = () => {
+  let len = props.nodeConfig.conditionNodes.length + 1
+  // eslint-disable-next-line vue/no-mutating-props
+  props.nodeConfig.conditionNodes.push({
+    nodeName: '条件' + len,
+    type: 3,
+    priorityLevel: len,
+    conditionList: [],
+    nodeUserList: [],
+    childNode: null
+  })
+  resetConditionNodesErr()
+  emits('update:nodeConfig', props.nodeConfig)
+}
+const delTerm = (index) => {
+  // eslint-disable-next-line vue/no-mutating-props
+  props.nodeConfig.conditionNodes.splice(index, 1)
+  props.nodeConfig.conditionNodes.map((item, index) => {
+    item.priorityLevel = index + 1
+    item.nodeName = `条件${index + 1}`
+  })
+  resetConditionNodesErr()
+  emits('update:nodeConfig', props.nodeConfig)
+  if (props.nodeConfig.conditionNodes.length == 1) {
+    if (props.nodeConfig.childNode) {
+      if (props.nodeConfig.conditionNodes[0].childNode) {
+        reData(props.nodeConfig.conditionNodes[0].childNode, props.nodeConfig.childNode)
+      } else {
+        // eslint-disable-next-line vue/no-mutating-props
+        props.nodeConfig.conditionNodes[0].childNode = props.nodeConfig.childNode
+      }
+    }
+    emits('update:nodeConfig', props.nodeConfig.conditionNodes[0].childNode)
+  }
+}
+const reData = (data, addData) => {
+  if (!data.childNode) {
+    data.childNode = addData
+  } else {
+    reData(data.childNode, addData)
+  }
+}
+const setPerson = (priorityLevel) => {
+  var { type } = props.nodeConfig
+  if (type == 0) {
+    setPromoter(true)
+    setFlowPermission({
+      value: props.flowPermission,
+      flag: false,
+      id: _uid
+    })
+  } else if (type == 1) {
+    setApprover(true)
+    setApproverConfig({
+      value: {
+        ...JSON.parse(JSON.stringify(props.nodeConfig)),
+        ...{ settype: props.nodeConfig.settype ? props.nodeConfig.settype : 1 }
+      },
+      flag: false,
+      id: _uid
+    })
+  } else if (type == 2) {
+    setCopyer(true)
+    setCopyerConfig({
+      value: JSON.parse(JSON.stringify(props.nodeConfig)),
+      flag: false,
+      id: _uid
+    })
+  } else {
+    setCondition(true)
+    setConditionsConfig({
+      value: JSON.parse(JSON.stringify(props.nodeConfig)),
+      priorityLevel,
+      flag: false,
+      id: _uid
+    })
+  }
+}
+const arrTransfer = (index, type = 1) => {
+  //向左-1,向右1
+  // eslint-disable-next-line vue/no-mutating-props
+  props.nodeConfig.conditionNodes[index] = props.nodeConfig.conditionNodes.splice(
+    index + type,
+    1,
+    props.nodeConfig.conditionNodes[index]
+  )[0]
+  props.nodeConfig.conditionNodes.map((item, index) => {
+    item.priorityLevel = index + 1
+  })
+  resetConditionNodesErr()
+  emits('update:nodeConfig', props.nodeConfig)
+}
+</script>

+ 92 - 0
src/views/mall/home/components/OperationDataCard.vue

@@ -0,0 +1,92 @@
+<template>
+  <el-card shadow="never">
+    <template #header>
+      <CardTitle title="运营数据" />
+    </template>
+    <div class="flex flex-row flex-wrap items-center gap-8 p-4">
+      <div
+        v-for="item in data"
+        :key="item.name"
+        class="h-20 w-20% flex flex-col cursor-pointer items-center justify-center gap-2"
+        @click="handleClick(item.routerName)"
+      >
+        <CountTo
+          :prefix="item.prefix"
+          :end-val="item.value"
+          :decimals="item.decimals"
+          class="text-3xl"
+        />
+        <span class="text-center">{{ item.name }}</span>
+      </div>
+    </div>
+  </el-card>
+</template>
+<script lang="ts" setup>
+import * as ProductSpuApi from '@/api/mall/product/spu'
+import * as TradeStatisticsApi from '@/api/mall/statistics/trade'
+import * as PayStatisticsApi from '@/api/mall/statistics/pay'
+import { CardTitle } from '@/components/Card'
+
+/** 运营数据卡片 */
+defineOptions({ name: 'OperationDataCard' })
+
+const router = useRouter() // 路由
+
+/** 数据 */
+const data = reactive({
+  orderUndelivered: { name: '待发货订单', value: 9, routerName: 'TradeOrder' },
+  orderAfterSaleApply: { name: '退款中订单', value: 4, routerName: 'TradeAfterSale' },
+  orderWaitePickUp: { name: '待核销订单', value: 0, routerName: 'TradeOrder' },
+  productAlertStock: { name: '库存预警', value: 0, routerName: 'ProductSpu' },
+  productForSale: { name: '上架商品', value: 0, routerName: 'ProductSpu' },
+  productInWarehouse: { name: '仓库商品', value: 0, routerName: 'ProductSpu' },
+  withdrawAuditing: { name: '提现待审核', value: 0, routerName: 'TradeBrokerageWithdraw' },
+  rechargePrice: {
+    name: '账户充值',
+    value: 0.0,
+    prefix: '¥',
+    decimals: 2,
+    routerName: 'PayWalletRecharge'
+  }
+})
+
+/** 查询订单数据 */
+const getOrderData = async () => {
+  const orderCount = await TradeStatisticsApi.getOrderCount()
+  data.orderUndelivered.value = orderCount.undelivered
+  data.orderAfterSaleApply.value = orderCount.afterSaleApply
+  data.orderWaitePickUp.value = orderCount.pickUp
+  data.withdrawAuditing.value = orderCount.auditingWithdraw
+}
+
+/** 查询商品数据 */
+const getProductData = async () => {
+  // TODO: @芋艿:这个接口的返回值,是不是用命名字段更好些?
+  const productCount = await ProductSpuApi.getTabsCount()
+  data.productForSale.value = productCount['0']
+  data.productInWarehouse.value = productCount['1']
+  data.productAlertStock.value = productCount['3']
+}
+
+/** 查询钱包充值数据 */
+const getWalletRechargeData = async () => {
+  const paySummary = await PayStatisticsApi.getWalletRechargePrice()
+  data.rechargePrice.value = paySummary.rechargePrice
+}
+
+/**
+ * 跳转到对应页面
+ *
+ * @param routerName 路由页面组件的名称
+ */
+const handleClick = (routerName: string) => {
+  router.push({ name: routerName })
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getOrderData()
+  getProductData()
+  getWalletRechargeData()
+})
+</script>

+ 91 - 0
src/views/mall/product/spu/form/OtherForm.vue

@@ -0,0 +1,91 @@
+<!-- 商品发布 - 其它设置 -->
+<template>
+  <el-form ref="formRef" :model="formData" :rules="rules" label-width="120px" :disabled="isDetail">
+    <el-form-item label="商品排序" prop="sort">
+      <el-input-number
+        v-model="formData.sort"
+        :min="0"
+        placeholder="请输入商品排序"
+        class="w-80!"
+      />
+    </el-form-item>
+    <el-form-item label="赠送积分" prop="giveIntegral">
+      <el-input-number
+        v-model="formData.giveIntegral"
+        :min="0"
+        placeholder="请输入赠送积分"
+        class="w-80!"
+      />
+    </el-form-item>
+    <el-form-item label="虚拟销量" prop="virtualSalesCount">
+      <el-input-number
+        v-model="formData.virtualSalesCount"
+        :min="0"
+        placeholder="请输入虚拟销量"
+        class="w-80!"
+      />
+    </el-form-item>
+  </el-form>
+</template>
+<script lang="ts" setup>
+import type { Spu } from '@/api/mall/product/spu'
+import { PropType } from 'vue'
+import { propTypes } from '@/utils/propTypes'
+import { copyValueToTarget } from '@/utils'
+
+defineOptions({ name: 'ProductOtherForm' })
+
+const message = useMessage() // 消息弹窗
+
+const props = defineProps({
+  propFormData: {
+    type: Object as PropType<Spu>,
+    default: () => {}
+  },
+  isDetail: propTypes.bool.def(false) // 是否作为详情组件
+})
+
+const formRef = ref() // 表单Ref
+// 表单数据
+const formData = ref<Spu>({
+  sort: 0, // 商品排序
+  giveIntegral: 0, // 赠送积分
+  virtualSalesCount: 0 // 虚拟销量
+})
+// 表单规则
+const rules = reactive({
+  sort: [required],
+  giveIntegral: [required],
+  virtualSalesCount: [required]
+})
+
+/** 将传进来的值赋值给 formData */
+watch(
+  () => props.propFormData,
+  (data) => {
+    if (!data) {
+      return
+    }
+    copyValueToTarget(formData.value, data)
+  },
+  {
+    immediate: true
+  }
+)
+
+/** 表单校验 */
+const emit = defineEmits(['update:activeName'])
+const validate = async () => {
+  if (!formRef) return
+  try {
+    await unref(formRef)?.validate()
+    // 校验通过更新数据
+    Object.assign(props.propFormData, formData.value)
+  } catch (e) {
+    message.error('【其它设置】不完善,请填写相关信息')
+    emit('update:activeName', 'other')
+    throw e // 目的截断之后的校验
+  }
+}
+defineExpose({ validate })
+</script>

BIN
src/views/mall/promotion/kefu/components/asserts/nanguo.png


+ 182 - 0
src/views/mall/promotion/kefu/components/message/OrderMessageItem.vue

@@ -0,0 +1,182 @@
+<template>
+  <!-- 图片消息 -->
+  <template v-if="KeFuMessageContentTypeEnum.ORDER === message.contentType">
+    <div
+      :class="[
+        message.senderType === UserTypeEnum.MEMBER
+          ? `ml-10px`
+          : message.senderType === UserTypeEnum.ADMIN
+            ? `mr-10px`
+            : ''
+      ]"
+    >
+      <div :key="getMessageContent.id" class="order-list-card-box mt-14px">
+        <div class="order-card-header flex items-center justify-between p-x-20px">
+          <div class="order-no">订单号:{{ getMessageContent.no }}</div>
+          <div :class="formatOrderColor(getMessageContent)" class="order-state font-26">
+            {{ formatOrderStatus(getMessageContent) }}
+          </div>
+        </div>
+        <div v-for="item in getMessageContent.items" :key="item.id" class="border-bottom">
+          <ProductItem
+            :num="item.count"
+            :picUrl="item.picUrl"
+            :price="item.price"
+            :skuText="item.properties.map((property: any) => property.valueName).join(' ')"
+            :title="item.spuName"
+          />
+        </div>
+        <div class="pay-box mt-30px flex justify-end pr-20px">
+          <div class="flex items-center">
+            <div class="discounts-title pay-color"
+              >共 {{ getMessageContent?.productCount }} 件商品,总金额:
+            </div>
+            <div class="discounts-money pay-color">
+              ¥{{ fenToYuan(getMessageContent?.payPrice) }}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </template>
+</template>
+
+<script lang="ts" setup>
+import { KeFuMessageContentTypeEnum } from '../tools/constants'
+import ProductItem from './ProductItem.vue'
+import { UserTypeEnum } from '@/utils/constants'
+import { KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message'
+import { fenToYuan } from '@/utils'
+
+defineOptions({ name: 'OrderMessageItem' })
+const props = defineProps<{
+  message: KeFuMessageRespVO
+}>()
+const getMessageContent = computed(() => JSON.parse(props.message.content))
+
+/**
+ * 格式化订单状态的颜色
+ *
+ * @param order 订单
+ * @return {string} 颜色的 class 名称
+ */
+function formatOrderColor(order: any) {
+  if (order.status === 0) {
+    return 'info-color'
+  }
+  if (order.status === 10 || order.status === 20 || (order.status === 30 && !order.commentStatus)) {
+    return 'warning-color'
+  }
+  if (order.status === 30 && order.commentStatus) {
+    return 'success-color'
+  }
+  return 'danger-color'
+}
+
+/**
+ * 格式化订单状态
+ *
+ * @param order 订单
+ */
+function formatOrderStatus(order: any) {
+  if (order.status === 0) {
+    return '待付款'
+  }
+  if (order.status === 10 && order.deliveryType === 1) {
+    return '待发货'
+  }
+  if (order.status === 10 && order.deliveryType === 2) {
+    return '待核销'
+  }
+  if (order.status === 20) {
+    return '待收货'
+  }
+  if (order.status === 30 && !order.commentStatus) {
+    return '待评价'
+  }
+  if (order.status === 30 && order.commentStatus) {
+    return '已完成'
+  }
+  return '已关闭'
+}
+</script>
+
+<style lang="scss" scoped>
+.order-list-card-box {
+  border-radius: 10px;
+  padding: 10px;
+  background-color: #e2e2e2;
+
+  .order-card-header {
+    height: 80px;
+
+    .order-no {
+      font-size: 26px;
+      font-weight: 500;
+    }
+  }
+
+  .pay-box {
+    .discounts-title {
+      font-size: 24px;
+      line-height: normal;
+      color: #999999;
+    }
+
+    .discounts-money {
+      font-size: 24px;
+      line-height: normal;
+      color: #999;
+      font-family: OPPOSANS;
+    }
+
+    .pay-color {
+      color: #333;
+    }
+  }
+
+  .order-card-footer {
+    height: 100px;
+
+    .more-item-box {
+      padding: 20px;
+
+      .more-item {
+        height: 60px;
+
+        .title {
+          font-size: 26px;
+        }
+      }
+    }
+
+    .more-btn {
+      color: #999999;
+      font-size: 24px;
+    }
+
+    .content {
+      width: 154px;
+      color: #333333;
+      font-size: 26px;
+      font-weight: 500;
+    }
+  }
+}
+
+.warning-color {
+  color: #faad14;
+}
+
+.danger-color {
+  color: #ff3000;
+}
+
+.success-color {
+  color: #52c41a;
+}
+
+.info-color {
+  color: #999999;
+}
+</style>

+ 263 - 0
src/views/mall/trade/order/components/OrderTableColumn.vue

@@ -0,0 +1,263 @@
+<template>
+  <el-table-column class-name="order-table-col">
+    <template #header>
+      <div class="flex items-center" style="width: 100%">
+        <div :style="{ width: orderTableHeadWidthList[0] + 'px' }" class="flex justify-center">
+          商品信息
+        </div>
+        <div :style="{ width: orderTableHeadWidthList[1] + 'px' }" class="flex justify-center">
+          单价(元)/数量
+        </div>
+        <div :style="{ width: orderTableHeadWidthList[2] + 'px' }" class="flex justify-center">
+          售后状态
+        </div>
+        <div :style="{ width: orderTableHeadWidthList[3] + 'px' }" class="flex justify-center">
+          实付金额(元)
+        </div>
+        <div :style="{ width: orderTableHeadWidthList[4] + 'px' }" class="flex justify-center">
+          买家/收货人
+        </div>
+        <div :style="{ width: orderTableHeadWidthList[5] + 'px' }" class="flex justify-center">
+          配送方式
+        </div>
+        <div :style="{ width: orderTableHeadWidthList[6] + 'px' }" class="flex justify-center">
+          订单状态
+        </div>
+        <div :style="{ width: orderTableHeadWidthList[7] + 'px' }" class="flex justify-center">
+          操作
+        </div>
+      </div>
+    </template>
+    <template #default="scope">
+      <el-table
+        :ref="setOrderTableRef"
+        :border="true"
+        :data="scope.row.items"
+        :header-cell-style="headerStyle"
+        :span-method="spanMethod"
+        style="width: 100%"
+      >
+        <el-table-column min-width="300" prop="spuName">
+          <template #header>
+            <div
+              class="mr-[20px] h-[35px] flex items-center pl-[10px] pr-[10px]"
+              style="background-color: #f7f7f7"
+            >
+              <span class="mr-20px">订单号:{{ scope.row.no }} </span>
+              <span class="mr-20px">下单时间:{{ formatDate(scope.row.createTime) }}</span>
+              <span>订单来源:</span>
+              <dict-tag :type="DICT_TYPE.TERMINAL" :value="scope.row.terminal" class="mr-20px" />
+              <span>支付方式:</span>
+              <dict-tag
+                v-if="scope.row.payChannelCode"
+                :type="DICT_TYPE.PAY_CHANNEL_CODE"
+                :value="scope.row.payChannelCode"
+                class="mr-20px"
+              />
+              <span v-else class="mr-20px">未支付</span>
+              <span v-if="scope.row.payTime" class="mr-20px">
+                支付时间:{{ formatDate(scope.row.payTime) }}
+              </span>
+              <span>订单类型:</span>
+              <dict-tag :type="DICT_TYPE.TRADE_ORDER_TYPE" :value="scope.row.type" />
+            </div>
+          </template>
+          <template #default="{ row }">
+            <div class="flex flex-wrap">
+              <div class="mb-[10px] mr-[10px] flex items-start">
+                <div class="mr-[10px]">
+                  <el-image
+                    :src="row.picUrl"
+                    class="!h-[45px] !w-[45px]"
+                    fit="contain"
+                    @click="imagePreview(row.picUrl)"
+                  >
+                    <template #error>
+                      <div class="image-slot">
+                        <icon icon="ep:picture" />
+                      </div>
+                    </template>
+                  </el-image>
+                </div>
+                <ElTooltip :content="row.spuName" placement="top">
+                  <span class="overflow-ellipsis max-h-[45px] overflow-hidden">
+                    {{ row.spuName }}
+                  </span>
+                </ElTooltip>
+              </div>
+              <el-tag
+                v-for="property in row.properties"
+                :key="property.propertyId"
+                class="mb-[10px] mr-[10px]"
+              >
+                {{ property.propertyName }}: {{ property.valueName }}
+              </el-tag>
+            </div>
+          </template>
+        </el-table-column>
+        <el-table-column label="商品原价*数量" prop="price" width="150">
+          <template #default="{ row }">
+            {{ floatToFixed2(row.price) }} 元 / {{ row.count }}
+          </template>
+        </el-table-column>
+        <el-table-column label="售后状态" prop="afterSaleStatus" width="120">
+          <template #default="{ row }">
+            <dict-tag
+              :type="DICT_TYPE.TRADE_ORDER_ITEM_AFTER_SALE_STATUS"
+              :value="row.afterSaleStatus"
+            />
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="实际支付" min-width="120" prop="payPrice">
+          <template #default>
+            {{ floatToFixed2(scope.row.payPrice) + '元' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="买家/收货人" min-width="160">
+          <template #default>
+            <!-- 快递发货  -->
+            <div
+              v-if="scope.row.deliveryType === DeliveryTypeEnum.EXPRESS.type"
+              class="flex flex-col"
+            >
+              <span>买家:{{ scope.row.user.nickname }}</span>
+              <span>
+                收货人:{{ scope.row.receiverName }} {{ scope.row.receiverMobile }}
+                {{ scope.row.receiverAreaName }} {{ scope.row.receiverDetailAddress }}
+              </span>
+            </div>
+            <!-- 自提  -->
+            <div
+              v-if="scope.row.deliveryType === DeliveryTypeEnum.PICK_UP.type"
+              class="flex flex-col"
+            >
+              <span>
+                门店名称:
+                {{ pickUpStoreList.find((p) => p.id === scope.row.pickUpStoreId)?.name }}
+              </span>
+              <span>
+                门店手机:
+                {{ pickUpStoreList.find((p) => p.id === scope.row.pickUpStoreId)?.phone }}
+              </span>
+              <span>
+                自提门店:
+                {{ pickUpStoreList.find((p) => p.id === scope.row.pickUpStoreId)?.detailAddress }}
+              </span>
+            </div>
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="配送方式" width="120">
+          <template #default>
+            <dict-tag :type="DICT_TYPE.TRADE_DELIVERY_TYPE" :value="scope.row.deliveryType" />
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="订单状态" width="120">
+          <template #default>
+            <dict-tag :type="DICT_TYPE.TRADE_ORDER_STATUS" :value="scope.row.status" />
+          </template>
+        </el-table-column>
+        <el-table-column align="center" fixed="right" label="操作" width="160">
+          <template #default>
+            <slot :row="scope.row"></slot>
+          </template>
+        </el-table-column>
+      </el-table>
+    </template>
+  </el-table-column>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE } from '@/utils/dict'
+import { DeliveryTypeEnum } from '@/utils/constants'
+import { formatDate } from '@/utils/formatTime'
+import { floatToFixed2 } from '@/utils'
+import * as TradeOrderApi from '@/api/mall/trade/order'
+import { OrderVO } from '@/api/mall/trade/order'
+import type { TableColumnCtx, TableInstance } from 'element-plus'
+import { createImageViewer } from '@/components/ImageViewer'
+import type { DeliveryPickUpStoreVO } from '@/api/mall/trade/delivery/pickUpStore'
+
+defineOptions({ name: 'OrderTableColumn' })
+
+const props = defineProps<{
+  list: OrderVO[]
+  pickUpStoreList: DeliveryPickUpStoreVO[]
+}>()
+
+const headerStyle = ({ row, columnIndex }: any) => {
+  // 表头第一行第一列占 8
+  if (columnIndex === 0) {
+    row[columnIndex].colSpan = 8
+  } else {
+    // 其余的不要
+    row[columnIndex].colSpan = 0
+    return {
+      display: 'none'
+    }
+  }
+}
+
+interface SpanMethodProps {
+  row: TradeOrderApi.OrderItemRespVO
+  column: TableColumnCtx<TradeOrderApi.OrderItemRespVO>
+  rowIndex: number
+  columnIndex: number
+}
+
+type spanMethodResp = number[] | { rowspan: number; colspan: number } | undefined
+const spanMethod = ({ row, rowIndex, columnIndex }: SpanMethodProps): spanMethodResp => {
+  const len = props.list.find(
+    (order) => order.items?.findIndex((item) => item.id === row.id) !== -1
+  )?.items?.length
+  // 要合并的列,从零开始
+  const colIndex = [3, 4, 5, 6, 7]
+  if (colIndex.includes(columnIndex)) {
+    // 除了第一行其余的不要
+    if (rowIndex !== 0) {
+      return {
+        rowspan: 0,
+        colspan: 0
+      }
+    }
+    // 动态合并行
+    return {
+      rowspan: len!,
+      colspan: 1
+    }
+  }
+}
+
+/** 解决 ref 在 v-for 中的获取问题*/
+const setOrderTableRef = (el: TableInstance) => {
+  if (!el) return
+  // 只要第一个表也就是开始的第一行
+  if (el.tableId !== 'el-table_2') {
+    return
+  }
+  tableHeadWidthAuto(el)
+}
+// 头部 col 宽度初始化
+const orderTableHeadWidthList = ref([300, 150, 120, 120, 160, 120, 120, 160])
+// 头部宽度自适应
+const tableHeadWidthAuto = (el: TableInstance) => {
+  const columns = el.store.states.columns.value
+  if (columns.length === 0) {
+    return
+  }
+  columns.forEach((col: TableColumnCtx<TableInstance>, index: number) => {
+    if (col.realWidth) {
+      orderTableHeadWidthList.value[index] = col.realWidth
+    }
+  })
+}
+/** 商品图预览 */
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    urlList: [imgUrl]
+  })
+}
+</script>
+<style lang="scss" scoped>
+:deep(.order-table-col > .cell) {
+  padding: 0;
+}
+</style>

+ 99 - 0
src/views/mall/trade/order/form/OrderDeliveryForm.vue

@@ -0,0 +1,99 @@
+<template>
+  <Dialog v-model="dialogVisible" title="订单发货" width="25%">
+    <el-form ref="formRef" v-loading="formLoading" :model="formData" label-width="80px">
+      <el-form-item label="发货方式">
+        <el-radio-group v-model="expressType">
+          <el-radio border label="express">快递物流</el-radio>
+          <el-radio border label="none">无需发货</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <template v-if="expressType === 'express'">
+        <el-form-item label="物流公司">
+          <el-select v-model="formData.logisticsId" placeholder="请选择" style="width: 100%">
+            <el-option
+              v-for="item in deliveryExpressList"
+              :key="item.id"
+              :label="item.name"
+              :value="item.id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="物流单号">
+          <el-input v-model="formData.logisticsNo" />
+        </el-form-item>
+      </template>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as DeliveryExpressApi from '@/api/mall/trade/delivery/express'
+import * as TradeOrderApi from '@/api/mall/trade/order'
+import { copyValueToTarget } from '@/utils'
+
+defineOptions({ name: 'OrderDeliveryForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const expressType = ref('express') // 如果值是 express,则是快递;none 则是无;未来做同城配送;
+const formData = ref<TradeOrderApi.DeliveryVO>({
+  id: undefined, // 订单编号
+  logisticsId: null, // 物流公司编号
+  logisticsNo: '' // 物流编号
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (row: TradeOrderApi.OrderVO) => {
+  resetForm()
+  // 设置数据
+  copyValueToTarget(formData.value, row)
+  if (row.logisticsId === 0) {
+    expressType.value = 'none'
+  }
+  dialogVisible.value = true
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = unref(formData)
+    if (expressType.value === 'none') {
+      // 无需发货的情况
+      data.logisticsId = 0
+      data.logisticsNo = ''
+    }
+    await TradeOrderApi.deliveryOrder(data)
+    message.success(t('common.updateSuccess'))
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success', true)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined, // 订单编号
+    logisticsId: null, // 物流公司编号
+    logisticsNo: '' // 物流编号
+  }
+  formRef.value?.resetFields()
+}
+const deliveryExpressList = ref([])
+onMounted(async () => {
+  deliveryExpressList.value = await DeliveryExpressApi.getSimpleDeliveryExpressList()
+})
+</script>

+ 108 - 0
src/views/mall/trade/order/form/OrderPickUpForm.vue

@@ -0,0 +1,108 @@
+<template>
+  <!-- 核销对话框 -->
+  <Dialog v-model="dialogVisible" title="订单核销" width="35%">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+    >
+      <el-form-item prop="pickUpVerifyCode" label="核销码">
+        <el-input v-model="formData.pickUpVerifyCode" placeholder="请输入核销码" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button type="primary" :disabled="formLoading" @click="getOrderByPickUpVerifyCode">
+        查询
+      </el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+  <!-- 核销确认对话框 -->
+  <Dialog v-model="detailDialogVisible" title="订单详情" width="55%">
+    <TradeOrderDetail v-if="orderDetails.id" :id="orderDetails.id" :show-pick-up="false" />
+    <template #footer>
+      <el-button type="primary" :disabled="formLoading" @click="submitForm"> 确认核销 </el-button>
+      <el-button @click="detailDialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as TradeOrderApi from '@/api/mall/trade/order'
+import { OrderVO } from '@/api/mall/trade/order'
+import { DeliveryTypeEnum, TradeOrderStatusEnum } from '@/utils/constants'
+import TradeOrderDetail from '@/views/mall/trade/order/detail/index.vue'
+
+/** 订单核销表单 */
+defineOptions({ name: 'OrderPickUpForm' })
+
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const detailDialogVisible = ref(false) // 详情弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formRules = reactive({
+  pickUpVerifyCode: [{ required: true, message: '核销码不能为空', trigger: 'blur' }]
+})
+const formData = ref({
+  pickUpVerifyCode: '' // 核销码
+})
+const formRef = ref() // 表单 Ref
+const orderDetails = ref<OrderVO>({})
+
+/** 打开弹窗 */
+const open = async () => {
+  resetForm()
+  dialogVisible.value = true
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 提交请求
+  formLoading.value = true
+  try {
+    await TradeOrderApi.pickUpOrderByVerifyCode(formData.value.pickUpVerifyCode)
+    message.success('核销成功')
+    detailDialogVisible.value = false
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success', true)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    pickUpVerifyCode: '' // 核销码
+  }
+  formRef.value?.resetFields()
+}
+
+/** 查询核销码对应的订单 */
+const getOrderByPickUpVerifyCode = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+
+  formLoading.value = true
+  const data = await TradeOrderApi.getOrderByPickUpVerifyCode(formData.value.pickUpVerifyCode)
+  formLoading.value = false
+  if (data?.deliveryType !== DeliveryTypeEnum.PICK_UP.type) {
+    message.error('请输入正确的核销码')
+    return
+  }
+  if (data?.status !== TradeOrderStatusEnum.UNDELIVERED.status) {
+    message.error('订单不是待核销状态')
+    return
+  }
+  orderDetails.value = data
+  // 显示详情对话框
+  detailDialogVisible.value = true
+}
+</script>

+ 98 - 0
src/views/mall/trade/order/form/OrderUpdateAddressForm.vue

@@ -0,0 +1,98 @@
+<template>
+  <Dialog v-model="dialogVisible" title="修改订单收货地址" width="35%">
+    <el-form ref="formRef" v-loading="formLoading" :model="formData" label-width="120px">
+      <el-form-item label="收件人">
+        <el-input v-model="formData.receiverName" placeholder="请输入收件人名称" />
+      </el-form-item>
+      <el-form-item label="手机号">
+        <el-input v-model="formData.receiverMobile" placeholder="请输入收件人手机号" />
+      </el-form-item>
+      <el-form-item label="所在地">
+        <el-tree-select
+          v-model="formData.receiverAreaId"
+          :data="areaList"
+          :props="defaultProps"
+          :render-after-expand="true"
+        />
+      </el-form-item>
+      <el-form-item label="详细地址">
+        <el-input
+          v-model="formData.receiverDetailAddress"
+          :rows="3"
+          placeholder="请输入收件人详细地址"
+          type="textarea"
+        />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as TradeOrderApi from '@/api/mall/trade/order'
+import { getAreaTree } from '@/api/system/area'
+import { copyValueToTarget } from '@/utils'
+import { defaultProps } from '@/utils/tree'
+
+defineOptions({ name: 'OrderUpdateAddressForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref({
+  id: undefined, // 订单编号
+  receiverName: '', // 收件人名称
+  receiverMobile: '', // 收件人手机
+  receiverAreaId: null, //收件人地区编号
+  receiverDetailAddress: '' //收件人详细地址
+})
+const areaList = ref([]) // 地区列表
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (row: TradeOrderApi.OrderVO) => {
+  resetForm()
+  // 设置数据
+  copyValueToTarget(formData.value, row)
+  dialogVisible.value = true
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = unref(formData)
+    await TradeOrderApi.updateOrderAddress(data)
+    message.success(t('common.updateSuccess'))
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success', true)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined, // 订单编号
+    receiverName: '', // 收件人名称
+    receiverMobile: '', // 收件人手机
+    receiverAreaId: null, //收件人地区编号
+    receiverDetailAddress: '' //收件人详细地址
+  }
+  formRef.value?.resetFields()
+}
+
+onMounted(async () => {
+  // 获得地区列表
+  areaList.value = await getAreaTree()
+})
+</script>

+ 95 - 0
src/views/mall/trade/order/form/OrderUpdatePriceForm.vue

@@ -0,0 +1,95 @@
+<template>
+  <Dialog v-model="dialogVisible" title="订单调价" width="25%">
+    <el-form ref="formRef" v-loading="formLoading" :model="formData" label-width="100px">
+      <el-form-item label="应付金额(总)">
+        <el-input v-model="formData.payPrice" disabled />
+      </el-form-item>
+      <el-form-item label="订单调价">
+        <el-input-number v-model="formData.adjustPrice" :precision="2" :step="0.1" class="w-100%" />
+        <el-tag class="ml-10px" type="warning">订单调价。 正数,加价;负数,减价</el-tag>
+      </el-form-item>
+      <el-form-item label="调价后">
+        <el-input v-model="formData.newPayPrice" disabled />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as TradeOrderApi from '@/api/mall/trade/order'
+import { convertToInteger, floatToFixed2, formatToFraction } from '@/utils'
+import { cloneDeep } from 'lodash-es'
+
+defineOptions({ name: 'OrderUpdatePriceForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref({
+  id: undefined, // 订单编号
+  adjustPrice: 0, // 订单调价
+  payPrice: '', // 应付金额(总)
+  newPayPrice: '' // 调价后应付金额(总)
+})
+watch(
+  () => formData.value.adjustPrice,
+  (adjustPrice: number | string) => {
+    const numMatch = formData.value.payPrice.match(/\d+(\.\d+)?/)
+    if (numMatch) {
+      const payPriceNum = parseFloat(numMatch[0])
+      adjustPrice = typeof adjustPrice === 'string' ? parseFloat(adjustPrice) : adjustPrice
+      formData.value.newPayPrice = (payPriceNum + adjustPrice).toFixed(2) + '元'
+    }
+  }
+)
+
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (row: TradeOrderApi.OrderVO) => {
+  resetForm()
+  formData.value.id = row.id!
+  // 设置数据
+  formData.value.adjustPrice = formatToFraction(row.adjustPrice!)
+  formData.value.payPrice = floatToFixed2(row.payPrice!) + '元'
+  formData.value.newPayPrice = formData.value.payPrice
+  dialogVisible.value = true
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = cloneDeep(unref(formData))
+    data.adjustPrice = convertToInteger(data.adjustPrice)
+    delete data.payPrice
+    delete data.newPayPrice
+    await TradeOrderApi.updateOrderPrice(data)
+    message.success(t('common.updateSuccess'))
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success', true)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined, // 订单编号
+    adjustPrice: 0, // 订单调价
+    payPrice: '', // 应付金额(总)
+    newPayPrice: '' // 调价后应付金额(总)
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 70 - 0
src/views/mall/trade/order/form/OrderUpdateRemarkForm.vue

@@ -0,0 +1,70 @@
+<template>
+  <Dialog v-model="dialogVisible" title="商家备注" width="45%">
+    <el-form ref="formRef" v-loading="formLoading" :model="formData" label-width="80px">
+      <el-form-item label="备注">
+        <el-input
+          v-model="formData.remark"
+          :rows="3"
+          placeholder="请输入订单备注"
+          type="textarea"
+        />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as TradeOrderApi from '@/api/mall/trade/order'
+
+defineOptions({ name: 'OrderUpdateRemarkForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref({
+  id: undefined, // 订单编号
+  remark: '' // 订单备注
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (row: TradeOrderApi.OrderVO) => {
+  resetForm()
+  // 设置数据
+  formData.value.id = row.id
+  formData.value.remark = row.remark
+  dialogVisible.value = true
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = unref(formData)
+    await TradeOrderApi.updateOrderRemark(data)
+    message.success(t('common.updateSuccess'))
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success', true)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined, // 订单编号
+    remark: '' // 订单备注
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 69 - 0
src/views/mp/components/wx-msg/components/Msg.vue

@@ -0,0 +1,69 @@
+<template>
+  <div>
+    <MsgEvent v-if="item.type === MsgType.Event" :item="item" />
+
+    <div v-else-if="item.type === MsgType.Text">{{ item.content }}</div>
+
+    <div v-else-if="item.type === MsgType.Voice">
+      <WxVoicePlayer :url="item.mediaUrl" :content="item.recognition" />
+    </div>
+
+    <div v-else-if="item.type === MsgType.Image">
+      <a target="_blank" :href="item.mediaUrl">
+        <img :src="item.mediaUrl" style="width: 100px" />
+      </a>
+    </div>
+
+    <div
+      v-else-if="item.type === MsgType.Video || item.type === 'shortvideo'"
+      style="text-align: center"
+    >
+      <WxVideoPlayer :url="item.mediaUrl" />
+    </div>
+
+    <div v-else-if="item.type === MsgType.Link" class="avue-card__detail">
+      <el-link type="success" :underline="false" target="_blank" :href="item.url">
+        <div class="avue-card__title"><i class="el-icon-link"></i>{{ item.title }}</div>
+      </el-link>
+      <div class="avue-card__info" style="height: unset">{{ item.description }}</div>
+    </div>
+
+    <div v-else-if="item.type === MsgType.Location">
+      <WxLocation :label="item.label" :location-y="item.locationY" :location-x="item.locationX" />
+    </div>
+
+    <div v-else-if="item.type === MsgType.News" style="width: 300px">
+      <WxNews :articles="item.articles" />
+    </div>
+
+    <div v-else-if="item.type === MsgType.Music">
+      <WxMusic
+        :title="item.title"
+        :description="item.description"
+        :thumb-media-url="item.thumbMediaUrl"
+        :music-url="item.musicUrl"
+        :hq-music-url="item.hqMusicUrl"
+      />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import WxVideoPlayer from '@/views/mp/components/wx-video-play'
+import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
+import WxNews from '@/views/mp/components/wx-news'
+import WxLocation from '@/views/mp/components/wx-location'
+import WxMusic from '@/views/mp/components/wx-music'
+import MsgEvent from './MsgEvent.vue'
+import { MsgType } from '../types'
+
+defineOptions({ name: 'Msg' })
+
+const props = defineProps<{
+  item: any
+}>()
+
+const item = ref<any>(props.item)
+</script>
+
+<style scoped></style>

+ 49 - 0
src/views/mp/components/wx-msg/components/MsgEvent.vue

@@ -0,0 +1,49 @@
+<template>
+  <div>
+    <div v-if="item.event === 'subscribe'">
+      <el-tag type="success">关注</el-tag>
+    </div>
+    <div v-else-if="item.event === 'unsubscribe'">
+      <el-tag type="danger">取消关注</el-tag>
+    </div>
+    <div v-else-if="item.event === 'CLICK'">
+      <el-tag>点击菜单</el-tag>
+      【{{ item.eventKey }}】
+    </div>
+    <div v-else-if="item.event === 'VIEW'">
+      <el-tag>点击菜单链接</el-tag>
+      【{{ item.eventKey }}】
+    </div>
+    <div v-else-if="item.event === 'scancode_waitmsg'">
+      <el-tag>扫码结果</el-tag>
+      【{{ item.eventKey }}】
+    </div>
+    <div v-else-if="item.event === 'scancode_push'">
+      <el-tag>扫码结果</el-tag>
+      【{{ item.eventKey }}】
+    </div>
+    <div v-else-if="item.event === 'pic_sysphoto'">
+      <el-tag>系统拍照发图</el-tag>
+    </div>
+    <div v-else-if="item.event === 'pic_photo_or_album'">
+      <el-tag>拍照或者相册</el-tag>
+    </div>
+    <div v-else-if="item.event === 'pic_weixin'">
+      <el-tag>微信相册</el-tag>
+    </div>
+    <div v-else-if="item.event === 'location_select'">
+      <el-tag>选择地理位置</el-tag>
+    </div>
+    <div v-else>
+      <el-tag type="danger">未知事件类型</el-tag>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+const props = defineProps<{
+  item: any
+}>()
+
+const item = ref(props.item)
+</script>

+ 62 - 0
src/views/mp/components/wx-msg/components/MsgList.vue

@@ -0,0 +1,62 @@
+<template>
+  <div class="execution" v-for="item in props.list" :key="item.id">
+    <div
+      class="avue-comment"
+      :class="{ 'avue-comment--reverse': item.sendFrom === SendFrom.MpBot }"
+    >
+      <div class="avatar-div">
+        <img :src="getAvatar(item.sendFrom)" class="avue-comment__avatar" />
+        <div class="avue-comment__author">
+          {{ getNickname(item.sendFrom) }}
+        </div>
+      </div>
+      <div class="avue-comment__main">
+        <div class="avue-comment__header">
+          <div class="avue-comment__create_time">{{ formatDate(item.createTime) }}</div>
+        </div>
+        <div
+          class="avue-comment__body"
+          :style="item.sendFrom === SendFrom.MpBot ? 'background: #6BED72;' : ''"
+        >
+          <Msg :item="item" />
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+<script lang="ts" setup>
+import Msg from './Msg.vue'
+import { formatDate } from '@/utils/formatTime'
+import { User } from '../types'
+import avatarWechat from '@/assets/imgs/wechat.png'
+
+defineOptions({ name: 'MsgList' })
+
+const props = defineProps<{
+  list: any[]
+  accountId: number
+  user: User
+}>()
+
+enum SendFrom {
+  User = 1,
+  MpBot = 2
+}
+
+const getAvatar = (sendFrom: SendFrom) =>
+  sendFrom === SendFrom.User ? props.user.avatar : avatarWechat
+
+const getNickname = (sendFrom: SendFrom) =>
+  sendFrom === SendFrom.User ? props.user.nickname : '公众号'
+</script>
+
+<style lang="scss" scoped>
+/* 因为 joolun 实现依赖 avue 组件,该页面使用了 comment.scss、card.scc  */
+@import url('../comment.scss');
+@import url('../card.scss');
+
+.avatar-div {
+  width: 80px;
+  text-align: center;
+}
+</style>

+ 304 - 0
src/views/mp/draft/components/NewsForm.vue

@@ -0,0 +1,304 @@
+<template>
+  <el-container>
+    <el-aside width="40%">
+      <div class="select-item">
+        <div v-for="(news, index) in newsList" :key="index">
+          <div
+            class="news-main father"
+            v-if="index === 0"
+            :class="{ activeAddNews: activeNewsIndex === index }"
+            @click="activeNewsIndex = index"
+          >
+            <div class="news-content">
+              <img class="material-img" :src="news.thumbUrl" />
+              <div class="news-content-title">{{ news.title }}</div>
+            </div>
+            <div class="child" v-if="newsList.length > 1">
+              <el-button type="info" circle size="small" @click="() => moveDownNews(index)">
+                <Icon icon="ep:arrow-down-bold" />
+              </el-button>
+              <el-button
+                v-if="isCreating"
+                type="danger"
+                circle
+                size="small"
+                @click="() => removeNews(index)"
+              >
+                <Icon icon="ep:delete" />
+              </el-button>
+            </div>
+          </div>
+          <div
+            class="news-main-item father"
+            v-if="index > 0"
+            :class="{ activeAddNews: activeNewsIndex === index }"
+            @click="activeNewsIndex = index"
+          >
+            <div class="news-content-item">
+              <div class="news-content-item-title">{{ news.title }}</div>
+              <div class="news-content-item-img">
+                <img class="material-img" :src="news.thumbUrl" width="100%" />
+              </div>
+            </div>
+            <div class="child">
+              <el-button
+                v-if="newsList.length > index + 1"
+                circle
+                type="info"
+                size="small"
+                @click="() => moveDownNews(index)"
+              >
+                <Icon icon="ep:arrow-down-bold" />
+              </el-button>
+              <el-button
+                v-if="index > 0"
+                type="info"
+                circle
+                size="small"
+                @click="() => moveUpNews(index)"
+              >
+                <Icon icon="ep:arrow-up-bold" />
+              </el-button>
+              <el-button
+                v-if="isCreating"
+                type="danger"
+                size="small"
+                circle
+                @click="() => removeNews(index)"
+              >
+                <Icon icon="ep:delete" />
+              </el-button>
+            </div>
+          </div>
+        </div>
+        <el-row justify="center" class="ope-row">
+          <el-button
+            type="primary"
+            circle
+            @click="plusNews"
+            v-if="newsList.length < 8 && isCreating"
+          >
+            <Icon icon="ep:plus" />
+          </el-button>
+        </el-row>
+      </div>
+    </el-aside>
+    <el-main>
+      <div v-if="newsList.length > 0">
+        <!-- 标题、作者、原文地址 -->
+        <el-row :gutter="20">
+          <el-input v-model="activeNewsItem.title" placeholder="请输入标题(必填)" />
+          <el-input
+            v-model="activeNewsItem.author"
+            placeholder="请输入作者"
+            style="margin-top: 5px"
+          />
+          <el-input
+            v-model="activeNewsItem.contentSourceUrl"
+            placeholder="请输入原文地址"
+            style="margin-top: 5px"
+          />
+        </el-row>
+        <!-- 封面和摘要 -->
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <CoverSelect v-model="activeNewsItem" :is-first="activeNewsIndex === 0" />
+          </el-col>
+          <el-col :span="12">
+            <p>摘要:</p>
+            <el-input
+              :rows="8"
+              type="textarea"
+              v-model="activeNewsItem.digest"
+              placeholder="请输入摘要"
+              class="digest"
+              maxlength="120"
+            />
+          </el-col>
+        </el-row>
+        <!--富文本编辑器组件-->
+        <el-row>
+          <Editor v-model="activeNewsItem.content" :editor-config="editorConfig" />
+        </el-row>
+      </div>
+    </el-main>
+  </el-container>
+</template>
+
+<script lang="ts" setup>
+import { Editor } from '@/components/Editor'
+import { createEditorConfig } from '../editor-config'
+import CoverSelect from './CoverSelect.vue'
+import { type NewsItem, createEmptyNewsItem } from './types'
+
+defineOptions({ name: 'NewsForm' })
+
+const message = useMessage()
+
+const props = defineProps<{
+  isCreating: boolean
+  modelValue: NewsItem[] | null
+}>()
+
+const accountId = inject<number>('accountId')
+
+// ========== 文件上传 ==========
+const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-permanent' // 上传永久素材的地址
+const editorConfig = createEditorConfig(UPLOAD_URL, accountId)
+
+// v-model=newsList
+const emit = defineEmits<{
+  (e: 'update:modelValue', v: NewsItem[])
+}>()
+const newsList = computed<NewsItem[]>({
+  get() {
+    return props.modelValue === null ? [createEmptyNewsItem()] : props.modelValue
+  },
+  set(val) {
+    emit('update:modelValue', val)
+  }
+})
+
+const activeNewsIndex = ref(0)
+const activeNewsItem = computed<NewsItem>(() => newsList.value[activeNewsIndex.value])
+
+// 将图文向下移动
+const moveDownNews = (index: number) => {
+  const temp = newsList.value[index]
+  newsList.value[index] = newsList.value[index + 1]
+  newsList.value[index + 1] = temp
+  activeNewsIndex.value = index + 1
+}
+
+// 将图文向上移动
+const moveUpNews = (index: number) => {
+  const temp = newsList.value[index]
+  newsList.value[index] = newsList.value[index - 1]
+  newsList.value[index - 1] = temp
+  activeNewsIndex.value = index - 1
+}
+
+// 删除指定 index 的图文
+const removeNews = async (index: number) => {
+  try {
+    await message.confirm('确定删除该图文吗?')
+    newsList.value.splice(index, 1)
+    if (activeNewsIndex.value === index) {
+      activeNewsIndex.value = 0
+    }
+  } catch {}
+}
+
+// 添加一个图文
+const plusNews = () => {
+  newsList.value.push(createEmptyNewsItem())
+  activeNewsIndex.value = newsList.value.length - 1
+}
+</script>
+
+<style lang="scss" scoped>
+.ope-row {
+  padding-top: 5px;
+  margin-top: 5px;
+  text-align: center;
+  border-top: 1px solid #eaeaea;
+}
+
+.el-row {
+  margin-bottom: 20px;
+}
+
+.el-row:last-child {
+  margin-bottom: 0;
+}
+
+.digest {
+  display: inline-block;
+  width: 100%;
+  vertical-align: top;
+}
+
+/* 新增图文 */
+.news-main {
+  width: 100%;
+  height: 120px;
+  margin: auto;
+  background-color: #fff;
+}
+
+.news-content {
+  position: relative;
+  width: 100%;
+  height: 120px;
+  background-color: #acadae;
+}
+
+.news-content-title {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  display: inline-block;
+  width: 98%;
+  height: 25px;
+  padding: 1%;
+  overflow: hidden;
+  font-size: 15px;
+  color: #fff;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  background-color: black;
+  opacity: 0.65;
+}
+
+.news-main-item {
+  width: 100%;
+  padding: 5px 0;
+  margin: auto;
+  background-color: #fff;
+  border-top: 1px solid #eaeaea;
+}
+
+.news-content-item {
+  position: relative;
+  margin-left: -3px;
+}
+
+.news-content-item-title {
+  display: inline-block;
+  width: 70%;
+  font-size: 12px;
+}
+
+.news-content-item-img {
+  display: inline-block;
+  width: 25%;
+  background-color: #acadae;
+}
+
+.select-item {
+  width: 60%;
+  padding: 10px;
+  margin: 0 auto 10px;
+  border: 1px solid #eaeaea;
+
+  .activeAddNews {
+    border: 5px solid #2bb673;
+  }
+}
+
+.father .child {
+  position: relative;
+  bottom: 25px;
+  display: none;
+  text-align: center;
+}
+
+.father:hover .child {
+  display: block;
+}
+
+.material-img {
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 86 - 0
src/views/pay/notify/NotifyDetail.vue

@@ -0,0 +1,86 @@
+<template>
+  <Dialog v-model="dialogVisible" title="通知详情" width="50%">
+    <el-descriptions :column="2">
+      <el-descriptions-item label="商户订单编号">
+        <el-tag>{{ detailData.merchantOrderId }}</el-tag>
+      </el-descriptions-item>
+      <el-descriptions-item label="通知状态">
+        <dict-tag :type="DICT_TYPE.PAY_NOTIFY_STATUS" :value="detailData.status" />
+      </el-descriptions-item>
+
+      <el-descriptions-item label="应用编号">{{ detailData.appId }}</el-descriptions-item>
+      <el-descriptions-item label="应用名称">{{ detailData.appName }}</el-descriptions-item>
+
+      <el-descriptions-item label="关联编号">{{ detailData.dataId }}</el-descriptions-item>
+      <el-descriptions-item label="通知类型">
+        <dict-tag :type="DICT_TYPE.PAY_NOTIFY_TYPE" :value="detailData.type" />
+      </el-descriptions-item>
+
+      <el-descriptions-item label="通知次数">{{ detailData.notifyTimes }}</el-descriptions-item>
+      <el-descriptions-item label="最大通知次数">
+        {{ detailData.maxNotifyTimes }}
+      </el-descriptions-item>
+
+      <el-descriptions-item label="最后通知时间">
+        {{ formatDate(detailData.lastExecuteTime) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="下次通知时间">
+        {{ formatDate(detailData.nextNotifyTime) }}
+      </el-descriptions-item>
+
+      <el-descriptions-item label="创建时间">
+        {{ formatDate(detailData.createTime) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="更新时间">
+        {{ formatDate(detailData.updateTime) }}
+      </el-descriptions-item>
+    </el-descriptions>
+
+    <!-- 分割线 -->
+    <el-divider />
+
+    <el-descriptions :column="1" direction="vertical" border>
+      <el-descriptions-item label="回调日志">
+        <el-table :data="detailData.logs">
+          <el-table-column label="日志编号" align="center" prop="id" />
+          <el-table-column label="通知状态" align="center" prop="status">
+            <template #default="scope">
+              <dict-tag :type="DICT_TYPE.PAY_NOTIFY_STATUS" :value="scope.row.status" />
+            </template>
+          </el-table-column>
+          <el-table-column label="通知次数" align="center" prop="notifyTimes" />
+          <el-table-column label="通知时间" align="center" prop="lastExecuteTime" width="180">
+            <template #default="scope">
+              <span>{{ formatDate(scope.row.createTime) }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="响应结果" align="center" prop="response" />
+        </el-table>
+      </el-descriptions-item>
+    </el-descriptions>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE } from '@/utils/dict'
+import * as PayNotifyApi from '@/api/pay/notify'
+import { formatDate } from '@/utils/formatTime'
+
+defineOptions({ name: 'PayNotifyDetail' })
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const detailLoading = ref(false) // 表单的加载中
+const detailData = ref({})
+
+/** 打开弹窗 */
+const open = async (id: number) => {
+  dialogVisible.value = true
+  // 设置数据
+  detailLoading.value = true
+  try {
+    detailData.value = await PayNotifyApi.getNotifyTaskDetail(id)
+  } finally {
+    detailLoading.value = false
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+</script>

+ 111 - 0
src/views/pay/order/OrderDetail.vue

@@ -0,0 +1,111 @@
+<template>
+  <Dialog v-model="dialogVisible" title="订单详情" width="700px">
+    <el-descriptions :column="2" label-class-name="desc-label">
+      <el-descriptions-item label="商户单号">
+        <el-tag size="small">{{ detailData.merchantOrderId }}</el-tag>
+      </el-descriptions-item>
+      <el-descriptions-item label="支付单号">
+        <el-tag type="warning" size="small" v-if="detailData.no">{{ detailData.no }}</el-tag>
+      </el-descriptions-item>
+      <el-descriptions-item label="应用编号">{{ detailData.appId }}</el-descriptions-item>
+      <el-descriptions-item label="应用名称">{{ detailData.appName }}</el-descriptions-item>
+      <el-descriptions-item label="支付状态">
+        <dict-tag :type="DICT_TYPE.PAY_ORDER_STATUS" :value="detailData.status" size="small" />
+      </el-descriptions-item>
+      <el-descriptions-item label="支付金额">
+        <el-tag type="success" size="small">¥{{ (detailData.price / 100.0).toFixed(2) }}</el-tag>
+      </el-descriptions-item>
+      <el-descriptions-item label="手续费">
+        <el-tag type="warning" size="small">
+          ¥{{ (detailData.channelFeePrice / 100.0).toFixed(2) }}
+        </el-tag>
+      </el-descriptions-item>
+      <el-descriptions-item label="手续费比例">
+        {{ (detailData.channelFeeRate / 100.0).toFixed(2) }}%
+      </el-descriptions-item>
+      <el-descriptions-item label="支付时间">
+        {{ formatDate(detailData.successTime) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="失效时间">
+        {{ formatDate(detailData.expireTime) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="创建时间">
+        {{ formatDate(detailData.createTime) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="更新时间">
+        {{ formatDate(detailData.updateTime) }}
+      </el-descriptions-item>
+    </el-descriptions>
+    <!-- 分割线 -->
+    <el-divider />
+    <el-descriptions :column="2" label-class-name="desc-label">
+      <el-descriptions-item label="商品标题">{{ detailData.subject }}</el-descriptions-item>
+      <el-descriptions-item label="商品描述">{{ detailData.body }}</el-descriptions-item>
+      <el-descriptions-item label="支付渠道">
+        <dict-tag :type="DICT_TYPE.PAY_CHANNEL_CODE" :value="detailData.channelCode" />
+      </el-descriptions-item>
+      <el-descriptions-item label="支付 IP">{{ detailData.userIp }}</el-descriptions-item>
+      <el-descriptions-item label="渠道单号">
+        <el-tag size="mini" type="success" v-if="detailData.channelOrderNo">
+          {{ detailData.channelOrderNo }}
+        </el-tag>
+      </el-descriptions-item>
+      <el-descriptions-item label="渠道用户">{{ detailData.channelUserId }}</el-descriptions-item>
+      <el-descriptions-item label="退款金额">
+        <el-tag size="mini" type="danger">
+          ¥{{ (detailData.refundPrice / 100.0).toFixed(2) }}
+        </el-tag>
+      </el-descriptions-item>
+      <el-descriptions-item label="通知 URL">{{ detailData.notifyUrl }}</el-descriptions-item>
+    </el-descriptions>
+    <!-- 分割线 -->
+    <el-divider />
+    <el-descriptions :column="1" label-class-name="desc-label" direction="vertical" border>
+      <el-descriptions-item label="支付通道异步回调内容">
+        <el-text>{{ detailData.extension.channelNotifyData }}</el-text>
+      </el-descriptions-item>
+    </el-descriptions>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE } from '@/utils/dict'
+import * as OrderApi from '@/api/pay/order'
+import { formatDate } from '@/utils/formatTime'
+
+defineOptions({ name: 'PayOrderDetail' })
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const detailLoading = ref(false) // 表单的加载中
+const detailData = ref({
+  extension: {}
+})
+
+/** 打开弹窗 */
+const open = async (id: number) => {
+  dialogVisible.value = true
+  // 设置数据
+  detailLoading.value = true
+  try {
+    detailData.value = await OrderApi.getOrderDetail(id)
+    if (!detailData.value.extension) {
+      detailData.value.extension = {}
+    }
+  } finally {
+    detailLoading.value = false
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+</script>
+<style>
+.tag-purple {
+  color: #722ed1;
+  background: #f9f0ff;
+  border-color: #d3adf7;
+}
+
+.tag-pink {
+  color: #eb2f96;
+  background: #fff0f6;
+  border-color: #ffadd2;
+}
+</style>

+ 132 - 0
src/views/system/notice/NoticeForm.vue

@@ -0,0 +1,132 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle" width="800">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="80px"
+    >
+      <el-form-item label="公告标题" prop="title">
+        <el-input v-model="formData.title" placeholder="请输入公告标题" />
+      </el-form-item>
+      <el-form-item label="公告内容" prop="content">
+        <Editor v-model="formData.content" height="150px" />
+      </el-form-item>
+      <el-form-item label="公告类型" prop="type">
+        <el-select v-model="formData.type" clearable placeholder="请选择公告类型">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_NOTICE_TYPE)"
+            :key="parseInt(dict.value as any)"
+            :label="dict.label"
+            :value="parseInt(dict.value as any)"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select v-model="formData.status" clearable placeholder="请选择状态">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="parseInt(dict.value as any)"
+            :label="dict.label"
+            :value="parseInt(dict.value as any)"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="备注" prop="remark">
+        <el-input v-model="formData.remark" placeholder="请输备注" type="textarea" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { CommonStatusEnum } from '@/utils/constants'
+import * as NoticeApi from '@/api/system/notice'
+
+defineOptions({ name: 'SystemNoticeForm' })
+
+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,
+  title: '',
+  type: undefined,
+  content: '',
+  status: CommonStatusEnum.ENABLE,
+  remark: ''
+})
+const formRules = reactive({
+  title: [{ required: true, message: '公告标题不能为空', trigger: 'blur' }],
+  type: [{ required: true, message: '公告类型不能为空', trigger: 'change' }],
+  status: [{ required: true, message: '状态不能为空', trigger: 'change' }],
+  content: [{ required: true, message: '公告内容不能为空', 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 NoticeApi.getNotice(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as NoticeApi.NoticeVO
+    if (formType.value === 'create') {
+      await NoticeApi.createNotice(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await NoticeApi.updateNotice(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    title: '',
+    type: undefined,
+    content: '',
+    status: CommonStatusEnum.ENABLE,
+    remark: ''
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 66 - 0
src/views/system/notify/message/NotifyMessageDetail.vue

@@ -0,0 +1,66 @@
+<template>
+  <Dialog v-model="dialogVisible" :max-height="500" :scroll="true" title="详情">
+    <el-descriptions :column="1" border>
+      <el-descriptions-item label="编号" min-width="120">
+        {{ detailData.id }}
+      </el-descriptions-item>
+      <el-descriptions-item label="用户类型">
+        <dict-tag :type="DICT_TYPE.USER_TYPE" :value="detailData.userType" />
+      </el-descriptions-item>
+      <el-descriptions-item label="用户编号">
+        {{ detailData.userId }}
+      </el-descriptions-item>
+      <el-descriptions-item label="模版编号">
+        {{ detailData.templateId }}
+      </el-descriptions-item>
+      <el-descriptions-item label="模板编码">
+        {{ detailData.templateCode }}
+      </el-descriptions-item>
+      <el-descriptions-item label="发送人名称">
+        {{ detailData.templateNickname }}
+      </el-descriptions-item>
+      <el-descriptions-item label="模版内容">
+        {{ detailData.templateContent }}
+      </el-descriptions-item>
+      <el-descriptions-item label="模版参数">
+        {{ detailData.templateParams }}
+      </el-descriptions-item>
+      <el-descriptions-item label="模版类型">
+        <dict-tag :type="DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE" :value="detailData.templateType" />
+      </el-descriptions-item>
+      <el-descriptions-item label="是否已读">
+        <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="detailData.readStatus" />
+      </el-descriptions-item>
+      <el-descriptions-item label="阅读时间">
+        {{ formatDate(detailData.readTime) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="创建时间">
+        {{ formatDate(detailData.createTime) }}
+      </el-descriptions-item>
+    </el-descriptions>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE } from '@/utils/dict'
+import { formatDate } from '@/utils/formatTime'
+import * as NotifyMessageApi from '@/api/system/notify/message'
+
+defineOptions({ name: 'SystemNotifyMessageDetail' })
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const detailLoading = ref(false) // 表单的加载中
+const detailData = ref({} as NotifyMessageApi.NotifyMessageVO) // 详情数据
+
+/** 打开弹窗 */
+const open = async (data: NotifyMessageApi.NotifyMessageVO) => {
+  dialogVisible.value = true
+  // 设置数据
+  detailLoading.value = true
+  try {
+    detailData.value = data
+  } finally {
+    detailLoading.value = false
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+</script>

+ 48 - 0
src/views/system/notify/my/MyNotifyMessageDetail.vue

@@ -0,0 +1,48 @@
+<template>
+  <Dialog v-model="dialogVisible" :max-height="500" :scroll="true" title="消息详情">
+    <el-descriptions :column="1" border>
+      <el-descriptions-item label="发送人">
+        {{ detailData.templateNickname }}
+      </el-descriptions-item>
+      <el-descriptions-item label="发送时间">
+        {{ formatDate(detailData.createTime) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="消息类型">
+        <dict-tag :type="DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE" :value="detailData.templateType" />
+      </el-descriptions-item>
+      <el-descriptions-item label="是否已读">
+        <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="detailData.readStatus" />
+      </el-descriptions-item>
+      <el-descriptions-item v-if="detailData.readStatus" label="阅读时间">
+        {{ formatDate(detailData.readTime) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="内容">
+        {{ detailData.templateContent }}
+      </el-descriptions-item>
+    </el-descriptions>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE } from '@/utils/dict'
+import { formatDate } from '@/utils/formatTime'
+import * as NotifyMessageApi from '@/api/system/notify/message'
+
+defineOptions({ name: 'MyNotifyMessageDetailDetail' })
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const detailLoading = ref(false) // 表单的加载中
+const detailData = ref({} as NotifyMessageApi.NotifyMessageVO) // 详情数据
+
+/** 打开弹窗 */
+const open = async (data: NotifyMessageApi.NotifyMessageVO) => {
+  dialogVisible.value = true
+  // 设置数据
+  detailLoading.value = true
+  try {
+    detailData.value = data
+  } finally {
+    detailLoading.value = false
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+</script>

+ 141 - 0
src/views/system/notify/template/NotifyTemplateForm.vue

@@ -0,0 +1,141 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="140px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="模版编码" prop="code">
+        <el-input v-model="formData.code" placeholder="请输入模版编码" />
+      </el-form-item>
+      <el-form-item label="模板名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入模版名称" />
+      </el-form-item>
+      <el-form-item label="发件人名称" prop="nickname">
+        <el-input v-model="formData.nickname" placeholder="请输入发件人名称" />
+      </el-form-item>
+      <el-form-item label="模板内容" prop="content">
+        <el-input type="textarea" v-model="formData.content" placeholder="请输入模板内容" />
+      </el-form-item>
+      <el-form-item label="类型" prop="type">
+        <el-select v-model="formData.type" placeholder="请选择类型">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="开启状态" prop="status">
+        <el-radio-group v-model="formData.status">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="备注" prop="remark">
+        <el-input v-model="formData.remark" 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 lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import * as NotifyTemplateApi from '@/api/system/notify/template'
+import { CommonStatusEnum } from '@/utils/constants'
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型
+const formData = ref<NotifyTemplateApi.NotifyTemplateVO>({
+  id: undefined,
+  name: '',
+  nickname: '',
+  code: '',
+  content: '',
+  type: undefined,
+  params: '',
+  status: CommonStatusEnum.ENABLE,
+  remark: ''
+})
+const formRules = reactive({
+  type: [{ required: true, message: '消息类型不能为空', trigger: 'change' }],
+  status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }],
+  code: [{ required: true, message: '模板编码不能为空', trigger: 'blur' }],
+  name: [{ required: true, message: '模板名称不能为空', trigger: 'blur' }],
+  nickname: [{ required: true, message: '发件人姓名不能为空', trigger: 'blur' }],
+  content: [{ required: true, message: '模板内容不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = type
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await NotifyTemplateApi.getNotifyTemplate(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as NotifyTemplateApi.NotifyTemplateVO
+    if (formType.value === 'create') {
+      await NotifyTemplateApi.createNotifyTemplate(data)
+      message.success('新增成功')
+    } else {
+      await NotifyTemplateApi.updateNotifyTemplate(data)
+      message.success('修改成功')
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: '',
+    nickname: '',
+    code: '',
+    content: '',
+    type: undefined,
+    params: '',
+    status: CommonStatusEnum.ENABLE,
+    remark: ''
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 146 - 0
src/views/system/notify/template/NotifyTemplateSendForm.vue

@@ -0,0 +1,146 @@
+<template>
+  <Dialog v-model="dialogVisible" title="测试发送" :max-height="500">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="140px"
+    >
+      <el-form-item label="模板内容" prop="content">
+        <el-input
+          v-model="formData.content"
+          placeholder="请输入模板内容"
+          readonly
+          type="textarea"
+        />
+      </el-form-item>
+      <el-form-item label="用户类型" prop="userType">
+        <el-radio-group v-model="formData.userType">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item v-show="formData.userType === 1" label="接收人ID" prop="userId">
+        <el-input v-model="formData.userId" style="width: 160px" />
+      </el-form-item>
+      <el-form-item v-show="formData.userType === 2" label="接收人" prop="userId">
+        <el-select v-model="formData.userId" placeholder="请选择接收人">
+          <el-option
+            v-for="item in userOption"
+            :key="item.id"
+            :label="item.nickname"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item
+        v-for="param in formData.params"
+        :key="param"
+        :label="'参数 {' + param + '}'"
+        :prop="'templateParams.' + param"
+      >
+        <el-input
+          v-model="formData.templateParams[param]"
+          :placeholder="'请输入 ' + param + ' 参数'"
+        />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as UserApi from '@/api/system/user'
+import * as NotifyTemplateApi from '@/api/system/notify/template'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+
+defineOptions({ name: 'SystemNotifyTemplateSendForm' })
+
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref({
+  content: '',
+  params: {},
+  userId: undefined,
+  userType: 1,
+  templateCode: '',
+  templateParams: new Map()
+})
+const formRules = reactive({
+  userId: [{ required: true, message: '用户编号不能为空', trigger: 'change' }],
+  templateCode: [{ required: true, message: '模版编号不能为空', trigger: 'blur' }],
+  templateParams: {}
+})
+const formRef = ref() // 表单 Ref
+const userOption = ref<UserApi.UserVO[]>([])
+
+const open = async (id: number) => {
+  dialogVisible.value = true
+  resetForm()
+  // 设置数据
+  formLoading.value = true
+  try {
+    const data = await NotifyTemplateApi.getNotifyTemplate(id)
+    // 设置动态表单
+    formData.value.content = data.content
+    formData.value.params = data.params
+    formData.value.templateCode = data.code
+    formData.value.templateParams = data.params.reduce((obj, item) => {
+      obj[item] = '' // 给每个动态属性赋值,避免无法读取
+      return obj
+    }, {})
+    formRules.templateParams = data.params.reduce((obj, item) => {
+      obj[item] = { required: true, message: '参数 ' + item + ' 不能为空', trigger: 'blur' }
+      return obj
+    }, {})
+  } finally {
+    formLoading.value = false
+  }
+  // 加载用户列表
+  userOption.value = await UserApi.getSimpleUserList()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as NotifyTemplateApi.NotifySendReqVO
+    const logId = await NotifyTemplateApi.sendNotify(data)
+    if (logId) {
+      message.success('提交发送成功!发送结果,见发送日志编号:' + logId)
+    }
+    dialogVisible.value = false
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    content: '',
+    params: {},
+    mobile: '',
+    templateCode: '',
+    templateParams: new Map(),
+    userType: 1
+  } as any
+  formRef.value?.resetFields()
+}
+</script>

+ 68 - 0
src/views/system/operatelog/OperateLogDetail.vue

@@ -0,0 +1,68 @@
+<template>
+  <Dialog v-model="dialogVisible" :max-height="500" :scroll="true" title="详情" width="800">
+    <el-descriptions :column="1" border>
+      <el-descriptions-item label="日志主键" min-width="120">
+        {{ detailData.id }}
+      </el-descriptions-item>
+      <el-descriptions-item label="链路追踪" v-if="detailData.traceId">
+        {{ detailData.traceId }}
+      </el-descriptions-item>
+      <el-descriptions-item label="操作人编号">
+        {{ detailData.userId }}
+      </el-descriptions-item>
+      <el-descriptions-item label="操作人名字">
+        {{ detailData.userName }}
+      </el-descriptions-item>
+      <el-descriptions-item label="操作人 IP">
+        {{ detailData.userIp }}
+      </el-descriptions-item>
+      <el-descriptions-item label="操作人 UA">
+        {{ detailData.userAgent }}
+      </el-descriptions-item>
+      <el-descriptions-item label="操作模块">
+        {{ detailData.type }}
+      </el-descriptions-item>
+      <el-descriptions-item label="操作名">
+        {{ detailData.subType }}
+      </el-descriptions-item>
+      <el-descriptions-item label="操作内容">
+        {{ detailData.action }}
+      </el-descriptions-item>
+      <el-descriptions-item v-if="detailData.extra" label="操作拓展参数">
+        {{ detailData.extra }}
+      </el-descriptions-item>
+      <el-descriptions-item label="请求 URL">
+        {{ detailData.requestMethod }} {{ detailData.requestUrl }}
+      </el-descriptions-item>
+      <el-descriptions-item label="操作时间">
+        {{ formatDate(detailData.createTime) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="业务编号">
+        {{ detailData.bizId }}
+      </el-descriptions-item>
+    </el-descriptions>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { formatDate } from '@/utils/formatTime'
+import * as OperateLogApi from '@/api/system/operatelog'
+
+defineOptions({ name: 'SystemOperateLogDetail' })
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const detailLoading = ref(false) // 表单的加载中
+const detailData = ref({} as OperateLogApi.OperateLogVO) // 详情数据
+
+/** 打开弹窗 */
+const open = async (data: OperateLogApi.OperateLogVO) => {
+  dialogVisible.value = true
+  // 设置数据
+  detailLoading.value = true
+  try {
+    detailData.value = data
+  } finally {
+    detailLoading.value = false
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+</script>