| | |
| | | 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: '请输入 ...', |
| | |
| | | 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> |
| | |
| | | |
| | | /** |
| | | * 获取邀请码列表 |
| | | * @param params |
| | | * @returns |
| | | * @param params |
| | | * @returns |
| | | */ |
| | | export function getInvitationPageList(params: any) { |
| | | return request.Post<Service.ResponseResult<any>>('/operation/api/v1/invitation/pageList', params) |
| | |
| | | /** |
| | | * 新增邀请码 |
| | | * @param phones 手机号 多个逗号隔开 |
| | | * @returns |
| | | * @returns |
| | | */ |
| | | export function addInvitationCode(phones: string) { |
| | | return request.Post<Service.ResponseResult<any>>('/operation/api/v1/invitation/add', { phones }) |
| | |
| | | /** |
| | | * 批量删除邀请码 |
| | | * @param ids 逗号拼接的id字符串 |
| | | * @returns |
| | | * @returns |
| | | */ |
| | | export function deleteInvitationCodes(ids: string) { |
| | | return request.Get<Service.ResponseResult<any>>('/operation/api/v1/invitation/deleteByIds', { params: { ids } }) |
| | |
| | | |
| | | /** |
| | | * 导出邀请码 |
| | | * @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' }) |
| | |
| | | |
| | | /** |
| | | * 导入邀请码 |
| | | * @param file |
| | | * @returns |
| | | * @param file |
| | | * @returns |
| | | */ |
| | | export function importInvitationExcel(file: File) { |
| | | const formData = new FormData() |
| | |
| | | |
| | | /** |
| | | * 下载导入模板 |
| | | * @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 } }) |
| | | } |
| | |
| | | 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 |
| | |
| | | file.id = res.data.id |
| | | file.status = 'finished' |
| | | onFinish() |
| | | } else { |
| | | } |
| | | else { |
| | | onError() |
| | | window.$message.error('文件上传失败') |
| | | } |
| | | console.log('模拟返回逻辑开始') |
| | | |
| | | // --- 模拟返回逻辑开始 --- |
| | | // 生成一个模拟的 URL (如果是图片则使用 base64 预览) |
| | |
| | | * @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)) { |
| | |
| | | // { 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' }, |
| | |
| | | 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 { |
| | |
| | | v-model:value="formModel.faqDisplayCount" |
| | | placeholder="请输入" |
| | | :min="0" |
| | | :max="999" |
| | | :show-button="false" |
| | | style="width: 225px;" |
| | | /> |
| | |
| | | <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 |
| | |
| | | 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 = { |
| | |
| | | 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) { |
| | |
| | | return true |
| | | }, |
| | | trigger: ['blur', 'input'], |
| | | }, |
| | | { |
| | | asyncValidator: async (_rule, value) => { |
| | | const result = await validatePhonesNotRegistered(value) |
| | | if (result === true) { |
| | | return |
| | | } |
| | | throw result |
| | | }, |
| | | trigger: ['blur'], |
| | | }, |
| | | ], |
| | | } |
| | |
| | | formModel.value = { |
| | | phoneNumbers: '', |
| | | } |
| | | lastValidatedValue = '' |
| | | lastValidatedResult = true |
| | | formRef.value?.restoreValidation() |
| | | } |
| | | |
| | |
| | | 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> |
| | | |