董国庆
2026-03-13 ee3fcf55aa77ec2e20ecf8720c98b2ce330bb127
修改文档
5个文件已修改
417 ■■■■■ 已修改文件
src/components/custom/Editor/RichTextEditor/index.vue 253 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/invitation.ts 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/upload.ts 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/config/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/userContent/whitelistManagement/invitationCode/components/GenerateModal.vue 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/custom/Editor/RichTextEditor/index.vue
@@ -2,52 +2,192 @@
import Quill from 'quill'
import { useTemplateRef } from 'vue'
import 'quill/dist/quill.snow.css'
import { uploadSingleFile } from '@/utils/upload'
defineOptions({
  name: 'RichTextEditor',
})
const { disabled } = defineProps<Props>()
const props = defineProps<Props>()
interface Props {
  disabled?: boolean
}
const model = defineModel<string>()
let editorInst = null
const editorModel = ref<string>()
const editorInst = shallowRef<Quill | null>(null)
const lastHtml = ref<string>('')
onMounted(() => {
  initEditor()
})
const editorRef = useTemplateRef<HTMLElement>('editorRef')
function pickFile(accept: string) {
  return new Promise<File | null>((resolve) => {
    const input = document.createElement('input')
    input.type = 'file'
    input.accept = accept
    input.onchange = () => resolve(input.files?.[0] ?? null)
    input.click()
  })
}
function getInsertIndex(quill: Quill) {
  const range = quill.getSelection(true)
  return range ? range.index : quill.getLength()
}
async function insertUploadedFile(params: { file: File, kind: 'image' | 'video' | 'file' }) {
  const quill = editorInst.value
  if (!quill)
    return
  if (props.disabled)
    return
  const res = await uploadSingleFile(params.file)
  const index = getInsertIndex(quill)
  if (params.kind === 'image') {
    quill.insertEmbed(index, 'image', res.url, 'user')
    quill.setSelection(index + 1, 0, 'silent')
    return
  }
  if (params.kind === 'video') {
    quill.insertEmbed(index, 'video', res.url, 'user')
    quill.setSelection(index + 1, 0, 'silent')
    return
  }
  quill.insertText(index, res.name || params.file.name, { link: res.url }, 'user')
  quill.insertText(index + (res.name || params.file.name).length, ' ', 'user')
  quill.setSelection(index + (res.name || params.file.name).length + 1, 0, 'silent')
}
async function handleToolbarImage() {
  const file = await pickFile('image/*')
  if (!file)
    return
  await insertUploadedFile({ file, kind: 'image' })
}
async function handleToolbarVideo() {
  const file = await pickFile('video/*')
  if (!file)
    return
  await insertUploadedFile({ file, kind: 'video' })
}
async function handleToolbarFile() {
  const file = await pickFile('*/*')
  if (!file)
    return
  await insertUploadedFile({ file, kind: 'file' })
}
function attachPasteAndDropHandlers(quill: Quill) {
  const root = quill.root
  const onPaste = async (e: ClipboardEvent) => {
    if (props.disabled)
      return
    const files = Array.from(e.clipboardData?.files ?? [])
    if (!files.length)
      return
    e.preventDefault()
    for (const f of files) {
      if (f.type.startsWith('image/'))
        await insertUploadedFile({ file: f, kind: 'image' })
      else if (f.type.startsWith('video/'))
        await insertUploadedFile({ file: f, kind: 'video' })
      else
        await insertUploadedFile({ file: f, kind: 'file' })
    }
  }
  const onDrop = async (e: DragEvent) => {
    if (props.disabled)
      return
    const files = Array.from(e.dataTransfer?.files ?? [])
    if (!files.length)
      return
    e.preventDefault()
    for (const f of files) {
      if (f.type.startsWith('image/'))
        await insertUploadedFile({ file: f, kind: 'image' })
      else if (f.type.startsWith('video/'))
        await insertUploadedFile({ file: f, kind: 'video' })
      else
        await insertUploadedFile({ file: f, kind: 'file' })
    }
  }
  const onDragOver = (e: DragEvent) => {
    if (!props.disabled)
      e.preventDefault()
  }
  root.addEventListener('paste', onPaste as EventListener)
  root.addEventListener('drop', onDrop as EventListener)
  root.addEventListener('dragover', onDragOver as EventListener)
  onBeforeUnmount(() => {
    root.removeEventListener('paste', onPaste as EventListener)
    root.removeEventListener('drop', onDrop as EventListener)
    root.removeEventListener('dragover', onDragOver as EventListener)
  })
}
function setDisabledState(quill: Quill, isDisabled: boolean) {
  quill.enable(!isDisabled)
  const toolbar = quill.getModule('toolbar') as any
  const container: HTMLElement | undefined = toolbar?.container
  if (container) {
    for (const el of Array.from(container.querySelectorAll('button, select'))) {
      ;(el as HTMLButtonElement | HTMLSelectElement).disabled = isDisabled
    }
  }
}
function initEditor() {
  const options = {
    modules: {
      toolbar: [
        { header: [1, 2, 3, 4, 5, 6, false] }, // 标题
        'bold', // 加粗
        'italic', // 斜体
        'strike', // 删除线
        { size: ['small', false, 'large', 'huge'] }, // 字体大小
        { font: [] }, // 字体种类
        { color: [] }, // 字体颜色、
        { background: [] }, // 字体背景颜色
        'link', // 插入链接
        'image', // 插入图片
        'blockquote', // 引用
        'link', // 超链接
        'image', // 插入图片
        'video', // 插入视频
        { list: 'bullet' }, // 无序列表
        { list: 'ordered' }, // 有序列表
        { script: 'sub' }, // 下标
        { script: 'super' }, // 上标
        { align: [] }, // 对齐方式
        'formula', // 公式
        'clean', // remove formatting button
      ],
      toolbar: {
        container: [
          { header: [1, 2, 3, 4, 5, 6, false] },
          'bold',
          'italic',
          'strike',
          { size: ['small', false, 'large', 'huge'] },
          { font: [] },
          { color: [] },
          { background: [] },
          'blockquote',
          'link',
          'image',
          'video',
          'file',
          { list: 'bullet' },
          { list: 'ordered' },
          { script: 'sub' },
          { script: 'super' },
          { align: [] },
          'formula',
          'clean',
        ],
        handlers: {
          image: () => {
            void handleToolbarImage()
          },
          video: () => {
            void handleToolbarVideo()
          },
          file: () => {
            void handleToolbarFile()
          },
        },
      },
    },
    placeholder: '请输入 ...',
@@ -56,52 +196,65 @@
  const quill = new Quill(editorRef.value!, options)
  quill.on('text-change', (_delta, _oldDelta, _source) => {
    editorModel.value = quill.getSemanticHTML()
    const html = quill.getSemanticHTML()
    if (html !== lastHtml.value) {
      lastHtml.value = html
      model.value = html
    }
  })
  if (disabled)
    quill.enable(false)
  setDisabledState(quill, !!props.disabled)
  editorInst = quill
  editorInst.value = quill
  attachPasteAndDropHandlers(quill)
  if (model.value)
  if (model.value) {
    lastHtml.value = model.value
    setContents(model.value)
  }
}
function setContents(html: string) {
  editorInst!.setContents(editorInst!.clipboard.convert({ html }))
  const quill = editorInst.value
  if (!quill)
    return
  if (!html) {
    quill.setContents([])
    return
  }
  quill.setContents(quill.clipboard.convert({ html }))
}
watch(
  () => model.value,
  (newValue, _oldValue) => {
    if (newValue && newValue !== editorModel.value) {
      setContents(newValue)
    }
    else if (!newValue) {
      setContents('')
    }
    const html = newValue ?? ''
    if (html === lastHtml.value)
      return
    lastHtml.value = html
    setContents(html)
  },
)
watch(editorModel, (newValue, oldValue) => {
  if (newValue && newValue !== oldValue)
    model.value = newValue
  else if (!newValue)
    editorInst!.setContents([])
})
watch(
  () => disabled,
  () => props.disabled,
  (newValue, _oldValue) => {
    editorInst!.enable(!newValue)
    const quill = editorInst.value
    if (!quill)
      return
    setDisabledState(quill, !!newValue)
  },
)
onBeforeUnmount(() => editorInst = null)
onBeforeUnmount(() => editorInst.value = null)
</script>
<template>
  <div ref="editorRef" />
</template>
<style scoped>
:deep(.ql-toolbar .ql-file)::before {
  content: '文件';
}
</style>
src/service/api/invitation.ts
@@ -3,8 +3,8 @@
/**
 * 获取邀请码列表
 * @param params
 * @returns
 * @param params
 * @returns
 */
export function getInvitationPageList(params: any) {
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/invitation/pageList', params)
@@ -20,7 +20,7 @@
/**
 * 新增邀请码
 * @param phones 手机号 多个逗号隔开
 * @returns
 * @returns
 */
export function addInvitationCode(phones: string) {
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/invitation/add', { phones })
@@ -37,7 +37,7 @@
/**
 * 批量删除邀请码
 * @param ids 逗号拼接的id字符串
 * @returns
 * @returns
 */
export function deleteInvitationCodes(ids: string) {
  return request.Get<Service.ResponseResult<any>>('/operation/api/v1/invitation/deleteByIds', { params: { ids } })
@@ -45,8 +45,8 @@
/**
 * 导出邀请码
 * @param params
 * @returns
 * @param params
 * @returns
 */
export function exportInvitationExcel(params: any) {
  const method = request.Post<Service.ResponseResult<any>>('/operation/api/v1/invitation/exportExcel', params, { responseType: 'blob' })
@@ -56,8 +56,8 @@
/**
 * 导入邀请码
 * @param file
 * @returns
 * @param file
 * @returns
 */
export function importInvitationExcel(file: File) {
  const formData = new FormData()
@@ -67,10 +67,14 @@
/**
 * 下载导入模板
 * @returns
 * @returns
 */
export function getInvitationTemplate() {
  const method = request.Get<Service.ResponseResult<any>>('/operation/api/v1/invitation/import-template', { responseType: 'blob' })
  method.meta = { ...method.meta, isBlob: true }
  return method
}
export function validateInvitationCodePhone(phone: string) {
  return request.Get<Service.ResponseResult<any>>('/operation/api/v1/invitation/validateCode', { params: { phone } })
}
src/utils/upload.ts
@@ -1,6 +1,24 @@
import type { UploadFileInfo } from 'naive-ui'
import { uploadFile } from '@/service/api/common'
export async function uploadSingleFile(file: File) {
  try {
    const formData = new FormData()
    formData.append('file', file)
    const res = await uploadFile(formData)
    if (res.code === 200)
      return res.data
    window.$message.error('文件上传失败')
    throw new Error('Upload failed')
  }
  catch (err) {
    window.$message.error('文件上传出错')
    console.error('Upload Error:', err)
    throw err
  }
}
/**
 * Naive UI n-upload 自定义上传请求封装
 * @param options Naive UI upload options
@@ -44,11 +62,11 @@
      file.id = res.data.id
      file.status = 'finished'
      onFinish()
    } else {
    }
    else {
      onError()
      window.$message.error('文件上传失败')
    }
   console.log('模拟返回逻辑开始')
    // --- 模拟返回逻辑开始 ---
    // 生成一个模拟的 URL (如果是图片则使用 base64 预览)
@@ -79,16 +97,14 @@
 * @param options 校验配置
 */
export function beforeUploadCheck(
  data: { file: UploadFileInfo; fileList: UploadFileInfo[] },
  options: {
    maxSize?: number // 单位 MB
    allowedTypes?: string[]
  } = {}
  data: { file: UploadFileInfo, fileList: UploadFileInfo[] },
  options: { maxSize?: number, allowedTypes?: string[] } = {},
) {
  const { maxSize = 5, allowedTypes = ['image/jpeg', 'image/png', 'image/jpg'] } = options
  const file = data.file.file
  if (!file) return false
  if (!file)
    return false
  // 检查文件类型
  if (allowedTypes.length > 0 && !allowedTypes.includes(file.type)) {
src/views/system/config/index.vue
@@ -55,7 +55,8 @@
  //   { required: true, message: '请输入经度', trigger: 'blur' }
  // ],
  faqDisplayCount: [
    { required: true, type: 'integer',min: 0, message: '请输入正整数', trigger: 'blur' }
    { required: true, type: 'integer', min: 0, message: '请输入正整数', trigger: 'blur' },
    { type: 'integer', max: 999, message: '最大999', trigger: 'blur' },
  ],
  nearPointDistance: [
    { required: true, type: 'integer', min: 0, message: '请输入正整数', trigger: 'blur' },
@@ -102,7 +103,7 @@
      formModel.value.timeThreshold = Number(d.minOrderDistance ?? 0)
      formModel.value.attractDistance = Number(d.adsorptionDistance ?? 0)
      formModel.value.destinationDistance = Number(d.arriveTime ?? 0)
      formModel.value.faqDisplayCount = Number(d.headShowCount ?? 0)
      formModel.value.faqDisplayCount = Math.min(999, Number(d.headShowCount ?? 0))
      formModel.value.nearPointDistance = Math.min(99999, Number(d.nearPointDistance ?? 0))
      // miniProgramLongitude / miniProgramLatitude 未在接口返回中定义,保持输入值
    } else {
@@ -224,6 +225,7 @@
              v-model:value="formModel.faqDisplayCount"
              placeholder="请输入"
              :min="0"
              :max="999"
              :show-button="false"
              style="width: 225px;"
            />
src/views/userContent/whitelistManagement/invitationCode/components/GenerateModal.vue
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { NButton, NForm, NFormItem, NInput, NModal, NSpace, useDialog, useMessage } from 'naive-ui'
import type { FormInst, FormRules } from 'naive-ui'
import { addInvitationCode } from '@/service'
import { addInvitationCode, validateInvitationCodePhone } from '@/service'
interface Props {
  visible: boolean
@@ -20,10 +20,74 @@
const formRef = ref<FormInst | null>(null)
const loading = ref(false)
const phoneRegex = /^1[3-9]\d{9}$/
let lastValidatedValue = ''
let lastValidatedResult: true | Error = true
// 表单数据
const formModel = ref({
  phoneNumbers: '',
})
function splitPhones(value: string) {
  return value
    .split(/[,,\n]/)
    .map((p: string) => p.trim())
    .filter((p: string) => p)
}
function uniquePhones(phones: string[]) {
  return Array.from(new Set(phones))
}
async function validatePhonesNotRegistered(value: string) {
  if (!value) {
    return true
  }
  const phones = splitPhones(value)
  const invalidPhones = phones.filter(p => !phoneRegex.test(p))
  if (invalidPhones.length > 0) {
    return true
  }
  if (phones.length > 500) {
    return true
  }
  if (value === lastValidatedValue) {
    return lastValidatedResult
  }
  const uniqPhones = uniquePhones(phones)
  try {
    const res = await validateInvitationCodePhone(uniqPhones.join(','))
    if (res.code !== 200) {
      throw new Error(res.message || '校验手机号失败')
    }
    const problemPhones = Array.isArray(res.data)
      ? res.data.map(v => String(v).trim()).filter(Boolean)
      : []
    if (problemPhones.length > 0) {
      const err = new Error(`${problemPhones.join(', ')} 手机号已注册`)
      lastValidatedValue = value
      lastValidatedResult = err
      return err
    }
    lastValidatedValue = value
    lastValidatedResult = true
    return true
  }
  catch (e: any) {
    const err = new Error(e?.message || '校验手机号失败,请稍后重试')
    lastValidatedValue = value
    lastValidatedResult = err
    return err
  }
}
// 表单验证规则
const rules: FormRules = {
@@ -42,7 +106,6 @@
        const phones = value.split(/[,,\n]/).map((p: string) => p.trim()).filter((p: string) => p)
        // 验证每个手机号格式
        const phoneRegex = /^1[3-9]\d{9}$/
        const invalidPhones = phones.filter((p: string) => !phoneRegex.test(p))
        if (invalidPhones.length > 0) {
@@ -57,6 +120,16 @@
        return true
      },
      trigger: ['blur', 'input'],
    },
    {
      asyncValidator: async (_rule, value) => {
        const result = await validatePhonesNotRegistered(value)
        if (result === true) {
          return
        }
        throw result
      },
      trigger: ['blur'],
    },
  ],
}
@@ -99,6 +172,8 @@
  formModel.value = {
    phoneNumbers: '',
  }
  lastValidatedValue = ''
  lastValidatedResult = true
  formRef.value?.restoreValidation()
}
@@ -170,22 +245,27 @@
          style="height: 80px;"
          :autosize="{
            minRows: 1,
            maxRows: 8
            maxRows: 8,
          }"
        />
      </NFormItem>
    </NForm>
    <template #action>
        <NSpace justify="end" :size="8">
          <NButton @click="handleClose" style="padding: 7px 29px; border-radius: 4px;">
            取消
          </NButton>
          <NButton type="primary" :loading="loading" @click="handleSave" style="padding: 7px 29px; border-radius: 4px;">
            保存
          </NButton>
        </NSpace>
      </template>
      <NSpace justify="end" :size="8">
        <NButton style="padding: 7px 29px; border-radius: 4px;" @click="handleClose">
          取消
        </NButton>
        <NButton
          type="primary"
          :loading="loading"
          style="padding: 7px 29px; border-radius: 4px;"
          @click="handleSave"
        >
          保存
        </NButton>
      </NSpace>
    </template>
  </NModal>
</template>