ydmyzx 5 mesi fa
parent
commit
393293dca2

+ 123 - 0
src/api/mall/statistics/member.ts

@@ -0,0 +1,123 @@
+import request from '@/config/axios'
+import dayjs from 'dayjs'
+import { DataComparisonRespVO } from '@/api/mall/statistics/common'
+import { formatDate } from '@/utils/formatTime'
+
+/** 会员分析 Request VO */
+export interface MemberAnalyseReqVO {
+  times: dayjs.ConfigType[]
+}
+
+/** 会员分析 Response VO */
+export interface MemberAnalyseRespVO {
+  visitUserCount: number
+  orderUserCount: number
+  payUserCount: number
+  atv: number
+  comparison: DataComparisonRespVO<MemberAnalyseComparisonRespVO>
+}
+
+/** 会员分析对照数据 Response VO */
+export interface MemberAnalyseComparisonRespVO {
+  registerUserCount: number
+  visitUserCount: number
+  rechargeUserCount: number
+}
+
+/** 会员地区统计 Response VO */
+export interface MemberAreaStatisticsRespVO {
+  areaId: number
+  areaName: string
+  userCount: number
+  orderCreateUserCount: number
+  orderPayUserCount: number
+  orderPayPrice: number
+}
+
+/** 会员性别统计 Response VO */
+export interface MemberSexStatisticsRespVO {
+  sex: number
+  userCount: number
+}
+
+/** 会员统计 Response VO */
+export interface MemberSummaryRespVO {
+  userCount: number
+  rechargeUserCount: number
+  rechargePrice: number
+  expensePrice: number
+}
+
+/** 会员终端统计 Response VO */
+export interface MemberTerminalStatisticsRespVO {
+  terminal: number
+  userCount: number
+}
+
+/** 会员数量统计 Response VO */
+export interface MemberCountRespVO {
+  /** 用户访问量 */
+  visitUserCount: string
+  /** 注册用户数量 */
+  registerUserCount: number
+}
+
+/** 会员注册数量 Response VO */
+export interface MemberRegisterCountRespVO {
+  date: string
+  count: number
+}
+
+// 查询会员统计
+export const getMemberSummary = () => {
+  return request.get<MemberSummaryRespVO>({
+    url: '/statistics/member/summary'
+  })
+}
+
+// 查询会员分析数据
+export const getMemberAnalyse = (params: MemberAnalyseReqVO) => {
+  return request.get<MemberAnalyseRespVO>({
+    url: '/statistics/member/analyse',
+    params: { times: [formatDate(params.times[0]), formatDate(params.times[1])] }
+  })
+}
+
+// 按照省份,查询会员统计列表
+export const getMemberAreaStatisticsList = () => {
+  return request.get<MemberAreaStatisticsRespVO[]>({
+    url: '/statistics/member/area-statistics-list'
+  })
+}
+
+// 按照性别,查询会员统计列表
+export const getMemberSexStatisticsList = () => {
+  return request.get<MemberSexStatisticsRespVO[]>({
+    url: '/statistics/member/sex-statistics-list'
+  })
+}
+
+// 按照终端,查询会员统计列表
+export const getMemberTerminalStatisticsList = () => {
+  return request.get<MemberTerminalStatisticsRespVO[]>({
+    url: '/statistics/member/terminal-statistics-list'
+  })
+}
+
+// 获得用户数量量对照
+export const getUserCountComparison = () => {
+  return request.get<DataComparisonRespVO<MemberCountRespVO>>({
+    url: '/statistics/member/user-count-comparison'
+  })
+}
+
+// 获得会员注册数量列表
+export const getMemberRegisterCountList = (
+  beginTime: dayjs.ConfigType,
+  endTime: dayjs.ConfigType
+) => {
+  return request.get<MemberRegisterCountRespVO[]>({
+    url: '/statistics/member/register-count-list',
+    params: { times: [formatDate(beginTime), formatDate(endTime)] }
+  })
+}

+ 88 - 0
src/layout/components/Logo/src/Logo.vue

@@ -0,0 +1,88 @@
+<script lang="ts" setup>
+import { computed, onMounted, ref, unref, watch } from 'vue'
+import { useAppStore } from '@/store/modules/app'
+import { useDesign } from '@/hooks/web/useDesign'
+
+defineOptions({ name: 'Logo' })
+
+const { getPrefixCls } = useDesign()
+
+const prefixCls = getPrefixCls('logo')
+
+const appStore = useAppStore()
+
+const show = ref(true)
+
+const title = computed(() => appStore.getTitle)
+
+const layout = computed(() => appStore.getLayout)
+
+const collapse = computed(() => appStore.getCollapse)
+
+onMounted(() => {
+  if (unref(collapse)) show.value = false
+})
+
+watch(
+  () => collapse.value,
+  (collapse: boolean) => {
+    if (unref(layout) === 'topLeft' || unref(layout) === 'cutMenu') {
+      show.value = true
+      return
+    }
+    if (!collapse) {
+      setTimeout(() => {
+        show.value = !collapse
+      }, 400)
+    } else {
+      show.value = !collapse
+    }
+  }
+)
+
+watch(
+  () => layout.value,
+  (layout) => {
+    if (layout === 'top' || layout === 'cutMenu') {
+      show.value = true
+    } else {
+      if (unref(collapse)) {
+        show.value = false
+      } else {
+        show.value = true
+      }
+    }
+  }
+)
+</script>
+
+<template>
+  <div>
+    <router-link
+      :class="[
+        prefixCls,
+        layout !== 'classic' ? `${prefixCls}__Top` : '',
+        'flex !h-[var(--logo-height)] items-center cursor-pointer pl-8px relative decoration-none overflow-hidden'
+      ]"
+      to="/"
+    >
+      <img
+        class="h-[calc(var(--logo-height)-10px)] w-[calc(var(--logo-height)-10px)]"
+        src="@/assets/imgs/logo.png"
+      />
+      <div
+        v-if="show"
+        :class="[
+          'ml-10px text-16px font-700',
+          {
+            'text-[var(--logo-title-text-color)]': layout === 'classic',
+            'text-[var(--top-header-text-color)]':
+              layout === 'topLeft' || layout === 'top' || layout === 'cutMenu'
+          }
+        ]"
+      >
+        {{ title }}
+      </div>
+    </router-link>
+  </div>
+</template>

+ 72 - 0
src/main.ts

@@ -0,0 +1,72 @@
+// 引入unocss css
+import '@/plugins/unocss'
+
+// 导入全局的svg图标
+import '@/plugins/svgIcon'
+
+// 初始化多语言
+import { setupI18n } from '@/plugins/vueI18n'
+
+// 引入状态管理
+import { setupStore } from '@/store'
+
+// 全局组件
+import { setupGlobCom } from '@/components'
+
+// 引入 element-plus
+import { setupElementPlus } from '@/plugins/elementPlus'
+
+// 引入 form-create
+import { setupFormCreate } from '@/plugins/formCreate'
+
+// 引入全局样式
+import '@/styles/index.scss'
+
+// 引入动画
+import '@/plugins/animate.css'
+
+// 路由
+import router, { setupRouter } from '@/router'
+
+// 权限
+import { setupAuth } from '@/directives'
+
+import { createApp } from 'vue'
+
+import App from './App.vue'
+
+import './permission'
+
+import '@/plugins/tongji' // 百度统计
+import Logger from '@/utils/Logger'
+
+import VueDOMPurifyHTML from 'vue-dompurify-html' // 解决v-html 的安全隐患
+
+// 创建实例
+const setupAll = async () => {
+  const app = createApp(App)
+
+  await setupI18n(app)
+
+  setupStore(app)
+
+  setupGlobCom(app)
+
+  setupElementPlus(app)
+
+  setupFormCreate(app)
+
+  setupRouter(app)
+
+  setupAuth(app)
+
+  await router.isReady()
+
+  app.use(VueDOMPurifyHTML)
+
+  app.mount('#app')
+}
+
+setupAll()
+
+Logger.prettyPrimary(`欢迎使用`, import.meta.env.VITE_APP_TITLE)

+ 10 - 0
src/types/localeDropdown.d.ts

@@ -0,0 +1,10 @@
+export interface Language {
+  el: Recordable
+  name: string
+}
+
+export interface LocaleDropdownType {
+  lang: LocaleType
+  name?: string
+  elLocale?: Language
+}

+ 106 - 0
src/views/Login/Login.vue

@@ -0,0 +1,106 @@
+<template>
+  <div
+    :class="prefixCls"
+    class="relative h-[100%] lt-md:px-10px lt-sm:px-10px lt-xl: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-100px" 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-650px" src="@/assets/svgs/login-bg6.svg" style="margin-left: -200px;" /> -->
+<!--            <img key="1" alt="" class="w-450px" src="@/assets/imgs/login-img5.png" style="margin-left: 20px;" />-->
+            <div style="margin-top: -30px;text-align: center;" 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"
+          >
+            <!-- 账号登录 -->
+            <LoginForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
+            <!-- 手机登录 -->
+            <MobileForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
+            <!-- 二维码登录 -->
+            <QrCodeForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
+            <!-- 注册 -->
+            <RegisterForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
+            <!-- 三方登录 -->
+            <SSOLoginVue class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
+          </div>
+        </Transition>
+      </div>
+    </div>
+  </div>
+</template>
+<script lang="ts" setup>
+import { underlineToHump } from '@/utils'
+
+import { useDesign } from '@/hooks/web/useDesign'
+import { useAppStore } from '@/store/modules/app'
+import { ThemeSwitch } from '@/layout/components/ThemeSwitch'
+import { LocaleDropdown } from '@/layout/components/LocaleDropdown'
+
+import { LoginForm, MobileForm, QrCodeForm, RegisterForm, SSOLoginVue } from './components'
+
+defineOptions({ name: 'Login' })
+
+const { t } = useI18n()
+const appStore = useAppStore()
+const { getPrefixCls } = useDesign()
+const prefixCls = getPrefixCls('login')
+</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-color: #001e1b;
+      background-position: center;
+      background-repeat: no-repeat;
+      content: '';
+    }
+  }
+}
+</style>

+ 360 - 0
src/views/Login/components/LoginForm.vue

@@ -0,0 +1,360 @@
+<template>
+  <div>
+    <div style="text-align: center">
+    <img style="width: 200px;height: auto;" alt="" class="mr-10px h-48px w-100px" src="@/assets/imgs/logo.png" />
+  </div>
+  <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 === 'true'" 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-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item>
+          <el-row :gutter="5" justify="space-between" style="width: 100%">
+            <el-col :span="8">
+              <XButton
+                :title="t('login.btnMobile')"
+                class="w-[100%]"
+                @click="setLoginState(LoginStateEnum.MOBILE)"
+              />
+            </el-col>
+            <el-col :span="8">
+              <XButton
+                :title="t('login.btnQRCode')"
+                class="w-[100%]"
+                @click="setLoginState(LoginStateEnum.QR_CODE)"
+              />
+            </el-col>
+            <el-col :span="8">
+              <XButton
+                :title="t('login.btnRegister')"
+                class="w-[100%]"
+                @click="setLoginState(LoginStateEnum.REGISTER)"
+              />
+            </el-col>
+          </el-row>
+        </el-form-item>
+      </el-col> -->
+      <!-- <el-divider content-position="center">{{ t('login.otherLogin') }}</el-divider>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item>
+          <div class="w-[100%] flex justify-between">
+            <Icon
+              v-for="(item, key) in socialList"
+              :key="key"
+              :icon="item.icon"
+              :size="30"
+              class="anticon cursor-pointer"
+              color="#999"
+              @click="doSocialLogin(item.type)"
+            />
+          </div>
+        </el-form-item>
+      </el-col> -->
+      <!-- <el-divider content-position="center">萌新必读</el-divider>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item>
+          <div class="w-[100%] flex justify-between">
+            <el-link href="https://doc.iocoder.cn/" target="_blank">📚开发指南</el-link>
+            <el-link href="https://doc.iocoder.cn/video/" target="_blank">🔥视频教程</el-link>
+            <el-link href="https://www.iocoder.cn/Interview/good-collection/" target="_blank">
+              ⚡面试手册
+            </el-link>
+            <el-link href="http://static.yudao.iocoder.cn/mp/Aix9975.jpeg" target="_blank">
+              🤝外包咨询
+            </el-link>
+          </div>
+        </el-form-item>
+      </el-col> -->
+    </el-row>
+  </el-form>
+  </div>
+ 
+</template>
+<script lang="ts" setup>
+import { ElLoading } from 'element-plus'
+import LoginFormTitle from './LoginFormTitle.vue'
+import type { RouteLocationNormalizedLoaded } from 'vue-router'
+
+import { useIcon } from '@/hooks/web/useIcon'
+
+import * as authUtil from '@/utils/auth'
+import { usePermissionStore } from '@/store/modules/permission'
+import * as LoginApi from '@/api/login'
+import { LoginStateEnum, useFormValid, useLoginState } from './useLogin'
+
+defineOptions({ name: 'LoginForm' })
+
+const { t } = useI18n()
+const message = useMessage()
+const iconHouse = useIcon({ icon: 'ep:house' })
+const iconAvatar = useIcon({ icon: 'ep:avatar' })
+const iconLock = useIcon({ icon: 'ep:lock' })
+const formLogin = ref()
+const { validForm } = useFormValid(formLogin)
+const { setLoginState, getLoginState } = useLoginState()
+const { currentRoute, push } = useRouter()
+const permissionStore = usePermissionStore()
+const redirect = ref<string>('')
+const loginLoading = ref(false)
+const verify = ref()
+const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字
+
+const getShow = computed(() => unref(getLoginState) === LoginStateEnum.LOGIN)
+
+const LoginRules = {
+  tenantName: [required],
+  username: [required],
+  password: [required]
+}
+const loginData = reactive({
+  isShowPassword: false,
+  captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE,
+  tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE,
+  loginForm: {
+    tenantName: import.meta.env.VITE_APP_DEFAULT_LOGIN_TENANT || '',
+    username: import.meta.env.VITE_APP_DEFAULT_LOGIN_USERNAME || '',
+    password: import.meta.env.VITE_APP_DEFAULT_LOGIN_PASSWORD || '',
+    captchaVerification: '',
+    rememberMe: true // 默认记录我。如果不需要,可手动修改
+  }
+})
+
+const socialList = [
+  { icon: 'ant-design:wechat-filled', type: 30 },
+  { icon: 'ant-design:dingtalk-circle-filled', type: 20 },
+  { icon: 'ant-design:github-filled', type: 0 },
+  { icon: 'ant-design:alipay-circle-filled', type: 0 }
+]
+
+// 获取验证码
+const getCode = async () => {
+  // 情况一,未开启:则直接登录
+  if (loginData.captchaEnable === 'false') {
+    await handleLogin({})
+  } else {
+    // 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行登录
+    // 弹出验证码
+    verify.value.show()
+  }
+}
+// 获取租户 ID
+const getTenantId = async () => {
+  if (loginData.tenantEnable === 'true') {
+    const res = await LoginApi.getTenantIdByName(loginData.loginForm.tenantName)
+    authUtil.setTenantId(res)
+  }
+}
+// 记住我
+const getLoginFormCache = () => {
+  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,
+      tenantName: loginForm.tenantName ? loginForm.tenantName : loginData.loginForm.tenantName
+    }
+  }
+}
+// 根据域名,获得租户信息
+const getTenantByWebsite = async () => {
+  const website = location.host
+  const res = await LoginApi.getTenantByWebsite(website)
+  if (res) {
+    loginData.loginForm.tenantName = res.name
+    authUtil.setTenantId(res.id)
+  }
+}
+const loading = ref() // ElLoading.service 返回的实例
+// 登录
+const handleLogin = async (params) => {
+  loginLoading.value = true
+  try {
+    await getTenantId()
+    const data = await validForm()
+    if (!data) {
+      return
+    }
+    loginData.loginForm.captchaVerification = params.captchaVerification
+    const res = await LoginApi.login(loginData.loginForm)
+    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.value) {
+      redirect.value = '/'
+    }
+    // 判断是否为SSO登录
+    if (redirect.value.indexOf('sso') !== -1) {
+      window.location.href = window.location.href.replace('/login?redirect=', '')
+    } else {
+      push({ path: redirect.value || permissionStore.addRouters[0].path })
+    }
+  } finally {
+    loginLoading.value = false
+    loading.value.close()
+  }
+}
+
+// 社交登录
+const doSocialLogin = async (type: number) => {
+  if (type === 0) {
+    message.error('此方式未配置')
+  } else {
+    loginLoading.value = true
+    if (loginData.tenantEnable === 'true') {
+      // 尝试先通过 tenantName 获取租户
+      await getTenantId()
+      // 如果获取不到,则需要弹出提示,进行处理
+      if (!authUtil.getTenantId()) {
+        try {
+          const data = await message.prompt('请输入租户名称', t('common.reminder'))
+          if (data?.action !== 'confirm') throw 'cancel'
+          const res = await LoginApi.getTenantIdByName(data.value)
+          authUtil.setTenantId(res)
+        } catch (error) {
+          if (error === 'cancel') return
+        } finally {
+          loginLoading.value = false
+        }
+      }
+    }
+    // 计算 redirectUri
+    // tricky: type、redirect需要先encode一次,否则钉钉回调会丢失。
+    // 配合 Login/SocialLogin.vue#getUrlValue() 使用
+    const redirectUri =
+      location.origin +
+      '/social-login?' +
+      encodeURIComponent(`type=${type}&redirect=${redirect.value || '/'}`)
+
+    // 进行跳转
+    const res = await LoginApi.socialAuthRedirect(type, encodeURIComponent(redirectUri))
+    window.location.href = res
+  }
+}
+watch(
+  () => currentRoute.value,
+  (route: RouteLocationNormalizedLoaded) => {
+    redirect.value = route?.query?.redirect as string
+  },
+  {
+    immediate: true
+  }
+)
+onMounted(() => {
+  getLoginFormCache()
+  getTenantByWebsite()
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.anticon) {
+  &:hover {
+    color: var(--el-color-primary) !important;
+  }
+}
+
+.login-code {
+  float: right;
+  width: 100%;
+  height: 38px;
+
+  img {
+    width: 100%;
+    height: auto;
+    max-width: 100px;
+    vertical-align: middle;
+    cursor: pointer;
+  }
+}
+</style>

+ 26 - 0
src/views/Login/components/LoginFormTitle.vue

@@ -0,0 +1,26 @@
+<template>
+  <h2 class="enter-x mb-3 text-center text-2xl font-bold xl:text-center xl:text-3xl">
+    {{ getFormTitle }}
+  </h2>
+</template>
+<script lang="ts" setup>
+import { LoginStateEnum, useLoginState } from './useLogin'
+
+defineOptions({ name: 'LoginFormTitle' })
+
+const { t } = useI18n()
+
+const { getLoginState } = useLoginState()
+
+const getFormTitle = computed(() => {
+  const titleObj = {
+    [LoginStateEnum.RESET_PASSWORD]: t('sys.login.forgetFormTitle'),
+    [LoginStateEnum.LOGIN]: t('sys.login.signInFormTitle'),
+    [LoginStateEnum.REGISTER]: t('sys.login.signUpFormTitle'),
+    [LoginStateEnum.MOBILE]: t('sys.login.mobileSignInFormTitle'),
+    [LoginStateEnum.QR_CODE]: t('sys.login.qrSignInFormTitle'),
+    [LoginStateEnum.SSO]: t('sys.login.ssoFormTitle')
+  }
+  return titleObj[unref(getLoginState)]
+})
+</script>

+ 83 - 0
src/views/ai/music/components/mode/lyric.vue

@@ -0,0 +1,83 @@
+<template>
+  <div class="">
+    <Title title="歌词" desc="自己编写歌词或使用Ai生成歌词,两节/8行效果最佳">
+      <el-input
+        v-model="formData.lyric"
+        :autosize="{ minRows: 6, maxRows: 6}"
+        resize="none"
+        type="textarea"
+        maxlength="1200"
+        show-word-limit
+        placeholder="请输入您自己的歌词"
+      />
+    </Title>
+
+    <Title title="音乐风格">
+      <el-space class="flex-wrap">
+        <el-tag v-for="tag in tags" :key="tag" round class="mb-8px">{{tag}}</el-tag>
+      </el-space>
+
+      <el-button
+        :type="showCustom ? 'primary': 'default'" 
+        round 
+        size="small" 
+        class="mb-6px"
+        @click="showCustom = !showCustom"
+      >自定义风格
+      </el-button>
+    </Title>
+
+    <Title v-show="showCustom" desc="描述您想要的音乐风格,Suno无法识别艺术家的名字,但可以理解流派和氛围" class="-mt-12px">
+      <el-input
+        v-model="formData.style"
+        :autosize="{ minRows: 4, maxRows: 4}"
+        resize="none"
+        type="textarea"
+        maxlength="256"
+        show-word-limit
+        placeholder="输入音乐风格(英文)"
+      />
+    </Title>
+
+    <Title title="音乐/歌曲名称">
+      <el-input v-model="formData.name" placeholder="请输入音乐/歌曲名称"/>
+    </Title>
+
+    <Title title="版本">
+      <el-select v-model="formData.version" placeholder="请选择">
+        <el-option
+          v-for="item in [{
+            value: '3',
+            label: 'V3'
+          }, {
+            value: '2',
+            label: 'V2'
+          }]"
+          :key="item.value"
+          :label="item.label"
+          :value="item.value"
+        />
+      </el-select>
+    </Title>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import Title from '../title/index.vue'
+defineOptions({ name: 'Lyric' })
+
+const tags = ['rock', 'punk', 'jazz', 'soul', 'country', 'kidsmusic', 'pop']
+
+const showCustom = ref(false)
+
+const formData = reactive({
+  lyric: '',
+  style: '',
+  name: '',
+  version: ''
+})
+
+defineExpose({
+  formData
+})
+</script>

+ 395 - 0
src/views/bpm/disbursement/LtyForm.vue

@@ -0,0 +1,395 @@
+<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="天地图配置">
+        <el-input  v-for="(tdtitem,index) in tdtArray" :key="index" v-model="tdtArray[index]" >
+          <template #append>
+            <el-button @click="tdtArray.splice(index,1) ">删除</el-button>
+          </template>
+        </el-input>
+        <el-button @click="tdtArray.push('') ">添加+</el-button>
+      </el-form-item>
+
+      <el-form-item label="地图初始位置">
+      <div>
+          <el-input v-model="formData.itemZoomSet[0]">
+                <template #prepend>
+                  经度
+                </template>
+          </el-input>
+          <el-input v-model="formData.itemZoomSet[1]">
+                <template #prepend>
+                  维度
+                </template>
+          </el-input>
+          <el-input v-model="formData.itemZoomSet[2]">
+                <template #prepend>
+                  高度
+                </template>
+          </el-input>
+          <el-input v-model="formData.itemZoomSet[3]">
+                <template #prepend>
+                  倾角
+                </template>
+          </el-input>
+      </div>
+    </el-form-item>
+
+      <el-form-item label="数据绑定" prop="itemShp">
+        <!-- <el-input v-model="formData.itemShp" placeholder="请输入项目相关shp上传(保存url,可null)" />
+          -->
+          <el-select
+              v-model="formData.itemShp"
+              multiple
+              filterable
+              allow-create
+              default-first-option
+              placeholder="请选择项目相关图层">
+              <el-option
+                v-for="dict in datagisname"
+                :key="dict.id"
+                :label="dict.shpName"
+                :value="dict.shpName"
+              />
+            </el-select>
+      </el-form-item>
+
+
+      <el-form-item :label="item" v-for="(item) in formData.itemShp" :key="item" >
+          图层属性配置:
+          <el-select
+                v-model="shpItemSelected[item]"
+                multiple
+                filterable
+                default-first-option
+                placeholder="图层属性配置">
+                <el-option
+                  v-for="dict in shpItemArr[item] "
+                  :key="dict"
+                  :label="dict"
+                  :value="dict"
+                />
+            </el-select>
+
+            标签注记配置:
+            <el-select
+                v-model="shpLabelSelected[item]"
+                multiple
+                filterable
+                default-first-option
+                placeholder="标签字段配置">
+                <el-option
+                  v-for="dict in shpItemArr[item] "
+                  :key="dict"
+                  :label="dict"
+                  :value="dict"
+                />
+            </el-select>
+
+            <div v-for="(tdtitem,index) in shpLabelSelected[item]" :key="index" >
+              {{ tdtitem }}:
+              <!-- <el-input v-model="shpLabelSelected[item][index]" placeholder="标签注记配置" /> -->
+              <el-input v-model="shpLabelConfig[item][index].FontColor" placeholder="标签注记配置2" >
+                <template #prepend>
+                  <el-button >字体颜色</el-button>
+                </template>
+                <template #append>
+                  颜色选择器:
+                  <el-color-picker v-model="shpLabelConfig[item][index].FontColor" show-alpha />
+                  <!-- <el-button >px</el-button> -->
+                </template>
+              </el-input>
+              <el-input v-model="shpLabelConfig[item][index].MaxDis" placeholder="标签注记配置3" >
+                <template #prepend>
+                  <el-button >最大可见距离:</el-button>
+                </template>
+                <template #append>
+                  <el-button >米</el-button>
+                </template>
+              </el-input>
+              <el-input v-model="shpLabelConfig[item][index].Scale[0]" placeholder="标签注记配置4" >
+                <template #prepend>
+                  <el-button >最远处缩放:</el-button>
+                </template>
+                <template #append>
+                  <el-button >倍</el-button>
+                </template>
+              </el-input>
+              <el-input v-model="shpLabelConfig[item][index].Scale[1]" placeholder="标签注记配置5" >
+                <template #prepend>
+                  <el-button >最近处缩放:</el-button>
+                </template>
+                <template #append>
+                  <el-button >倍</el-button>
+                </template>
+              </el-input>
+            </div>
+
+        <!-- {{ shpItemArr[item] }} -->
+        <!-- <div v-for="item2 in formData.itemShp" :key="item2">
+          <el-button/>
+        </div> -->
+
+      </el-form-item>
+
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { DisbursementApi, DisbursementVO } from '@/api/bpm/disbursement'
+import {getSimpleGisNameList} from "@/api/layer/gisname";
+import {getgisAttr} from "@/api/layer/gisname";
+
+/** 资金拨付流程表	 表单 */
+defineOptions({ name: 'DisbursementForm' })
+
+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({
+  userId: undefined,
+  reason: undefined,
+  processInstanceId: undefined,
+  file: undefined,
+  id: undefined,
+  type: undefined,
+  funding: undefined,
+  status: undefined,
+  specialLoansFile: undefined,
+  itemName: undefined,
+  itemType: undefined,
+  itemBelong: undefined,
+  itemShp: undefined,
+  itemImage: undefined,
+  fundingApplicationFile: undefined,
+  itemDisbursementStatus: undefined,
+  shpItemSelected: {},
+  shpLabelSelected : {},
+  itemZoomSet:[]
+})
+const formRules = reactive({
+  userId: [{ required: true, message: '申请人的用户编号不能为空', trigger: 'blur' }],
+  reason: [{ required: true, message: '备注不能为空', trigger: 'blur' }],
+  type: [{ required: true, message: '申请类型不能为空', trigger: 'change' }],
+  funding: [{ required: true, message: '申请总金额不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '审批结果不能为空', trigger: 'blur' }],
+})
+const formRef = ref() // 表单 Ref
+
+const datagisname = ref([]);
+
+const shpItemArr = ref({});
+const shpItemSelected = ref({});
+const shpLabelSelected = ref({});
+const tdtArray = ref([""]);
+const shpLabelConfig = ref({});
+//[109.2,31.277,156777.64156853,0]
+
+watch(() => formData.value.itemShp,
+  async (newValue) => {
+    console.log("ltyWatching",newValue);
+    if(newValue){
+      (await newValue).forEach(element => {
+        // console.log(element);
+        getgisAttr(element).then((res)=> {
+          // console.log("ltyWriting",[element,res])
+          shpItemArr.value[element] = res
+          if(!shpItemSelected.value[element] )
+            shpItemSelected.value[element] = [] || shpItemSelected.value[element];
+          if(!shpLabelSelected.value[element] )
+            shpLabelSelected.value[element] = [] || shpLabelSelected.value[element];
+        });
+      });
+    }
+    // let ltyShpHelper = newValue.value.itemShp;
+    // ltyShpHelper.forEach(lsp => {
+    //   console.log(getgisAttr(lsp));
+    //   // shpItemArr.value[element] = getgisAttr(element);
+    // });
+  },
+  {
+    deep:true,
+    immediate:true
+  }
+)
+
+watch(()=>shpLabelSelected.value,
+  async (newValue)=>{
+    console.log("ltyWatching_shpLabelSelected",newValue);
+    if(newValue){
+      for(let shp in newValue){
+        // console.log(shp);
+        // console.log(newValue[shp]);
+        // shpLabelConfig.value[shp] = shpLabelSelected.value[shp].map((shpLabelSelectedMapper)=>{
+        //   return {
+        //     Font: shpLabelSelectedMapper+" "+"Font空的!",
+        //     MaxDis:800,
+        //     Scale:1
+        //   }
+        // })
+        shpLabelConfig.value[shp] = shpLabelConfig.value[shp] || [];
+        shpLabelSelected.value[shp].forEach((shpLabelSelectedMapper,index)=>{
+          shpLabelConfig.value[shp][index] = shpLabelConfig.value[shp][index] || {};
+          shpLabelConfig.value[shp][index].FontColor =  shpLabelConfig.value[shp][index].FontColor || "rgba(255, 255, 255, 1)";
+          shpLabelConfig.value[shp][index].MaxDis = shpLabelConfig.value[shp][index].MaxDis || 800 ;
+          shpLabelConfig.value[shp][index].Scale = shpLabelConfig.value[shp][index].Scale || [0.1,1] ;
+        })
+        console.log("ltyDebuging",[newValue,shpLabelConfig.value[shp]])
+        // {Font: shp+" "+"Font空的!",MaxDis:800, Scale:1};
+      }
+    }
+  },
+  {
+    deep:true,
+    immediate:true
+  }
+)
+
+const gisnamedatalist = async () => {
+  datagisname.value = await getSimpleGisNameList();
+  console.log(datagisname.value);
+}
+
+/** 打开弹窗 */
+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 DisbursementApi.getDisbursement(id)
+      tdtArray.value = formData.value.itemBaseLayer || [""];
+      formData.value.itemZoomSet = formData.value.itemZoomSet || [109.2,31.277,156777.64156853,0];
+
+      //NOTE - 解析JSON中的shpItemSelected
+      let shpItemInfo = JSON.parse(formData.value.shpItemInfo);
+      let shpItemSelected_helper = {};
+
+      for(let shp in shpItemInfo){
+        shpItemSelected_helper[shp] = shpItemInfo[shp].map((shpItemInfoMapper)=>{return shpItemInfoMapper.shpItem});
+      }
+
+      //NOTE - 解析JSON中的shpLabelSelected
+      let shpLabelInfo = JSON.parse(formData.value.shpLabelInfo);
+      console.log("ltyLabelInfo",shpLabelInfo)
+      let shpLabelSelected_helper = {};
+
+      for(let shp in shpLabelInfo){
+        shpLabelSelected_helper[shp] = shpLabelInfo[shp].map((shpLabelInfoMapper)=>{return shpLabelInfoMapper.shpLabel});
+        shpLabelConfig.value[shp] = shpLabelInfo[shp].map((shpLabelInfoMapper)=> {
+            return shpLabelInfoMapper.config
+        })
+      }
+
+
+      Object.assign(shpItemSelected.value, shpItemSelected_helper );
+      Object.assign(shpLabelSelected.value, shpLabelSelected_helper );
+
+      console.log("lty162",[formData.value,shpItemSelected.value,JSON.parse(formData.value.shpItemInfo) ]);
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  // await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    //NOTE -  处理 shp属性配置
+    let shpItemSelected_helper = {}
+    for(let item in shpItemSelected.value){
+      shpItemSelected_helper[item] = shpItemSelected.value[item].map((shpItem)=>{
+        return {
+          shpItem: shpItem
+        };
+      });
+    }
+
+    //NOTE -  处理 shp属性配置
+    let shpLabelSelected_helper = {};
+    for(let item in shpLabelSelected.value){
+      shpLabelSelected_helper[item] = shpLabelSelected.value[item].map((shpLabel,index)=>{
+        console.log("ltyLabel_Updata",shpLabelConfig.value[item])
+        return {
+          shpLabel: shpLabel,
+          config: shpLabelConfig.value[item][index] || {}
+        };
+      });
+    }
+
+    const data = {
+      ... formData.value ,
+      shpItemInfo: shpItemSelected_helper,
+      itemBaseLayer: tdtArray.value,
+      shpLabelInfo: shpLabelSelected_helper,
+
+    };// as unknown as DisbursementVO
+    console.log("Submiting",data);
+    // formLoading.value = false
+    // return;
+    if (formType.value === 'create') {
+      await DisbursementApi.createDisbursement(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await DisbursementApi.updateDisbursement(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    userId: undefined,
+    reason: undefined,
+    processInstanceId: undefined,
+    file: undefined,
+    id: undefined,
+    type: undefined,
+    funding: undefined,
+    status: undefined,
+    specialLoansFile: undefined,
+    itemName: undefined,
+    itemType: undefined,
+    itemBelong: undefined,
+    itemShp: undefined,
+    itemImage: undefined,
+    fundingApplicationFile: undefined,
+    itemDisbursementStatus: undefined,
+    shpItemSelected: {},
+    shpLabelSelected : {},
+    itemBaseLayer:undefined,
+    itemZoomSet:[]
+  }
+  formRef.value?.resetFields()
+  gisnamedatalist()
+}
+</script>

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

@@ -0,0 +1,47 @@
+<template>
+  <el-select v-model="account.id" placeholder="请选择公众号" class="!w-240px" @change="onChanged">
+    <el-option v-for="item in accountList" :key="item.id" :label="item.name" :value="item.id" />
+  </el-select>
+</template>
+
+<script lang="ts" setup>
+import * as MpAccountApi from '@/api/mp/account'
+
+defineOptions({ name: 'WxAccountSelect' })
+
+const account: MpAccountApi.AccountVO = reactive({
+  id: -1,
+  name: ''
+})
+
+const accountList = ref<MpAccountApi.AccountVO[]>([])
+
+const emit = defineEmits<{
+  (e: 'change', id: number, name: string)
+}>()
+
+const handleQuery = async () => {
+  accountList.value = await MpAccountApi.getSimpleAccountList()
+  // 默认选中第一个
+  if (accountList.value.length > 0) {
+    account.id = accountList.value[0].id
+    if (account.id) {
+      account.name = accountList.value[0].name
+      emit('change', account.id, account.name)
+    }
+  }
+}
+
+const onChanged = (id?: number) => {
+  const found = accountList.value.find((v) => v.id === id)
+  if (account.id) {
+    account.name = found ? found.name : ''
+    emit('change', account.id, account.name)
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  handleQuery()
+})
+</script>

+ 73 - 0
src/views/mp/components/wx-location/main.vue

@@ -0,0 +1,73 @@
+<!--
+  【微信消息 - 定位】TODO @Dhb52 目前未启用
+-->
+<template>
+  <div>
+    <el-link
+      type="primary"
+      target="_blank"
+      :href="
+        'https://map.qq.com/?type=marker&isopeninfowin=1&markertype=1&pointx=' +
+        locationY +
+        '&pointy=' +
+        locationX +
+        '&name=' +
+        label +
+        '&ref=yudao'
+      "
+    >
+      <el-col>
+        <el-row>
+          <img
+            :src="
+              'https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|' +
+              locationX +
+              ',' +
+              locationY +
+              '&key=' +
+              qqMapKey +
+              '&size=250*180'
+            "
+          />
+        </el-row>
+        <el-row>
+          <Icon icon="ep:location" />
+          {{ label }}
+        </el-row>
+      </el-col>
+    </el-link>
+  </div>
+</template>
+
+<script lang="ts" setup>
+defineOptions({ name: 'WxLocation' })
+
+const props = defineProps({
+  locationX: {
+    required: true,
+    type: Number
+  },
+  locationY: {
+    required: true,
+    type: Number
+  },
+  label: {
+    // 地名
+    required: true,
+    type: String
+  },
+  qqMapKey: {
+    // QQ 地图的密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc
+    required: false,
+    type: String,
+    default: 'TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E' // 需要自定义
+  }
+})
+
+defineExpose({
+  locationX: props.locationX,
+  locationY: props.locationY,
+  label: props.label,
+  qqMapKey: props.qqMapKey
+})
+</script>

+ 279 - 0
src/views/mp/components/wx-material-select/main.vue

@@ -0,0 +1,279 @@
+<!--
+  - Copyright (C) 2018-2019
+  - All rights reserved, Designed By www.joolun.com
+  芋道源码:
+  ① 移除 avue 组件,使用 ElementUI 原生组件
+-->
+<template>
+  <div class="pb-30px">
+    <!-- 类型:image -->
+    <div v-if="props.type === 'image'">
+      <div class="waterfall" v-loading="loading">
+        <div class="waterfall-item" v-for="item in list" :key="item.mediaId">
+          <img class="material-img" :src="item.url" />
+          <p class="item-name">{{ item.name }}</p>
+          <el-row class="ope-row">
+            <el-button type="success" @click="selectMaterialFun(item)">
+              选择
+              <Icon icon="ep:circle-check" />
+            </el-button>
+          </el-row>
+        </div>
+      </div>
+      <!-- 分页组件 -->
+      <Pagination
+        :total="total"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getMaterialPageFun"
+      />
+    </div>
+    <!-- 类型:voice -->
+    <div v-else-if="props.type === 'voice'">
+      <!-- 列表 -->
+      <el-table v-loading="loading" :data="list">
+        <el-table-column label="编号" align="center" prop="mediaId" />
+        <el-table-column label="文件名" align="center" prop="name" />
+        <el-table-column label="语音" align="center">
+          <template #default="scope">
+            <WxVoicePlayer :url="scope.row.url" />
+          </template>
+        </el-table-column>
+        <el-table-column
+          label="上传时间"
+          align="center"
+          prop="createTime"
+          width="180"
+          :formatter="dateFormatter"
+        />
+        <el-table-column label="操作" align="center" fixed="right">
+          <template #default="scope">
+            <el-button type="primary" link @click="selectMaterialFun(scope.row)"
+              >选择
+              <Icon icon="ep:plus" />
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <!-- 分页组件 -->
+      <Pagination
+        :total="total"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getPage"
+      />
+    </div>
+    <!-- 类型:video -->
+    <div v-else-if="props.type === 'video'">
+      <!-- 列表 -->
+      <el-table v-loading="loading" :data="list">
+        <el-table-column label="编号" align="center" prop="mediaId" />
+        <el-table-column label="文件名" align="center" prop="name" />
+        <el-table-column label="标题" align="center" prop="title" />
+        <el-table-column label="介绍" align="center" prop="introduction" />
+        <el-table-column label="视频" align="center">
+          <template #default="scope">
+            <WxVideoPlayer :url="scope.row.url" />
+          </template>
+        </el-table-column>
+        <el-table-column
+          label="上传时间"
+          align="center"
+          prop="createTime"
+          width="180"
+          :formatter="dateFormatter"
+        />
+        <el-table-column
+          label="操作"
+          align="center"
+          fixed="right"
+          class-name="small-padding fixed-width"
+        >
+          <template #default="scope">
+            <el-button type="primary" link @click="selectMaterialFun(scope.row)"
+              >选择
+              <Icon icon="akar-icons:circle-plus" />
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <!-- 分页组件 -->
+      <Pagination
+        :total="total"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getMaterialPageFun"
+      />
+    </div>
+    <!-- 类型:news -->
+    <div v-else-if="props.type === 'news'">
+      <div class="waterfall" v-loading="loading">
+        <div class="waterfall-item" v-for="item in list" :key="item.mediaId">
+          <div v-if="item.content && item.content.newsItem">
+            <WxNews :articles="item.content.newsItem" />
+            <el-row class="ope-row">
+              <el-button type="success" @click="selectMaterialFun(item)">
+                选择
+                <Icon icon="ep:circle-check" />
+              </el-button>
+            </el-row>
+          </div>
+        </div>
+      </div>
+      <!-- 分页组件 -->
+      <Pagination
+        :total="total"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getMaterialPageFun"
+      />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import WxNews from '@/views/mp/components/wx-news'
+import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
+import WxVideoPlayer from '@/views/mp/components/wx-video-play'
+import { NewsType } from './types'
+import * as MpMaterialApi from '@/api/mp/material'
+import * as MpFreePublishApi from '@/api/mp/freePublish'
+import * as MpDraftApi from '@/api/mp/draft'
+import { dateFormatter } from '@/utils/formatTime'
+
+defineOptions({ name: 'WxMaterialSelect' })
+
+const props = withDefaults(
+  defineProps<{
+    type: string
+    accountId: number
+    newsType?: NewsType
+  }>(),
+  {
+    newsType: NewsType.Published
+  }
+)
+
+const emit = defineEmits(['select-material'])
+
+// 遮罩层
+const loading = ref(false)
+// 总条数
+const total = ref(0)
+// 数据列表
+const list = ref<any[]>([])
+// 查询参数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  accountId: props.accountId
+})
+
+const selectMaterialFun = (item) => {
+  emit('select-material', item)
+}
+
+const getPage = async () => {
+  loading.value = true
+  try {
+    if (props.type === 'news' && props.newsType === NewsType.Published) {
+      // 【图文】+ 【已发布】
+      await getFreePublishPageFun()
+    } else if (props.type === 'news' && props.newsType === NewsType.Draft) {
+      // 【图文】+ 【草稿】
+      await getDraftPageFun()
+    } else {
+      // 【素材】
+      await getMaterialPageFun()
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+const getMaterialPageFun = async () => {
+  const data = await MpMaterialApi.getMaterialPage({
+    ...queryParams,
+    type: props.type
+  })
+  list.value = data.list
+  total.value = data.total
+}
+
+const getFreePublishPageFun = async () => {
+  const data = await MpFreePublishApi.getFreePublishPage(queryParams)
+  data.list.forEach((item: any) => {
+    const articles = item.content.newsItem
+    articles.forEach((article: any) => {
+      article.picUrl = article.thumbUrl
+    })
+  })
+  list.value = data.list
+  total.value = data.total
+}
+
+const getDraftPageFun = async () => {
+  const data = await MpDraftApi.getDraftPage(queryParams)
+  data.list.forEach((draft: any) => {
+    const articles = draft.content.newsItem
+    articles.forEach((article: any) => {
+      article.picUrl = article.thumbUrl
+    })
+  })
+  list.value = data.list
+  total.value = data.total
+}
+
+onMounted(async () => {
+  getPage()
+})
+</script>
+<style lang="scss" scoped>
+@media (width >= 992px) and (width <= 1300px) {
+  .waterfall {
+    column-count: 3;
+  }
+
+  p {
+    color: red;
+  }
+}
+
+@media (width >= 768px) and (width <= 991px) {
+  .waterfall {
+    column-count: 2;
+  }
+
+  p {
+    color: orange;
+  }
+}
+
+@media (width <= 767px) {
+  .waterfall {
+    column-count: 1;
+  }
+}
+
+.waterfall {
+  width: 100%;
+  column-gap: 10px;
+  column-count: 5;
+  margin: 0 auto;
+}
+
+.waterfall-item {
+  padding: 10px;
+  margin-bottom: 10px;
+  break-inside: avoid;
+  border: 1px solid #eaeaea;
+}
+
+.material-img {
+  width: 100%;
+}
+
+p {
+  line-height: 30px;
+}
+</style>

+ 192 - 0
src/views/mp/components/wx-msg/main.vue

@@ -0,0 +1,192 @@
+<!--
+  - Copyright (C) 2018-2019
+  - All rights reserved, Designed By www.joolun.com
+  芋道源码:
+  ① 移除暂时用不到的 websocket
+  ② 代码优化,补充注释,提升阅读性
+-->
+<template>
+  <ContentWrap>
+    <div class="msg-div" ref="msgDivRef">
+      <!-- 加载更多 -->
+      <div v-loading="loading"></div>
+      <div v-if="!loading">
+        <div class="el-table__empty-block" v-if="hasMore" @click="loadMore"
+          ><span class="el-table__empty-text">点击加载更多</span></div
+        >
+        <div class="el-table__empty-block" v-if="!hasMore"
+          ><span class="el-table__empty-text">没有更多了</span></div
+        >
+      </div>
+
+      <!-- 消息列表 -->
+      <MsgList :list="list" :account-id="accountId" :user="user" />
+    </div>
+
+    <div class="msg-send" v-loading="sendLoading">
+      <WxReplySelect ref="replySelectRef" v-model="reply" />
+      <el-button type="success" class="send-but" @click="sendMsg">发送(S)</el-button>
+    </div>
+  </ContentWrap>
+</template>
+
+<script lang="ts" setup>
+import WxReplySelect, { Reply, ReplyType } from '@/views/mp/components/wx-reply'
+import MsgList from './components/MsgList.vue'
+import { getMessagePage, sendMessage } from '@/api/mp/message'
+import { getUser } from '@/api/mp/user'
+import profile from '@/assets/imgs/profile.jpg'
+import { User } from './types'
+
+defineOptions({ name: 'WxMsg' })
+
+const message = useMessage() // 消息弹窗
+
+const props = defineProps({
+  userId: {
+    type: Number,
+    required: true
+  }
+})
+
+const accountId = ref(-1) // 公众号ID,需要通过userId初始化
+const loading = ref(false) // 消息列表是否正在加载中
+const hasMore = ref(true) // 是否可以加载更多
+const list = ref<any[]>([]) // 消息列表
+const queryParams = reactive({
+  pageNo: 1, // 当前页数
+  pageSize: 14, // 每页显示多少条
+  accountId: accountId
+})
+
+// 由于微信不再提供昵称,直接使用“用户”展示
+const user: User = reactive({
+  nickname: '用户',
+  avatar: profile,
+  accountId: accountId // 公众号账号编号
+})
+
+// ========= 消息发送 =========
+const sendLoading = ref(false) // 发送消息是否加载中
+// 微信发送消息
+const reply = ref<Reply>({
+  type: ReplyType.Text,
+  accountId: -1,
+  articles: []
+})
+
+const replySelectRef = ref<InstanceType<typeof WxReplySelect> | null>(null) // WxReplySelect组件ref,用于消息发送成功后清除内容
+const msgDivRef = ref<HTMLDivElement | null>(null) // 消息显示窗口ref,用于滚动到底部
+
+/** 完成加载 */
+onMounted(async () => {
+  const data = await getUser(props.userId)
+  user.nickname = data.nickname?.length > 0 ? data.nickname : user.nickname
+  user.avatar = user.avatar?.length > 0 ? data.avatar : user.avatar
+  accountId.value = data.accountId
+  reply.value.accountId = data.accountId
+
+  refreshChange()
+})
+
+// 执行发送
+const sendMsg = async () => {
+  if (!unref(reply)) {
+    return
+  }
+  // 公众号限制:客服消息,公众号只允许发送一条
+  if (
+    reply.value.type === ReplyType.News &&
+    reply.value.articles &&
+    reply.value.articles.length > 1
+  ) {
+    reply.value.articles = [reply.value.articles[0]]
+    message.success('图文消息条数限制在 1 条以内,已默认发送第一条')
+  }
+
+  const data = await sendMessage({ userId: props.userId, ...reply.value })
+  sendLoading.value = false
+
+  list.value = [...list.value, ...[data]]
+  await scrollToBottom()
+
+  // 发送后清空数据
+  replySelectRef.value?.clear()
+}
+
+const loadMore = () => {
+  queryParams.pageNo++
+  getPage(queryParams, null)
+}
+
+const getPage = async (page: any, params: any = null) => {
+  loading.value = true
+  let dataTemp = await getMessagePage(
+    Object.assign(
+      {
+        pageNo: page.pageNo,
+        pageSize: page.pageSize,
+        userId: props.userId,
+        accountId: page.accountId
+      },
+      params
+    )
+  )
+
+  const scrollHeight = msgDivRef.value?.scrollHeight ?? 0
+  // 处理数据
+  const data = dataTemp.list.reverse()
+  list.value = [...data, ...list.value]
+  loading.value = false
+  if (data.length < queryParams.pageSize || data.length === 0) {
+    hasMore.value = false
+  }
+  queryParams.pageNo = page.pageNo
+  queryParams.pageSize = page.pageSize
+  // 滚动到原来的位置
+  if (queryParams.pageNo === 1) {
+    // 定位到消息底部
+    await scrollToBottom()
+  } else if (data.length !== 0) {
+    // 定位滚动条
+    await nextTick()
+    if (scrollHeight !== 0) {
+      if (msgDivRef.value) {
+        msgDivRef.value.scrollTop = msgDivRef.value.scrollHeight - scrollHeight - 100
+      }
+    }
+  }
+}
+
+const refreshChange = () => {
+  getPage(queryParams)
+}
+
+/** 定位到消息底部 */
+const scrollToBottom = async () => {
+  await nextTick()
+  if (msgDivRef.value) {
+    msgDivRef.value.scrollTop = msgDivRef.value.scrollHeight
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.msg-div {
+  height: 50vh;
+  margin-right: 10px;
+  margin-left: 10px;
+  overflow: auto;
+  background-color: #eaeaea;
+}
+
+.msg-send {
+  padding: 10px;
+}
+
+.send-but {
+  float: right;
+  margin-top: 8px;
+  margin-bottom: 8px;
+}
+</style>

+ 62 - 0
src/views/mp/components/wx-music/main.vue

@@ -0,0 +1,62 @@
+<!--
+  【微信消息 - 音乐】
+-->
+<template>
+  <div>
+    <el-link
+      type="success"
+      :underline="false"
+      target="_blank"
+      :href="hqMusicUrl ? hqMusicUrl : musicUrl"
+    >
+      <div
+        class="avue-card__body"
+        style="padding: 10px; background-color: #fff; border-radius: 5px"
+      >
+        <div class="avue-card__avatar">
+          <img :src="thumbMediaUrl" alt="" />
+        </div>
+        <div class="avue-card__detail">
+          <div class="avue-card__title" style="margin-bottom: unset">{{ title }}</div>
+          <div class="avue-card__info" style="height: unset">{{ description }}</div>
+        </div>
+      </div>
+    </el-link>
+  </div>
+</template>
+
+<script lang="ts" setup>
+defineOptions({ name: 'WxMusic' })
+
+const props = defineProps({
+  title: {
+    required: false,
+    type: String
+  },
+  description: {
+    required: false,
+    type: String
+  },
+  musicUrl: {
+    required: false,
+    type: String
+  },
+  hqMusicUrl: {
+    required: false,
+    type: String
+  },
+  thumbMediaUrl: {
+    required: true,
+    type: String
+  }
+})
+
+defineExpose({
+  musicUrl: props.musicUrl
+})
+</script>
+
+<style lang="scss" scoped>
+/* 因为 joolun 实现依赖 avue 组件,该页面使用了 card.scss  */
+@import url('../wx-msg/card.scss');
+</style>

+ 119 - 0
src/views/mp/components/wx-news/main.vue

@@ -0,0 +1,119 @@
+<!--
+  - Copyright (C) 2018-2019
+  - All rights reserved, Designed By www.joolun.com
+  【微信消息 - 图文】
+  芋道源码:
+  ① 代码优化,补充注释,提升阅读性
+-->
+<template>
+  <div class="news-home">
+    <div v-for="(article, index) in articles" :key="index" class="news-div">
+      <!-- 头条 -->
+      <a v-if="index === 0" :href="article.url" target="_blank">
+        <div class="news-main">
+          <div class="news-content">
+            <el-image
+              :src="article.picUrl"
+              class="material-img"
+              style="width: 100%; height: 120px"
+            />
+            <div class="news-content-title">
+              <span>{{ article.title }}</span>
+            </div>
+          </div>
+        </div>
+      </a>
+      <!-- 二条/三条等等 -->
+      <a v-else :href="article.url" target="_blank">
+        <div class="news-main-item">
+          <div class="news-content-item">
+            <div class="news-content-item-title">{{ article.title }}</div>
+            <div class="news-content-item-img">
+              <img :src="article.picUrl" class="material-img" height="100%" />
+            </div>
+          </div>
+        </div>
+      </a>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+defineOptions({ name: 'WxNews' })
+
+const props = withDefaults(
+  defineProps<{
+    articles: any[] | null
+  }>(),
+  {
+    articles: null
+  }
+)
+
+defineExpose({
+  articles: props.articles
+})
+</script>
+
+<style lang="scss" scoped>
+.news-home {
+  width: 100%;
+  margin: auto;
+  background-color: #fff;
+}
+
+.news-main {
+  width: 100%;
+  margin: auto;
+}
+
+.news-content {
+  position: relative;
+  width: 100%;
+  background-color: #acadae;
+}
+
+.news-content-title {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  display: inline-block;
+  width: 98%;
+  padding: 1%;
+  font-size: 12px;
+  color: #fff;
+  white-space: normal;
+  background-color: black;
+  opacity: 0.65;
+  box-sizing: unset !important;
+}
+
+.news-main-item {
+  padding: 5px 0;
+  background-color: #fff;
+  border-top: 1px solid #eaeaea;
+}
+
+.news-content-item {
+  position: relative;
+}
+
+.news-content-item-title {
+  display: inline-block;
+  width: 70%;
+  margin-left: 1%;
+  font-size: 10px;
+  white-space: normal;
+}
+
+.news-content-item-img {
+  display: inline-block;
+  width: 25%;
+  margin-right: 1%;
+  background-color: #acadae;
+}
+
+.material-img {
+  width: 100%;
+}
+</style>

+ 208 - 0
src/views/mp/components/wx-reply/main.vue

@@ -0,0 +1,208 @@
+<!--
+  - Copyright (C) 2018-2019
+  - All rights reserved, Designed By www.joolun.com
+  芋道源码:
+  ① 移除多余的 rep 为前缀的变量,让 message 消息更简单
+  ② 代码优化,补充注释,提升阅读性
+  ③ 优化消息的临时缓存策略,发送消息时,只清理被发送消息的 tab,不会强制切回到 text 输入
+  ④ 支持发送【视频】消息时,支持新建视频
+-->
+<template>
+  <el-tabs type="border-card" v-model="currentTab">
+    <!-- 类型 1:文本 -->
+    <el-tab-pane :name="ReplyType.Text">
+      <template #label>
+        <el-row align="middle"><Icon icon="ep:document" /> 文本</el-row>
+      </template>
+      <TabText v-model="reply.content" />
+    </el-tab-pane>
+
+    <!-- 类型 2:图片 -->
+    <el-tab-pane :name="ReplyType.Image">
+      <template #label>
+        <el-row align="middle"><Icon icon="ep:picture" class="mr-5px" /> 图片</el-row>
+      </template>
+      <TabImage v-model="reply" />
+    </el-tab-pane>
+
+    <!-- 类型 3:语音 -->
+    <el-tab-pane :name="ReplyType.Voice">
+      <template #label>
+        <el-row align="middle"><Icon icon="ep:phone" /> 语音</el-row>
+      </template>
+      <TabVoice v-model="reply" />
+    </el-tab-pane>
+
+    <!-- 类型 4:视频 -->
+    <el-tab-pane :name="ReplyType.Video">
+      <template #label>
+        <el-row align="middle"><Icon icon="ep:share" /> 视频</el-row>
+      </template>
+      <TabVideo v-model="reply" />
+    </el-tab-pane>
+
+    <!-- 类型 5:图文 -->
+    <el-tab-pane :name="ReplyType.News">
+      <template #label>
+        <el-row align="middle"><Icon icon="ep:reading" /> 图文</el-row>
+      </template>
+      <TabNews v-model="reply" :news-type="newsType" />
+    </el-tab-pane>
+
+    <!-- 类型 6:音乐 -->
+    <el-tab-pane :name="ReplyType.Music">
+      <template #label>
+        <el-row align="middle"><Icon icon="ep:service" />音乐</el-row>
+      </template>
+      <TabMusic v-model="reply" />
+    </el-tab-pane>
+  </el-tabs>
+</template>
+
+<script lang="ts" setup>
+import { Reply, NewsType, ReplyType, createEmptyReply } from './components/types'
+import TabText from './components/TabText.vue'
+import TabImage from './components/TabImage.vue'
+import TabVoice from './components/TabVoice.vue'
+import TabVideo from './components/TabVideo.vue'
+import TabNews from './components/TabNews.vue'
+import TabMusic from './components/TabMusic.vue'
+
+defineOptions({ name: 'WxReplySelect' })
+
+interface Props {
+  modelValue: Reply
+  newsType?: NewsType
+}
+const props = withDefaults(defineProps<Props>(), {
+  newsType: () => NewsType.Published
+})
+const emit = defineEmits<{
+  (e: 'update:modelValue', v: Reply)
+}>()
+
+const reply = computed<Reply>({
+  get: () => props.modelValue,
+  set: (val) => emit('update:modelValue', val)
+})
+// 作为多个标签保存各自Reply的缓存
+const tabCache = new Map<ReplyType, Reply>()
+// 采用独立的ref来保存当前tab,避免在watch标签变化,对reply进行赋值会产生了循环调用
+const currentTab = ref<ReplyType>(props.modelValue.type || ReplyType.Text)
+
+watch(
+  currentTab,
+  (newTab, oldTab) => {
+    // 第一次进入:oldTab 为 undefined
+    // 判断 newTab 是因为 Reply 为 Partial
+    if (oldTab === undefined || newTab === undefined) {
+      return
+    }
+
+    tabCache.set(oldTab, unref(reply))
+
+    // 从缓存里面取出新tab内容,有则覆盖Reply,没有则创建空Reply
+    const temp = tabCache.get(newTab)
+    if (temp) {
+      reply.value = temp
+    } else {
+      let newData = createEmptyReply(reply)
+      newData.type = newTab
+      reply.value = newData
+    }
+  },
+  {
+    immediate: true
+  }
+)
+
+/** 清除除了`type`, `accountId`的字段 */
+const clear = () => {
+  reply.value = createEmptyReply(reply)
+}
+
+defineExpose({
+  clear
+})
+</script>
+
+<style lang="scss" scoped>
+.select-item {
+  width: 280px;
+  padding: 10px;
+  margin: 0 auto 10px;
+  border: 1px solid #eaeaea;
+}
+
+.select-item2 {
+  padding: 10px;
+  margin: 0 auto 10px;
+  border: 1px solid #eaeaea;
+}
+
+.ope-row {
+  padding-top: 10px;
+  text-align: center;
+}
+
+.input-margin-bottom {
+  margin-bottom: 2%;
+}
+
+.item-name {
+  overflow: hidden;
+  font-size: 12px;
+  text-align: center;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.el-form-item__content {
+  line-height: unset !important;
+}
+
+.col-select {
+  width: 49.5%;
+  height: 160px;
+  padding: 50px 0;
+  border: 1px solid rgb(234 234 234);
+}
+
+.col-select2 {
+  height: 160px;
+  padding: 50px 0;
+  border: 1px solid rgb(234 234 234);
+}
+
+.col-add {
+  float: right;
+  width: 49.5%;
+  height: 160px;
+  padding: 50px 0;
+  border: 1px solid rgb(234 234 234);
+}
+
+.avatar-uploader-icon {
+  width: 100px !important;
+  height: 100px !important;
+  font-size: 28px;
+  line-height: 100px !important;
+  color: #8c939d;
+  text-align: center;
+  border: 1px solid #d9d9d9;
+}
+
+.material-img {
+  width: 100%;
+}
+
+.thumb-div {
+  display: inline-block;
+  text-align: center;
+}
+
+.item-infos {
+  width: 30%;
+  margin: auto;
+}
+</style>

+ 73 - 0
src/views/mp/components/wx-video-play/main.vue

@@ -0,0 +1,73 @@
+<!--
+  - Copyright (C) 2018-2019
+  - All rights reserved, Designed By www.joolun.com
+  【微信消息 - 视频】
+  芋道源码:
+  ① bug 修复:
+    1)joolun 的做法:使用 mediaId 从微信公众号,下载对应的 mp4 素材,从而播放内容;
+      存在的问题:mediaId 有效期是 3 天,超过时间后无法播放
+    2)重构后的做法:后端接收到微信公众号的视频消息后,将视频消息的 media_id 的文件内容保存到文件服务器中,这样前端可以直接使用 URL 播放。
+  ② 体验优化:弹窗关闭后,自动暂停视频的播放
+
+-->
+<template>
+  <div @click="playVideo()">
+    <!-- 提示 -->
+    <div>
+      <Icon icon="ep:video-play" :size="32" class="mr-5px" />
+      <p class="text-sm">点击播放视频</p>
+    </div>
+
+    <!-- 弹窗播放 -->
+    <el-dialog v-model="dialogVideo" title="视频播放" append-to-body>
+      <video-player
+        v-if="dialogVideo"
+        class="video-player vjs-big-play-centered"
+        :src="props.url"
+        poster=""
+        crossorigin="anonymous"
+        controls
+        playsinline
+        :volume="0.6"
+        :width="800"
+        :playback-rates="[0.7, 1.0, 1.5, 2.0]"
+      />
+      <!--     事件,暫時沒用
+      @mounted="handleMounted"-->
+      <!--        @ready="handleEvent($event)"-->
+      <!--        @play="handleEvent($event)"-->
+      <!--        @pause="handleEvent($event)"-->
+      <!--        @ended="handleEvent($event)"-->
+      <!--        @loadeddata="handleEvent($event)"-->
+      <!--        @waiting="handleEvent($event)"-->
+      <!--        @playing="handleEvent($event)"-->
+      <!--        @canplay="handleEvent($event)"-->
+      <!--        @canplaythrough="handleEvent($event)"-->
+      <!--        @timeupdate="handleEvent(player?.currentTime())"-->
+    </el-dialog>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import 'video.js/dist/video-js.css'
+import { VideoPlayer } from '@videojs-player/vue'
+
+defineOptions({ name: 'WxVideoPlayer' })
+
+const props = defineProps({
+  url: {
+    type: String,
+    required: true
+  }
+})
+
+const dialogVideo = ref(false)
+
+// const handleEvent = (log) => {
+//   console.log('Basic player event', log)
+// }
+
+const playVideo = () => {
+  dialogVideo.value = true
+}
+</script>

+ 105 - 0
src/views/mp/components/wx-voice-play/main.vue

@@ -0,0 +1,105 @@
+<!--
+  - Copyright (C) 2018-2019
+  - All rights reserved, Designed By www.joolun.com
+  【微信消息 - 语音】
+   芋道源码:
+  ① bug 修复:
+    1)joolun 的做法:使用 mediaId 从微信公众号,下载对应的 mp4 素材,从而播放内容;
+      存在的问题:mediaId 有效期是 3 天,超过时间后无法播放
+    2)重构后的做法:后端接收到微信公众号的视频消息后,将视频消息的 media_id 的文件内容保存到文件服务器中,这样前端可以直接使用 URL 播放。
+  ② 代码优化:将 props 中的 reply 调成为 data 中对应的属性,并补充相关注释
+-->
+<template>
+  <div class="wx-voice-div" @click="playVoice">
+    <el-icon>
+      <Icon v-if="playing !== true" icon="ep:video-play" :size="32" />
+      <Icon v-else icon="ep:video-pause" :size="32" />
+      <span class="amr-duration" v-if="duration">{{ duration }} 秒</span>
+    </el-icon>
+    <div v-if="content">
+      <el-tag type="success" size="small">语音识别</el-tag>
+      {{ content }}
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+// 因为微信语音是 amr 格式,所以需要用到 amr 解码器:https://www.npmjs.com/package/benz-amr-recorder
+import BenzAMRRecorder from 'benz-amr-recorder'
+
+defineOptions({ name: 'WxVoicePlayer' })
+
+const props = defineProps({
+  url: {
+    type: String, // 语音地址,例如说:https://www.iocoder.cn/xxx.amr
+    required: true
+  },
+  content: {
+    type: String, // 语音文本
+    required: false
+  }
+})
+
+const amr = ref()
+const playing = ref(false)
+const duration = ref()
+
+/** 处理点击,播放或暂停 */
+const playVoice = () => {
+  // 情况一:未初始化,则创建 BenzAMRRecorder
+  if (amr.value === undefined) {
+    amrInit()
+    return
+  }
+  // 情况二:已经初始化,则根据情况播放或暂时
+  if (amr.value.isPlaying()) {
+    amrStop()
+  } else {
+    amrPlay()
+  }
+}
+
+/** 音频初始化 */
+const amrInit = () => {
+  amr.value = new BenzAMRRecorder()
+  // 设置播放
+  amr.value.initWithUrl(props.url).then(function () {
+    amrPlay()
+    duration.value = amr.value.getDuration()
+  })
+  // 监听暂停
+  amr.value.onEnded(function () {
+    playing.value = false
+  })
+}
+
+/** 音频播放 */
+const amrPlay = () => {
+  playing.value = true
+  amr.value.play()
+}
+
+/** 音频暂停 */
+const amrStop = () => {
+  playing.value = false
+  amr.value.stop()
+}
+// TODO 芋艿:下面样式有点问题
+</script>
+<style lang="scss" scoped>
+.wx-voice-div {
+  display: flex;
+  width: 120px;
+  height: 50px;
+  padding: 5px;
+  background-color: #eaeaea;
+  border-radius: 10px;
+  justify-content: center;
+  align-items: center;
+}
+
+.amr-duration {
+  margin-left: 5px;
+  font-size: 11px;
+}
+</style>

+ 51 - 0
src/views/system/loginlog/LoginLogDetail.vue

@@ -0,0 +1,51 @@
+<template>
+  <Dialog v-model="dialogVisible" title="详情" width="800">
+    <el-descriptions :column="1" border>
+      <el-descriptions-item label="日志编号" min-width="120">
+        {{ detailData.id }}
+      </el-descriptions-item>
+      <el-descriptions-item label="操作类型">
+        <dict-tag :type="DICT_TYPE.SYSTEM_LOGIN_TYPE" :value="detailData.logType" />
+      </el-descriptions-item>
+      <el-descriptions-item label="用户名称">
+        {{ detailData.username }}
+      </el-descriptions-item>
+      <el-descriptions-item label="登录地址">
+        {{ detailData.userIp }}
+      </el-descriptions-item>
+      <el-descriptions-item label="浏览器">
+        {{ detailData.userAgent }}
+      </el-descriptions-item>
+      <el-descriptions-item label="登陆结果">
+        <dict-tag :type="DICT_TYPE.SYSTEM_LOGIN_RESULT" :value="detailData.result" />
+      </el-descriptions-item>
+      <el-descriptions-item label="登录日期">
+        {{ formatDate(detailData.createTime) }}
+      </el-descriptions-item>
+    </el-descriptions>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE } from '@/utils/dict'
+import { formatDate } from '@/utils/formatTime'
+import * as LoginLogApi from '@/api/system/loginLog'
+
+defineOptions({ name: 'SystemLoginLogDetail' })
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const detailLoading = ref(false) // 表单的加载中
+const detailData = ref({} as LoginLogApi.LoginLogVO) // 详情数据
+
+/** 打开弹窗 */
+const open = async (data: LoginLogApi.LoginLogVO) => {
+  dialogVisible.value = true
+  // 设置数据
+  detailLoading.value = true
+  try {
+    detailData.value = data
+  } finally {
+    detailLoading.value = false
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+</script>

+ 28 - 0
src/views/system/mail/account/MailAccountDetail.vue

@@ -0,0 +1,28 @@
+<template>
+  <Dialog v-model="dialogVisible" title="详情">
+    <Descriptions :data="detailData" :schema="allSchemas.detailSchema" />
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as MailAccountApi from '@/api/system/mail/account'
+import { allSchemas } from './account.data'
+
+defineOptions({ name: 'SystemMailAccountDetail' })
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const detailLoading = ref(false) // 表单的加载中
+const detailData = ref() // 详情数据
+
+/** 打开弹窗 */
+const open = async (id: number) => {
+  dialogVisible.value = true
+  // 设置数据
+  detailLoading.value = true
+  try {
+    detailData.value = await MailAccountApi.getMailAccount(id)
+  } finally {
+    detailLoading.value = false
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+</script>

+ 68 - 0
src/views/system/mail/account/MailAccountForm.vue

@@ -0,0 +1,68 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <Form ref="formRef" v-loading="formLoading" :rules="rules" :schema="allSchemas.formSchema" />
+    <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 MailAccountApi from '@/api/system/mail/account'
+import { allSchemas, rules } from './account.data'
+
+defineOptions({ name: 'SystemMailAccountForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      const data = await MailAccountApi.getMailAccount(id)
+      formRef.value.setValues(data)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.getElFormRef().validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formRef.value.formModel as MailAccountApi.MailAccountVO
+    if (formType.value === 'create') {
+      await MailAccountApi.createMailAccount(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await MailAccountApi.updateMailAccount(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+</script>

+ 33 - 0
src/views/system/mail/log/MailLogDetail.vue

@@ -0,0 +1,33 @@
+<template>
+  <Dialog v-model="dialogVisible" :max-height="500" :scroll="true" title="详情">
+    <Descriptions :data="detailData" :schema="allSchemas.detailSchema">
+      <!-- 展示 HTML 内容 -->
+      <template #templateContent="{ row }">
+        <div v-dompurify-html="row.templateContent"></div>
+      </template>
+    </Descriptions>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as MailLogApi from '@/api/system/mail/log'
+import { allSchemas } from './log.data'
+
+defineOptions({ name: 'SystemMailLogDetail' })
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const detailLoading = ref(false) // 表单的加载中
+const detailData = ref() // 详情数据
+
+/** 打开弹窗 */
+const open = async (id: number) => {
+  dialogVisible.value = true
+  // 设置数据
+  detailLoading.value = true
+  try {
+    detailData.value = await MailLogApi.getMailLog(id)
+  } finally {
+    detailLoading.value = false
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+</script>

+ 74 - 0
src/views/system/mail/template/MailTemplateForm.vue

@@ -0,0 +1,74 @@
+<template>
+  <Dialog
+    v-model="dialogVisible"
+    :max-height="500"
+    :scroll="true"
+    :title="dialogTitle"
+    :width="800"
+  >
+    <Form ref="formRef" v-loading="formLoading" :rules="rules" :schema="allSchemas.formSchema" />
+    <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 MailTemplateApi from '@/api/system/mail/template'
+import { allSchemas, rules } from './template.data'
+
+defineOptions({ name: 'SystemMailTemplateForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      const data = await MailTemplateApi.getMailTemplate(id)
+      formRef.value.setValues(data)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.getElFormRef().validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formRef.value.formModel as MailTemplateApi.MailTemplateVO
+    if (formType.value === 'create') {
+      await MailTemplateApi.createMailTemplate(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await MailTemplateApi.updateMailTemplate(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+</script>

+ 115 - 0
src/views/system/mail/template/MailTemplateSendForm.vue

@@ -0,0 +1,115 @@
+<template>
+  <Dialog v-model="dialogVisible" title="测试">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+    >
+      <el-form-item label="模板内容" prop="content">
+        <Editor :model-value="formData.content" height="150px" readonly />
+      </el-form-item>
+      <el-form-item label="收件邮箱" prop="mail">
+        <el-input v-model="formData.mail" 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 MailTemplateApi from '@/api/system/mail/template'
+
+defineOptions({ name: 'SystemMailTemplateSendForm' })
+
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref({
+  content: '',
+  params: {},
+  mail: '',
+  templateCode: '',
+  templateParams: new Map()
+})
+const formRules = reactive({
+  mail: [{ 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 MailTemplateApi.getMailTemplate(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 MailTemplateApi.MailSendReqVO
+    const logId = await MailTemplateApi.sendMail(data)
+    if (logId) {
+      message.success('提交发送成功!发送结果,见发送日志编号:' + logId)
+    }
+    dialogVisible.value = false
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    content: '',
+    params: {},
+    mail: '',
+    templateCode: '',
+    templateParams: new Map()
+  }
+  formRules.templateParams = {}
+  formRef.value?.resetFields()
+}
+</script>