ydmyzx 3 ماه پیش
والد
کامیت
232f91bb7f

+ 109 - 0
src/api/mall/product/spu.ts

@@ -0,0 +1,109 @@
+import request from '@/config/axios'
+
+export interface Property {
+  propertyId?: number // 属性编号
+  propertyName?: string // 属性名称
+  valueId?: number // 属性值编号
+  valueName?: string // 属性值名称
+}
+
+export interface Sku {
+  id?: number // 商品 SKU 编号
+  name?: string // 商品 SKU 名称
+  spuId?: number // SPU 编号
+  properties?: Property[] // 属性数组
+  price?: number | string // 商品价格
+  marketPrice?: number | string // 市场价
+  costPrice?: number | string // 成本价
+  barCode?: string // 商品条码
+  picUrl?: string // 图片地址
+  stock?: number // 库存
+  weight?: number // 商品重量,单位:kg 千克
+  volume?: number // 商品体积,单位:m^3 平米
+  firstBrokeragePrice?: number | string // 一级分销的佣金
+  secondBrokeragePrice?: number | string // 二级分销的佣金
+  salesCount?: number // 商品销量
+}
+
+export interface GiveCouponTemplate {
+  id?: number
+  name?: string // 优惠券名称
+}
+
+export interface Spu {
+  id?: number
+  name?: string // 商品名称
+  categoryId?: number // 商品分类
+  keyword?: string // 关键字
+  unit?: number | undefined // 单位
+  picUrl?: string // 商品封面图
+  sliderPicUrls?: string[] // 商品轮播图
+  introduction?: string // 商品简介
+  deliveryTypes?: number[] // 配送方式
+  deliveryTemplateId?: number | undefined // 运费模版
+  brandId?: number // 商品品牌编号
+  specType?: boolean // 商品规格
+  subCommissionType?: boolean // 分销类型
+  skus?: Sku[] // sku数组
+  description?: string // 商品详情
+  sort?: number // 商品排序
+  giveIntegral?: number // 赠送积分
+  virtualSalesCount?: number // 虚拟销量
+  price?: number // 商品价格
+  salesCount?: number // 商品销量
+  marketPrice?: number // 市场价
+  costPrice?: number // 成本价
+  stock?: number // 商品库存
+  createTime?: Date // 商品创建时间
+  status?: number // 商品状态
+}
+
+// 获得 Spu 列表
+export const getSpuPage = (params: PageParam) => {
+  return request.get({ url: '/product/spu/page', params })
+}
+
+// 获得 Spu 列表 tabsCount
+export const getTabsCount = () => {
+  return request.get({ url: '/product/spu/get-count' })
+}
+
+// 创建商品 Spu
+export const createSpu = (data: Spu) => {
+  return request.post({ url: '/product/spu/create', data })
+}
+
+// 更新商品 Spu
+export const updateSpu = (data: Spu) => {
+  return request.put({ url: '/product/spu/update', data })
+}
+
+// 更新商品 Spu status
+export const updateStatus = (data: { id: number; status: number }) => {
+  return request.put({ url: '/product/spu/update-status', data })
+}
+
+// 获得商品 Spu
+export const getSpu = (id: number) => {
+  return request.get({ url: `/product/spu/get-detail?id=${id}` })
+}
+
+// 获得商品 Spu 详情列表
+export const getSpuDetailList = (ids: number[]) => {
+  return request.get({ url: `/product/spu/list?spuIds=${ids}` })
+}
+
+// 删除商品 Spu
+export const deleteSpu = (id: number) => {
+  return request.delete({ url: `/product/spu/delete?id=${id}` })
+}
+
+// 导出商品 Spu Excel
+export const exportSpu = async (params) => {
+  return await request.download({ url: '/product/spu/export', params })
+}
+
+// 获得商品 SPU 精简列表
+export const getSpuSimpleList = async () => {
+  return request.get({ url: '/product/spu/list-all-simple' })
+}

+ 31 - 0
src/api/system/user/socialUser.ts

@@ -0,0 +1,31 @@
+import request from '@/config/axios'
+
+// 社交绑定,使用 code 授权码
+export const socialBind = (type, code, state) => {
+  return request.post({
+    url: '/system/social-user/bind',
+    data: {
+      type,
+      code,
+      state
+    }
+  })
+}
+
+// 取消社交绑定
+export const socialUnbind = (type, openid) => {
+  return request.delete({
+    url: '/system/social-user/unbind',
+    data: {
+      type,
+      openid
+    }
+  })
+}
+
+// 社交授权的跳转
+export const socialAuthRedirect = (type, redirectUri) => {
+  return request.get({
+    url: '/system/auth/social-auth-redirect?type=' + type + '&redirectUri=' + redirectUri
+  })
+}

+ 113 - 0
src/components/bpmnProcessDesigner/package/penal/signal-message/SignalAndMessage.vue

@@ -0,0 +1,113 @@
+<template>
+  <div class="panel-tab__content">
+    <div class="panel-tab__content--title">
+      <span><Icon icon="ep:menu" style="margin-right: 8px; color: #555" />消息列表</span>
+      <XButton type="primary" title="创建新消息" preIcon="ep:plus" @click="openModel('message')" />
+    </div>
+    <el-table :data="messageList" border>
+      <el-table-column type="index" label="序号" width="60px" />
+      <el-table-column label="消息ID" prop="id" max-width="300px" show-overflow-tooltip />
+      <el-table-column label="消息名称" prop="name" max-width="300px" show-overflow-tooltip />
+    </el-table>
+    <div
+      class="panel-tab__content--title"
+      style="padding-top: 8px; margin-top: 8px; border-top: 1px solid #eee"
+    >
+      <span><Icon icon="ep:menu" style="margin-right: 8px; color: #555" />信号列表</span>
+      <XButton type="primary" title="创建新信号" preIcon="ep:plus" @click="openModel('signal')" />
+    </div>
+    <el-table :data="signalList" border>
+      <el-table-column type="index" label="序号" width="60px" />
+      <el-table-column label="信号ID" prop="id" max-width="300px" show-overflow-tooltip />
+      <el-table-column label="信号名称" prop="name" max-width="300px" show-overflow-tooltip />
+    </el-table>
+
+    <el-dialog
+      v-model="dialogVisible"
+      :title="modelConfig.title"
+      :close-on-click-modal="false"
+      width="400px"
+      append-to-body
+      destroy-on-close
+    >
+      <el-form :model="modelObjectForm" label-width="90px">
+        <el-form-item :label="modelConfig.idLabel">
+          <el-input v-model="modelObjectForm.id" clearable />
+        </el-form-item>
+        <el-form-item :label="modelConfig.nameLabel">
+          <el-input v-model="modelObjectForm.name" clearable />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取 消</el-button>
+        <el-button type="primary" @click="addNewObject">保 存</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+<script lang="ts" setup>
+defineOptions({ name: 'SignalAndMassage' })
+
+const message = useMessage()
+const signalList = ref<any[]>([])
+const messageList = ref<any[]>([])
+const dialogVisible = ref(false)
+const modelType = ref('')
+const modelObjectForm = ref<any>({})
+const rootElements = ref()
+const messageIdMap = ref()
+const signalIdMap = ref()
+const modelConfig = computed(() => {
+  if (modelType.value === 'message') {
+    return { title: '创建消息', idLabel: '消息ID', nameLabel: '消息名称' }
+  } else {
+    return { title: '创建信号', idLabel: '信号ID', nameLabel: '信号名称' }
+  }
+})
+const bpmnInstances = () => (window as any)?.bpmnInstances
+
+const initDataList = () => {
+  console.log(window, 'window')
+  rootElements.value = bpmnInstances().modeler.getDefinitions().rootElements
+  messageIdMap.value = {}
+  signalIdMap.value = {}
+  messageList.value = []
+  signalList.value = []
+  rootElements.value.forEach((el) => {
+    if (el.$type === 'bpmn:Message') {
+      messageIdMap.value[el.id] = true
+      messageList.value.push({ ...el })
+    }
+    if (el.$type === 'bpmn:Signal') {
+      signalIdMap.value[el.id] = true
+      signalList.value.push({ ...el })
+    }
+  })
+}
+const openModel = (type) => {
+  modelType.value = type
+  modelObjectForm.value = {}
+  dialogVisible.value = true
+}
+const addNewObject = () => {
+  if (modelType.value === 'message') {
+    if (messageIdMap.value[modelObjectForm.value.id]) {
+      message.error('该消息已存在,请修改id后重新保存')
+    }
+    const messageRef = bpmnInstances().moddle.create('bpmn:Message', modelObjectForm.value)
+    rootElements.value.push(messageRef)
+  } else {
+    if (signalIdMap.value[modelObjectForm.value.id]) {
+      message.error('该信号已存在,请修改id后重新保存')
+    }
+    const signalRef = bpmnInstances().moddle.create('bpmn:Signal', modelObjectForm.value)
+    rootElements.value.push(signalRef)
+  }
+  dialogVisible.value = false
+  initDataList()
+}
+
+onMounted(() => {
+  initDataList()
+})
+</script>

+ 40 - 0
src/layout/components/SizeDropdown/src/SizeDropdown.vue

@@ -0,0 +1,40 @@
+<script lang="ts" setup>
+import { useAppStore } from '@/store/modules/app'
+
+import { propTypes } from '@/utils/propTypes'
+import { useDesign } from '@/hooks/web/useDesign'
+import { ElementPlusSize } from '@/types/elementPlus'
+
+defineOptions({ name: 'SizeDropdown' })
+
+const { getPrefixCls } = useDesign()
+
+const prefixCls = getPrefixCls('size-dropdown')
+
+defineProps({
+  color: propTypes.string.def('')
+})
+
+const { t } = useI18n()
+
+const appStore = useAppStore()
+
+const sizeMap = computed(() => appStore.sizeMap)
+
+const setCurrentSize = (size: ElementPlusSize) => {
+  appStore.setCurrentSize(size)
+}
+</script>
+
+<template>
+  <ElDropdown :class="prefixCls" trigger="click" @command="setCurrentSize">
+    <Icon :color="color" :size="18" class="cursor-pointer" icon="mdi:format-size" />
+    <template #dropdown>
+      <ElDropdownMenu>
+        <ElDropdownItem v-for="item in sizeMap" :key="item" :command="item">
+          {{ t(`size.${item}`) }}
+        </ElDropdownItem>
+      </ElDropdownMenu>
+    </template>
+  </ElDropdown>
+</template>

+ 55 - 0
src/store/modules/simpleWorkflow.ts

@@ -0,0 +1,55 @@
+import { store } from '../index'
+import { defineStore } from 'pinia'
+
+export const useWorkFlowStore = defineStore('simpleWorkflow', {
+  state: () => ({
+    tableId: '',
+    isTried: false,
+    promoterDrawer: false,
+    flowPermission1: {},
+    approverDrawer: false,
+    approverConfig1: {},
+    copyerDrawer: false,
+    copyerConfig1: {},
+    conditionDrawer: false,
+    conditionsConfig1: {
+      conditionNodes: []
+    }
+  }),
+  actions: {
+    setTableId(payload) {
+      this.tableId = payload
+    },
+    setIsTried(payload) {
+      this.isTried = payload
+    },
+    setPromoter(payload) {
+      this.promoterDrawer = payload
+    },
+    setFlowPermission(payload) {
+      this.flowPermission1 = payload
+    },
+    setApprover(payload) {
+      this.approverDrawer = payload
+    },
+    setApproverConfig(payload) {
+      this.approverConfig1 = payload
+    },
+    setCopyer(payload) {
+      this.copyerDrawer = payload
+    },
+    setCopyerConfig(payload) {
+      this.copyerConfig1 = payload
+    },
+    setCondition(payload) {
+      this.conditionDrawer = payload
+    },
+    setConditionsConfig(payload) {
+      this.conditionsConfig1 = payload
+    }
+  }
+})
+
+export const useWorkFlowStoreWithOut = () => {
+  return useWorkFlowStore(store)
+}

+ 343 - 0
src/views/Login/SocialLogin.vue

@@ -0,0 +1,343 @@
+<template>
+  <div
+    :class="prefixCls"
+    class="relative h-[100%] lt-xl:bg-[var(--login-bg-color)] lt-md:px-10px lt-sm:px-10px lt-xl:px-10px"
+  >
+    <div class="relative mx-auto h-full flex">
+      <div
+        :class="`${prefixCls}__left flex-1 bg-gray-500 bg-opacity-20 relative p-30px lt-xl:hidden`"
+      >
+        <!-- 左上角的 logo + 系统标题 -->
+        <div class="relative flex items-center text-white">
+          <img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" />
+          <span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span>
+        </div>
+        <!-- 左边的背景图 + 欢迎语 -->
+        <div class="h-[calc(100%-60px)] flex items-center justify-center">
+          <TransitionGroup
+            appear
+            enter-active-class="animate__animated animate__bounceInLeft"
+            tag="div"
+          >
+            <img key="1" alt="" class="w-350px" src="@/assets/svgs/login-box-bg.svg" />
+            <div key="2" class="text-3xl text-white">{{ t('login.welcome') }}</div>
+            <!-- <div key="3" class="mt-5 text-14px font-normal text-white">
+              {{ t('login.message') }}
+            </div> -->
+          </TransitionGroup>
+        </div>
+      </div>
+      <div class="relative flex-1 p-30px dark:bg-[var(--login-bg-color)] lt-sm:p-10px">
+        <!-- 右上角的主题、语言选择 -->
+        <div
+          class="flex items-center justify-between text-white at-2xl:justify-end at-xl:justify-end"
+        >
+          <div class="flex items-center at-2xl:hidden at-xl:hidden">
+            <img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" />
+            <span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span>
+          </div>
+          <div class="flex items-center justify-end space-x-10px">
+            <ThemeSwitch />
+            <LocaleDropdown class="dark:text-white lt-xl:text-white" />
+          </div>
+        </div>
+        <!-- 右边的登录界面 -->
+        <Transition appear enter-active-class="animate__animated animate__bounceInRight">
+          <div
+            class="m-auto h-full w-[100%] flex items-center at-2xl:max-w-500px at-lg:max-w-500px at-md:max-w-500px at-xl:max-w-500px"
+          >
+            <!-- 账号登录 -->
+            <el-form
+              v-show="getShow"
+              ref="formLogin"
+              :model="loginData.loginForm"
+              :rules="LoginRules"
+              class="login-form"
+              label-position="top"
+              label-width="120px"
+              size="large"
+            >
+              <el-row style="margin-right: -10px; margin-left: -10px">
+                <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+                  <el-form-item>
+                    <LoginFormTitle style="width: 100%" />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+                  <el-form-item v-if="loginData.tenantEnable" prop="tenantName">
+                    <el-input
+                      v-model="loginData.loginForm.tenantName"
+                      :placeholder="t('login.tenantNamePlaceholder')"
+                      :prefix-icon="iconHouse"
+                      link
+                      type="primary"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+                  <el-form-item prop="username">
+                    <el-input
+                      v-model="loginData.loginForm.username"
+                      :placeholder="t('login.usernamePlaceholder')"
+                      :prefix-icon="iconAvatar"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+                  <el-form-item prop="password">
+                    <el-input
+                      v-model="loginData.loginForm.password"
+                      :placeholder="t('login.passwordPlaceholder')"
+                      :prefix-icon="iconLock"
+                      show-password
+                      type="password"
+                      @keyup.enter="getCode()"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col
+                  :span="24"
+                  style="
+                    padding-right: 10px;
+                    padding-left: 10px;
+                    margin-top: -20px;
+                    margin-bottom: -20px;
+                  "
+                >
+                  <el-form-item>
+                    <el-row justify="space-between" style="width: 100%">
+                      <el-col :span="6">
+                        <el-checkbox v-model="loginData.loginForm.rememberMe">
+                          {{ t('login.remember') }}
+                        </el-checkbox>
+                      </el-col>
+                      <el-col :offset="6" :span="12">
+                        <el-link style="float: right" type="primary">{{
+                          t('login.forgetPassword')
+                        }}</el-link>
+                      </el-col>
+                    </el-row>
+                  </el-form-item>
+                </el-col>
+                <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+                  <el-form-item>
+                    <XButton
+                      :loading="loginLoading"
+                      :title="t('login.login')"
+                      class="w-[100%]"
+                      type="primary"
+                      @click="getCode()"
+                    />
+                  </el-form-item>
+                </el-col>
+                <Verify
+                  ref="verify"
+                  :captchaType="captchaType"
+                  :imgSize="{ width: '400px', height: '200px' }"
+                  mode="pop"
+                  @success="handleLogin"
+                />
+              </el-row>
+            </el-form>
+          </div>
+        </Transition>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { underlineToHump } from '@/utils'
+
+import { ElLoading } from 'element-plus'
+
+import { useDesign } from '@/hooks/web/useDesign'
+import { useAppStore } from '@/store/modules/app'
+import { useIcon } from '@/hooks/web/useIcon'
+import { usePermissionStore } from '@/store/modules/permission'
+
+import * as LoginApi from '@/api/login'
+import * as authUtil from '@/utils/auth'
+import { ThemeSwitch } from '@/layout/components/ThemeSwitch'
+import { LocaleDropdown } from '@/layout/components/LocaleDropdown'
+import { LoginStateEnum, useFormValid, useLoginState } from './components/useLogin'
+import LoginFormTitle from './components/LoginFormTitle.vue'
+import router from '@/router'
+
+defineOptions({ name: 'SocialLogin' })
+
+const { t } = useI18n()
+const route = useRoute()
+
+const appStore = useAppStore()
+const { getPrefixCls } = useDesign()
+const prefixCls = getPrefixCls('login')
+const iconHouse = useIcon({ icon: 'ep:house' })
+const iconAvatar = useIcon({ icon: 'ep:avatar' })
+const iconLock = useIcon({ icon: 'ep:lock' })
+const formLogin = ref<any>()
+const { validForm } = useFormValid(formLogin)
+const { getLoginState } = useLoginState()
+const { push } = useRouter()
+const permissionStore = usePermissionStore()
+const loginLoading = ref(false)
+const verify = ref()
+const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字
+
+const getShow = computed(() => unref(getLoginState) === LoginStateEnum.LOGIN)
+
+const LoginRules = {
+  tenantName: [required],
+  username: [required],
+  password: [required]
+}
+const loginData = reactive({
+  isShowPassword: false,
+  captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE !== 'false',
+  tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE !== 'false',
+  loginForm: {
+    tenantName: 'M-Twins',
+    // username: 'admin',
+    // password: 'admin123',
+    captchaVerification: '',
+    rememberMe: false
+  }
+})
+
+// 获取验证码
+const getCode = async () => {
+  // 情况一,未开启:则直接登录
+  if (!loginData.captchaEnable) {
+    await handleLogin({})
+  } else {
+    // 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行登录
+    // 弹出验证码
+    verify.value.show()
+  }
+}
+//获取租户ID
+const getTenantId = async () => {
+  if (loginData.tenantEnable) {
+    const res = await LoginApi.getTenantIdByName(loginData.loginForm.tenantName)
+    authUtil.setTenantId(res)
+  }
+}
+// 记住我
+const getCookie = () => {
+  const loginForm = authUtil.getLoginForm()
+  if (loginForm) {
+    loginData.loginForm = {
+      ...loginData.loginForm,
+      username: loginForm.username ? loginForm.username : loginData.loginForm.username,
+      password: loginForm.password ? loginForm.password : loginData.loginForm.password,
+      rememberMe: loginForm.rememberMe ? true : false,
+      tenantName: loginForm.tenantName ? loginForm.tenantName : loginData.loginForm.tenantName
+    }
+  }
+}
+const loading = ref() // ElLoading.service 返回的实例
+
+// tricky: 配合LoginForm.vue中redirectUri需要对参数进行encode,需要在回调后进行decode
+function getUrlValue(key: string): string {
+  const url = new URL(decodeURIComponent(location.href))
+  return url.searchParams.get(key) ?? ''
+}
+
+// 尝试登录: 当账号已经绑定,socialLogin会直接获得token
+const tryLogin = async () => {
+  try {
+    const type = getUrlValue('type')
+    const redirect = getUrlValue('redirect')
+    const code = route?.query?.code as string
+    const state = route?.query?.state as string
+
+    const res = await LoginApi.socialLogin(type, code, state)
+    authUtil.setToken(res)
+
+    router.push({ path: redirect || '/' })
+  } catch (err) {}
+}
+
+// 登录
+const handleLogin = async (params) => {
+  loginLoading.value = true
+  try {
+    await getTenantId()
+    const data = await validForm()
+    if (!data) {
+      return
+    }
+
+    let redirect = getUrlValue('redirect')
+
+    const type = getUrlValue('type')
+    const code = route?.query?.code as string
+    const state = route?.query?.state as string
+
+    const res = await LoginApi.login({
+      // 账号密码登录
+      username: loginData.loginForm.username,
+      password: loginData.loginForm.password,
+      captchaVerification: params.captchaVerification,
+      // 社交登录
+      socialCode: code,
+      socialState: state,
+      socialType: type
+    })
+    if (!res) {
+      return
+    }
+    loading.value = ElLoading.service({
+      lock: true,
+      text: '正在加载系统中...',
+      background: 'rgba(0, 0, 0, 0.7)'
+    })
+    if (loginData.loginForm.rememberMe) {
+      authUtil.setLoginForm(loginData.loginForm)
+    } else {
+      authUtil.removeLoginForm()
+    }
+    authUtil.setToken(res)
+    if (!redirect) {
+      redirect = '/'
+    }
+    // 判断是否为SSO登录
+    if (redirect.indexOf('sso') !== -1) {
+      window.location.href = window.location.href.replace('/login?redirect=', '')
+    } else {
+      push({ path: redirect || permissionStore.addRouters[0].path })
+    }
+  } finally {
+    loginLoading.value = false
+    loading.value.close()
+  }
+}
+
+onMounted(() => {
+  getCookie()
+  tryLogin()
+})
+</script>
+
+<style lang="scss" scoped>
+$prefix-cls: #{$namespace}-login;
+
+.#{$prefix-cls} {
+  overflow: auto;
+
+  &__left {
+    &::before {
+      position: absolute;
+      top: 0;
+      left: 0;
+      z-index: -1;
+      width: 100%;
+      height: 100%;
+      background-image: url('@/assets/svgs/login-bg.svg');
+      background-position: center;
+      background-repeat: no-repeat;
+      content: '';
+    }
+  }
+}
+</style>

+ 199 - 0
src/views/Login/components/SSOLogin.vue

@@ -0,0 +1,199 @@
+<template>
+  <div v-show="ssoVisible" class="form-cont">
+    <!-- 应用名 -->
+    <LoginFormTitle style="width: 100%" />
+    <el-tabs class="form" style="float: none" value="uname">
+      <el-tab-pane :label="client.name" name="uname" />
+    </el-tabs>
+    <div>
+      <el-form :model="formData" class="login-form">
+        <!-- 授权范围的选择 -->
+        此第三方应用请求获得以下权限:
+        <el-form-item prop="scopes">
+          <el-checkbox-group v-model="formData.scopes">
+            <el-checkbox
+              v-for="scope in queryParams.scopes"
+              :key="scope"
+              :label="scope"
+              style="display: block; margin-bottom: -10px"
+            >
+              {{ formatScope(scope) }}
+            </el-checkbox>
+          </el-checkbox-group>
+        </el-form-item>
+        <!-- 下方的登录按钮 -->
+        <el-form-item class="w-1/1">
+          <el-button
+            :loading="formLoading"
+            class="w-6/10"
+            type="primary"
+            @click.prevent="handleAuthorize(true)"
+          >
+            <span v-if="!formLoading">同意授权</span>
+            <span v-else>授 权 中...</span>
+          </el-button>
+          <el-button class="w-3/10" @click.prevent="handleAuthorize(false)">拒绝</el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+  </div>
+</template>
+<script lang="ts" setup>
+import LoginFormTitle from './LoginFormTitle.vue'
+import * as OAuth2Api from '@/api/login/oauth2'
+import { LoginStateEnum, useLoginState } from './useLogin'
+import type { RouteLocationNormalizedLoaded } from 'vue-router'
+
+defineOptions({ name: 'SSOLogin' })
+
+const route = useRoute() // 路由
+const { currentRoute } = useRouter() // 路由
+const { getLoginState, setLoginState } = useLoginState()
+
+const client = ref({
+  // 客户端信息
+  name: '',
+  logo: ''
+})
+interface queryType {
+  responseType: string
+  clientId: string
+  redirectUri: string
+  state: string
+  scopes: string[]
+}
+const queryParams = reactive<queryType>({
+  // URL 上的 client_id、scope 等参数
+  responseType: '',
+  clientId: '',
+  redirectUri: '',
+  state: '',
+  scopes: [] // 优先从 query 参数获取;如果未传递,从后端获取
+})
+const ssoVisible = computed(() => unref(getLoginState) === LoginStateEnum.SSO) // 是否展示 SSO 登录的表单
+interface formType {
+  scopes: string[]
+}
+const formData = reactive<formType>({
+  scopes: [] // 已选中的 scope 数组
+})
+const formLoading = ref(false) // 表单是否提交中
+
+/** 初始化授权信息 */
+const init = async () => {
+  // 防止在没有登录的情况下循环弹窗
+  if (typeof route.query.client_id === 'undefined') return
+  // 解析参数
+  // 例如说【自动授权不通过】:client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read%20user.write
+  // 例如说【自动授权通过】:client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read
+  queryParams.responseType = route.query.response_type as string
+  queryParams.clientId = route.query.client_id as string
+  queryParams.redirectUri = route.query.redirect_uri as string
+  queryParams.state = route.query.state as string
+  if (route.query.scope) {
+    queryParams.scopes = (route.query.scope as string).split(' ')
+  }
+
+  // 如果有 scope 参数,先执行一次自动授权,看看是否之前都授权过了。
+  if (queryParams.scopes.length > 0) {
+    const data = await doAuthorize(true, queryParams.scopes, [])
+    if (data) {
+      location.href = data
+      return
+    }
+  }
+
+  // 获取授权页的基本信息
+  const data = await OAuth2Api.getAuthorize(queryParams.clientId)
+  client.value = data.client
+  // 解析 scope
+  let scopes
+  // 1.1 如果 params.scope 非空,则过滤下返回的 scopes
+  if (queryParams.scopes.length > 0) {
+    scopes = []
+    for (const scope of data.scopes) {
+      if (queryParams.scopes.indexOf(scope.key) >= 0) {
+        scopes.push(scope)
+      }
+    }
+    // 1.2 如果 params.scope 为空,则使用返回的 scopes 设置它
+  } else {
+    scopes = data.scopes
+    for (const scope of scopes) {
+      queryParams.scopes.push(scope.key)
+    }
+  }
+  // 生成已选中的 checkedScopes
+  for (const scope of scopes) {
+    if (scope.value) {
+      formData.scopes.push(scope.key)
+    }
+  }
+}
+
+/** 处理授权的提交 */
+const handleAuthorize = async (approved) => {
+  // 计算 checkedScopes + uncheckedScopes
+  let checkedScopes
+  let uncheckedScopes
+  if (approved) {
+    // 同意授权,按照用户的选择
+    checkedScopes = formData.scopes
+    uncheckedScopes = queryParams.scopes.filter((item) => checkedScopes.indexOf(item) === -1)
+  } else {
+    // 拒绝,则都是取消
+    checkedScopes = []
+    uncheckedScopes = queryParams.scopes
+  }
+  // 提交授权的请求
+  formLoading.value = true
+  try {
+    const data = await doAuthorize(false, checkedScopes, uncheckedScopes)
+    if (!data) {
+      return
+    }
+    location.href = data
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 调用授权 API 接口 */
+const doAuthorize = (autoApprove, checkedScopes, uncheckedScopes) => {
+  return OAuth2Api.authorize(
+    queryParams.responseType,
+    queryParams.clientId,
+    queryParams.redirectUri,
+    queryParams.state,
+    autoApprove,
+    checkedScopes,
+    uncheckedScopes
+  )
+}
+
+/** 格式化 scope 文本 */
+const formatScope = (scope) => {
+  // 格式化 scope 授权范围,方便用户理解。
+  // 这里仅仅是一个 demo,可以考虑录入到字典数据中,例如说字典类型 "system_oauth2_scope",它的每个 scope 都是一条字典数据。
+  switch (scope) {
+    case 'user.read':
+      return '访问你的个人信息'
+    case 'user.write':
+      return '修改你的个人信息'
+    default:
+      return scope
+  }
+}
+
+/** 监听当前路由为 SSOLogin 时,进行数据的初始化 */
+watch(
+  () => currentRoute.value,
+  (route: RouteLocationNormalizedLoaded) => {
+    if (route.name === 'SSOLogin') {
+      setLoginState(LoginStateEnum.SSO)
+      init()
+    }
+  },
+  { immediate: true }
+)
+</script>

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

@@ -0,0 +1,576 @@
+<template>
+  <!-- 情况一:添加/修改 -->
+  <el-table
+    v-if="!isDetail && !isActivityComponent"
+    :data="isBatch ? skuList : formData!.skus!"
+    border
+    class="tabNumWidth"
+    max-height="500"
+    size="small"
+  >
+    <el-table-column align="center" label="图片" min-width="65">
+      <template #default="{ row }">
+        <UploadImg v-model="row.picUrl" height="50px" width="50px" />
+      </template>
+    </el-table-column>
+    <template v-if="formData!.specType && !isBatch">
+      <!--  根据商品属性动态添加 -->
+      <el-table-column
+        v-for="(item, index) in tableHeaders"
+        :key="index"
+        :label="item.label"
+        align="center"
+        min-width="120"
+      >
+        <template #default="{ row }">
+          <span style="font-weight: bold; color: #40aaff">
+            {{ row.properties[index]?.valueName }}
+          </span>
+        </template>
+      </el-table-column>
+    </template>
+    <el-table-column align="center" label="商品条码" min-width="168">
+      <template #default="{ row }">
+        <el-input v-model="row.barCode" class="w-100%" />
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="销售价" min-width="168">
+      <template #default="{ row }">
+        <el-input-number
+          v-model="row.price"
+          :min="0"
+          :precision="2"
+          :step="0.1"
+          class="w-100%"
+          controls-position="right"
+        />
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="市场价" min-width="168">
+      <template #default="{ row }">
+        <el-input-number
+          v-model="row.marketPrice"
+          :min="0"
+          :precision="2"
+          :step="0.1"
+          class="w-100%"
+          controls-position="right"
+        />
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="成本价" min-width="168">
+      <template #default="{ row }">
+        <el-input-number
+          v-model="row.costPrice"
+          :min="0"
+          :precision="2"
+          :step="0.1"
+          class="w-100%"
+          controls-position="right"
+        />
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="库存" min-width="168">
+      <template #default="{ row }">
+        <el-input-number v-model="row.stock" :min="0" class="w-100%" controls-position="right" />
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="重量(kg)" min-width="168">
+      <template #default="{ row }">
+        <el-input-number
+          v-model="row.weight"
+          :min="0"
+          :precision="2"
+          :step="0.1"
+          class="w-100%"
+          controls-position="right"
+        />
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="体积(m^3)" min-width="168">
+      <template #default="{ row }">
+        <el-input-number
+          v-model="row.volume"
+          :min="0"
+          :precision="2"
+          :step="0.1"
+          class="w-100%"
+          controls-position="right"
+        />
+      </template>
+    </el-table-column>
+    <template v-if="formData!.subCommissionType">
+      <el-table-column align="center" label="一级返佣(元)" min-width="168">
+        <template #default="{ row }">
+          <el-input-number
+            v-model="row.firstBrokeragePrice"
+            :min="0"
+            :precision="2"
+            :step="0.1"
+            class="w-100%"
+            controls-position="right"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="二级返佣(元)" min-width="168">
+        <template #default="{ row }">
+          <el-input-number
+            v-model="row.secondBrokeragePrice"
+            :min="0"
+            :precision="2"
+            :step="0.1"
+            class="w-100%"
+            controls-position="right"
+          />
+        </template>
+      </el-table-column>
+    </template>
+    <el-table-column v-if="formData?.specType" align="center" fixed="right" label="操作" width="80">
+      <template #default="{ row }">
+        <el-button v-if="isBatch" link size="small" type="primary" @click="batchAdd">
+          批量添加
+        </el-button>
+        <el-button v-else link size="small" type="primary" @click="deleteSku(row)">删除</el-button>
+      </template>
+    </el-table-column>
+  </el-table>
+
+  <!-- 情况二:详情 -->
+  <el-table
+    v-if="isDetail"
+    ref="activitySkuListRef"
+    :data="formData!.skus!"
+    border
+    max-height="500"
+    size="small"
+    style="width: 99%"
+    @selection-change="handleSelectionChange"
+  >
+    <el-table-column v-if="isComponent" type="selection" width="45" />
+    <el-table-column align="center" label="图片" min-width="80">
+      <template #default="{ row }">
+        <el-image
+          v-if="row.picUrl"
+          :src="row.picUrl"
+          class="h-50px w-50px"
+          @click="imagePreview(row.picUrl)"
+        />
+      </template>
+    </el-table-column>
+    <template v-if="formData!.specType && !isBatch">
+      <!--  根据商品属性动态添加 -->
+      <el-table-column
+        v-for="(item, index) in tableHeaders"
+        :key="index"
+        :label="item.label"
+        align="center"
+        min-width="80"
+      >
+        <template #default="{ row }">
+          <span style="font-weight: bold; color: #40aaff">
+            {{ row.properties[index]?.valueName }}
+          </span>
+        </template>
+      </el-table-column>
+    </template>
+    <el-table-column align="center" label="商品条码" min-width="100">
+      <template #default="{ row }">
+        {{ row.barCode }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="销售价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.price }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="市场价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.marketPrice }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="成本价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.costPrice }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="库存" min-width="80">
+      <template #default="{ row }">
+        {{ row.stock }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="重量(kg)" min-width="80">
+      <template #default="{ row }">
+        {{ row.weight }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="体积(m^3)" min-width="80">
+      <template #default="{ row }">
+        {{ row.volume }}
+      </template>
+    </el-table-column>
+    <template v-if="formData!.subCommissionType">
+      <el-table-column align="center" label="一级返佣(元)" min-width="80">
+        <template #default="{ row }">
+          {{ row.firstBrokeragePrice }}
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="二级返佣(元)" min-width="80">
+        <template #default="{ row }">
+          {{ row.secondBrokeragePrice }}
+        </template>
+      </el-table-column>
+    </template>
+  </el-table>
+
+  <!-- 情况三:作为活动组件 -->
+  <el-table
+    v-if="isActivityComponent"
+    :data="formData!.skus!"
+    border
+    max-height="500"
+    size="small"
+    style="width: 99%"
+  >
+    <el-table-column v-if="isComponent" type="selection" width="45" />
+    <el-table-column align="center" label="图片" min-width="80">
+      <template #default="{ row }">
+        <el-image :src="row.picUrl" class="h-60px w-60px" @click="imagePreview(row.picUrl)" />
+      </template>
+    </el-table-column>
+    <template v-if="formData!.specType">
+      <!--  根据商品属性动态添加 -->
+      <el-table-column
+        v-for="(item, index) in tableHeaders"
+        :key="index"
+        :label="item.label"
+        align="center"
+        min-width="80"
+      >
+        <template #default="{ row }">
+          <span style="font-weight: bold; color: #40aaff">
+            {{ row.properties[index]?.valueName }}
+          </span>
+        </template>
+      </el-table-column>
+    </template>
+    <el-table-column align="center" label="商品条码" min-width="100">
+      <template #default="{ row }">
+        {{ row.barCode }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="销售价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.price }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="市场价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.marketPrice }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="成本价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.costPrice }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="库存" min-width="80">
+      <template #default="{ row }">
+        {{ row.stock }}
+      </template>
+    </el-table-column>
+    <!--  方便扩展每个活动配置的属性不一样  -->
+    <slot name="extension"></slot>
+  </el-table>
+</template>
+<script lang="ts" setup>
+import { PropType, Ref } from 'vue'
+import { copyValueToTarget } from '@/utils'
+import { propTypes } from '@/utils/propTypes'
+import { UploadImg } from '@/components/UploadFile'
+import type { Property, Sku, Spu } from '@/api/mall/product/spu'
+import { createImageViewer } from '@/components/ImageViewer'
+import { RuleConfig } from '@/views/mall/product/spu/components/index'
+import { PropertyAndValues } from './index'
+import { ElTable } from 'element-plus'
+import { isEmpty } from '@/utils/is'
+
+defineOptions({ name: 'SkuList' })
+const message = useMessage() // 消息弹窗
+
+const props = defineProps({
+  propFormData: {
+    type: Object as PropType<Spu>,
+    default: () => {}
+  },
+  propertyList: {
+    type: Array as PropType<PropertyAndValues[]>,
+    default: () => []
+  },
+  ruleConfig: {
+    type: Array as PropType<RuleConfig[]>,
+    default: () => []
+  },
+  isBatch: propTypes.bool.def(false), // 是否作为批量操作组件
+  isDetail: propTypes.bool.def(false), // 是否作为 sku 详情组件
+  isComponent: propTypes.bool.def(false), // 是否作为 sku 选择组件
+  isActivityComponent: propTypes.bool.def(false) // 是否作为 sku 活动配置组件
+})
+const formData: Ref<Spu | undefined> = ref<Spu>() // 表单数据
+const skuList = ref<Sku[]>([
+  {
+    price: 0, // 商品价格
+    marketPrice: 0, // 市场价
+    costPrice: 0, // 成本价
+    barCode: '', // 商品条码
+    picUrl: '', // 图片地址
+    stock: 0, // 库存
+    weight: 0, // 商品重量
+    volume: 0, // 商品体积
+    firstBrokeragePrice: 0, // 一级分销的佣金
+    secondBrokeragePrice: 0 // 二级分销的佣金
+  }
+]) // 批量添加时的临时数据
+
+/** 商品图预览 */
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    zIndex: 9999999,
+    urlList: [imgUrl]
+  })
+}
+
+/** 批量添加 */
+const batchAdd = () => {
+  validateProperty()
+  formData.value!.skus!.forEach((item) => {
+    copyValueToTarget(item, skuList.value[0])
+  })
+}
+/** 校验商品属性属性值 */
+const validateProperty = () => {
+  // 校验商品属性属性值是否为空,有一个为空都不给过
+  const warningInfo = '存在属性属性值为空,请先检查完善属性值后重试!!!'
+  for (const item of props.propertyList) {
+    if (!item.values || isEmpty(item.values)) {
+      message.warning(warningInfo)
+      throw new Error(warningInfo)
+    }
+  }
+}
+/** 删除 sku */
+const deleteSku = (row) => {
+  const index = formData.value!.skus!.findIndex(
+    // 直接把列表转成字符串比较
+    (sku) => JSON.stringify(sku.properties) === JSON.stringify(row.properties)
+  )
+  formData.value!.skus!.splice(index, 1)
+}
+const tableHeaders = ref<{ prop: string; label: string }[]>([]) // 多属性表头
+/**
+ * 保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。
+ */
+const validateSku = () => {
+  validateProperty()
+  let warningInfo = '请检查商品各行相关属性配置,'
+  let validate = true // 默认通过
+  for (const sku of formData.value!.skus!) {
+    // 作为活动组件的校验
+    for (const rule of props?.ruleConfig) {
+      const arg = getValue(sku, rule.name)
+      if (!rule.rule(arg)) {
+        validate = false // 只要有一个不通过则直接不通过
+        warningInfo += rule.message
+        break
+      }
+    }
+    // 只要有一个不通过则结束后续的校验
+    if (!validate) {
+      message.warning(warningInfo)
+      throw new Error(warningInfo)
+    }
+  }
+}
+const getValue = (obj, arg) => {
+  const keys = arg.split('.')
+  let value = obj
+  for (const key of keys) {
+    if (value && typeof value === 'object' && key in value) {
+      value = value[key]
+    } else {
+      value = undefined
+      break
+    }
+  }
+  return value
+}
+
+const emit = defineEmits<{
+  (e: 'selectionChange', value: Sku[]): void
+}>()
+/**
+ * 选择时触发
+ * @param Sku 传递过来的选中的 sku 是一个数组
+ */
+const handleSelectionChange = (val: Sku[]) => {
+  emit('selectionChange', val)
+}
+
+/**
+ * 将传进来的值赋值给 skuList
+ */
+watch(
+  () => props.propFormData,
+  (data) => {
+    if (!data) return
+    formData.value = data
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+
+/** 生成表数据 */
+const generateTableData = (propertyList: any[]) => {
+  // 构建数据结构
+  const propertyValues = propertyList.map((item) =>
+    item.values.map((v: any) => ({
+      propertyId: item.id,
+      propertyName: item.name,
+      valueId: v.id,
+      valueName: v.name
+    }))
+  )
+  const buildSkuList = build(propertyValues)
+  // 如果回显的 sku 属性和添加的属性不一致则重置 skus 列表
+  if (!validateData(propertyList)) {
+    // 如果不一致则重置表数据,默认添加新的属性重新生成 sku 列表
+    formData.value!.skus = []
+  }
+  for (const item of buildSkuList) {
+    const row = {
+      properties: Array.isArray(item) ? item : [item], // 如果只有一个属性的话返回的是一个 property 对象
+      price: 0,
+      marketPrice: 0,
+      costPrice: 0,
+      barCode: '',
+      picUrl: '',
+      stock: 0,
+      weight: 0,
+      volume: 0,
+      firstBrokeragePrice: 0,
+      secondBrokeragePrice: 0
+    }
+    // 如果存在属性相同的 sku 则不做处理
+    const index = formData.value!.skus!.findIndex(
+      (sku) => JSON.stringify(sku.properties) === JSON.stringify(row.properties)
+    )
+    if (index !== -1) {
+      continue
+    }
+    formData.value!.skus!.push(row)
+  }
+}
+
+/**
+ * 生成 skus 前置校验
+ */
+const validateData = (propertyList: any[]) => {
+  const skuPropertyIds: number[] = []
+  formData.value!.skus!.forEach((sku) =>
+    sku.properties
+      ?.map((property) => property.propertyId)
+      ?.forEach((propertyId) => {
+        if (skuPropertyIds.indexOf(propertyId!) === -1) {
+          skuPropertyIds.push(propertyId!)
+        }
+      })
+  )
+  const propertyIds = propertyList.map((item) => item.id)
+  return skuPropertyIds.length === propertyIds.length
+}
+
+/** 构建所有排列组合 */
+const build = (propertyValuesList: Property[][]) => {
+  if (propertyValuesList.length === 0) {
+    return []
+  } else if (propertyValuesList.length === 1) {
+    return propertyValuesList[0]
+  } else {
+    const result: Property[][] = []
+    const rest = build(propertyValuesList.slice(1))
+    for (let i = 0; i < propertyValuesList[0].length; i++) {
+      for (let j = 0; j < rest.length; j++) {
+        // 第一次不是数组结构,后面的都是数组结构
+        if (Array.isArray(rest[j])) {
+          result.push([propertyValuesList[0][i], ...rest[j]])
+        } else {
+          result.push([propertyValuesList[0][i], rest[j]])
+        }
+      }
+    }
+    return result
+  }
+}
+
+/** 监听属性列表,生成相关参数和表头 */
+watch(
+  () => props.propertyList,
+  (propertyList: PropertyAndValues[]) => {
+    // 如果不是多规格则结束
+    if (!formData.value!.specType) {
+      return
+    }
+    // 如果当前组件作为批量添加数据使用,则重置表数据
+    if (props.isBatch) {
+      skuList.value = [
+        {
+          price: 0,
+          marketPrice: 0,
+          costPrice: 0,
+          barCode: '',
+          picUrl: '',
+          stock: 0,
+          weight: 0,
+          volume: 0,
+          firstBrokeragePrice: 0,
+          secondBrokeragePrice: 0
+        }
+      ]
+    }
+
+    // 判断代理对象是否为空
+    if (JSON.stringify(propertyList) === '[]') {
+      return
+    }
+    // 重置表头
+    tableHeaders.value = []
+    // 生成表头
+    propertyList.forEach((item, index) => {
+      // name加属性项index区分属性值
+      tableHeaders.value.push({ prop: `name${index}`, label: item.name })
+    })
+    // 如果回显的 sku 属性和添加的属性一致则不处理
+    if (validateData(propertyList)) {
+      return
+    }
+    // 添加新属性没有属性值也不做处理
+    if (propertyList.some((item) => !item.values || isEmpty(item.values))) {
+      return
+    }
+    // 生成 table 数据,即 sku 列表
+    generateTableData(propertyList)
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+const activitySkuListRef = ref<InstanceType<typeof ElTable>>()
+
+const getSkuTableRef = () => {
+  return activitySkuListRef.value
+}
+// 暴露出生成 sku 方法,给添加属性成功时调用
+defineExpose({ generateTableData, validateSku, getSkuTableRef })
+</script>

+ 95 - 0
src/views/mall/product/spu/components/SkuTableSelect.vue

@@ -0,0 +1,95 @@
+<template>
+  <Dialog v-model="dialogVisible" :appendToBody="true" title="选择规格" width="700">
+    <el-table v-loading="loading" :data="list" show-overflow-tooltip>
+      <el-table-column label="#" width="55">
+        <template #default="{ row }">
+          <el-radio :label="row.id" v-model="selectedSkuId" @change="handleSelected(row)"
+            >&nbsp;
+          </el-radio>
+        </template>
+      </el-table-column>
+      <el-table-column label="图片" min-width="80">
+        <template #default="{ row }">
+          <el-image
+            :src="row.picUrl"
+            class="h-30px w-30px"
+            :preview-src-list="[row.picUrl]"
+            preview-teleported
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="规格" align="center" min-width="80">
+        <template #default="{ row }">
+          {{ row.properties?.map((p) => p.valueName)?.join(' ') }}
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="销售价(元)" min-width="80">
+        <template #default="{ row }">
+          {{ fenToYuan(row.price) }}
+        </template>
+      </el-table-column>
+    </el-table>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import { ElTable } from 'element-plus'
+import * as ProductSpuApi from '@/api/mall/product/spu'
+import { propTypes } from '@/utils/propTypes'
+import { fenToYuan } from '@/utils'
+
+defineOptions({ name: 'SkuTableSelect' })
+
+const props = defineProps({
+  spuId: propTypes.number.def(null)
+})
+
+const message = useMessage() // 消息弹窗
+const list = ref<any[]>([]) // 列表的数据
+const loading = ref(false) // 列表的加载中
+const dialogVisible = ref(false) // 弹窗的是否展示
+
+const selectedSkuId = ref() // 选中的商品 spuId
+
+/** 选中时触发 */
+const handleSelected = (row: ProductSpuApi.Sku) => {
+  emits('change', row)
+  // 关闭弹窗
+  dialogVisible.value = false
+  selectedSkuId.value = undefined
+}
+
+// 确认选择时的触发事件
+const emits = defineEmits<{
+  (e: 'change', spu: ProductSpuApi.Sku): void
+}>()
+
+/** 打开弹窗 */
+const open = () => {
+  dialogVisible.value = true
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 查询列表 */
+const getSpuDetail = async () => {
+  loading.value = true
+  try {
+    const spu = await ProductSpuApi.getSpu(props.spuId)
+    list.value = spu.skus
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 初始化 **/
+onMounted(async () => {})
+watch(
+  () => props.spuId,
+  () => {
+    if (!props.spuId) {
+      return
+    }
+    getSpuDetail()
+  }
+)
+</script>

+ 142 - 0
src/views/mall/product/spu/components/SpuShowcase.vue

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

+ 303 - 0
src/views/mall/product/spu/components/SpuTableSelect.vue

@@ -0,0 +1,303 @@
+<template>
+  <Dialog v-model="dialogVisible" :appendToBody="true" title="选择商品" width="70%">
+    <ContentWrap>
+      <el-form
+        ref="queryFormRef"
+        :inline="true"
+        :model="queryParams"
+        class="-mb-15px"
+        label-width="68px"
+      >
+        <el-form-item label="商品名称" prop="name">
+          <el-input
+            v-model="queryParams.name"
+            class="!w-240px"
+            clearable
+            placeholder="请输入商品名称"
+            @keyup.enter="handleQuery"
+          />
+        </el-form-item>
+        <el-form-item label="商品分类" prop="categoryId">
+          <el-tree-select
+            v-model="queryParams.categoryId"
+            :data="categoryTreeList"
+            :props="defaultProps"
+            check-strictly
+            class="!w-240px"
+            node-key="id"
+            placeholder="请选择商品分类"
+          />
+        </el-form-item>
+        <el-form-item label="创建时间" prop="createTime">
+          <el-date-picker
+            v-model="queryParams.createTime"
+            :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+            class="!w-240px"
+            end-placeholder="结束日期"
+            start-placeholder="开始日期"
+            type="daterange"
+            value-format="YYYY-MM-DD HH:mm:ss"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="handleQuery">
+            <Icon class="mr-5px" icon="ep:search" />
+            搜索
+          </el-button>
+          <el-button @click="resetQuery">
+            <Icon class="mr-5px" icon="ep:refresh" />
+            重置
+          </el-button>
+        </el-form-item>
+      </el-form>
+      <el-table v-loading="loading" :data="list" show-overflow-tooltip>
+        <!-- 1. 多选模式(不能使用type="selection",Element会忽略Header插槽) -->
+        <el-table-column width="55" v-if="multiple">
+          <template #header>
+            <el-checkbox
+              v-model="isCheckAll"
+              :indeterminate="isIndeterminate"
+              @change="handleCheckAll"
+            />
+          </template>
+          <template #default="{ row }">
+            <el-checkbox
+              v-model="checkedStatus[row.id]"
+              @change="(checked: boolean) => handleCheckOne(checked, row, true)"
+            />
+          </template>
+        </el-table-column>
+        <!-- 2. 单选模式 -->
+        <el-table-column label="#" width="55" v-else>
+          <template #default="{ row }">
+            <el-radio :label="row.id" v-model="selectedSpuId" @change="handleSingleSelected(row)">
+              <!-- 空格不能省略,是为了让单选框不显示label,如果不指定label不会有选中的效果 -->
+              &nbsp;
+            </el-radio>
+          </template>
+        </el-table-column>
+        <el-table-column key="id" align="center" label="商品编号" prop="id" min-width="60" />
+        <el-table-column label="商品图" min-width="80">
+          <template #default="{ row }">
+            <el-image
+              :src="row.picUrl"
+              class="h-30px w-30px"
+              :preview-src-list="[row.picUrl]"
+              preview-teleported
+            />
+          </template>
+        </el-table-column>
+        <el-table-column label="商品名称" min-width="200" prop="name" />
+        <el-table-column label="商品分类" min-width="100" prop="categoryId">
+          <template #default="{ row }">
+            <span>{{ categoryList?.find((c) => c.id === row.categoryId)?.name }}</span>
+          </template>
+        </el-table-column>
+      </el-table>
+      <!-- 分页 -->
+      <Pagination
+        v-model:limit="queryParams.pageSize"
+        v-model:page="queryParams.pageNo"
+        :total="total"
+        @pagination="getList"
+      />
+    </ContentWrap>
+    <template #footer v-if="multiple">
+      <el-button type="primary" @click="handleEmitChange">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import { defaultProps, handleTree } from '@/utils/tree'
+
+import * as ProductCategoryApi from '@/api/mall/product/category'
+import * as ProductSpuApi from '@/api/mall/product/spu'
+import { propTypes } from '@/utils/propTypes'
+import { CHANGE_EVENT } from 'element-plus'
+
+type Spu = Required<ProductSpuApi.Spu>
+
+/**
+ * 商品表格选择对话框
+ * 1. 单选模式:
+ *    1.1 点击表格左侧的单选框时,结束选择,并关闭对话框
+ *    1.2 再次打开时,保持选中状态
+ * 2. 多选模式:
+ *    2.1 点击表格左侧的多选框时,记录选中的商品
+ *    2.2 切换分页时,保持商品的选中的状态
+ *    2.3 点击右下角的确定按钮时,结束选择,关闭对话框
+ *    2.4 再次打开时,保持选中状态
+ */
+defineOptions({ name: 'SpuTableSelect' })
+
+defineProps({
+  // 多选模式
+  multiple: propTypes.bool.def(false)
+})
+
+// 列表的总页数
+const total = ref(0)
+// 列表的数据
+const list = ref<Spu[]>([])
+// 列表的加载中
+const loading = ref(false)
+// 弹窗的是否展示
+const dialogVisible = ref(false)
+// 查询参数
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+  // 默认获取上架的商品
+  tabType: 0,
+  name: '',
+  categoryId: null,
+  createTime: []
+})
+
+/** 打开弹窗 */
+const open = (spuList?: Spu[]) => {
+  // 重置
+  checkedSpus.value = []
+  checkedStatus.value = {}
+  isCheckAll.value = false
+  isIndeterminate.value = false
+
+  // 处理已选中
+  if (spuList && spuList.length > 0) {
+    checkedSpus.value = [...spuList]
+    checkedStatus.value = Object.fromEntries(spuList.map((spu) => [spu.id, true]))
+  }
+
+  dialogVisible.value = true
+  resetQuery()
+}
+// 提供 open 方法,用于打开弹窗
+defineExpose({ open })
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ProductSpuApi.getSpuPage(queryParams.value)
+    list.value = data.list
+    total.value = data.total
+    // checkbox绑定undefined会有问题,需要给一个bool值
+    list.value.forEach(
+      (spu) => (checkedStatus.value[spu.id] = checkedStatus.value[spu.id] || false)
+    )
+    // 计算全选框状态
+    calculateIsCheckAll()
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.value.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryParams.value = {
+    pageNo: 1,
+    pageSize: 10,
+    // 默认获取上架的商品
+    tabType: 0,
+    name: '',
+    categoryId: null,
+    createTime: []
+  }
+  getList()
+}
+
+// 是否全选
+const isCheckAll = ref(false)
+// 全选框是否处于中间状态:不是全部选中 && 任意一个选中
+const isIndeterminate = ref(false)
+// 选中的商品
+const checkedSpus = ref<Spu[]>([])
+// 选中状态:key为商品ID,value为是否选中
+const checkedStatus = ref<Record<string, boolean>>({})
+
+// 选中的商品 spuId
+const selectedSpuId = ref()
+/** 单选中时触发 */
+const handleSingleSelected = (spu: Spu) => {
+  emits(CHANGE_EVENT, spu)
+  // 关闭弹窗
+  dialogVisible.value = false
+  // 记住上次选择的ID
+  selectedSpuId.value = spu.id
+}
+
+/** 多选完成 */
+const handleEmitChange = () => {
+  // 关闭弹窗
+  dialogVisible.value = false
+  emits(CHANGE_EVENT, [...checkedSpus.value])
+}
+
+/** 确认选择时的触发事件 */
+const emits = defineEmits<{
+  change: [spu: Spu | Spu[] | any]
+}>()
+
+/** 全选/全不选 */
+const handleCheckAll = (checked: boolean) => {
+  isCheckAll.value = checked
+  isIndeterminate.value = false
+
+  list.value.forEach((spu) => handleCheckOne(checked, spu, false))
+}
+
+/**
+ * 选中一行
+ * @param checked 是否选中
+ * @param spu 商品
+ * @param isCalcCheckAll 是否计算全选
+ */
+const handleCheckOne = (checked: boolean, spu: Spu, isCalcCheckAll: boolean) => {
+  if (checked) {
+    checkedSpus.value.push(spu)
+    checkedStatus.value[spu.id] = true
+  } else {
+    const index = findCheckedIndex(spu)
+    if (index > -1) {
+      checkedSpus.value.splice(index, 1)
+      checkedStatus.value[spu.id] = false
+      isCheckAll.value = false
+    }
+  }
+
+  // 计算全选框状态
+  if (isCalcCheckAll) {
+    calculateIsCheckAll()
+  }
+}
+
+// 查找商品在已选中商品列表中的索引
+const findCheckedIndex = (spu: Spu) => checkedSpus.value.findIndex((item) => item.id === spu.id)
+
+// 计算全选框状态
+const calculateIsCheckAll = () => {
+  isCheckAll.value = list.value.every((spu) => checkedStatus.value[spu.id])
+  // 计算中间状态:不是全部选中 && 任意一个选中
+  isIndeterminate.value = !isCheckAll.value && list.value.some((spu) => checkedStatus.value[spu.id])
+}
+
+// 分类列表
+const categoryList = ref()
+// 分类树
+const categoryTreeList = ref()
+/** 初始化 **/
+onMounted(async () => {
+  await getList()
+  // 获得分类树
+  categoryList.value = await ProductCategoryApi.getCategoryList({})
+  categoryTreeList.value = handleTree(categoryList.value, 'id', 'parentId')
+})
+</script>

+ 187 - 0
src/views/mall/product/spu/form/SkuForm.vue

@@ -0,0 +1,187 @@
+<!-- 商品发布 - 库存价格 -->
+<template>
+  <el-form ref="formRef" :disabled="isDetail" :model="formData" :rules="rules" label-width="120px">
+    <el-form-item label="分销类型" props="subCommissionType">
+      <el-radio-group
+        v-model="formData.subCommissionType"
+        class="w-80"
+        @change="changeSubCommissionType"
+      >
+        <el-radio :label="false">默认设置</el-radio>
+        <el-radio :label="true" class="radio">单独设置</el-radio>
+      </el-radio-group>
+    </el-form-item>
+    <el-form-item label="商品规格" props="specType">
+      <el-radio-group v-model="formData.specType" class="w-80" @change="onChangeSpec">
+        <el-radio :label="false" class="radio">单规格</el-radio>
+        <el-radio :label="true">多规格</el-radio>
+      </el-radio-group>
+    </el-form-item>
+    <!-- 多规格添加-->
+    <el-form-item v-if="!formData.specType">
+      <SkuList
+        ref="skuListRef"
+        :prop-form-data="formData"
+        :property-list="propertyList"
+        :rule-config="ruleConfig"
+      />
+    </el-form-item>
+    <el-form-item v-if="formData.specType" label="商品属性">
+      <el-button class="mb-10px mr-15px" @click="attributesAddFormRef.open">添加属性</el-button>
+      <ProductAttributes
+        :is-detail="isDetail"
+        :property-list="propertyList"
+        @success="generateSkus"
+      />
+    </el-form-item>
+    <template v-if="formData.specType && propertyList.length > 0">
+      <el-form-item v-if="!isDetail" label="批量设置">
+        <SkuList :is-batch="true" :prop-form-data="formData" :property-list="propertyList" />
+      </el-form-item>
+      <el-form-item label="规格列表">
+        <SkuList
+          ref="skuListRef"
+          :is-detail="isDetail"
+          :prop-form-data="formData"
+          :property-list="propertyList"
+          :rule-config="ruleConfig"
+        />
+      </el-form-item>
+    </template>
+  </el-form>
+
+  <!-- 商品属性添加 Form 表单 -->
+  <ProductPropertyAddForm ref="attributesAddFormRef" :propertyList="propertyList" />
+</template>
+<script lang="ts" setup>
+import { PropType } from 'vue'
+import { copyValueToTarget } from '@/utils'
+import { propTypes } from '@/utils/propTypes'
+import {
+  getPropertyList,
+  PropertyAndValues,
+  RuleConfig,
+  SkuList
+} from '@/views/mall/product/spu/components/index'
+import ProductAttributes from './ProductAttributes.vue'
+import ProductPropertyAddForm from './ProductPropertyAddForm.vue'
+import type { Spu } from '@/api/mall/product/spu'
+
+defineOptions({ name: 'ProductSpuSkuForm' })
+
+// sku 相关属性校验规则
+const ruleConfig: RuleConfig[] = [
+  {
+    name: 'stock',
+    rule: (arg) => arg >= 0,
+    message: '商品库存必须大于等于 1 !!!'
+  },
+  {
+    name: 'price',
+    rule: (arg) => arg >= 0.01,
+    message: '商品销售价格必须大于等于 0.01 元!!!'
+  },
+  {
+    name: 'marketPrice',
+    rule: (arg) => arg >= 0.01,
+    message: '商品市场价格必须大于等于 0.01 元!!!'
+  },
+  {
+    name: 'costPrice',
+    rule: (arg) => arg >= 0.01,
+    message: '商品成本价格必须大于等于 0.00 元!!!'
+  }
+]
+
+const message = useMessage() // 消息弹窗
+
+const props = defineProps({
+  propFormData: {
+    type: Object as PropType<Spu>,
+    default: () => {}
+  },
+  isDetail: propTypes.bool.def(false) // 是否作为详情组件
+})
+const attributesAddFormRef = ref() // 添加商品属性表单
+const formRef = ref() // 表单 Ref
+const propertyList = ref<PropertyAndValues[]>([]) // 商品属性列表
+const skuListRef = ref() // 商品属性列表 Ref
+const formData = reactive<Spu>({
+  specType: false, // 商品规格
+  subCommissionType: false, // 分销类型
+  skus: []
+})
+const rules = reactive({
+  specType: [required],
+  subCommissionType: [required]
+})
+
+/** 将传进来的值赋值给 formData */
+watch(
+  () => props.propFormData,
+  (data) => {
+    if (!data) {
+      return
+    }
+    copyValueToTarget(formData, data)
+    // 将 SKU 的属性,整理成 PropertyAndValues 数组
+    propertyList.value = getPropertyList(data)
+  },
+  {
+    immediate: true
+  }
+)
+
+/** 表单校验 */
+const emit = defineEmits(['update:activeName'])
+const validate = async () => {
+  if (!formRef) return
+  try {
+    // 校验 sku
+    skuListRef.value.validateSku()
+    await unref(formRef).validate()
+    // 校验通过更新数据
+    Object.assign(props.propFormData, formData)
+  } catch (e) {
+    message.error('【库存价格】不完善,请填写相关信息')
+    emit('update:activeName', 'sku')
+    throw e // 目的截断之后的校验
+  }
+}
+defineExpose({ validate })
+
+/** 分销类型 */
+const changeSubCommissionType = () => {
+  // 默认为零,类型切换后也要重置为零
+  for (const item of formData.skus!) {
+    item.firstBrokeragePrice = 0
+    item.secondBrokeragePrice = 0
+  }
+}
+
+/** 选择规格 */
+const onChangeSpec = () => {
+  // 重置商品属性列表
+  propertyList.value = []
+  // 重置sku列表
+  formData.skus = [
+    {
+      price: 0,
+      marketPrice: 0,
+      costPrice: 0,
+      barCode: '',
+      picUrl: '',
+      stock: 0,
+      weight: 0,
+      volume: 0,
+      firstBrokeragePrice: 0,
+      secondBrokeragePrice: 0
+    }
+  ]
+}
+
+/** 调用 SkuList generateTableData 方法*/
+const generateSkus = (propertyList: any[]) => {
+  skuListRef.value.generateTableData(propertyList)
+}
+</script>

+ 112 - 0
src/views/mall/promotion/components/SpuAndSkuList.vue

@@ -0,0 +1,112 @@
+<template>
+  <el-table :data="spuData" :expand-row-keys="expandRowKeys" row-key="id">
+    <el-table-column type="expand" width="30">
+      <template #default="{ row }">
+        <SkuList
+          ref="skuListRef"
+          :is-activity-component="true"
+          :prop-form-data="spuPropertyList.find((item) => item.spuId === row.id)?.spuDetail"
+          :property-list="spuPropertyList.find((item) => item.spuId === row.id)?.propertyList"
+          :rule-config="ruleConfig"
+        >
+          <template #extension>
+            <slot></slot>
+          </template>
+        </SkuList>
+      </template>
+    </el-table-column>
+    <el-table-column key="id" align="center" label="商品编号" prop="id" />
+    <el-table-column label="商品图" min-width="80">
+      <template #default="{ row }">
+        <el-image :src="row.picUrl" class="h-30px w-30px" @click="imagePreview(row.picUrl)" />
+      </template>
+    </el-table-column>
+    <el-table-column :show-overflow-tooltip="true" label="商品名称" min-width="300" prop="name" />
+    <el-table-column align="center" label="商品售价" min-width="90" prop="price">
+      <template #default="{ row }">
+        {{ formatToFraction(row.price) }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="销量" min-width="90" prop="salesCount" />
+    <el-table-column align="center" label="库存" min-width="90" prop="stock" />
+  </el-table>
+</template>
+<script generic="T extends Spu" lang="ts" setup>
+import { formatToFraction } from '@/utils'
+import { createImageViewer } from '@/components/ImageViewer'
+import { Spu } from '@/api/mall/product/spu'
+import { RuleConfig, SkuList } from '@/views/mall/product/spu/components'
+import { SpuProperty } from '@/views/mall/promotion/components/index'
+
+defineOptions({ name: 'PromotionSpuAndSkuList' })
+
+const props = defineProps<{
+  spuList: T[]
+  ruleConfig: RuleConfig[]
+  spuPropertyListP: SpuProperty<T>[]
+}>()
+
+const spuData = ref<Spu[]>([]) // spu 详情数据列表
+const skuListRef = ref() // 商品属性列表Ref
+const spuPropertyList = ref<SpuProperty<T>[]>([]) // spuId 对应的 sku 的属性列表
+const expandRowKeys = ref<number[]>() // 控制展开行需要设置 row-key 属性才能使用,该属性为展开行的 keys 数组。
+
+/**
+ * 获取所有 sku 活动配置
+ *
+ * @param extendedAttribute 在 sku 上扩展的属性,例:秒杀活动 sku 扩展属性 productConfig 请参考 seckillActivity.ts
+ */
+const getSkuConfigs = (extendedAttribute: string) => {
+  skuListRef.value.validateSku()
+  const seckillProducts = []
+  spuPropertyList.value.forEach((item) => {
+    item.spuDetail.skus.forEach((sku) => {
+      seckillProducts.push(sku[extendedAttribute])
+    })
+  })
+  return seckillProducts
+}
+// 暴露出给表单提交时使用
+defineExpose({ getSkuConfigs })
+
+/** 商品图预览 */
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    zIndex: 99999999,
+    urlList: [imgUrl]
+  })
+}
+
+/**
+ * 将传进来的值赋值给 skuList
+ */
+watch(
+  () => props.spuList,
+  (data) => {
+    if (!data) return
+    spuData.value = data as Spu[]
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+/**
+ * 将传进来的值赋值给 skuList
+ */
+watch(
+  () => props.spuPropertyListP,
+  (data) => {
+    if (!data) return
+    spuPropertyList.value = data as SpuProperty<T>[]
+    // 解决如果之前选择的是单规格 spu 的话后面选择多规格 sku 多规格属性信息不展示的问题。解决方法:让 SkuList 组件重新渲染(行折叠会干掉包含的组件展开时会重新加载)
+    setTimeout(() => {
+      expandRowKeys.value = data.map((item) => item.spuId)
+    }, 200)
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+</script>

+ 317 - 0
src/views/mall/promotion/components/SpuSelect.vue

@@ -0,0 +1,317 @@
+<template>
+  <Dialog v-model="dialogVisible" :appendToBody="true" :title="dialogTitle" width="70%">
+    <ContentWrap>
+      <el-row :gutter="20" class="mb-10px">
+        <el-col :span="6">
+          <el-input
+            v-model="queryParams.name"
+            class="!w-240px"
+            clearable
+            placeholder="请输入商品名称"
+            @keyup.enter="handleQuery"
+          />
+        </el-col>
+        <el-col :span="6">
+          <el-tree-select
+            v-model="queryParams.categoryId"
+            :data="categoryList"
+            :props="defaultProps"
+            check-strictly
+            class="w-1/1"
+            node-key="id"
+            placeholder="请选择商品分类"
+          />
+        </el-col>
+        <el-col :span="6">
+          <el-date-picker
+            v-model="queryParams.createTime"
+            :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+            class="!w-240px"
+            end-placeholder="结束日期"
+            start-placeholder="开始日期"
+            type="daterange"
+            value-format="YYYY-MM-DD HH:mm:ss"
+          />
+        </el-col>
+        <el-col :span="6">
+          <el-button @click="handleQuery">
+            <Icon class="mr-5px" icon="ep:search" />
+            搜索
+          </el-button>
+          <el-button @click="resetQuery">
+            <Icon class="mr-5px" icon="ep:refresh" />
+            重置
+          </el-button>
+        </el-col>
+      </el-row>
+      <el-table
+        ref="spuListRef"
+        v-loading="loading"
+        :data="list"
+        :expand-row-keys="expandRowKeys"
+        row-key="id"
+        @expand-change="expandChange"
+        @selection-change="selectSpu"
+      >
+        <el-table-column v-if="isSelectSku" type="expand" width="30">
+          <template #default>
+            <SkuList
+              v-if="isExpand"
+              ref="skuListRef"
+              :isComponent="true"
+              :isDetail="true"
+              :prop-form-data="spuData"
+              :property-list="propertyList"
+              @selection-change="selectSku"
+            />
+          </template>
+        </el-table-column>
+        <el-table-column type="selection" width="55" />
+        <el-table-column key="id" align="center" label="商品编号" prop="id" />
+        <el-table-column label="商品图" min-width="80">
+          <template #default="{ row }">
+            <el-image :src="row.picUrl" class="h-30px w-30px" @click="imagePreview(row.picUrl)" />
+          </template>
+        </el-table-column>
+        <el-table-column
+          :show-overflow-tooltip="true"
+          label="商品名称"
+          min-width="300"
+          prop="name"
+        />
+        <el-table-column align="center" label="商品售价" min-width="90" prop="price">
+          <template #default="{ row }">
+            {{ formatToFraction(row.price) }}
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="销量" min-width="90" prop="salesCount" />
+        <el-table-column align="center" label="库存" min-width="90" prop="stock" />
+        <el-table-column align="center" label="排序" min-width="70" prop="sort" />
+        <el-table-column
+          :formatter="dateFormatter"
+          align="center"
+          label="创建时间"
+          prop="createTime"
+          width="180"
+        />
+      </el-table>
+      <!-- 分页 -->
+      <Pagination
+        v-model:limit="queryParams.pageSize"
+        v-model:page="queryParams.pageNo"
+        :total="total"
+        @pagination="getList"
+      />
+    </ContentWrap>
+    <template #footer>
+      <el-button type="primary" @click="confirm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import { getPropertyList, PropertyAndValues, SkuList } from '@/views/mall/product/spu/components'
+import { ElTable } from 'element-plus'
+import { dateFormatter } from '@/utils/formatTime'
+import { createImageViewer } from '@/components/ImageViewer'
+import { formatToFraction } from '@/utils'
+import { defaultProps, handleTree } from '@/utils/tree'
+
+import * as ProductCategoryApi from '@/api/mall/product/category'
+import * as ProductSpuApi from '@/api/mall/product/spu'
+import { propTypes } from '@/utils/propTypes'
+
+defineOptions({ name: 'PromotionSpuSelect' })
+
+const props = defineProps({
+  // 默认不需要(不需要的情况下只返回 spu,需要的情况下返回 选中的 spu 和 sku 列表)
+  // 其它活动需要选择商品和商品属性导入此组件即可,需添加组件属性 :isSelectSku='true'
+  isSelectSku: propTypes.bool.def(false), // 是否需要选择 sku 属性
+  radio: propTypes.bool.def(false) // 是否单选 sku
+})
+
+const message = useMessage() // 消息弹窗
+const total = ref(0) // 列表的总页数
+const list = ref<any[]>([]) // 列表的数据
+const loading = ref(false) // 列表的加载中
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+  tabType: 0, // 默认获取上架的商品
+  name: '',
+  categoryId: null,
+  createTime: []
+}) // 查询参数
+const propertyList = ref<PropertyAndValues[]>([]) // 商品属性列表
+const spuListRef = ref<InstanceType<typeof ElTable>>()
+const skuListRef = ref<InstanceType<typeof SkuList>>() // 商品属性选择 Ref
+const spuData = ref<ProductSpuApi.Spu>() // 商品详情
+const isExpand = ref(false) // 控制 SKU 列表显示
+const expandRowKeys = ref<number[]>() // 控制展开行需要设置 row-key 属性才能使用,该属性为展开行的 keys 数组。
+
+//============ 商品选择相关 ============
+const selectedSpuId = ref<number>(0) // 选中的商品 spuId
+const selectedSkuIds = ref<number[]>([]) // 选中的商品 skuIds
+const selectSku = (val: ProductSpuApi.Sku[]) => {
+  const skuTable = skuListRef.value?.getSkuTableRef()
+  if (selectedSpuId.value === 0) {
+    message.warning('请先选择商品再选择相应的规格!!!')
+    skuTable?.clearSelection()
+    return
+  }
+  if (val.length === 0) {
+    selectedSkuIds.value = []
+    return
+  }
+  if (props.radio) {
+    // 只选择一个
+    selectedSkuIds.value = [val.map((sku) => sku.id!)[0]]
+    // 如果大于1个
+    if (val.length > 1) {
+      // 清空选择
+      skuTable?.clearSelection()
+      // 变更为最后一次选择的
+      skuTable?.toggleRowSelection(val.pop(), true)
+      return
+    }
+  } else {
+    selectedSkuIds.value = val.map((sku) => sku.id!)
+  }
+}
+const selectSpu = (val: ProductSpuApi.Spu[]) => {
+  if (val.length === 0) {
+    selectedSpuId.value = 0
+    return
+  }
+  // 只选择一个
+  selectedSpuId.value = val.map((spu) => spu.id!)[0]
+  // 切换选择 spu 如果有选择的 sku 则清空,确保选择的 sku 是对应的 spu 下面的
+  if (selectedSkuIds.value.length > 0) {
+    selectedSkuIds.value = []
+  }
+  // 如果大于1个
+  if (val.length > 1) {
+    // 清空选择
+    spuListRef.value?.clearSelection()
+    // 变更为最后一次选择的
+    spuListRef.value?.toggleRowSelection(val.pop(), true)
+    return
+  }
+  expandChange(val[0], val)
+}
+
+// 计算商品属性
+const expandChange = async (row: ProductSpuApi.Spu, expandedRows?: ProductSpuApi.Spu[]) => {
+  // 判断需要展开的 spuId === 选择的 spuId。如果选择了 A 就展开 A 的 skuList。如果选择了 A 手动展开 B 则阻断
+  // 目的防止误选 sku
+  if (selectedSpuId.value !== 0) {
+    if (row.id !== selectedSpuId.value) {
+      message.warning('你已选择商品请先取消')
+      expandRowKeys.value = [selectedSpuId.value]
+      return
+    }
+    // 如果已展开 skuList 则选择此对应的 spu 不需要重新获取渲染 skuList
+    if (isExpand.value && spuData.value?.id === row.id) {
+      return
+    }
+  }
+  spuData.value = {}
+  propertyList.value = []
+  isExpand.value = false
+  if (expandedRows?.length === 0) {
+    // 如果展开个数为 0
+    expandRowKeys.value = []
+    return
+  }
+  // 获取 SPU 详情
+  const res = (await ProductSpuApi.getSpu(row.id as number)) as ProductSpuApi.Spu
+  propertyList.value = getPropertyList(res)
+  spuData.value = res
+  isExpand.value = true
+  expandRowKeys.value = [row.id!]
+}
+
+// 确认选择时的触发事件
+const emits = defineEmits<{
+  (e: 'confirm', spuId: number, skuIds?: number[]): void
+}>()
+/**
+ * 确认选择返回选中的 spu 和 sku (如果需要选择sku的话)
+ */
+const confirm = () => {
+  if (selectedSpuId.value === 0) {
+    message.warning('没有选择任何商品')
+    return
+  }
+  if (props.isSelectSku && selectedSkuIds.value.length === 0) {
+    message.warning('没有选择任何商品属性')
+    return
+  }
+  // 返回各自 id 列表
+  props.isSelectSku
+    ? emits('confirm', selectedSpuId.value, selectedSkuIds.value)
+    : emits('confirm', selectedSpuId.value)
+  // 关闭弹窗
+  dialogVisible.value = false
+  selectedSpuId.value = 0
+  selectedSkuIds.value = []
+}
+
+/** 打开弹窗 */
+const open = () => {
+  dialogTitle.value = '商品选择'
+  dialogVisible.value = true
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ProductSpuApi.getSpuPage(queryParams.value)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryParams.value = {
+    pageNo: 1,
+    pageSize: 10,
+    tabType: 0, // 默认获取上架的商品
+    name: '',
+    categoryId: null,
+    createTime: []
+  }
+  getList()
+}
+
+/** 商品图预览 */
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    zIndex: 99999999,
+    urlList: [imgUrl]
+  })
+}
+
+const categoryList = ref() // 分类树
+
+/** 初始化 **/
+onMounted(async () => {
+  await getList()
+  // 获得分类树
+  const data = await ProductCategoryApi.getCategoryList({})
+  categoryList.value = handleTree(data, 'id', 'parentId')
+})
+</script>

+ 132 - 0
src/views/member/signin/config/SignInConfigForm.vue

@@ -0,0 +1,132 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="签到天数" prop="day">
+        <el-input-number v-model="formData.day" :min="1" :max="7" :precision="0" />
+        <el-text class="mx-1" style="margin-left: 10px" type="danger">
+          只允许设置 1-7,默认签到 7 天为一个周期
+        </el-text>
+      </el-form-item>
+      <el-form-item label="奖励积分" prop="point">
+        <el-input-number v-model="formData.point" :min="0" :precision="0" />
+      </el-form-item>
+      <el-form-item label="奖励经验" prop="experience">
+        <el-input-number v-model="formData.experience" :min="0" :precision="0" />
+      </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>
+    <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 * as SignInConfigApi from '@/api/member/signin/config'
+import { CommonStatusEnum } from '@/utils/constants'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+
+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<SignInConfigApi.SignInConfigVO>({} as SignInConfigApi.SignInConfigVO)
+// 奖励校验规则
+const awardValidator = (rule: any, _value: any, callback: any) => {
+  if (!formData.value.point && !formData.value.experience) {
+    callback(new Error('奖励积分与奖励经验至少配置一个'))
+    return
+  }
+
+  // 清除另一个字段的错误提示
+  const otherAwardField = rule?.field === 'point' ? 'experience' : 'point'
+  formRef.value.validateField(otherAwardField, () => null)
+  callback()
+}
+const formRules = reactive({
+  day: [{ required: true, message: '签到天数不能空', trigger: 'blur' }],
+  point: [
+    { required: true, message: '奖励积分不能空', trigger: 'blur' },
+    { validator: awardValidator, trigger: 'blur' }
+  ],
+  experience: [
+    { required: true, message: '奖励经验不能空', trigger: 'blur' },
+    { validator: awardValidator, 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 SignInConfigApi.getSignInConfig(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 {
+    if (formType.value === 'create') {
+      await SignInConfigApi.createSignInConfig(formData.value)
+      message.success(t('common.createSuccess'))
+    } else {
+      await SignInConfigApi.updateSignInConfig(formData.value)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    day: undefined,
+    point: 0,
+    experience: 0,
+    status: CommonStatusEnum.ENABLE
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 144 - 0
src/views/system/sms/channel/SmsChannelForm.vue

@@ -0,0 +1,144 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="130px"
+    >
+      <el-form-item label="短信签名" prop="signature">
+        <el-input v-model="formData.signature" placeholder="请输入短信签名" />
+      </el-form-item>
+      <el-form-item label="渠道编码" prop="code">
+        <el-select v-model="formData.code" clearable placeholder="请选择渠道编码">
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="启用状态">
+        <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-item label="短信 API 的账号" prop="apiKey">
+        <el-input v-model="formData.apiKey" placeholder="请输入短信 API 的账号" />
+      </el-form-item>
+      <el-form-item label="短信 API 的密钥" prop="apiSecret">
+        <el-input v-model="formData.apiSecret" placeholder="请输入短信 API 的密钥" />
+      </el-form-item>
+      <el-form-item label="短信发送回调 URL" prop="callbackUrl">
+        <el-input v-model="formData.callbackUrl" placeholder="请输入短信发送回调 URL" />
+      </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, getStrDictOptions } from '@/utils/dict'
+import * as SmsChannelApi from '@/api/system/sms/smsChannel'
+import { CommonStatusEnum } from '@/utils/constants'
+
+defineOptions({ name: 'SystemSmsChannelForm' })
+
+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,
+  signature: '',
+  code: '',
+  status: CommonStatusEnum.ENABLE,
+  remark: '',
+  apiKey: '',
+  apiSecret: '',
+  callbackUrl: ''
+})
+const formRules = reactive({
+  signature: [{ required: true, message: '短信签名不能为空', trigger: 'blur' }],
+  code: [{ required: true, message: '渠道编码不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '启用状态不能为空', trigger: 'blur' }],
+  apiKey: [{ required: true, message: '短信 API 的账号不能为空', 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 SmsChannelApi.getSmsChannel(id)
+      console.log(formData)
+    } 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 SmsChannelApi.SmsChannelVO
+    if (formType.value === 'create') {
+      await SmsChannelApi.createSmsChannel(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await SmsChannelApi.updateSmsChannel(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    signature: '',
+    code: '',
+    status: CommonStatusEnum.ENABLE,
+    remark: '',
+    apiKey: '',
+    apiSecret: '',
+    callbackUrl: ''
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 86 - 0
src/views/system/sms/log/SmsLogDetail.vue

@@ -0,0 +1,86 @@
+<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="短信渠道">
+        {{ channelList.find((channel) => channel.id === detailData.channelId)?.signature }}
+        <dict-tag :type="DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE" :value="detailData.channelCode" />
+      </el-descriptions-item>
+      <el-descriptions-item label="短信模板">
+        {{ detailData.templateId }} | {{ detailData.templateCode }}
+        <dict-tag :type="DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE" :value="detailData.templateType" />
+      </el-descriptions-item>
+      <el-descriptions-item label="API 的模板编号">
+        {{ detailData.apiTemplateId }}
+      </el-descriptions-item>
+      <el-descriptions-item label="用户信息">
+        {{ detailData.mobile }}
+        <span v-if="detailData.userType && detailData.userId">
+          <dict-tag :type="DICT_TYPE.USER_TYPE" :value="detailData.userType" />
+          ({{ detailData.userId }})
+        </span>
+      </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="创建时间">
+        {{ formatDate(detailData.createTime) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="发送状态">
+        <dict-tag :type="DICT_TYPE.SYSTEM_SMS_SEND_STATUS" :value="detailData.sendStatus" />
+      </el-descriptions-item>
+      <el-descriptions-item label="发送时间">
+        {{ formatDate(detailData.sendTime) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="API 发送结果">
+        {{ detailData.apiSendCode }} | {{ detailData.apiSendMsg }}
+      </el-descriptions-item>
+      <el-descriptions-item label="API 短信编号">
+        {{ detailData.apiSerialNo }}
+      </el-descriptions-item>
+      <el-descriptions-item label="API 请求编号">
+        {{ detailData.apiRequestId }}
+      </el-descriptions-item>
+      <el-descriptions-item label="API 接收状态">
+        <dict-tag :type="DICT_TYPE.SYSTEM_SMS_RECEIVE_STATUS" :value="detailData.receiveStatus" />
+        {{ formatDate(detailData.receiveTime) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="API 接收结果">
+        {{ detailData.apiReceiveCode }} | {{ detailData.apiReceiveMsg }}
+      </el-descriptions-item>
+    </el-descriptions>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE } from '@/utils/dict'
+import { formatDate } from '@/utils/formatTime'
+import * as SmsLogApi from '@/api/system/sms/smsLog'
+import * as SmsChannelApi from '@/api/system/sms/smsChannel'
+
+defineOptions({ name: 'SystemSmsLogDetail' })
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const detailLoading = ref(false) // 表单的加载中
+const detailData = ref() // 详情数据
+const channelList = ref([]) // 短信渠道列表
+
+/** 打开弹窗 */
+const open = async (data: SmsLogApi.SmsLogVO) => {
+  dialogVisible.value = true
+  // 设置数据
+  detailLoading.value = true
+  try {
+    detailData.value = data
+  } finally {
+    detailLoading.value = false
+  }
+  // 加载渠道列表
+  channelList.value = await SmsChannelApi.getSimpleSmsChannelList()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+</script>

+ 163 - 0
src/views/system/sms/template/SmsTemplateForm.vue

@@ -0,0 +1,163 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="140px"
+    >
+      <el-form-item label="短信渠道编号" prop="channelId">
+        <el-select v-model="formData.channelId" placeholder="请选择短信渠道编号">
+          <el-option
+            v-for="channel in channelList"
+            :key="channel.id"
+            :label="
+              channel.signature +
+              `【 ${getDictLabel(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE, channel.code)}】`
+            "
+            :value="channel.id"
+          />
+        </el-select>
+      </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_SMS_TEMPLATE_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <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="content">
+        <el-input v-model="formData.content" placeholder="请输入模板内容" type="textarea" />
+      </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="短信 API 模板编号" prop="apiTemplateId">
+        <el-input v-model="formData.apiTemplateId" placeholder="请输入短信 API 的模板编号" />
+      </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 :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, getDictLabel, getIntDictOptions } from '@/utils/dict'
+import * as SmsTemplateApi from '@/api/system/sms/smsTemplate'
+import * as SmsChannelApi from '@/api/system/sms/smsChannel'
+import { CommonStatusEnum } from '@/utils/constants'
+
+defineOptions({ name: 'SystemSmsTemplateForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型
+const formData = ref<SmsTemplateApi.SmsTemplateVO>({
+  id: undefined,
+  type: undefined,
+  status: CommonStatusEnum.ENABLE,
+  code: '',
+  name: '',
+  content: '',
+  remark: '',
+  apiTemplateId: '',
+  channelId: undefined
+})
+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' }],
+  content: [{ required: true, message: '模板内容不能为空', trigger: 'blur' }],
+  apiTemplateId: [{ required: true, message: '短信 API 的模板编号不能为空', trigger: 'blur' }],
+  channelId: [{ required: true, message: '短信渠道编号不能为空', trigger: 'change' }]
+})
+const formRef = ref() // 表单 Ref
+const channelList = ref<SmsChannelApi.SmsChannelVO[]>([]) // 短信渠道列表
+
+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 SmsTemplateApi.getSmsTemplate(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 加载渠道列表
+  channelList.value = await SmsChannelApi.getSimpleSmsChannelList()
+}
+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 SmsTemplateApi.SmsTemplateVO
+    if (formType.value === 'create') {
+      await SmsTemplateApi.createSmsTemplate(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await SmsTemplateApi.updateSmsTemplate(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    type: undefined,
+    status: CommonStatusEnum.ENABLE,
+    code: '',
+    name: '',
+    content: '',
+    remark: '',
+    apiTemplateId: '',
+    channelId: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 120 - 0
src/views/system/sms/template/SmsTemplateSendForm.vue

@@ -0,0 +1,120 @@
+<template>
+  <Dialog v-model="dialogVisible" title="测试">
+    <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="mobile">
+        <el-input v-model="formData.mobile" placeholder="请输入手机号" />
+      </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 SmsTemplateApi from '@/api/system/sms/smsTemplate'
+
+defineOptions({ name: 'SystemSmsTemplateSendForm' })
+
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+
+// 发送短信表单相关
+const formData = ref({
+  content: '',
+  params: {},
+  mobile: '',
+  templateCode: '',
+  templateParams: new Map()
+})
+const formRules = reactive({
+  mobile: [{ required: true, message: '手机不能为空', trigger: 'blur' }],
+  templateCode: [{ required: true, message: '模版编码不能为空', trigger: 'blur' }],
+  templateParams: {}
+})
+const formRef = ref() // 表单 Ref
+
+const open = async (id: number) => {
+  dialogVisible.value = true
+  resetForm()
+  // 设置数据
+  formLoading.value = true
+  try {
+    const data = await SmsTemplateApi.getSmsTemplate(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
+  }
+}
+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 SmsTemplateApi.SendSmsReqVO
+    const logId = await SmsTemplateApi.sendSms(data)
+    if (logId) {
+      message.success('提交发送成功!发送结果,见发送日志编号:' + logId)
+    }
+    dialogVisible.value = false
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    content: '',
+    params: {},
+    mobile: '',
+    templateCode: '',
+    templateParams: new Map()
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 154 - 0
src/views/system/social/client/SocialClientForm.vue

@@ -0,0 +1,154 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+    >
+      <el-form-item label="应用名" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入应用名" />
+      </el-form-item>
+      <el-form-item label="社交平台" prop="socialType">
+        <el-radio-group v-model="formData.socialType">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_SOCIAL_TYPE)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </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 label="客户端编号" prop="clientId">
+        <el-input v-model="formData.clientId" placeholder="请输入客户端编号,对应各平台的appKey" />
+      </el-form-item>
+      <el-form-item label="客户端密钥" prop="clientSecret">
+        <el-input
+          v-model="formData.clientSecret"
+          placeholder="请输入客户端密钥,对应各平台的appSecret"
+        />
+      </el-form-item>
+      <el-form-item label="agentId" prop="agentId" v-if="formData!.socialType === 30">
+        <el-input v-model="formData.agentId" placeholder="授权方的网页应用 ID,有则填" />
+      </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>
+    <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 * as SocialClientApi from '@/api/system/social/client'
+
+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,
+  name: undefined,
+  socialType: undefined,
+  userType: undefined,
+  clientId: undefined,
+  clientSecret: undefined,
+  agentId: undefined,
+  status: 0
+})
+const formRules = reactive({
+  name: [{ required: true, message: '应用名不能为空', trigger: 'blur' }],
+  socialType: [{ required: true, message: '社交平台不能为空', trigger: 'blur' }],
+  userType: [{ required: true, message: '用户类型不能为空', trigger: 'blur' }],
+  clientId: [{ required: true, message: '客户端编号不能为空', trigger: 'blur' }],
+  clientSecret: [{ required: true, message: '客户端密钥不能为空', trigger: 'blur' }],
+  status: [{ 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 SocialClientApi.getSocialClient(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 SocialClientApi.SocialClientVO
+    if (formType.value === 'create') {
+      await SocialClientApi.createSocialClient(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await SocialClientApi.updateSocialClient(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    socialType: undefined,
+    userType: undefined,
+    clientId: undefined,
+    clientSecret: undefined,
+    agentId: undefined,
+    status: 0
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 60 - 0
src/views/system/social/user/SocialUserDetail.vue

@@ -0,0 +1,60 @@
+<template>
+  <Dialog v-model="dialogVisible" title="详情" width="800">
+    <el-descriptions :column="1" border>
+      <el-descriptions-item label="社交平台" min-width="160">
+        <dict-tag :type="DICT_TYPE.SYSTEM_SOCIAL_TYPE" :value="detailData.type" />
+      </el-descriptions-item>
+      <el-descriptions-item label="用户昵称" min-width="120">
+        {{ detailData.nickname }}
+      </el-descriptions-item>
+      <el-descriptions label="用户头像" min-width="120">
+        <el-image :src="detailData.avatar" class="h-30px w-30px" />
+      </el-descriptions>
+      <el-descriptions-item label="社交 token" min-width="120">
+        {{ detailData.token }}
+      </el-descriptions-item>
+      <el-descriptions-item label="原始 Token 数据" min-width="120">
+        <el-input
+          v-model="detailData.rawTokenInfo"
+          :autosize="{ maxRows: 20 }"
+          :readonly="true"
+          type="textarea"
+        />
+      </el-descriptions-item>
+      <el-descriptions-item label="原始 User 数据" min-width="120">
+        <el-input
+          v-model="detailData.rawUserInfo"
+          :autosize="{ maxRows: 20 }"
+          :readonly="true"
+          type="textarea"
+        />
+      </el-descriptions-item>
+      <el-descriptions-item label="最后一次的认证 code" min-width="120">
+        {{ detailData.code }}
+      </el-descriptions-item>
+      <el-descriptions-item label="最后一次的认证 state" min-width="120">
+        {{ detailData.state }}
+      </el-descriptions-item>
+    </el-descriptions>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE } from '@/utils/dict'
+import * as SocialUserApi from '@/api/system/social/user'
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const detailLoading = ref(false) // 表单的加载中
+const detailData = ref({} as SocialUserApi.SocialUserVO) // 详情数据
+
+/** 打开弹窗 */
+const open = async (id: number) => {
+  dialogVisible.value = true
+  // 设置数据
+  try {
+    detailData.value = await SocialUserApi.getSocialUser(id)
+  } finally {
+    detailLoading.value = false
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+</script>