董国庆
2026-03-14 2eb7fe98deeb613a22cb5a7da71c77f7b38e8e27
优化
3个文件已修改
493 ■■■■■ 已修改文件
src/views/system/accountPermission/permissionGroup/form.vue 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/userContent/active/coupon/add.vue 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/userContent/banner/components/BannerFormModal.vue 266 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/accountPermission/permissionGroup/form.vue
@@ -3,6 +3,7 @@
import {
  NButton,
  NCard,
  NDataTable,
  NForm,
  NFormItem,
  NGrid,
@@ -12,14 +13,13 @@
  NTabPane,
  NTabs,
  NTree,
  NDataTable,
} from 'naive-ui'
import {
  createPermissionGroup,
  fetchGroupUserPageList,
  getPermissionGroupDetail as fetchPermissionGroupDetail,
  getMenuList,
  updatePermissionGroup,
  getMenuList
} from '@/service'
// 用于生成唯一的key
@@ -42,6 +42,15 @@
// 详情页主 Tab
const mainTab = ref<'permission' | 'accounts'>('permission')
// 当前选中的端 1=管理端 2=运维app
const activeTab = ref<'management' | 'operation'>('management')
const groupType = computed(() => (activeTab.value === 'management' ? 1 : 2))
function handleTabChange(value: 'management' | 'operation') {
  activeTab.value = value
  ensureMenuTreeLoaded(value)
}
// 关联账户表格数据
const userList = ref([])
@@ -144,16 +153,6 @@
  }
})
// 当前选中的端 1=管理端 2=运维app
const activeTab = ref<'management' | 'operation'>('management')
const groupType = computed(() => (activeTab.value === 'management' ? 1 : 2))
    // 处理标签页切换
function handleTabChange(value: 'management' | 'operation') {
  activeTab.value = value
  ensureMenuTreeLoaded(value)
}
// 表单数据
const formModel = ref({
  groupName: '',
@@ -202,10 +201,9 @@
  }
}
// 转换树数据,为每个节点生成唯一 key
function transformTreeData(nodes: any[]): any[] {
  return nodes.map(node => {
  return nodes.map((node) => {
    const key = generateUniqueKey()
    const newNode = {
      ...node,
@@ -291,9 +289,12 @@
    for (const child of node.children) {
      const status = processNode(child)
      if (status === 1) hasChecked = true
      else if (status === 0) hasUnchecked = true
      else hasIndeterminate = true
      if (status === 1)
        hasChecked = true
      else if (status === 0)
        hasUnchecked = true
      else
        hasIndeterminate = true
    }
    if (hasIndeterminate || (hasChecked && hasUnchecked)) {
@@ -378,7 +379,7 @@
    // 从 keys 中还原出原始 ID
    const allKeys = new Set([...checkedKeys, ...indeterminateKeys])
    const menuIds = Array.from(allKeys).map(key => {
    const menuIds = Array.from(allKeys).map((key) => {
      const node = nodeMap.get(key as string)
      return node ? node.id : null
    }).filter(id => id !== null)
@@ -434,8 +435,15 @@
    </div>
    <NCard>
      <NForm ref="formRef" :model="formModel" :rules="rules" label-placement="top" label-width="120" require-mark-placement="left"
        :disabled="mode === 'view'">
      <NForm
        ref="formRef"
        :model="formModel"
        :rules="rules"
        label-placement="top"
        label-width="120"
        require-mark-placement="left"
        :disabled="mode === 'view'"
      >
        <!-- 基本信息 -->
        <NGrid :cols="24" :x-gap="24">
          <NGridItem :span="8">
@@ -474,13 +482,22 @@
              <NTabPane name="accounts" tab="关联用户">
                <div class="user-search-bar" style="margin-bottom: 16px;">
                  <NSpace align="center">
                    <NInput v-model:value="userSearchModel.nickName" placeholder="用户名称" clearable style="width: 200px"
                      :disabled="false"
                      @keyup.enter="handleUserSearch" />
                    <NInput v-model:value="userSearchModel.phoneNumber" placeholder="用户电话" clearable
                    <NInput
                      v-model:value="userSearchModel.nickName"
                      placeholder="用户名称"
                      clearable
                      style="width: 200px"
                      :disabled="false"
                      @keyup.enter="handleUserSearch" />
                      @keyup.enter="handleUserSearch"
                    />
                    <NInput
                      v-model:value="userSearchModel.phoneNumber"
                      placeholder="用户电话"
                      clearable
                      style="width: 200px"
                      :disabled="false"
                      @keyup.enter="handleUserSearch"
                    />
                    <NButton type="primary" :disabled="false" @click="handleUserSearch">
                      查询
                    </NButton>
@@ -489,8 +506,14 @@
                    </NButton>
                  </NSpace>
                </div>
                <NDataTable :columns="userColumns" :data="userList" :border="false" :loading="userLoading" :pagination="userPagination"
                  remote />
                <NDataTable
                  :columns="userColumns"
                  :data="userList"
                  :border="false"
                  :loading="userLoading"
                  :pagination="userPagination"
                  remote
                />
              </NTabPane>
            </NTabs>
@@ -576,6 +599,7 @@
  border-radius: 4px;
}
</style>
<style lang="scss">
.tree-container {
  .n-tree {
@@ -588,5 +612,4 @@
    }
  }
}
</style>
src/views/userContent/active/coupon/add.vue
@@ -1,14 +1,18 @@
<script setup lang="tsx">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import type { FormInst, FormRules } from 'naive-ui'
import { useDialog, useMessage } from 'naive-ui'
import RichTextEditor from '@/components/custom/Editor/RichTextEditor/index.vue'
import { addCoupon, fetchCouponDetail } from '@/service/api/coupon'
const route = useRoute()
const router = useRouter()
const message = useMessage()
const dialog = useDialog()
const loading = ref(false)
const initialSnapshot = ref('')
const formRef = ref<FormInst | null>(null)
const isReadonly = computed(() => route.query.mode === 'detail')
const couponId = computed(() => {
@@ -35,6 +39,72 @@
  { label: '满减券', value: 2 },
]
const rules: FormRules = {
  name: [
    { required: true, message: '请输入优惠券名称', trigger: ['blur', 'input'] },
    {
      validator: (_rule, value) => {
        const raw = (value ?? '').toString().trim()
        if (!raw)
          return true
        if (raw.length > 20)
          return new Error('最多20个字')
        return true
      },
      trigger: ['blur', 'input'],
    },
  ],
  type: [
    { required: true, type: 'number', message: '请选择优惠券类型', trigger: 'change' },
  ],
  conditionAmount: [
    {
      validator: (_rule, value) => {
        if (formModel.value.type !== 2)
          return true
        if (value === null || value === undefined)
          return new Error('请输入满减条件金额')
        const num = Number(value)
        if (!Number.isFinite(num))
          return new Error('请输入有效的条件金额')
        if (num <= 0)
          return new Error('条件金额必须大于0')
        return true
      },
      trigger: ['blur', 'change'],
    },
  ],
  amount: [
    {
      required: true,
      validator: (_rule, value) => {
        if (value === null || value === undefined)
          return new Error('请输入优惠金额')
        const amount = Number(value)
        if (!Number.isFinite(amount))
          return new Error('请输入有效的优惠金额')
        if (amount <= 0)
          return new Error('优惠金额必须大于0')
        if (!formModel.value.type)
          return new Error('请先选择优惠券类型')
        if (formModel.value.type === 2) {
          const condition = formModel.value.conditionAmount
          if (condition === null || condition === undefined)
            return true
          const conditionNum = Number(condition)
          if (Number.isFinite(conditionNum) && amount >= conditionNum)
            return new Error('满减金额必须小于条件金额')
        }
        return true
      },
      trigger: ['blur', 'change'],
    },
  ],
}
function safeDecode(value: string) {
  try {
    return decodeURIComponent(value)
@@ -44,24 +114,32 @@
  }
}
function getSnapshot() {
  return JSON.stringify({
    name: formModel.value.name ?? '',
    type: formModel.value.type ?? null,
    conditionAmount: formModel.value.conditionAmount ?? null,
    amount: formModel.value.amount ?? null,
    description: formModel.value.description ?? '',
  })
}
function hasUnsavedInput() {
  if (isReadonly.value)
    return false
  if (!initialSnapshot.value)
    return false
  return getSnapshot() !== initialSnapshot.value
}
async function handleSave() {
  if (isReadonly.value)
    return
  if (!formModel.value.name) {
    message.warning('请输入优惠券名称')
    return
  try {
    await formRef.value?.validate()
  }
  if (!formModel.value.type) {
    message.warning('请选择优惠券类型')
    return
  }
  if (formModel.value.type === 2 && (formModel.value.conditionAmount === null || formModel.value.conditionAmount === undefined)) {
    message.warning('请输入满减条件金额')
    return
  }
  if (formModel.value.amount === null || formModel.value.amount === undefined) {
    message.warning('请输入优惠金额')
  catch {
    return
  }
@@ -86,7 +164,20 @@
}
function handleBack() {
  router.back()
  if (!hasUnsavedInput()) {
    router.back()
    return
  }
  dialog.warning({
    title: '确认取消创建吗?',
    content: '当前优惠券未保存,确认取消创建吗?',
    positiveText: '确定取消',
    negativeText: '继续编辑',
    onPositiveClick: () => {
      router.back()
    },
  })
}
async function getDetail() {
@@ -112,8 +203,28 @@
}
onMounted(() => {
  getDetail()
  getDetail().finally(() => {
    initialSnapshot.value = getSnapshot()
  })
})
watch(
  () => formModel.value.type,
  () => {
    formRef.value?.validate(undefined, (rule) => {
      return rule?.key === 'conditionAmount' || rule?.key === 'amount'
    })
  },
)
watch(
  () => formModel.value.conditionAmount,
  () => {
    formRef.value?.validate(undefined, (rule) => {
      return rule?.key === 'amount'
    })
  },
)
</script>
<template>
@@ -127,6 +238,7 @@
      <n-form
        ref="formRef"
        :model="formModel"
        :rules="rules"
        label-placement="left"
        label-width="auto"
        label-align="left"
@@ -175,7 +287,7 @@
              v-model:value="formModel.amount"
              :disabled="isReadonly"
              placeholder="最多4位数字"
              :min="0"
              :min="0.01"
              :max="9999"
              :precision="2"
              clearable
src/views/userContent/banner/components/BannerFormModal.vue
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { FormInst, FormRules, UploadFileInfo, UploadCustomRequestOptions } from 'naive-ui'
import { useMessage } from 'naive-ui'
import { addBanner, updateBanner, type Banner } from '@/service/api/banner'
import type { FormInst, FormRules, UploadCustomRequestOptions, UploadFileInfo } from 'naive-ui'
import { useDialog, useMessage } from 'naive-ui'
import { addBanner, updateBanner } from '@/service/api/banner'
import type { Banner } from '@/service/api/banner'
import { customUploadRequest } from '@/utils/upload'
import dayjs from 'dayjs'
@@ -14,18 +15,40 @@
const props = withDefaults(defineProps<Props>(), {
  mode: 'add',
  rowData: null
  rowData: null,
})
const emit = defineEmits<{
  'update:show': [value: boolean]
  'confirm': []
  'switch-edit': []
  'switchEdit': []
}>()
const message = useMessage()
const dialog = useDialog()
const formRef = ref<FormInst | null>(null)
const fileList = ref<UploadFileInfo[]>([])
const initialSnapshot = ref('')
const initialStartTime = ref<number | null>(null)
const bannerImageWidth = 750
const bannerImageHeight = 200
function getImageDimensions(file: File) {
  return new Promise<{ width: number, height: number }>((resolve, reject) => {
    const url = URL.createObjectURL(file)
    const img = new Image()
    img.onload = () => {
      resolve({ width: img.naturalWidth, height: img.naturalHeight })
      URL.revokeObjectURL(url)
    }
    img.onerror = () => {
      reject(new Error('load image failed'))
      URL.revokeObjectURL(url)
    }
    img.src = url
  })
}
interface FormModel {
  bannerTitle: string
@@ -50,21 +73,33 @@
  appletName: '',
  appletId: '',
  jumpUrl: '',
  bannerStatus: true
  bannerStatus: true,
})
const jumpTypeOptions = [
  { label: '小程序内页面', value: 1 },
  { label: '小程序地址', value: 2 },
  { label: '网页地址', value: 3 }
  { label: '网页地址', value: 3 },
]
const rules: FormRules = {
  bannerPictures: [
    { required: true, message: '请上传广告图', trigger: 'change' }
    { required: true, message: '请上传广告图', trigger: 'change' },
  ],
  bannerTitle: [
    { required: true, message: '请输入广告标题', trigger: 'blur' }
    { required: true, message: '请输入广告标题', trigger: ['blur', 'input'] },
    {
      validator(_rule, value) {
        const raw = (value ?? '').toString()
        if (!raw)
          return true
        if (raw.length > 30) {
          return new Error('广告标题长度不超过30个字符')
        }
        return true
      },
      trigger: ['blur', 'input'],
    },
  ],
  sort: [
    {
@@ -81,17 +116,52 @@
        }
        return true
      },
      trigger: 'change'
    }
      trigger: 'change',
    },
  ],
  startTime: [
    { required: true, type: 'number', message: '请选择广告开始时间', trigger: 'change' }
    {
      required: true,
      validator(_rule, value) {
        if (value === null || value === undefined || value === '') {
          return new Error('请选择广告开始时间')
        }
        if (typeof value !== 'number') {
          return new Error('请选择广告开始时间')
        }
        const now = Date.now()
        if (value < now) {
          if (props.mode !== 'add' && value === initialStartTime.value) {
            return true
          }
          return new Error('开始时间不能早于当前时间')
        }
        return true
      },
      trigger: ['blur', 'change'],
    },
  ],
  endTime: [
    { required: true, type: 'number', message: '请选择广告结束时间', trigger: 'change' }
    {
      required: true,
      validator(_rule, value) {
        if (value === null || value === undefined || value === '') {
          return new Error('请选择广告结束时间')
        }
        if (typeof value !== 'number') {
          return new Error('请选择广告结束时间')
        }
        const start = formModel.value.startTime
        if (typeof start === 'number' && value < start) {
          return new Error('结束时间不能小于开始时间')
        }
        return true
      },
      trigger: ['blur', 'change'],
    },
  ],
  jumpType: [
    { required: true, type: 'number', message: '请选择跳转类型', trigger: 'change' }
    { required: true, type: 'number', message: '请选择跳转类型', trigger: 'change' },
  ],
  appletName: [
    {
@@ -102,8 +172,8 @@
        }
        return true
      },
      trigger: 'blur'
    }
      trigger: 'blur',
    },
  ],
  appletId: [
    {
@@ -114,8 +184,8 @@
        }
        return true
      },
      trigger: 'blur'
    }
      trigger: 'blur',
    },
  ],
  jumpUrl: [
    {
@@ -126,49 +196,120 @@
        }
        return true
      },
      trigger: 'blur'
    }
  ]
      trigger: 'blur',
    },
  ],
}
const handleCustomUpload = async (options: UploadCustomRequestOptions) => {
async function handleCustomUpload(options: UploadCustomRequestOptions) {
  const rawFile = (options.file as any)?.file as File | undefined
  if (rawFile) {
    const allowedTypes = ['image/jpeg', 'image/png']
    if (!allowedTypes.includes(rawFile.type)) {
      message.error('仅支持 jpg、png 格式')
      formModel.value.bannerPictures = ''
      fileList.value = []
      options.onError?.()
      return
    }
    try {
      const { width, height } = await getImageDimensions(rawFile)
      if (width !== bannerImageWidth || height !== bannerImageHeight) {
        message.error(`请上传尺寸为 ${bannerImageWidth} x ${bannerImageHeight} 的图片`)
        formModel.value.bannerPictures = ''
        fileList.value = []
        options.onError?.()
        return
      }
    }
    catch {
      message.error('图片读取失败,请重试')
      formModel.value.bannerPictures = ''
      fileList.value = []
      options.onError?.()
      return
    }
  }
  const result = await customUploadRequest(options as any)
  console.log('上传结果', result)
  if (result) {
    // 构建 JSON 数组字符串
    const pictureData = [{
      id: result.id,
      name: result.name,
      url: result.url
      url: result.url,
    }]
    formModel.value.bannerPictures = JSON.stringify(pictureData)
    fileList.value = [{
      id: result.id,
      name: result.name,
      status: 'finished',
      url: result.url
      url: result.url,
    }]
    // 触发校验
    formRef.value?.validate(errors => {
      if (!errors) return
    }, (rule) => rule?.key === 'bannerPictures')
    formRef.value?.validate(undefined, (rule) => {
      return rule?.key === 'bannerPictures'
    })
  }
}
const handleRemove = () => {
function handleRemove() {
  formModel.value.bannerPictures = ''
  fileList.value = []
}
const handleClose = () => {
function closeModal() {
  emit('update:show', false)
}
const handleSwitchEdit = () => {
  emit('switch-edit')
function getSnapshot() {
  const m = formModel.value
  return JSON.stringify({
    bannerTitle: m.bannerTitle ?? '',
    bannerPictures: m.bannerPictures ?? '',
    sort: m.sort ?? null,
    startTime: m.startTime ?? null,
    endTime: m.endTime ?? null,
    jumpType: m.jumpType ?? 1,
    appletName: m.appletName ?? '',
    appletId: m.appletId ?? '',
    jumpUrl: m.jumpUrl ?? '',
    bannerStatus: m.bannerStatus ?? true,
  })
}
const handleConfirm = async () => {
function handleRequestClose() {
  if (props.mode === 'view') {
    closeModal()
    return
  }
  if (initialSnapshot.value && getSnapshot() === initialSnapshot.value) {
    closeModal()
    return
  }
  if (!initialSnapshot.value) {
    closeModal()
    return
  }
  dialog.warning({
    title: '确认关闭?',
    content: '当前广告配置未保存,确定取消吗?',
    positiveText: '确定关闭',
    negativeText: '继续编辑',
    onPositiveClick: () => {
      closeModal()
    },
  })
}
function handleSwitchEdit() {
  emit('switchEdit')
}
async function handleConfirm() {
  try {
    await formRef.value?.validate()
@@ -177,7 +318,7 @@
      sort: formModel.value.sort ?? 0,
      bannerStatus: formModel.value.bannerStatus ? 1 : 2,
      startTime: dayjs(formModel.value.startTime).format('YYYY-MM-DD HH:mm:ss'),
      endTime: dayjs(formModel.value.endTime).format('YYYY-MM-DD HH:mm:ss')
      endTime: dayjs(formModel.value.endTime).format('YYYY-MM-DD HH:mm:ss'),
    }
    let error = null
@@ -186,10 +327,11 @@
      const res = await addBanner(params) as any
      error = res.error
      code = res.code
    } else {
    }
    else {
      const res = await updateBanner({
        ...params,
        id: props.rowData?.id
        id: props.rowData?.id,
      }) as any
      error = res.error
      code = res.code
@@ -198,10 +340,11 @@
    if (!error && code === 200) {
      message.success(props.mode === 'add' ? '新增成功' : '编辑成功')
      emit('confirm')
      emit('update:show', false)
      closeModal()
    }
  } catch (error) {
    console.log('验证失败', error)
  }
  catch (error) {
    console.error(error)
  }
}
@@ -219,12 +362,14 @@
            id: pictures[0].id,
            name: pictures[0].name,
            status: 'finished',
            url: pictures[0].url
            url: pictures[0].url,
          }]
        } else {
        }
        else {
          fileList.value = []
        }
      } catch (e) {
      }
      catch {
        fileList.value = []
      }
@@ -238,9 +383,11 @@
        appletName: row.appletName || '',
        appletId: row.appletId || '',
        jumpUrl: row.jumpUrl || '',
        bannerStatus: row.bannerStatus === 1
        bannerStatus: row.bannerStatus === 1,
      }
    } else {
      initialStartTime.value = formModel.value.startTime
    }
    else {
      // 新增模式
      formModel.value = {
        bannerTitle: '',
@@ -252,13 +399,24 @@
        appletName: '',
        appletId: '',
        jumpUrl: '',
        bannerStatus: true
        bannerStatus: true,
      }
      fileList.value = []
      initialStartTime.value = null
    }
  } else {
    formRef.value?.restoreValidation()
    initialSnapshot.value = getSnapshot()
  }
  else {
    formRef.value?.restoreValidation()
    initialSnapshot.value = ''
    initialStartTime.value = null
  }
})
watch(() => formModel.value.startTime, () => {
  formRef.value?.validate(undefined, (rule) => {
    return rule?.key === 'endTime'
  })
})
</script>
@@ -273,14 +431,14 @@
    :auto-focus="false"
    :segmented="{
      content: true,
      action: true
      action: true,
    }"
    :content-style="{
      // padding: '16px 24px 0px 24px',
      // maxHeight: '60vh',
      // overflow: 'auto'
    }"
    @close="handleClose"
    @close="handleRequestClose"
  >
    <n-form
      ref="formRef"
@@ -297,6 +455,7 @@
            :max="1"
            list-type="image-card"
            :custom-request="handleCustomUpload"
            accept="image/png,image/jpeg"
            :disabled="mode === 'view'"
            @remove="handleRemove"
          >
@@ -306,14 +465,13 @@
            </div>
          </n-upload>
          <div style="margin-top: 8px; color: #999; font-size: 12px;">
            支持jpg、png 格式 banner广告建议尺寸:X px Xpx<br />
            弹出广告建议尺寸:X px Xpx
            支持 jpg、png 格式,建议尺寸:750 x 200
          </div>
        </div>
      </n-form-item>
      <n-form-item label="广告标题" path="bannerTitle" required>
        <n-input v-model:value="formModel.bannerTitle" placeholder="请输入" />
        <n-input v-model:value="formModel.bannerTitle" placeholder="请输入" :maxlength="30" show-count />
      </n-form-item>
      <n-form-item label="广告开始时间" path="startTime" required>
@@ -375,7 +533,7 @@
        >
          编辑
        </n-button>
        <n-button @click="handleClose" style="padding: 7px 29px; border-radius: 4px;">
        <n-button @click="handleRequestClose" style="padding: 7px 29px; border-radius: 4px;">
          {{ mode === 'view' ? '关闭' : '取消' }}
        </n-button>
        <n-button v-if="mode !== 'view'" type="primary" @click="handleConfirm" style="padding: 7px 29px; border-radius: 4px;">