diff --git a/CHANGE_RECORD_ADD_SERVICE.md b/CHANGE_RECORD_ADD_SERVICE.md new file mode 100644 index 0000000..c0ed179 --- /dev/null +++ b/CHANGE_RECORD_ADD_SERVICE.md @@ -0,0 +1,81 @@ +# 添加服务页改动记录 + +## 2026-06-11 + +### 1. 接口返回视频字段未正常显示 + +涉及文件: + +- `pages/shop/add-service.vue` +- `components/dynamic-form/dynamic-form.vue` +- `components/dynamic-form/FormItemVideo.vue` + +改动内容: + +- `pages/shop/add-service.vue` + - 动态模板转换时不再过滤 `fieldType === 'video'` 的字段。 + - 新增 `hasDynamicVideoField`,当接口模板已经返回视频字段时,使用动态表单内的视频上传组件。 + - 原页面固定视频上传块保留为兜底,仅在模板没有视频字段时显示,避免重复显示两个视频上传组件。 + - 给 `dynamic-form` 传入 `upload-user-id`,让动态视频上传能拿到当前发布用户 ID。 + - 初始化动态字段时支持分组结构,视频多选字段按数组初始化。 + +- `components/dynamic-form/dynamic-form.vue` + - 视频字段渲染时传入 `maxCount`、`maxSize/maxFileSize`。 + - 新增 `uploadUserId` prop,并传给 `form-item-video`。 + - 多视频字段初始化为空数组,单视频字段初始化为空字符串。 + +- `components/dynamic-form/FormItemVideo.vue` + - 支持单视频和多视频值类型。 + - 使用 `ali-oss-uploader` 上传视频时传入 `kind=2`。 + - 支持视频上传成功后回写、删除后清空、已有视频回显。 + - 上传用户 ID 优先使用父组件传入值,兜底读取 Vuex 中的商家信息。 + +### 2. 视频上传后传给后端的地址打不开 + +涉及文件: + +- `components/ali-oss-uploader/ali-oss-uploader.vue` + +改动内容: + +- 上传 OSS 后不再无条件拼接 URL 并回传。 +- H5 `fetch` 上传和 APP/小程序 `uni.uploadFile` 上传都必须返回 `2xx` 才认为成功。 +- 上传失败时 reject,并触发 `error`,避免把不存在的 OSS 地址提交给后端。 +- 统一生成 OSS object key,上传使用的 key 和回传给后端的 URL 使用同一套路径规则。 +- 文件扩展名提取增加兜底逻辑: + - 视频默认 `.mp4` + - 图片默认 `.jpg` + - 清理临时路径中的 query/hash,避免生成异常对象名。 + +### 3. 添加服务页多图上传支持拖动排序 + +涉及文件: + +- `components/ali-oss-uploader/ali-oss-uploader.vue` +- `components/dynamic-form/FormItemImage.vue` +- `pages/shop/add-service.vue` + +改动内容: + +- `components/ali-oss-uploader/ali-oss-uploader.vue` + - 新增 `sortable` prop,默认关闭。 + - 图片上传列表支持长按开始拖动。 + - 拖动到其他图片位置时实时交换数组顺序。 + - 每次排序后立即触发 `input` 和 `change`,向父组件回写排序后的 `fileList`。 + - 拖动中增加视觉状态,降低透明度并缩放。 + - 拖动结束后短暂抑制图片预览,避免排序后误触发预览。 + - 图片预览使用当前 `fileList` 字符串数组,保持预览顺序和提交顺序一致。 + +- `components/dynamic-form/FormItemImage.vue` + - 动态表单多图上传开启 `sortable`。 + +- `pages/shop/add-service.vue` + - 提交逻辑沿用当前数组顺序: + - `image_multi` 字段按动态表单当前数组顺序传参。 + - `photo` 字段通过 `Object.values(this.formData.photo || {})` 取当前顺序传参。 + +### 4. 校验情况 + +- 已执行 `git diff --check`,当前相关改动无 whitespace 错误。 +- 项目 `package.json` 未配置 build/lint 脚本,未执行完整构建校验。 +- 本地缺少 `vue-template-compiler`,未执行 Vue 模板编译校验。 diff --git a/components/ali-oss-uploader/ali-oss-uploader.vue b/components/ali-oss-uploader/ali-oss-uploader.vue index 0a96154..1433694 100644 --- a/components/ali-oss-uploader/ali-oss-uploader.vue +++ b/components/ali-oss-uploader/ali-oss-uploader.vue @@ -3,10 +3,15 @@ - - - + - + - @@ -52,7 +57,7 @@ - - + @@ -77,7 +82,7 @@ import permissionUtils from '../../utils/per' import locationService from '../../utils/locationService'; export default { name: 'AliOssUploader', - + props: { // 已上传文件列表 value: { @@ -145,17 +150,24 @@ export default { kind: { type: Number, default: 1 + }, + sortable: { + type: Boolean, + default: false } }, - + data() { return { fileList: [], oneFile: '', - loading: false + loading: false, + dragIndex: -1, + isDragging: false, + suppressPreview: false } }, - + watch: { value: { immediate: true, @@ -170,9 +182,9 @@ export default { } } }, - + methods: { - + // 显示权限说明弹窗 showPermissionDialog(title, content) { return new Promise((resolve) => { @@ -294,29 +306,17 @@ export default { return Promise.reject(new Error('文件大小超出限制')) } let date = new Date().getTime() - let imageType; - if(this.accept == 'image/*') { - imageType = file.path.split('.') - }else if(this.accept == 'video/*') { - // #ifdef H5 - imageType = file.name.split('.') - // #endif - // #ifdef APP-PLUS || MP-WEIXIN - imageType = file.tempFilePath.split('.') - // #endif - } - console.log('imageType',imageType) - + const fileExt = this.getFileExt(file) + let artisan = this.type==1 ? 'yh' : this.type==2 ? 'syr' : this.type==3 ? 'sj' : ''; - let name = `${this.userId}_${artisan}_${date}.${imageType[imageType.length - 1]}` + let name = `${this.userId}_${artisan}_${date}.${fileExt}` console.log('name',name) try { // 1. 获取OSS上传凭证 const ossConfig = await this.getOssConfig() // 2. 上传文件 - const uploadRes = await this.uploadToOss(file, ossConfig, name) + const fileUrl = await this.uploadToOss(file, ossConfig, name) // 3. 添加到文件列表 - const fileUrl = `${ossConfig.host}/${ossConfig.dir}${name}` if(this.accept == 'image/*') { const newFile = [`${fileUrl}`] this.fileList = this.fileList.concat(newFile) @@ -332,14 +332,14 @@ export default { this.$emit('success', newFile) return newFile } - + } catch (error) { console.error('上传失败:', error) this.$emit('error', error) throw error } }, - + // 获取OSS配置 async getOssConfig() { try { @@ -350,10 +350,30 @@ export default { throw error } }, - + + getFileExt(file) { + const defaultExt = this.accept === 'video/*' ? 'mp4' : 'jpg' + const source = file && (file.name || file.tempFilePath || file.path || file.url || '') + const cleanPath = String(source).split('?')[0].split('#')[0] + const ext = cleanPath.includes('.') ? cleanPath.split('.').pop().toLowerCase() : '' + return /^[a-z0-9]+$/.test(ext) ? ext : defaultExt + }, + + buildFileUrl(ossConfig, name) { + const host = String(ossConfig.host || '').replace(/\/+$/, '') + return `${host}/${this.buildObjectKey(ossConfig, name)}` + }, + + buildObjectKey(ossConfig, name) { + const dir = String(ossConfig.dir || '').replace(/^\/+/, '').replace(/\/+$/, '') + return dir ? `${dir}/${name}` : name + }, + // 上传到OSS uploadToOss(file, ossConfig, name) { return new Promise((resolve, reject) => { + const objectKey = this.buildObjectKey(ossConfig, name) + const fileUrl = this.buildFileUrl(ossConfig, name) // #ifdef H5 const formData = new FormData() formData.append('name',name); @@ -361,16 +381,20 @@ export default { formData.append('OSSAccessKeyId', ossConfig.ossAccessKeyId); formData.append('success_action_status', '200'); formData.append('signature', ossConfig.signature); - formData.append('key', ossConfig.dir + name); + formData.append('key', objectKey); // file必须为最后一个表单域,除file以外的其他表单域无顺序要求。 - formData.append('file', file); + formData.append('file', file.file || file.tempFile || file); fetch(ossConfig.host, { method: 'POST', body: formData},).then((res) => { - resolve(res) - }).catch(()=>{ - reject(new Error(`上传失败: ${res.statusCode}`)) + if (res.ok) { + resolve(fileUrl) + } else { + reject(new Error(`上传失败: ${res.status}`)) + } + }).catch((error)=>{ + reject(error) }) // #endif - + // #ifdef APP-PLUS || MP-WEIXIN let formData = {}; formData.name = name; @@ -378,7 +402,7 @@ export default { formData.OSSAccessKeyId = ossConfig.ossAccessKeyId; formData.success_action_status = '200'; formData.signature = ossConfig.signature; - formData.key = ossConfig.dir + name; + formData.key = objectKey; formData.file = file; let path; if(this.accept == 'image/*') { @@ -397,29 +421,83 @@ export default { filePath: path, // 手机端文件路径 name: 'file', formData: { - key: ossConfig.dir + name, // 文件名 + key: objectKey, // 文件名 policy: ossConfig.policy, // 后台获取超时时间 OSSAccessKeyId: ossConfig.ossAccessKeyId, // 后台获取临时ID success_action_status: 200, // 让服务端返回200,不然,默认会返回204 signature: ossConfig.signature // 后台获取签名 }, - success(res) { - resolve(`${ossConfig.host}/${ossConfig.dir}${name}`) + success: (res) => { + const statusCode = Number(res.statusCode) + if (statusCode >= 200 && statusCode < 300) { + resolve(fileUrl) + } else { + reject(new Error(`上传失败: ${statusCode} ${res.data || ''}`)) + } + }, + fail(error) { + reject(error) } }); // #endif }) - + }, - + // 预览图片 handlePreview(index) { + if (this.isDragging || this.suppressPreview) return uni.previewImage({ current: index, - urls: this.fileList.map(item => item.url) + urls: this.fileList }) }, - + + startDrag(index) { + if (!this.sortable || this.accept !== 'image/*' || this.fileList.length < 2) return + this.dragIndex = index + this.isDragging = true + }, + + handleDragMove(event) { + if (!this.isDragging || this.dragIndex < 0) return + if (event.preventDefault) event.preventDefault() + if (event.stopPropagation) event.stopPropagation() + const touch = event.touches && event.touches[0] + if (!touch) return + uni.createSelectorQuery() + .in(this) + .selectAll('.image-preview-item') + .boundingClientRect((rects) => { + if (!Array.isArray(rects) || !rects.length) return + const targetIndex = rects.findIndex((rect) => { + return touch.clientX >= rect.left && + touch.clientX <= rect.right && + touch.clientY >= rect.top && + touch.clientY <= rect.bottom + }) + if (targetIndex < 0 || targetIndex === this.dragIndex || targetIndex >= this.fileList.length) return + const nextList = this.fileList.slice() + const moved = nextList.splice(this.dragIndex, 1)[0] + nextList.splice(targetIndex, 0, moved) + this.fileList = nextList + this.dragIndex = targetIndex + this.$emit('input', this.fileList) + this.$emit('change', this.fileList) + }) + .exec() + }, + + endDrag() { + if (!this.isDragging) return + this.dragIndex = -1 + this.suppressPreview = true + setTimeout(() => { + this.isDragging = false + this.suppressPreview = false + }, 200) + }, + // 删除文件 handleDelete(index) { // uni.showModal({ @@ -440,7 +518,7 @@ export default { this.$emit('change', this.oneFile) this.$emit('delete', deletedFile) } - + // } // } // }) @@ -504,6 +582,11 @@ export default { margin-top: 20rpx; } +.image-preview-item.dragging { + opacity: 0.65; + transform: scale(0.96); +} + .preview-image { width: 200rpx; height: 200rpx; @@ -525,4 +608,4 @@ export default { font-size: 32rpx; z-index: 1; } - \ No newline at end of file + diff --git a/components/dynamic-form/FormItemImage.vue b/components/dynamic-form/FormItemImage.vue index 3439c93..887038d 100644 --- a/components/dynamic-form/FormItemImage.vue +++ b/components/dynamic-form/FormItemImage.vue @@ -1,6 +1,6 @@ + + diff --git a/components/dynamic-form/FormItemVideo.vue b/components/dynamic-form/FormItemVideo.vue index 345887d..cd0bada 100644 --- a/components/dynamic-form/FormItemVideo.vue +++ b/components/dynamic-form/FormItemVideo.vue @@ -1,11 +1,26 @@