Browse Source

feat:【system】短信管理的增加 50%

YunaiV 4 tháng trước cách đây
mục cha
commit
71f99e0f64

+ 138 - 0
src/api/system/sms/index.ts

@@ -0,0 +1,138 @@
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+// TODO @AI:拆成三个文件:channel.ts、template.ts、log.ts
+
+// ==================== 短信渠道 ====================
+
+/** 短信渠道信息 */
+export interface SmsChannel {
+  id?: number
+  code: string
+  status: number
+  signature: string
+  remark?: string
+  apiKey: string
+  apiSecret?: string
+  callbackUrl?: string
+  createTime?: Date
+}
+
+/** 获取短信渠道分页列表 */
+export function getSmsChannelPage(params: PageParam) {
+  return http.get<PageResult<SmsChannel>>('/system/sms-channel/page', params)
+}
+
+/** 获取短信渠道精简列表 */
+export function getSimpleSmsChannelList() {
+  return http.get<SmsChannel[]>('/system/sms-channel/simple-list')
+}
+
+/** 获取短信渠道详情 */
+export function getSmsChannel(id: number) {
+  return http.get<SmsChannel>(`/system/sms-channel/get?id=${id}`)
+}
+
+/** 创建短信渠道 */
+export function createSmsChannel(data: SmsChannel) {
+  return http.post<number>('/system/sms-channel/create', data)
+}
+
+/** 更新短信渠道 */
+export function updateSmsChannel(data: SmsChannel) {
+  return http.put<boolean>('/system/sms-channel/update', data)
+}
+
+/** 删除短信渠道 */
+export function deleteSmsChannel(id: number) {
+  return http.delete<boolean>(`/system/sms-channel/delete?id=${id}`)
+}
+
+// ==================== 短信模板 ====================
+
+/** 短信模板信息 */
+export interface SmsTemplate {
+  id?: number
+  type?: number
+  status: number
+  code: string
+  name: string
+  content: string
+  remark?: string
+  apiTemplateId: string
+  channelId?: number
+  channelCode?: string
+  params?: string[]
+  createTime?: Date
+}
+
+/** 发送短信请求 */
+export interface SmsSendReqVO {
+  mobile: string
+  templateCode: string
+  templateParams: Record<string, any>
+}
+
+/** 获取短信模板分页列表 */
+export function getSmsTemplatePage(params: PageParam) {
+  return http.get<PageResult<SmsTemplate>>('/system/sms-template/page', params)
+}
+
+/** 获取短信模板详情 */
+export function getSmsTemplate(id: number) {
+  return http.get<SmsTemplate>(`/system/sms-template/get?id=${id}`)
+}
+
+/** 创建短信模板 */
+export function createSmsTemplate(data: SmsTemplate) {
+  return http.post<number>('/system/sms-template/create', data)
+}
+
+/** 更新短信模板 */
+export function updateSmsTemplate(data: SmsTemplate) {
+  return http.put<boolean>('/system/sms-template/update', data)
+}
+
+/** 删除短信模板 */
+export function deleteSmsTemplate(id: number) {
+  return http.delete<boolean>(`/system/sms-template/delete?id=${id}`)
+}
+
+/** 发送短信 */
+export function sendSms(data: SmsSendReqVO) {
+  return http.post<number>('/system/sms-template/send-sms', data)
+}
+
+// ==================== 短信日志 ====================
+
+/** 短信日志信息 */
+export interface SmsLog {
+  id?: number
+  channelId?: number
+  channelCode: string
+  templateId?: number
+  templateCode: string
+  templateType?: number
+  templateContent: string
+  templateParams?: Record<string, any>
+  apiTemplateId: string
+  mobile: string
+  userId?: number
+  userType?: number
+  sendStatus?: number
+  sendTime?: string
+  apiSendCode?: string
+  apiSendMsg?: string
+  apiRequestId?: string
+  apiSerialNo?: string
+  receiveStatus?: number
+  receiveTime?: string
+  apiReceiveCode?: string
+  apiReceiveMsg?: string
+  createTime?: string
+}
+
+/** 获取短信日志分页列表 */
+export function getSmsLogPage(params: PageParam) {
+  return http.get<PageResult<SmsLog>>('/system/sms-log/page', params)
+}

+ 133 - 0
src/pages-system/sms/channel/detail/index.vue

@@ -0,0 +1,133 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="短信渠道详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 详情内容 -->
+    <view>
+      <wd-cell-group border>
+        <wd-cell title="渠道编号" :value="String(formData?.id ?? '-')" />
+        <wd-cell title="短信签名" :value="String(formData?.signature ?? '-')" />
+        <wd-cell title="渠道编码">
+          <dict-tag :type="DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE" :value="formData?.code" />
+        </wd-cell>
+        <wd-cell title="启用状态">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
+        </wd-cell>
+        <wd-cell title="短信 API 账号" :value="String(formData?.apiKey ?? '-')" />
+        <wd-cell title="短信 API 密钥" :value="String(formData?.apiSecret ?? '-')" />
+        <wd-cell title="回调 URL" :value="String(formData?.callbackUrl ?? '-')" />
+        <wd-cell title="备注" :value="String(formData?.remark ?? '-')" />
+        <wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
+      </wd-cell-group>
+    </view>
+
+    <!-- 底部操作按钮 -->
+    <view class="fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <view class="w-full flex gap-24rpx">
+        <wd-button
+          v-if="hasAccessByCodes(['system:sms-channel:update'])"
+          class="flex-1" type="warning" @click="handleEdit"
+        >
+          编辑
+        </wd-button>
+        <wd-button
+          v-if="hasAccessByCodes(['system:sms-channel:delete'])"
+          class="flex-1" type="error" :loading="deleting" @click="handleDelete"
+        >
+          删除
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { SmsChannel } from '@/api/system/sms'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteSmsChannel, getSmsChannel } from '@/api/system/sms'
+import { useAccess } from '@/hooks/useAccess'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const { hasAccessByCodes } = useAccess()
+const toast = useToast()
+const formData = ref<SmsChannel>()
+const deleting = ref(false)
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/sms/index')
+}
+
+/** 加载详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  try {
+    toast.loading('加载中...')
+    formData.value = await getSmsChannel(props.id)
+  } finally {
+    toast.close()
+  }
+}
+
+/** 编辑 */
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-system/sms/channel/form/index?id=${props.id}`,
+  })
+}
+
+/** 删除 */
+function handleDelete() {
+  if (!props.id) {
+    return
+  }
+  uni.showModal({
+    title: '提示',
+    content: '确定要删除该短信渠道吗?',
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      deleting.value = true
+      try {
+        await deleteSmsChannel(props.id)
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 183 - 0
src/pages-system/sms/channel/form/index.vue

@@ -0,0 +1,183 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      :title="getTitle"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 表单区域 -->
+    <view>
+      <wd-form ref="formRef" :model="formData" :rules="formRules">
+        <wd-cell-group border>
+          <wd-input
+            v-model="formData.signature"
+            label="短信签名"
+            label-width="200rpx"
+            prop="signature"
+            clearable
+            placeholder="请输入短信签名"
+          />
+          <wd-cell title="渠道编码" title-width="200rpx" prop="code" center>
+            <wd-picker
+              v-model="formData.code"
+              :columns="channelCodeOptions"
+              placeholder="请选择渠道编码"
+            />
+          </wd-cell>
+          <wd-cell title="启用状态" title-width="200rpx" prop="status" center>
+            <wd-radio-group v-model="formData.status" shape="button">
+              <wd-radio
+                v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+                :key="dict.value"
+                :value="dict.value"
+              >
+                {{ dict.label }}
+              </wd-radio>
+            </wd-radio-group>
+          </wd-cell>
+          <wd-input
+            v-model="formData.apiKey"
+            label="API 账号"
+            label-width="200rpx"
+            prop="apiKey"
+            clearable
+            placeholder="请输入短信 API 账号"
+          />
+          <wd-input
+            v-model="formData.apiSecret"
+            label="API 密钥"
+            label-width="200rpx"
+            prop="apiSecret"
+            clearable
+            placeholder="请输入短信 API 密钥"
+          />
+          <wd-input
+            v-model="formData.callbackUrl"
+            label="回调 URL"
+            label-width="200rpx"
+            prop="callbackUrl"
+            clearable
+            placeholder="请输入短信发送回调 URL"
+          />
+          <wd-textarea
+            v-model="formData.remark"
+            label="备注"
+            label-width="200rpx"
+            prop="remark"
+            clearable
+            placeholder="请输入备注"
+          />
+        </wd-cell-group>
+      </wd-form>
+    </view>
+
+    <!-- 底部保存按钮 -->
+    <view class="fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <wd-button
+        type="primary"
+        block
+        :loading="formLoading"
+        @click="handleSubmit"
+      >
+        保存
+      </wd-button>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { SmsChannel } from '@/api/system/sms'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { createSmsChannel, getSmsChannel, updateSmsChannel } from '@/api/system/sms'
+import { getIntDictOptions, getStrDictOptions } from '@/hooks/useDict'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const getTitle = computed(() => props.id ? '编辑短信渠道' : '新增短信渠道')
+const formLoading = ref(false)
+const formData = ref<SmsChannel>({
+  id: undefined,
+  signature: '',
+  code: '',
+  status: 0,
+  apiKey: '',
+  apiSecret: '',
+  callbackUrl: '',
+  remark: '',
+})
+const formRules = {
+  signature: [{ required: true, message: '短信签名不能为空' }],
+  code: [{ required: true, message: '渠道编码不能为空' }],
+  status: [{ required: true, message: '启用状态不能为空' }],
+  apiKey: [{ required: true, message: 'API 账号不能为空' }],
+}
+const formRef = ref()
+
+/** 渠道编码选项 */
+const channelCodeOptions = computed(() => {
+  return getStrDictOptions(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE).map(item => ({
+    value: item.value,
+    label: item.label,
+  }))
+})
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/sms/index')
+}
+
+/** 加载详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getSmsChannel(props.id)
+}
+
+/** 提交表单 */
+async function handleSubmit() {
+  const { valid } = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    if (props.id) {
+      await updateSmsChannel(formData.value)
+      toast.success('修改成功')
+    } else {
+      await createSmsChannel(formData.value)
+      toast.success('新增成功')
+    }
+    setTimeout(() => {
+      handleBack()
+    }, 500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 146 - 0
src/pages-system/sms/components/channel-list.vue

@@ -0,0 +1,146 @@
+<template>
+  <view>
+    <!-- 搜索组件 -->
+    <ChannelSearchForm @search="handleQuery" @reset="handleReset" />
+
+    <!-- 渠道列表 -->
+    <view class="p-24rpx">
+      <view
+        v-for="item in list"
+        :key="item.id"
+        class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
+        @click="handleDetail(item)"
+      >
+        <view class="p-24rpx">
+          <view class="mb-16rpx flex items-center justify-between">
+            <view class="text-32rpx text-[#333] font-semibold">
+              {{ item.signature }}
+            </view>
+            <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx shrink-0 text-[#999]">渠道编码:</text>
+            <dict-tag :type="DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE" :value="item.code" />
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">API 账号:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.apiKey || '-' }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">创建时间:</text>
+            <text>{{ formatDateTime(item.createTime) || '-' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 加载更多 -->
+      <view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
+        <wd-status-tip image="content" tip="暂无短信渠道数据" />
+      </view>
+      <wd-loadmore
+        v-if="list.length > 0"
+        :state="loadMoreState"
+        @reload="loadMore"
+      />
+    </view>
+
+    <!-- 新增按钮 -->
+    <wd-fab
+      v-if="hasAccessByCodes(['system:sms-channel:create'])"
+      position="right-bottom"
+      type="primary"
+      :expandable="false"
+      @click="handleAdd"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { SmsChannel } from '@/api/system/sms'
+import type { LoadMoreState } from '@/http/types'
+import { ref, watch } from 'vue'
+import { getSmsChannelPage } from '@/api/system/sms'
+import { useAccess } from '@/hooks/useAccess'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+import ChannelSearchForm from './channel-search-form.vue'
+
+const props = defineProps<{
+  active?: boolean
+}>()
+
+const { hasAccessByCodes } = useAccess()
+const total = ref(0)
+const list = ref<SmsChannel[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+})
+const initialized = ref(false)
+
+/** 查询列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getSmsChannelPage(queryParams.value)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 搜索按钮操作 */
+function handleQuery(data?: Record<string, any>) {
+  queryParams.value = {
+    ...data,
+    pageNo: 1,
+    pageSize: queryParams.value.pageSize,
+  }
+  list.value = []
+  getList()
+}
+
+/** 重置按钮操作 */
+function handleReset() {
+  handleQuery()
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.value.pageNo++
+  getList()
+}
+
+/** 新增 */
+function handleAdd() {
+  uni.navigateTo({
+    url: '/pages-system/sms/channel/form/index',
+  })
+}
+
+/** 查看详情 */
+function handleDetail(item: SmsChannel) {
+  uni.navigateTo({
+    url: `/pages-system/sms/channel/detail/index?id=${item.id}`,
+  })
+}
+
+/** 监听 active 变化,首次激活时加载数据 */
+watch(
+  () => props.active,
+  (val) => {
+    if (val && !initialized.value) {
+      initialized.value = true
+      getList()
+    }
+  },
+  { immediate: true },
+)
+</script>

+ 118 - 0
src/pages-system/sms/components/channel-search-form.vue

@@ -0,0 +1,118 @@
+<template>
+  <!-- 搜索框入口 -->
+  <view @click="visible = true">
+    <wd-search :placeholder="placeholder" hide-cancel disabled />
+  </view>
+
+  <!-- 搜索弹窗 -->
+  <wd-popup v-model="visible" position="top" @close="visible = false">
+    <view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          短信签名
+        </view>
+        <wd-input
+          v-model="formData.signature"
+          placeholder="请输入短信签名"
+          clearable
+        />
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          渠道编码
+        </view>
+        <wd-radio-group v-model="formData.code" shape="button">
+          <wd-radio value="">
+            全部
+          </wd-radio>
+          <wd-radio
+            v-for="dict in getStrDictOptions(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE)"
+            :key="dict.value"
+            :value="dict.value"
+          >
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          状态
+        </view>
+        <wd-radio-group v-model="formData.status" shape="button">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :value="dict.value"
+          >
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <!-- TODO @AI:缺了“创建时间” -->
+      <view class="yd-search-form-actions">
+        <wd-button class="flex-1" plain @click="handleReset">
+          重置
+        </wd-button>
+        <wd-button class="flex-1" type="primary" @click="handleSearch">
+          搜索
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import { computed, reactive, ref } from 'vue'
+import { getDictLabel, getIntDictOptions, getStrDictOptions } from '@/hooks/useDict'
+import { getNavbarHeight } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+
+const emit = defineEmits<{
+  search: [data: Record<string, any>]
+  reset: []
+}>()
+
+const visible = ref(false)
+const formData = reactive({
+  signature: undefined as string | undefined,
+  code: '',
+  status: -1,
+})
+
+/** 搜索条件 placeholder 拼接 */
+const placeholder = computed(() => {
+  const conditions: string[] = []
+  if (formData.signature) {
+    conditions.push(`签名:${formData.signature}`)
+  }
+  if (formData.code) {
+    conditions.push(`渠道:${getDictLabel(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE, formData.code)}`)
+  }
+  if (formData.status !== -1) {
+    conditions.push(`状态:${getDictLabel(DICT_TYPE.COMMON_STATUS, formData.status)}`)
+  }
+  return conditions.length > 0 ? conditions.join(' | ') : '搜索短信渠道'
+})
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', {
+    signature: formData.signature || undefined,
+    code: formData.code || undefined,
+    status: formData.status === -1 ? undefined : formData.status,
+  })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.signature = undefined
+  formData.code = ''
+  formData.status = -1
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 132 - 0
src/pages-system/sms/components/log-list.vue

@@ -0,0 +1,132 @@
+<template>
+  <view>
+    <!-- 搜索组件 -->
+    <LogSearchForm @search="handleQuery" @reset="handleReset" />
+
+    <!-- 日志列表 -->
+    <view class="p-24rpx">
+      <view
+        v-for="item in list"
+        :key="item.id"
+        class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
+        @click="handleDetail(item)"
+      >
+        <view class="p-24rpx">
+          <view class="mb-16rpx flex items-center justify-between">
+            <view class="text-32rpx text-[#333] font-semibold">
+              {{ item.mobile }}
+            </view>
+            <dict-tag :type="DICT_TYPE.SYSTEM_SMS_SEND_STATUS" :value="item.sendStatus" />
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx shrink-0 text-[#999]">短信渠道:</text>
+            <dict-tag :type="DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE" :value="item.channelCode" />
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx shrink-0 text-[#999]">短信类型:</text>
+            <dict-tag :type="DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE" :value="item.templateType" />
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx shrink-0 text-[#999]">短信内容:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.templateContent }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">发送时间:</text>
+            <text>{{ formatDateTime(item.sendTime) || '-' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 加载更多 -->
+      <view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
+        <wd-status-tip image="content" tip="暂无短信日志数据" />
+      </view>
+      <wd-loadmore
+        v-if="list.length > 0"
+        :state="loadMoreState"
+        @reload="loadMore"
+      />
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { SmsLog } from '@/api/system/sms'
+import type { LoadMoreState } from '@/http/types'
+import { ref, watch } from 'vue'
+import { getSmsLogPage } from '@/api/system/sms'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+import LogSearchForm from './log-search-form.vue'
+
+const props = defineProps<{
+  active?: boolean
+}>()
+
+const total = ref(0)
+const list = ref<SmsLog[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+})
+const initialized = ref(false)
+
+/** 查询列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getSmsLogPage(queryParams.value)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 搜索按钮操作 */
+function handleQuery(data?: Record<string, any>) {
+  queryParams.value = {
+    ...data,
+    pageNo: 1,
+    pageSize: queryParams.value.pageSize,
+  }
+  list.value = []
+  getList()
+}
+
+/** 重置按钮操作 */
+function handleReset() {
+  handleQuery()
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.value.pageNo++
+  getList()
+}
+
+/** 查看详情 */
+function handleDetail(item: SmsLog) {
+  uni.navigateTo({
+    url: `/pages-system/sms/log/detail/index?id=${item.id}`,
+  })
+}
+
+/** 监听 active 变化,首次激活时加载数据 */
+watch(
+  () => props.active,
+  (val) => {
+    if (val && !initialized.value) {
+      initialized.value = true
+      getList()
+    }
+  },
+  { immediate: true },
+)
+</script>

+ 118 - 0
src/pages-system/sms/components/log-search-form.vue

@@ -0,0 +1,118 @@
+<template>
+  <!-- 搜索框入口 -->
+  <view @click="visible = true">
+    <wd-search :placeholder="placeholder" hide-cancel disabled />
+  </view>
+
+  <!-- 搜索弹窗 -->
+  <wd-popup v-model="visible" position="top" @close="visible = false">
+    <view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          手机号
+        </view>
+        <wd-input
+          v-model="formData.mobile"
+          placeholder="请输入手机号"
+          clearable
+        />
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          发送状态
+        </view>
+        <wd-radio-group v-model="formData.sendStatus" shape="button">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_SMS_SEND_STATUS)"
+            :key="dict.value"
+            :value="dict.value"
+          >
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          接收状态
+        </view>
+        <wd-radio-group v-model="formData.receiveStatus" shape="button">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_SMS_RECEIVE_STATUS)"
+            :key="dict.value"
+            :value="dict.value"
+          >
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <!-- TODO @AI:参考 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/views/system/sms/log/data.ts 很多搜搜项,没搞过来 -->
+      <view class="yd-search-form-actions">
+        <wd-button class="flex-1" plain @click="handleReset">
+          重置
+        </wd-button>
+        <wd-button class="flex-1" type="primary" @click="handleSearch">
+          搜索
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import { computed, reactive, ref } from 'vue'
+import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
+import { getNavbarHeight } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+
+const emit = defineEmits<{
+  search: [data: Record<string, any>]
+  reset: []
+}>()
+
+const visible = ref(false)
+const formData = reactive({
+  mobile: undefined as string | undefined,
+  sendStatus: -1,
+  receiveStatus: -1,
+})
+
+/** 搜索条件 placeholder 拼接 */
+const placeholder = computed(() => {
+  const conditions: string[] = []
+  if (formData.mobile) {
+    conditions.push(`手机号:${formData.mobile}`)
+  }
+  if (formData.sendStatus !== -1) {
+    conditions.push(`发送:${getDictLabel(DICT_TYPE.SYSTEM_SMS_SEND_STATUS, formData.sendStatus)}`)
+  }
+  if (formData.receiveStatus !== -1) {
+    conditions.push(`接收:${getDictLabel(DICT_TYPE.SYSTEM_SMS_RECEIVE_STATUS, formData.receiveStatus)}`)
+  }
+  return conditions.length > 0 ? conditions.join(' | ') : '搜索短信日志'
+})
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', {
+    mobile: formData.mobile || undefined,
+    sendStatus: formData.sendStatus === -1 ? undefined : formData.sendStatus,
+    receiveStatus: formData.receiveStatus === -1 ? undefined : formData.receiveStatus,
+  })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.mobile = undefined
+  formData.sendStatus = -1
+  formData.receiveStatus = -1
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 150 - 0
src/pages-system/sms/components/template-list.vue

@@ -0,0 +1,150 @@
+<template>
+  <view>
+    <!-- 搜索组件 -->
+    <TemplateSearchForm @search="handleQuery" @reset="handleReset" />
+
+    <!-- 模板列表 -->
+    <view class="p-24rpx">
+      <view
+        v-for="item in list"
+        :key="item.id"
+        class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
+        @click="handleDetail(item)"
+      >
+        <view class="p-24rpx">
+          <view class="mb-16rpx flex items-center justify-between">
+            <view class="text-32rpx text-[#333] font-semibold">
+              {{ item.name }}
+            </view>
+            <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx shrink-0 text-[#999]">模板编码:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.code }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx shrink-0 text-[#999]">短信类型:</text>
+            <dict-tag :type="DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE" :value="item.type" />
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx shrink-0 text-[#999]">模板内容:</text>
+            <text class="min-w-0 flex-1 truncate">{{ item.content }}</text>
+          </view>
+          <view class="mb-12rpx flex items-center text-28rpx text-[#666]">
+            <text class="mr-8rpx text-[#999]">创建时间:</text>
+            <text>{{ formatDateTime(item.createTime) || '-' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 加载更多 -->
+      <view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
+        <wd-status-tip image="content" tip="暂无短信模板数据" />
+      </view>
+      <wd-loadmore
+        v-if="list.length > 0"
+        :state="loadMoreState"
+        @reload="loadMore"
+      />
+    </view>
+
+    <!-- 新增按钮 -->
+    <wd-fab
+      v-if="hasAccessByCodes(['system:sms-template:create'])"
+      position="right-bottom"
+      type="primary"
+      :expandable="false"
+      @click="handleAdd"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { SmsTemplate } from '@/api/system/sms'
+import type { LoadMoreState } from '@/http/types'
+import { ref, watch } from 'vue'
+import { getSmsTemplatePage } from '@/api/system/sms'
+import { useAccess } from '@/hooks/useAccess'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+import TemplateSearchForm from './template-search-form.vue'
+
+const props = defineProps<{
+  active?: boolean
+}>()
+
+const { hasAccessByCodes } = useAccess()
+const total = ref(0)
+const list = ref<SmsTemplate[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+})
+const initialized = ref(false)
+
+/** 查询列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getSmsTemplatePage(queryParams.value)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 搜索按钮操作 */
+function handleQuery(data?: Record<string, any>) {
+  queryParams.value = {
+    ...data,
+    pageNo: 1,
+    pageSize: queryParams.value.pageSize,
+  }
+  list.value = []
+  getList()
+}
+
+/** 重置按钮操作 */
+function handleReset() {
+  handleQuery()
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.value.pageNo++
+  getList()
+}
+
+/** 新增 */
+function handleAdd() {
+  uni.navigateTo({
+    url: '/pages-system/sms/template/form/index',
+  })
+}
+
+/** 查看详情 */
+function handleDetail(item: SmsTemplate) {
+  uni.navigateTo({
+    url: `/pages-system/sms/template/detail/index?id=${item.id}`,
+  })
+}
+
+/** 监听 active 变化,首次激活时加载数据 */
+watch(
+  () => props.active,
+  (val) => {
+    if (val && !initialized.value) {
+      initialized.value = true
+      getList()
+    }
+  },
+  { immediate: true },
+)
+</script>

+ 134 - 0
src/pages-system/sms/components/template-search-form.vue

@@ -0,0 +1,134 @@
+<template>
+  <!-- 搜索框入口 -->
+  <view @click="visible = true">
+    <wd-search :placeholder="placeholder" hide-cancel disabled />
+  </view>
+
+  <!-- 搜索弹窗 -->
+  <wd-popup v-model="visible" position="top" @close="visible = false">
+    <view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
+      <!-- TODO @AI:参考 /Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/views/system/sms/template/data.ts 很多搜搜项,没搞过来 -->
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          模板名称
+        </view>
+        <wd-input
+          v-model="formData.name"
+          placeholder="请输入模板名称"
+          clearable
+        />
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          模板编码
+        </view>
+        <wd-input
+          v-model="formData.code"
+          placeholder="请输入模板编码"
+          clearable
+        />
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          短信类型
+        </view>
+        <wd-radio-group v-model="formData.type" shape="button">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE)"
+            :key="dict.value"
+            :value="dict.value"
+          >
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          开启状态
+        </view>
+        <wd-radio-group v-model="formData.status" shape="button">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :value="dict.value"
+          >
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <view class="yd-search-form-actions">
+        <wd-button class="flex-1" plain @click="handleReset">
+          重置
+        </wd-button>
+        <wd-button class="flex-1" type="primary" @click="handleSearch">
+          搜索
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import { computed, reactive, ref } from 'vue'
+import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
+import { getNavbarHeight } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+
+const emit = defineEmits<{
+  search: [data: Record<string, any>]
+  reset: []
+}>()
+
+const visible = ref(false)
+const formData = reactive({
+  name: undefined as string | undefined,
+  code: undefined as string | undefined,
+  type: -1,
+  status: -1,
+})
+
+/** 搜索条件 placeholder 拼接 */
+const placeholder = computed(() => {
+  const conditions: string[] = []
+  if (formData.name) {
+    conditions.push(`名称:${formData.name}`)
+  }
+  if (formData.code) {
+    conditions.push(`编码:${formData.code}`)
+  }
+  if (formData.type !== -1) {
+    conditions.push(`类型:${getDictLabel(DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE, formData.type)}`)
+  }
+  if (formData.status !== -1) {
+    conditions.push(`状态:${getDictLabel(DICT_TYPE.COMMON_STATUS, formData.status)}`)
+  }
+  return conditions.length > 0 ? conditions.join(' | ') : '搜索短信模板'
+})
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', {
+    name: formData.name || undefined,
+    code: formData.code || undefined,
+    type: formData.type === -1 ? undefined : formData.type,
+    status: formData.status === -1 ? undefined : formData.status,
+  })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.name = undefined
+  formData.code = undefined
+  formData.type = -1
+  formData.status = -1
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 56 - 0
src/pages-system/sms/index.vue

@@ -0,0 +1,56 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="短信管理"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- Tab 切换 -->
+    <view class="bg-white">
+      <wd-tabs v-model="tabIndex" shrink @change="handleTabChange">
+        <wd-tab title="短信渠道" />
+        <wd-tab title="短信模板" />
+        <wd-tab title="短信日志" />
+      </wd-tabs>
+    </view>
+
+    <!-- 列表内容 -->
+    <ChannelList v-show="tabType === 'channel'" :active="tabType === 'channel'" />
+    <TemplateList v-show="tabType === 'template'" :active="tabType === 'template'" />
+    <LogList v-show="tabType === 'log'" :active="tabType === 'log'" />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import { navigateBackPlus } from '@/utils'
+import ChannelList from './components/channel-list.vue'
+import LogList from './components/log-list.vue'
+import TemplateList from './components/template-list.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const tabTypes: string[] = ['channel', 'template', 'log']
+const tabIndex = ref(0)
+const tabType = computed<string>(() => tabTypes[tabIndex.value])
+
+/** Tab 切换 */
+function handleTabChange({ index }: { index: number }) {
+  tabIndex.value = index
+}
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus()
+}
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 95 - 0
src/pages-system/sms/log/detail/index.vue

@@ -0,0 +1,95 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="短信日志详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 详情内容 -->
+    <view>
+      <wd-cell-group border>
+        <wd-cell title="日志编号" :value="String(formData?.id ?? '-')" />
+        <wd-cell title="手机号" :value="String(formData?.mobile ?? '-')" />
+        <wd-cell title="短信渠道">
+          <dict-tag :type="DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE" :value="formData?.channelCode" />
+        </wd-cell>
+        <wd-cell title="模板编号" :value="String(formData?.templateId ?? '-')" />
+        <wd-cell title="模板类型">
+          <dict-tag :type="DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE" :value="formData?.templateType" />
+        </wd-cell>
+        <wd-cell title="短信内容" :value="String(formData?.templateContent ?? '-')" />
+        <wd-cell title="发送状态">
+          <dict-tag :type="DICT_TYPE.SYSTEM_SMS_SEND_STATUS" :value="formData?.sendStatus" />
+        </wd-cell>
+        <wd-cell title="发送时间" :value="formatDateTime(formData?.sendTime) || '-'" />
+        <wd-cell title="API 发送编码" :value="String(formData?.apiSendCode ?? '-')" />
+        <wd-cell title="API 发送消息" :value="String(formData?.apiSendMsg ?? '-')" />
+        <wd-cell title="接收状态">
+          <dict-tag :type="DICT_TYPE.SYSTEM_SMS_RECEIVE_STATUS" :value="formData?.receiveStatus" />
+        </wd-cell>
+        <wd-cell title="接收时间" :value="formatDateTime(formData?.receiveTime) || '-'" />
+        <wd-cell title="API 接收编码" :value="String(formData?.apiReceiveCode ?? '-')" />
+        <wd-cell title="API 接收消息" :value="String(formData?.apiReceiveMsg ?? '-')" />
+        <wd-cell title="API 请求 ID" :value="String(formData?.apiRequestId ?? '-')" />
+        <wd-cell title="API 序列号" :value="String(formData?.apiSerialNo ?? '-')" />
+        <wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
+      </wd-cell-group>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { SmsLog } from '@/api/system/sms'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { getSmsLogPage } from '@/api/system/sms'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const formData = ref<SmsLog>()
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/sms/index')
+}
+
+/** 加载详情 - 由于没有单独的获取详情接口,通过列表接口获取 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  try {
+    toast.loading('加载中...')
+    // 通过分页接口获取单条数据
+    const data = await getSmsLogPage({ pageNo: 1, pageSize: 1, id: props.id })
+    if (data.list && data.list.length > 0) {
+      formData.value = data.list[0]
+    }
+  } finally {
+    toast.close()
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 235 - 0
src/pages-system/sms/template/detail/index.vue

@@ -0,0 +1,235 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="短信模板详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 详情内容 -->
+    <view>
+      <wd-cell-group border>
+        <wd-cell title="模板编号" :value="String(formData?.id ?? '-')" />
+        <wd-cell title="模板名称" :value="String(formData?.name ?? '-')" />
+        <wd-cell title="模板编码" :value="String(formData?.code ?? '-')" />
+        <wd-cell title="短信类型">
+          <dict-tag :type="DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE" :value="formData?.type" />
+        </wd-cell>
+        <wd-cell title="开启状态">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
+        </wd-cell>
+        <wd-cell title="模板内容" :value="String(formData?.content ?? '-')" />
+        <wd-cell title="API 模板编号" :value="String(formData?.apiTemplateId ?? '-')" />
+        <wd-cell title="短信渠道">
+          <dict-tag :type="DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE" :value="formData?.channelCode" />
+        </wd-cell>
+        <wd-cell title="备注" :value="String(formData?.remark ?? '-')" />
+        <wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
+      </wd-cell-group>
+    </view>
+
+    <!-- 底部操作按钮 -->
+    <view class="fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <view class="w-full flex gap-24rpx">
+        <wd-button
+          v-if="hasAccessByCodes(['system:sms-template:send-sms'])"
+          class="flex-1" type="primary" @click="handleSendTest"
+        >
+          测试
+        </wd-button>
+        <wd-button
+          v-if="hasAccessByCodes(['system:sms-template:update'])"
+          class="flex-1" type="warning" @click="handleEdit"
+        >
+          编辑
+        </wd-button>
+        <wd-button
+          v-if="hasAccessByCodes(['system:sms-template:delete'])"
+          class="flex-1" type="error" :loading="deleting" @click="handleDelete"
+        >
+          删除
+        </wd-button>
+      </view>
+    </view>
+
+    <!-- 发送测试短信弹窗 -->
+    <wd-popup v-model="sendVisible" position="bottom" closable custom-style="border-radius: 16rpx 16rpx 0 0;">
+      <view class="p-24rpx">
+        <view class="mb-24rpx text-32rpx text-[#333] font-semibold">
+          发送测试短信
+        </view>
+        <wd-form ref="sendFormRef" :model="sendFormData" :rules="sendFormRules">
+          <wd-cell-group border>
+            <wd-textarea
+              v-model="sendFormData.content"
+              label="模板内容"
+              label-width="180rpx"
+              disabled
+              :rows="3"
+            />
+            <wd-input
+              v-model="sendFormData.mobile"
+              label="手机号码"
+              label-width="180rpx"
+              prop="mobile"
+              clearable
+              placeholder="请输入手机号码"
+            />
+            <template v-for="param in formData?.params" :key="param">
+              <wd-input
+                v-model="sendFormData.templateParams[param]"
+                :label="`参数 ${param}`"
+                label-width="180rpx"
+                :prop="`templateParams.${param}`"
+                clearable
+                :placeholder="`请输入参数 ${param}`"
+              />
+            </template>
+          </wd-cell-group>
+        </wd-form>
+        <view class="mt-24rpx">
+          <wd-button type="primary" block :loading="sendLoading" @click="handleSendSubmit">
+            发送
+          </wd-button>
+        </view>
+      </view>
+    </wd-popup>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { SmsTemplate } from '@/api/system/sms'
+import { onMounted, ref, watch } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteSmsTemplate, getSmsTemplate, sendSms } from '@/api/system/sms'
+import { useAccess } from '@/hooks/useAccess'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const { hasAccessByCodes } = useAccess()
+const toast = useToast()
+const formData = ref<SmsTemplate>()
+const deleting = ref(false)
+
+// 发送测试短信相关
+const sendVisible = ref(false)
+const sendLoading = ref(false)
+const sendFormRef = ref()
+const sendFormData = ref({
+  content: '',
+  mobile: '',
+  templateParams: {} as Record<string, string>,
+})
+const sendFormRules = {
+  mobile: [{ required: true, message: '手机号码不能为空' }],
+}
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/sms/index')
+}
+
+/** 加载详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  try {
+    toast.loading('加载中...')
+    formData.value = await getSmsTemplate(props.id)
+  } finally {
+    toast.close()
+  }
+}
+
+/** 编辑 */
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-system/sms/template/form/index?id=${props.id}`,
+  })
+}
+
+/** 删除 */
+function handleDelete() {
+  if (!props.id) {
+    return
+  }
+  uni.showModal({
+    title: '提示',
+    content: '确定要删除该短信模板吗?',
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      deleting.value = true
+      try {
+        await deleteSmsTemplate(props.id)
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+/** 打开发送测试短信弹窗 */
+function handleSendTest() {
+  sendFormData.value = {
+    content: formData.value?.content || '',
+    mobile: '',
+    templateParams: {},
+  }
+  // 初始化模板参数
+  if (formData.value?.params) {
+    formData.value.params.forEach((param) => {
+      sendFormData.value.templateParams[param] = ''
+    })
+  }
+  sendVisible.value = true
+}
+
+/** 发送测试短信 */
+async function handleSendSubmit() {
+  const { valid } = await sendFormRef.value.validate()
+  if (!valid) {
+    return
+  }
+
+  sendLoading.value = true
+  try {
+    await sendSms({
+      mobile: sendFormData.value.mobile,
+      templateCode: formData.value?.code || '',
+      templateParams: sendFormData.value.templateParams,
+    })
+    toast.success('短信发送成功')
+    sendVisible.value = false
+  } finally {
+    sendLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 212 - 0
src/pages-system/sms/template/form/index.vue

@@ -0,0 +1,212 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      :title="getTitle"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 表单区域 -->
+    <view>
+      <wd-form ref="formRef" :model="formData" :rules="formRules">
+        <wd-cell-group border>
+          <wd-cell title="短信类型" title-width="200rpx" prop="type" center>
+            <wd-picker
+              v-model="formData.type"
+              :columns="templateTypeOptions"
+              placeholder="请选择短信类型"
+            />
+          </wd-cell>
+          <wd-input
+            v-model="formData.name"
+            label="模板名称"
+            label-width="200rpx"
+            prop="name"
+            clearable
+            placeholder="请输入模板名称"
+          />
+          <wd-input
+            v-model="formData.code"
+            label="模板编码"
+            label-width="200rpx"
+            prop="code"
+            clearable
+            placeholder="请输入模板编码"
+          />
+          <wd-cell title="短信渠道" title-width="200rpx" prop="channelId" center>
+            <wd-picker
+              v-model="formData.channelId"
+              :columns="channelOptions"
+              placeholder="请选择短信渠道"
+            />
+          </wd-cell>
+          <wd-cell title="开启状态" title-width="200rpx" prop="status" center>
+            <wd-radio-group v-model="formData.status" shape="button">
+              <wd-radio
+                v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+                :key="dict.value"
+                :value="dict.value"
+              >
+                {{ dict.label }}
+              </wd-radio>
+            </wd-radio-group>
+          </wd-cell>
+          <wd-textarea
+            v-model="formData.content"
+            label="模板内容"
+            label-width="200rpx"
+            prop="content"
+            clearable
+            placeholder="请输入模板内容"
+            :rows="4"
+          />
+          <wd-input
+            v-model="formData.apiTemplateId"
+            label="API 模板编号"
+            label-width="200rpx"
+            prop="apiTemplateId"
+            clearable
+            placeholder="请输入短信 API 的模板编号"
+          />
+          <wd-textarea
+            v-model="formData.remark"
+            label="备注"
+            label-width="200rpx"
+            prop="remark"
+            clearable
+            placeholder="请输入备注"
+          />
+        </wd-cell-group>
+      </wd-form>
+    </view>
+
+    <!-- 底部保存按钮 -->
+    <view class="fixed bottom-0 left-0 right-0 bg-white p-24rpx">
+      <wd-button
+        type="primary"
+        block
+        :loading="formLoading"
+        @click="handleSubmit"
+      >
+        保存
+      </wd-button>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { SmsChannel, SmsTemplate } from '@/api/system/sms'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { createSmsTemplate, getSimpleSmsChannelList, getSmsTemplate, updateSmsTemplate } from '@/api/system/sms'
+import { getIntDictOptions } from '@/hooks/useDict'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const getTitle = computed(() => props.id ? '编辑短信模板' : '新增短信模板')
+const formLoading = ref(false)
+const formData = ref<SmsTemplate>({
+  id: undefined,
+  type: undefined,
+  name: '',
+  code: '',
+  channelId: undefined,
+  status: 0,
+  content: '',
+  apiTemplateId: '',
+  remark: '',
+})
+const formRules = {
+  type: [{ required: true, message: '短信类型不能为空' }],
+  name: [{ required: true, message: '模板名称不能为空' }],
+  code: [{ required: true, message: '模板编码不能为空' }],
+  channelId: [{ required: true, message: '短信渠道不能为空' }],
+  status: [{ required: true, message: '开启状态不能为空' }],
+  content: [{ required: true, message: '模板内容不能为空' }],
+  apiTemplateId: [{ required: true, message: 'API 模板编号不能为空' }],
+}
+const formRef = ref()
+
+/** 短信渠道列表 */
+const channelList = ref<SmsChannel[]>([])
+
+/** 短信类型选项 */
+const templateTypeOptions = computed(() => {
+  return getIntDictOptions(DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE).map(item => ({
+    value: item.value,
+    label: item.label,
+  }))
+})
+
+/** 短信渠道选项 */
+const channelOptions = computed(() => {
+  return channelList.value.map(item => ({
+    value: item.id,
+    label: item.signature,
+  }))
+})
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-system/sms/index')
+}
+
+/** 加载短信渠道列表 */
+async function loadChannelList() {
+  channelList.value = await getSimpleSmsChannelList()
+}
+
+/** 加载详情 */
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  formData.value = await getSmsTemplate(props.id)
+}
+
+/** 提交表单 */
+async function handleSubmit() {
+  const { valid } = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    if (props.id) {
+      await updateSmsTemplate(formData.value)
+      toast.success('修改成功')
+    } else {
+      await createSmsTemplate(formData.value)
+      toast.success('新增成功')
+    }
+    setTimeout(() => {
+      handleBack()
+    }, 500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(async () => {
+  await loadChannelList()
+  await getDetail()
+})
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 8 - 0
src/pages/index/index.ts

@@ -93,6 +93,14 @@ const menuGroupsData: MenuGroup[] = [
         iconColor: '#1677ff',
         permission: 'system:login-log:query',
       },
+      {
+        key: 'sms',
+        name: '短信管理',
+        icon: 'chat1',
+        url: '/pages-system/sms/index',
+        iconColor: '#36cfc9',
+        permission: 'system:sms-channel:query',
+      },
     ],
   },
   {