Compare commits

...

5 Commits

Author SHA1 Message Date
丁杰 79ee295c18 视频上传按钮修复 2026-06-12 13:59:52 +08:00
丁杰 0e9bca8d97 修复bug,优化流程 2026-06-11 20:48:30 +08:00
丁杰 6b659a6878 备份 2026-06-10 16:23:47 +08:00
丁杰 6aa66ce311 弹窗样式修改,组件预定义 2026-06-10 10:15:10 +08:00
丁杰 a7f9544bc5 预定义组件备份 2026-06-09 17:49:15 +08:00
18 changed files with 5800 additions and 2440 deletions

View File

@ -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 模板编译校验。

View File

@ -3,10 +3,15 @@
<view class="preview-container" :style="'width:'+width"> <view class="preview-container" :style="'width:'+width">
<!-- 预览区域 --> <!-- 预览区域 -->
<view v-if="accept == 'image/*'" class="preview-container" :style="'width:'+width"> <view v-if="accept == 'image/*'" class="preview-container" :style="'width:'+width">
<view <view
v-for="(file, index) in fileList" v-for="(file, index) in fileList"
:key="index" :key="file || index"
class="preview-item" class="preview-item image-preview-item"
:class="{ dragging: dragIndex === index }"
@longpress="startDrag(index)"
@touchmove="handleDragMove"
@touchend="endDrag"
@touchcancel="endDrag"
> >
<image <image
:src="file" :src="file"
@ -14,13 +19,17 @@
mode="aspectFill" mode="aspectFill"
@click="handlePreview(index)" @click="handlePreview(index)"
/> />
<view v-if="deletable" class="delete-icon" @click.stop="handleDelete(index)"> <image
× v-if="deletable"
</view> class="delete-icon"
src="https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/6ab3fc63-3bad-403d-bbfc-9609a93c048a.png"
mode="aspectFit"
@click.stop="handleDelete(index)"
></image>
</view> </view>
<!-- 上传按钮 --> <!-- 上传按钮 -->
<view class="preview-item"> <view class="preview-item">
<view <view
class="upload-btn" class="upload-btn"
@click="handleUpload" @click="handleUpload"
:style="btnStyle" :style="btnStyle"
@ -31,32 +40,39 @@
</slot> </slot>
</view> </view>
</view> </view>
</view> </view>
<!-- 视频预览区域 --> <!-- 视频预览区域 -->
<view class="preview-container" :style="'width:'+width" v-if="accept == 'video/*'"> <view class="preview-container" :style="'width:'+width" v-if="accept == 'video/*'">
<view <block v-if="showPreview">
class="preview-item" <view
v-if="oneFile" v-for="(file, index) in videoList"
> :key="file || index"
<!-- <video class="preview-item"
:src="oneFile" >
class="preview-image" <!-- <video
@click="handlePreview(index)" :src="file"
/> --> class="preview-image"
<image class="preview-image" :src="oneFile + '?x-oss-process=video/snapshot,t_0,f_jpg'" mode="aspectFill"></image> @click="handlePreview(index)"
<view v-if="deletable" class="delete-icon" @click.stop="handleDelete()"> /> -->
× <image class="preview-image" :src="getVideoPoster(file)" mode="aspectFill"></image>
<image
v-if="deletable"
class="delete-icon"
src="https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/6ab3fc63-3bad-403d-bbfc-9609a93c048a.png"
mode="aspectFit"
@click.stop="handleDelete(index)"
></image>
</view> </view>
</view> </block>
<view class="preview-item"> <view class="preview-item">
<!-- 上传按钮 --> <!-- 上传按钮 -->
<view <view
class="upload-btn" class="upload-btn"
@click="handleUpload" @click="handleUpload"
:style="btnStyle" :style="btnStyle"
v-if="!oneFile" v-if="videoCount < maxCount"
> >
<slot name="button"> <slot name="button">
<text class="plus-icon">+</text> <text class="plus-icon">+</text>
@ -67,7 +83,7 @@
</view> </view>
<!-- 提示文字 --> <!-- 提示文字 -->
<!-- <text v-if="tips" class="tips-text">{{ tips }}</text> --> <!-- <text v-if="tips" class="tips-text">{{ tips }}</text> -->
</view> </view>
</template> </template>
@ -77,7 +93,7 @@ import permissionUtils from '../../utils/per'
import locationService from '../../utils/locationService'; import locationService from '../../utils/locationService';
export default { export default {
name: 'AliOssUploader', name: 'AliOssUploader',
props: { props: {
// //
value: { value: {
@ -145,17 +161,31 @@ export default {
kind: { kind: {
type: Number, type: Number,
default: 1 default: 1
},
sortable: {
type: Boolean,
default: false
} }
}, },
data() { data() {
return { return {
fileList: [], fileList: [],
oneFile: '', oneFile: '',
loading: false videoList: [],
loading: false,
dragIndex: -1,
isDragging: false,
suppressPreview: false
} }
}, },
computed: {
videoCount() {
return this.showPreview ? this.videoList.length : 0
}
},
watch: { watch: {
value: { value: {
immediate: true, immediate: true,
@ -167,12 +197,13 @@ export default {
immediate: true, immediate: true,
handler(newVal) { handler(newVal) {
this.oneFile = newVal this.oneFile = newVal
this.videoList = newVal ? [newVal] : []
} }
} }
}, },
methods: { methods: {
// //
showPermissionDialog(title, content) { showPermissionDialog(title, content) {
return new Promise((resolve) => { return new Promise((resolve) => {
@ -272,12 +303,15 @@ export default {
await this.uploadFile(file); await this.uploadFile(file);
} }
} else if (this.accept === 'video/*') { } else if (this.accept === 'video/*') {
const res = await uni.chooseVideo({ const count = this.maxCount - this.videoCount;
sourceType: [sourceType], if (count <= 0) {
compressed: true, uni.showToast({ title: `最多上传${this.maxCount}个文件`, icon: 'none' });
maxDuration: 60 return;
}); }
await this.uploadFile(res); const files = await this.chooseVideoFiles(sourceType, count);
for (const file of files) {
await this.uploadFile(file);
}
} }
} catch (error) { } catch (error) {
console.error('选择文件失败:', error); console.error('选择文件失败:', error);
@ -293,30 +327,18 @@ export default {
}) })
return Promise.reject(new Error('文件大小超出限制')) return Promise.reject(new Error('文件大小超出限制'))
} }
let date = new Date().getTime() let date = `${new Date().getTime()}_${Math.floor(Math.random() * 100000)}`
let imageType; const fileExt = this.getFileExt(file)
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)
let artisan = this.type==1 ? 'yh' : this.type==2 ? 'syr' : this.type==3 ? 'sj' : ''; 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) console.log('name',name)
try { try {
// 1. OSS // 1. OSS
const ossConfig = await this.getOssConfig() const ossConfig = await this.getOssConfig()
// 2. // 2.
const uploadRes = await this.uploadToOss(file, ossConfig, name) const fileUrl = await this.uploadToOss(file, ossConfig, name)
// 3. // 3.
const fileUrl = `${ossConfig.host}/${ossConfig.dir}${name}`
if(this.accept == 'image/*') { if(this.accept == 'image/*') {
const newFile = [`${fileUrl}`] const newFile = [`${fileUrl}`]
this.fileList = this.fileList.concat(newFile) this.fileList = this.fileList.concat(newFile)
@ -327,19 +349,23 @@ export default {
}else if(this.accept == 'video/*') { }else if(this.accept == 'video/*') {
const newFile = fileUrl const newFile = fileUrl
this.oneFile = fileUrl this.oneFile = fileUrl
this.$emit('input', this.oneFile) if (this.showPreview) {
this.$emit('change', this.oneFile) this.videoList = this.videoList.concat([newFile]).slice(0, this.maxCount)
}
const emitValue = this.maxCount > 1 && this.showPreview ? this.videoList : this.oneFile
this.$emit('input', emitValue)
this.$emit('change', emitValue)
this.$emit('success', newFile) this.$emit('success', newFile)
return newFile return newFile
} }
} catch (error) { } catch (error) {
console.error('上传失败:', error) console.error('上传失败:', error)
this.$emit('error', error) this.$emit('error', error)
throw error throw error
} }
}, },
// OSS // OSS
async getOssConfig() { async getOssConfig() {
try { try {
@ -350,10 +376,68 @@ export default {
throw error 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
},
getVideoPoster(url) {
if (!url) return ''
if (String(url).indexOf('x-oss-process=') > -1) return url
const connector = String(url).indexOf('?') > -1 ? '&' : '?'
return `${url}${connector}x-oss-process=video/snapshot,t_2000,f_jpg,m_fast`
},
chooseVideoFiles(sourceType, count) {
if (uni.chooseMedia) {
return new Promise((resolve, reject) => {
uni.chooseMedia({
count,
mediaType: ['video'],
sourceType: [sourceType],
maxDuration: 60,
success: (res) => {
const files = (res.tempFiles || []).map((item) => ({
...item,
tempFilePath: item.tempFilePath || item.path,
path: item.tempFilePath || item.path,
}));
resolve(files);
},
fail: reject,
});
});
}
return new Promise((resolve, reject) => {
uni.chooseVideo({
sourceType: [sourceType],
compressed: true,
maxDuration: 60,
success: (res) => resolve([res]),
fail: reject,
});
});
},
// OSS // OSS
uploadToOss(file, ossConfig, name) { uploadToOss(file, ossConfig, name) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const objectKey = this.buildObjectKey(ossConfig, name)
const fileUrl = this.buildFileUrl(ossConfig, name)
// #ifdef H5 // #ifdef H5
const formData = new FormData() const formData = new FormData()
formData.append('name',name); formData.append('name',name);
@ -361,16 +445,20 @@ export default {
formData.append('OSSAccessKeyId', ossConfig.ossAccessKeyId); formData.append('OSSAccessKeyId', ossConfig.ossAccessKeyId);
formData.append('success_action_status', '200'); formData.append('success_action_status', '200');
formData.append('signature', ossConfig.signature); formData.append('signature', ossConfig.signature);
formData.append('key', ossConfig.dir + name); formData.append('key', objectKey);
// filefile // filefile
formData.append('file', file); formData.append('file', file.file || file.tempFile || file);
fetch(ossConfig.host, { method: 'POST', body: formData},).then((res) => { fetch(ossConfig.host, { method: 'POST', body: formData},).then((res) => {
resolve(res) if (res.ok) {
}).catch(()=>{ resolve(fileUrl)
reject(new Error(`上传失败: ${res.statusCode}`)) } else {
reject(new Error(`上传失败: ${res.status}`))
}
}).catch((error)=>{
reject(error)
}) })
// #endif // #endif
// #ifdef APP-PLUS || MP-WEIXIN // #ifdef APP-PLUS || MP-WEIXIN
let formData = {}; let formData = {};
formData.name = name; formData.name = name;
@ -378,7 +466,7 @@ export default {
formData.OSSAccessKeyId = ossConfig.ossAccessKeyId; formData.OSSAccessKeyId = ossConfig.ossAccessKeyId;
formData.success_action_status = '200'; formData.success_action_status = '200';
formData.signature = ossConfig.signature; formData.signature = ossConfig.signature;
formData.key = ossConfig.dir + name; formData.key = objectKey;
formData.file = file; formData.file = file;
let path; let path;
if(this.accept == 'image/*') { if(this.accept == 'image/*') {
@ -397,29 +485,83 @@ export default {
filePath: path, // filePath: path, //
name: 'file', name: 'file',
formData: { formData: {
key: ossConfig.dir + name, // key: objectKey, //
policy: ossConfig.policy, // policy: ossConfig.policy, //
OSSAccessKeyId: ossConfig.ossAccessKeyId, // ID OSSAccessKeyId: ossConfig.ossAccessKeyId, // ID
success_action_status: 200, // 200,204 success_action_status: 200, // 200,204
signature: ossConfig.signature // signature: ossConfig.signature //
}, },
success(res) { success: (res) => {
resolve(`${ossConfig.host}/${ossConfig.dir}${name}`) const statusCode = Number(res.statusCode)
if (statusCode >= 200 && statusCode < 300) {
resolve(fileUrl)
} else {
reject(new Error(`上传失败: ${statusCode} ${res.data || ''}`))
}
},
fail(error) {
reject(error)
} }
}); });
// #endif // #endif
}) })
}, },
// //
handlePreview(index) { handlePreview(index) {
if (this.isDragging || this.suppressPreview) return
uni.previewImage({ uni.previewImage({
current: index, 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) { handleDelete(index) {
// uni.showModal({ // uni.showModal({
@ -434,13 +576,16 @@ export default {
this.$emit('change', this.fileList) this.$emit('change', this.fileList)
this.$emit('delete', deletedFile, index) this.$emit('delete', deletedFile, index)
}else if(this.accept == 'video/*') { }else if(this.accept == 'video/*') {
const deletedFile = this.oneFile const deleteIndex = index === undefined ? 0 : index
this.oneFile = '' const deletedFile = this.videoList[deleteIndex]
this.$emit('input', this.oneFile) this.videoList.splice(deleteIndex, 1)
this.$emit('change', this.oneFile) this.oneFile = this.videoList[0] || ''
this.$emit('delete', deletedFile) const emitValue = this.maxCount > 1 ? this.videoList : this.oneFile
this.$emit('input', emitValue)
this.$emit('change', emitValue)
this.$emit('delete', deletedFile, deleteIndex)
} }
// } // }
// } // }
// }) // })
@ -461,8 +606,11 @@ export default {
.preview-container { .preview-container {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 200rpx);
justify-items: center; column-gap: 18rpx;
row-gap: 20rpx;
justify-content: flex-start;
justify-items: stretch;
align-items: center; align-items: center;
position: relative; position: relative;
} }
@ -501,7 +649,11 @@ export default {
position: relative; position: relative;
width: 200rpx; width: 200rpx;
height: 200rpx; height: 200rpx;
margin-top: 20rpx; }
.image-preview-item.dragging {
opacity: 0.65;
transform: scale(0.96);
} }
.preview-image { .preview-image {
@ -514,15 +666,8 @@ export default {
position: absolute; position: absolute;
top: -10rpx; top: -10rpx;
right: -10rpx; right: -10rpx;
width: 40rpx; width: 42rpx;
height: 40rpx; height: 42rpx;
background-color: #ff4d4f; z-index: 5;
color: white;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
font-size: 32rpx;
z-index: 1;
} }
</style> </style>

View File

@ -0,0 +1,86 @@
<template>
<view class="form-checkbox">
<view v-for="(item, index) in options" :key="index" class="checkbox-item" @click="onToggle(item)">
<view class="checkbox-dot" :class="{ active: isChecked(item) }"></view>
<text class="checkbox-label">{{ item }}</text>
</view>
</view>
</template>
<script>
export default {
name: 'FormItemCheckbox',
props: {
value: { type: Array, default: function () { return []; } },
options: { type: Array, default: function () { return []; } },
maxCount: { type: Number, default: 0 }
},
methods: {
isChecked: function (val) {
return this.value && this.value.indexOf(val) !== -1;
},
onToggle: function (val) {
var newValue = (this.value || []).slice();
var index = newValue.indexOf(val);
if (index === -1) {
if (this.maxCount > 0 && newValue.length >= this.maxCount) {
uni.showToast({
title: '最多只能选择' + this.maxCount + '项',
icon: 'none'
});
return;
}
newValue.push(val);
} else {
newValue.splice(index, 1);
}
this.$emit('input', newValue);
}
}
};
</script>
<style scoped>
.form-checkbox {
display: flex;
flex-wrap: wrap;
}
.checkbox-item {
display: flex;
align-items: center;
margin-right: 40rpx;
margin-bottom: 16rpx;
}
.checkbox-dot {
width: 36rpx;
height: 36rpx;
border-radius: 8rpx;
border: 2rpx solid #cccccc;
margin-right: 12rpx;
position: relative;
}
.checkbox-dot.active {
border-color: #e8101e;
background-color: #e8101e;
}
.checkbox-dot.active::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -60%) rotate(-45deg);
width: 20rpx;
height: 12rpx;
border-left: 4rpx solid #ffffff;
border-bottom: 4rpx solid #ffffff;
}
.checkbox-label {
font-size: 28rpx;
color: #333333;
}
</style>

View File

@ -0,0 +1,49 @@
<template>
<ali-oss-uploader v-model="currentValue" :max-count="maxCount" :max-size="maxSize" accept="image/*" :userId="userId"
:type="ossType" width="100%" :sortable="true" @input="onInput" @change="onChange" />
</template>
<script>
import AliOssUploader from '../ali-oss-uploader/ali-oss-uploader.vue'
export default {
name: 'FormItemImage',
components: { AliOssUploader },
props: {
value: { type: Array, default: function () { return []; } },
maxCount: { type: Number, default: 9 },
maxSize: { type: Number, default: 5 }
},
data: function () {
return {
currentValue: []
};
},
computed: {
userId: function () {
return this.$store && this.$store.state && this.$store.state.sjInfo
? this.$store.state.sjInfo.id : null;
},
ossType: function () {
var artisanType = getApp().globalData ? getApp().globalData.artisanType : 0;
return (artisanType || 0) + 1;
}
},
watch: {
value: {
handler: function (val) {
this.currentValue = Array.isArray(val) ? val.slice() : [];
},
immediate: true
}
},
methods: {
onInput: function (list) {
this.$emit('input', list);
},
onChange: function (list) {
this.$emit('change', list);
}
}
};
</script>

View File

@ -0,0 +1,39 @@
<template>
<view class="form-number-wrap">
<input type="digit" :value="value" :placeholder="placeholder" placeholder-class="placeholder" class="form-input" @input="onInput" />
</view>
</template>
<script>
export default {
name: 'FormItemNumber',
props: {
value: { type: [String, Number], default: '' },
placeholder: { type: String, default: '请输入' }
},
methods: {
onInput: function(e) {
this.$emit('input', e.target.value);
}
}
};
</script>
<style scoped>
.form-number-wrap {
flex: 1;
display: flex;
align-items: center;
}
.form-input {
flex: 1;
font-size: 28rpx;
color: #333333;
text-align: center;
}
.placeholder {
font-weight: 400;
font-size: 28rpx;
color: #999999;
}
</style>

View File

@ -0,0 +1,68 @@
<template>
<view class="form-radio">
<view v-for="(item, index) in options" :key="index" class="radio-item" @click="onSelect(item)">
<view class="radio-dot" :class="{ active: value === item }"></view>
<text class="radio-label">{{ item }}</text>
</view>
</view>
</template>
<script>
export default {
name: 'FormItemRadio',
props: {
value: { type: [String, Number], default: '' },
options: { type: Array, default: function () { return []; } }
},
methods: {
onSelect: function (val) {
this.$emit('input', val);
}
}
};
</script>
<style scoped>
.form-radio {
display: flex;
flex-wrap: wrap;
}
.radio-item {
display: flex;
align-items: center;
margin-right: 40rpx;
margin-bottom: 16rpx;
}
.radio-dot {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
border: 2rpx solid #cccccc;
margin-right: 12rpx;
position: relative;
}
.radio-dot.active {
border-color: #e8101e;
background-color: #e8101e;
}
.radio-dot.active::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background-color: #ffffff;
}
.radio-label {
font-size: 28rpx;
color: #333333;
}
</style>

View File

@ -0,0 +1,43 @@
<template>
<view class="form-richtext">
<view class="richtext-trigger" @click="goEdit">
<text class="richtext-value">{{ value ? '已设置' : '去设置' }}</text>
</view>
</view>
</template>
<script>
export default {
name: 'FormItemRichtext',
props: {
value: { type: String, default: '' }
},
methods: {
goEdit: function () {
uni.navigateTo({
url: '/pages/shop/add-img-text?list=' + this.value
});
}
}
};
</script>
<style scoped>
.form-richtext {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
}
.richtext-trigger {
display: flex;
align-items: center;
}
.richtext-value {
font-size: 28rpx;
color: #333333;
text-align: right;
}
</style>

View File

@ -0,0 +1,56 @@
<template>
<picker :range="options" @change="onChange">
<view class="form-select">
<text :class="['select-text', value ? '' : 'placeholder']">
{{ value || placeholder }}
</text>
<image src="https://mrrplus.oss-cn-beijing.aliyuncs.com/wxstaic/images/arrow_right.png" class="arrow-right"
mode="aspectFit" />
</view>
</picker>
</template>
<script>
export default {
name: 'FormItemSelect',
props: {
value: { type: [String, Number], default: '' },
options: { type: Array, default: function () { return []; } },
placeholder: { type: String, default: '请选择' }
},
methods: {
onChange: function (e) {
var index = e.detail.value;
var selected = this.options[index];
if (selected !== undefined) {
this.$emit('input', selected);
}
}
}
};
</script>
<style scoped>
.form-select {
display: flex;
align-items: center;
justify-content: space-between;
}
.select-text {
font-size: 28rpx;
color: #333333;
margin-right: 16rpx;
}
.placeholder {
font-weight: 400;
font-size: 28rpx;
color: #999999;
}
.arrow-right {
width: 32rpx;
height: 32rpx;
}
</style>

View File

@ -0,0 +1,143 @@
<template>
<view class="form-step">
<view class="form-item">
<text :class="['form-label', required ? 'required' : '']">{{ label }}</text>
<text class="add-btn" @click="addStep">添加</text>
</view>
<view class="step" v-for="(item, i) in steps" :key="i">
<text class="step-label">{{ i + 1 }}</text>
<input type="text" class="step-inp" :value="item.text" placeholder="请输入步骤内容"
placeholder-class="placeholder" @input="onStepInput(i, $event)" />
<image src="https://mrrplus.oss-cn-beijing.aliyuncs.com/wxstaic/images/delete_icon.png"
class="delete-icon" mode="aspectFit" @click="deleteStep(i)"></image>
</view>
</view>
</template>
<script>
export default {
name: 'FormItemServiceStep',
props: {
value: {
type: [Array, String],
default: function () { return []; }
},
label: {
type: String,
default: '服务流程'
},
required: {
type: Boolean,
default: false
}
},
data: function () {
return {
steps: []
};
},
watch: {
value: {
handler: function (val) {
if (typeof val === 'string') {
try {
this.steps = JSON.parse(val) || [];
} catch (e) {
this.steps = [];
}
} else if (Array.isArray(val)) {
this.steps = val.slice();
} else {
this.steps = [];
}
},
immediate: true
}
},
methods: {
addStep: function () {
var i = this.steps.length + 1;
this.steps.push({
title: '第' + i + '步',
text: '',
photo: ''
});
this._emit();
},
deleteStep: function (i) {
this.steps.splice(i, 1);
this._emit();
},
onStepInput: function (i, e) {
var value = e && e.target ? e.target.value : e;
this.steps[i].text = value;
this._emit();
},
_emit: function () {
this.$emit('input', this.steps);
}
}
};
</script>
<style scoped>
.form-step {
width: 100%;
}
.form-item {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
padding: 0;
}
.form-label {
font-size: 28rpx;
color: #333333;
}
.required::before {
content: '*';
color: #ff0000;
margin-right: 4rpx;
}
.add-btn {
font-size: 28rpx;
color: #ff4d6a;
}
.step {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
padding: 20rpx 0 0;
}
.step-label {
font-size: 28rpx;
color: #333333;
white-space: nowrap;
}
.step-inp {
flex: 1;
font-size: 28rpx;
color: #333333;
margin-left: 20rpx;
padding: 0 24rpx;
background-color: rgba(230, 230, 230, 0.5);
height: 72rpx;
border-radius: 8rpx;
}
.delete-icon {
width: 40rpx;
height: 40rpx;
margin-left: 16rpx;
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<view class="form-text-wrap">
<input type="text" :value="value" :placeholder="placeholder" placeholder-class="placeholder" class="form-input" @input="onInput" />
</view>
</template>
<script>
export default {
name: 'FormItemText',
props: {
value: { type: [String, Number], default: '' },
placeholder: { type: String, default: '请输入' }
},
methods: {
onInput: function(e) {
this.$emit('input', e.target.value);
}
}
};
</script>
<style scoped>
.form-text-wrap {
flex: 1;
display: flex;
align-items: center;
}
.form-input {
flex: 1;
font-size: 28rpx;
color: #333333;
text-align: center;
}
.placeholder {
font-weight: 400;
font-size: 28rpx;
color: #999999;
}
</style>

View File

@ -0,0 +1,52 @@
<template>
<view class="detail-textarea-wrap">
<textarea class="detail-textarea" :value="value" :placeholder="placeholder" placeholder-class="placeholder" maxlength="200" @input="onInput" />
<text class="detail-count">{{ (value || '').length }}/200</text>
</view>
</template>
<script>
export default {
name: 'FormItemTextarea',
props: {
value: { type: String, default: '' },
placeholder: { type: String, default: '请输入' }
},
methods: {
onInput: function(e) {
this.$emit('input', e.target.value);
}
}
};
</script>
<style scoped>
.detail-textarea-wrap {
position: relative;
background: #f7f7f7;
border-radius: 16rpx;
padding: 24rpx 24rpx 52rpx;
box-sizing: border-box;
}
.detail-textarea {
width: 100%;
height: 108rpx;
min-height: 108rpx;
margin: 0;
padding: 0;
background: transparent;
border: 0;
font-size: 28rpx;
line-height: 40rpx;
color: #333333;
box-sizing: border-box;
}
.detail-count {
position: absolute;
right: 24rpx;
bottom: 16rpx;
font-size: 22rpx;
color: #999999;
line-height: 32rpx;
}
</style>

View File

@ -0,0 +1,256 @@
<template>
<view>
<view v-if="maxCount > 1" class="video-list">
<view v-for="(item, index) in currentList" :key="index" class="video-wrapper">
<image :src="getVideoPoster(item)" class="preview-video" mode="aspectFill" @click="previewVideo(item)" />
<view class="play-mask" @click="previewVideo(item)">
<view class="play-circle">
<view class="play-icon"></view>
</view>
</view>
<image
class="delete-btn"
src="https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/6ab3fc63-3bad-403d-bbfc-9609a93c048a.png"
mode="aspectFit"
@click="deleteVideo(index)"
></image>
</view>
<ali-oss-uploader v-if="currentList.length < maxCount"
:max-count="maxCount - currentList.length"
:max-size="maxSize" accept="video/*" :userId="uploadUserId" :type="ossType" :kind="2" width="200rpx"
:show-preview="false"
@input="onMultiInput" @change="onChange" />
</view>
<view v-else>
<ali-oss-uploader v-if="!currentValue" :max-count="1" :max-size="maxSize" accept="video/*"
:userId="uploadUserId" :type="ossType" :kind="2" width="100%" :file-string="currentValue" @input="onInput"
@change="onChange" @success="onInput" @delete="deleteSingleVideo" />
<view v-else class="video-wrapper">
<image :src="getVideoPoster(currentValue)" class="preview-video" mode="aspectFill" @click="previewVideo(currentValue)" />
<view class="play-mask" @click="previewVideo(currentValue)">
<view class="play-circle">
<view class="play-icon"></view>
</view>
</view>
<image
class="delete-btn"
src="https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/6ab3fc63-3bad-403d-bbfc-9609a93c048a.png"
mode="aspectFit"
@click="deleteSingleVideo"
></image>
</view>
</view>
<view v-if="showPlayer" class="player-mask" @click="closePlayer">
<view class="player-box" @click.stop>
<video :src="playingVideo" class="player-video" controls autoplay show-fullscreen-btn />
<view class="player-close" @click="closePlayer">
<text class="player-close-text">&times;</text>
</view>
</view>
</view>
</view>
</template>
<script>
import AliOssUploader from '../ali-oss-uploader/ali-oss-uploader.vue'
export default {
name: 'FormItemVideo',
components: { AliOssUploader },
props: {
value: { type: [String, Array], default: '' },
maxSize: { type: Number, default: 100 },
maxCount: { type: Number, default: 1 },
userId: { type: [Number, String], default: null }
},
data: function () {
return {
currentValue: '',
currentList: [],
showPlayer: false,
playingVideo: ''
};
},
computed: {
uploadUserId: function () {
if (this.userId) return this.userId;
return this.$store && this.$store.state && this.$store.state.sjInfo
? this.$store.state.sjInfo.id : null;
},
ossType: function () {
var artisanType = getApp().globalData ? getApp().globalData.artisanType : 0;
return (artisanType || 0) + 1;
}
},
watch: {
value: {
handler: function (val) {
if (this.maxCount > 1) {
if (Array.isArray(val)) {
this.currentList = val.filter(function (v) { return !!v; });
} else if (val) {
this.currentList = [val];
} else {
this.currentList = [];
}
} else {
this.currentValue = Array.isArray(val) ? (val[0] || '') : (val || '');
}
},
immediate: true
}
},
methods: {
onInput: function (val) {
this.currentValue = val;
this.$emit('input', val);
},
onMultiInput: function (val) {
var arr = Array.isArray(val) ? val : [val];
var existing = this.currentList.filter(function (item) {
return arr.indexOf(item) === -1;
});
var list = existing.concat(arr).filter(function (item, index, self) {
return item && self.indexOf(item) === index;
}).slice(0, this.maxCount);
this.currentList = list;
this.$emit('input', list);
},
onChange: function (val) {
this.$emit('change', val);
},
getVideoPoster: function (url) {
if (!url) return '';
if (String(url).indexOf('x-oss-process=') > -1) return url;
var connector = String(url).indexOf('?') > -1 ? '&' : '?';
return url + connector + 'x-oss-process=video/snapshot,t_2000,f_jpg,m_fast';
},
previewVideo: function (url) {
if (!url) return;
this.playingVideo = url;
this.showPlayer = true;
},
closePlayer: function () {
this.showPlayer = false;
this.playingVideo = '';
},
deleteSingleVideo: function () {
this.currentValue = '';
this.$emit('input', '');
},
deleteVideo: function (index) {
this.currentList.splice(index, 1);
this.$emit('input', this.currentList);
}
}
};
</script>
<style scoped>
.video-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.video-wrapper {
position: relative;
width: 200rpx;
height: 200rpx;
overflow: visible;
}
.preview-video {
width: 200rpx;
height: 200rpx;
border-radius: 8rpx;
background-color: #f5f5f5;
}
.play-mask {
position: absolute;
left: 0;
top: 0;
width: 200rpx;
height: 200rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.15);
border-radius: 8rpx;
z-index: 2;
}
.play-circle {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
}
.play-icon {
width: 0;
height: 0;
border-top: 14rpx solid transparent;
border-bottom: 14rpx solid transparent;
border-left: 22rpx solid #ffffff;
margin-left: 6rpx;
}
.delete-btn {
position: absolute;
top: -10rpx;
right: -10rpx;
width: 42rpx;
height: 42rpx;
z-index: 5;
}
.player-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.82);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
box-sizing: border-box;
}
.player-box {
position: relative;
width: 100%;
}
.player-video {
width: 100%;
height: 420rpx;
background-color: #000000;
}
.player-close {
position: absolute;
right: -12rpx;
top: -58rpx;
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.22);
display: flex;
align-items: center;
justify-content: center;
}
.player-close-text {
color: #ffffff;
font-size: 42rpx;
line-height: 48rpx;
}
</style>

View File

@ -0,0 +1,440 @@
<template>
<view class="dynamic-form">
<view v-for="(group, gi) in fields" :key="gi" class="form-group">
<!-- single -->
<block v-if="group.type === 'single'">
<view v-if="group.field.type === 'text'" class="form-item">
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
<input type="text" :value="formData[group.field.key]" :placeholder="group.field.placeholder"
placeholder-class="placeholder" class="form-input" @input="onInput(group.field.key, $event)" />
</view>
<view v-else-if="group.field.type === 'number'" class="form-item"
:style="group.field.tip ? 'flex-wrap: wrap;' : ''">
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
<input type="digit" :value="formData[group.field.key]" :placeholder="group.field.placeholder"
placeholder-class="placeholder" class="form-input" @input="onInput(group.field.key, $event)" />
<text v-if="group.field.tips && !group.field.tip" class="form-tips">{{ group.field.tips }}</text>
<view v-if="group.field.tip" class="price-tip">
<text class="price-tip-card">{{ group.field.tip }}</text>
</view>
</view>
<view v-else-if="group.field.type === 'image_multi'" class="form-item-up upload-item">
<view>
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
<text class="upload-tip">(仅可上传{{ group.field.maxCount || 9 }}个图片)</text>
</view>
<view class="upload-content">
<form-item-image :value="formData[group.field.key]" :maxCount="group.field.maxCount || 9"
@input="onInput(group.field.key, $event)" />
</view>
</view>
<view v-else-if="group.field.type === 'video'" class="form-item-up upload-item">
<view>
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
<text class="upload-tip">(仅可上传{{ group.field.maxCount || 1 }}个视频)</text>
</view>
<view class="upload-content">
<form-item-video :value="formData[group.field.key]" :maxCount="group.field.maxCount || 1"
:maxSize="group.field.maxSize || group.field.maxFileSize || 100"
:userId="uploadUserId"
@input="onInput(group.field.key, $event)" />
</view>
</view>
<view v-else-if="group.field.type === 'richtext_editor'" class="form-item">
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
<form-item-richtext :value="formData[group.field.key]" @input="onInput(group.field.key, $event)" />
</view>
<view v-else-if="group.field.type === 'service_step'" class="form-item-up upload-item">
<form-item-service-step :value="formData[group.field.key]" :label="group.field.label"
:required="group.field.is_required === true" @input="onInput(group.field.key, $event)" />
</view>
</block>
<!-- section -->
<view v-if="group.type === 'section'" class="section-card">
<view v-if="group.sectionTitle" class="section-title-bar">
<text class="section-title">{{ group.sectionTitle }}</text>
</view>
<view v-for="(field, fi) in group.fields" :key="field.key || fi" class="section-field">
<view v-if="field.type === 'text'" class="form-item no-bg">
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
<input type="text" :value="formData[field.key]" :placeholder="field.placeholder"
placeholder-class="placeholder" class="form-input" @input="onInput(field.key, $event)" />
</view>
<view v-else-if="field.type === 'textarea'" class="detail-row">
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
<view class="detail-textarea-wrap">
<textarea class="detail-textarea" :value="formData[field.key]" :placeholder="field.placeholder"
placeholder-class="placeholder" maxlength="200" @input="onInput(field.key, $event)" />
<text class="detail-count">{{ (formData[field.key] || "").length }}/200</text>
</view>
</view>
<view v-else-if="field.type === 'number'" class="form-item no-bg"
:style="field.tip ? 'flex-wrap: wrap;' : ''">
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
<input type="digit" :value="formData[field.key]" :placeholder="field.placeholder"
placeholder-class="placeholder" class="form-input" @input="onInput(field.key, $event)" />
<text v-if="field.tips && !field.tip" class="form-tips">{{ field.tips }}</text>
<view v-if="field.tip" class="price-tip">
<text class="price-tip-card">{{ field.tip }}</text>
</view>
</view>
<view v-else-if="field.type === 'select'" class="form-item no-bg">
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
<form-item-select :value="formData[field.key]" :options="field.options || []"
:placeholder="field.placeholder" @input="onInput(field.key, $event)" />
</view>
<view v-else-if="field.type === 'single_select'" class="form-item no-bg" style="flex-wrap: wrap;">
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
<view class="select-options">
<form-item-radio :value="formData[field.key]" :options="field.options || []"
@input="onInput(field.key, $event)" />
</view>
</view>
<view v-else-if="field.type === 'multi_select'" class="form-item no-bg" style="flex-wrap: wrap;">
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
<view class="select-options">
<form-item-checkbox :value="formData[field.key]" :options="field.options || []"
:maxCount="field.maxCount || 0" @input="onInput(field.key, $event)" />
</view>
</view>
<view v-else-if="field.type === 'image_multi'" class="form-item-up upload-item no-bg">
<view>
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
<text class="upload-tip">(仅可上传{{ field.maxCount || 9 }}个图片)</text>
</view>
<view class="upload-content">
<form-item-image :value="formData[field.key]" :maxCount="field.maxCount || 9"
@input="onInput(field.key, $event)" />
</view>
</view>
<view v-else-if="field.type === 'video'" class="form-item-up upload-item no-bg">
<view>
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
<text class="upload-tip">(仅可上传{{ field.maxCount || 1 }}个视频)</text>
</view>
<view class="upload-content">
<form-item-video :value="formData[field.key]" :maxCount="field.maxCount || 1"
:maxSize="field.maxSize || field.maxFileSize || 100" :userId="uploadUserId"
@input="onInput(field.key, $event)" />
</view>
</view>
<view v-else-if="field.type === 'richtext_editor'" class="form-item no-bg">
<text :class="['form-label', field.is_required === true ? 'required' : '']">{{ field.label }}</text>
<form-item-richtext :value="formData[field.key]" @input="onInput(field.key, $event)" />
</view>
<view v-else-if="field.type === 'service_step'" class="form-item-up upload-item no-bg">
<form-item-service-step :value="formData[field.key]" :label="field.label"
:required="field.is_required === true" @input="onInput(field.key, $event)" />
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import FormItemText from './FormItemText.vue';
import FormItemTextarea from './FormItemTextarea.vue';
import FormItemNumber from './FormItemNumber.vue';
import FormItemImage from './FormItemImage.vue';
import FormItemVideo from './FormItemVideo.vue';
import FormItemSelect from './FormItemSelect.vue';
import FormItemRadio from './FormItemRadio.vue';
import FormItemCheckbox from './FormItemCheckbox.vue';
import FormItemRichtext from './FormItemRichtext.vue';
import FormItemServiceStep from './FormItemServiceStep.vue';
export default {
name: 'DynamicForm',
components: {
'form-item-text': FormItemText,
'form-item-textarea': FormItemTextarea,
'form-item-number': FormItemNumber,
'form-item-image': FormItemImage,
'form-item-video': FormItemVideo,
'form-item-select': FormItemSelect,
'form-item-radio': FormItemRadio,
'form-item-checkbox': FormItemCheckbox,
'form-item-richtext': FormItemRichtext,
'form-item-service-step': FormItemServiceStep
},
props: {
fields: {
type: Array,
default: function () { return []; }
},
value: {
type: Object,
default: function () { return {}; }
},
uploadUserId: {
type: [Number, String],
default: null
}
},
data: function () {
return {
formData: {}
};
},
watch: {
value: {
handler: function (val) {
this.formData = Object.assign({}, val);
},
immediate: true
},
fields: {
handler: function () {
var self = this;
var data = {};
this.fields.forEach(function (group) {
if (group.type === 'single' && group.field) {
self._initFieldData(data, group.field);
} else if (group.type === 'section' && group.fields) {
group.fields.forEach(function (field) {
self._initFieldData(data, field);
});
}
});
this.formData = Object.assign({}, this.formData, data);
},
immediate: true
}
},
methods: {
_initFieldData: function (data, field) {
if (this.formData[field.key] !== undefined) {
data[field.key] = this.formData[field.key];
} else if (field.default_value !== undefined) {
data[field.key] = field.default_value;
} else if (
field.type === 'multi_select' ||
field.type === 'image_multi' ||
field.type === 'service_step' ||
(field.type === 'video' && field.maxCount > 1)
) {
data[field.key] = [];
} else {
data[field.key] = '';
}
},
onInput: function (key, event) {
var value = event;
if (event && event.target && event.target.value !== undefined) {
value = event.target.value;
}
this.$set(this.formData, key, value);
this.$emit('input', this.formData);
},
getData: function () {
return Object.assign({}, this.formData);
},
validate: function () {
var self = this;
for (var i = 0; i < this.fields.length; i++) {
var group = this.fields[i];
var fieldsToCheck = [];
if (group.type === 'single' && group.field) {
fieldsToCheck.push(group.field);
} else if (group.type === 'section' && group.fields) {
fieldsToCheck = group.fields;
}
for (var j = 0; j < fieldsToCheck.length; j++) {
var field = fieldsToCheck[j];
if (field.is_required === true) {
var value = self.formData[field.key];
if (!value || (Array.isArray(value) && value.length === 0)) {
uni.showToast({
title: field.label + '不能为空',
icon: 'none'
});
return false;
}
}
}
}
return true;
}
}
};
</script>
<style scoped>
.dynamic-form {
width: 100%;
}
.form-group {
width: 100%;
}
.form-item {
background-color: #fff;
padding: 24rpx;
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
box-sizing: border-box;
border-radius: 10rpx;
margin-bottom: 24rpx;
}
.form-label {
font-size: 28rpx;
color: #333333;
}
.required::before {
content: '*';
color: #ff0000;
margin-right: 4rpx;
}
.form-tips {
font-size: 24rpx;
color: #333333;
margin-left: 0;
}
.price-tip {
width: 100%;
margin-top: 40rpx;
}
.price-tip-card {
padding: 8rpx 12rpx;
background: #FDF0F0;
border-radius: 8rpx;
font-weight: 400;
font-size: 24rpx;
color: #E8101E;
line-height: 32rpx;
}
.form-item-up {
background-color: #fff;
padding: 24rpx;
display: flex;
flex-flow: column nowrap;
justify-content: space-between;
align-items: flex-start;
box-sizing: border-box;
border-radius: 10rpx;
margin-bottom: 24rpx;
}
.upload-item {
padding-bottom: 30rpx;
}
.upload-tip {
font-size: 24rpx;
color: #999999;
margin-left: 10rpx;
}
.upload-content {
margin-top: 20rpx;
width: 100%;
}
.select-options {
width: 100%;
margin-top: 16rpx;
}
.section-card {
background: #ffffff;
border-radius: 20rpx;
padding: 0 24rpx 24rpx;
margin-bottom: 24rpx;
box-sizing: border-box;
overflow: hidden;
}
/* section内字段间距20rpx通过section-field包装器控制 */
.section-field+.section-field {
padding-top: 12rpx;
}
/* section内字段去掉独立白色背景和底部间距 */
.section-card .form-item,
.section-card .form-item-up {
background-color: transparent;
padding-left: 0;
padding-right: 0;
margin-bottom: 0;
border-radius: 0;
}
.section-card .detail-row {
padding-top: 0;
padding-bottom: 0;
}
.section-title-bar {
padding: 0;
margin: 0;
}
.section-title {
padding: 24rpx 0 20rpx;
font-size: 30rpx;
font-weight: 500;
color: #333;
line-height: 40rpx;
display: block;
}
.detail-row {
padding: 20rpx 0 0;
}
.detail-row:first-child {
padding-top: 0;
}
.detail-row .form-label {
display: block;
margin-bottom: 20rpx;
line-height: 40rpx;
}
.detail-textarea-wrap {
position: relative;
background: #f7f7f7;
border-radius: 16rpx;
padding: 24rpx 24rpx 52rpx;
box-sizing: border-box;
}
.detail-textarea {
width: 100%;
height: 108rpx;
min-height: 108rpx;
margin: 0;
padding: 0;
background: transparent;
border: 0;
font-size: 28rpx;
line-height: 40rpx;
color: #333333;
box-sizing: border-box;
}
.detail-count {
position: absolute;
right: 24rpx;
bottom: 16rpx;
font-size: 22rpx;
color: #999999;
line-height: 32rpx;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -10,25 +10,26 @@
<view v-else class="addTextAndImg-content"> <view v-else class="addTextAndImg-content">
<view v-for="(item, index) in list" :key="index" class="addTextAndImg-content-card"> <view v-for="(item, index) in list" :key="index" class="addTextAndImg-content-card">
<!-- <rich-text :nodes="item.value" v-if="item.type==1"></rich-text> --> <!-- <rich-text :nodes="item.value" v-if="item.type==1"></rich-text> -->
<uv-divider dashed v-if="item.type == 1" ></uv-divider> <uv-divider dashed v-if="item.type == 1"></uv-divider>
<view class="addTextAndImg-content-card-text" v-if="item.type==2">{{ item.value }}</view> <view class="addTextAndImg-content-card-text" v-if="item.type == 2">{{ item.value }}</view>
<image style="width: 100%;" mode="widthFix" :src="item.value" v-else-if="item.type==3|| item.type==4"></image> <image style="width: 100%;" mode="widthFix" :src="item.value" v-else-if="item.type == 3 || item.type == 4">
</image>
<view class="addTextAndImg-content-card-btns"> <view class="addTextAndImg-content-card-btns">
<view class="addTextAndImg-content-card-btns-btn" v-for="item2 in btns" :key="item2.id" <view class="addTextAndImg-content-card-btns-btn" v-for="item2 in btns" :key="item2.id"
@click.stop="handleCard(item2.id, index)" v-if=" @click.stop="handleCard(item2.id, index)" v-if="
!(index == 0 && item2.id == 2) && !(index == 0 && item2.id == 2) &&
!(index == list.length - 1 && item2.id == 3) !(index == list.length - 1 && item2.id == 3)
"> ">
{{ item2.text }} {{ item2.text }}
</view> </view>
</view> </view>
</view> </view>
</view> </view>
<view class="bottom-buttons" v-if="list.length != 0"> <view class="bottom-buttons" v-if="list.length != 0">
<button class="btn-submit" @click="submitForm">保存</button> <button class="btn-submit" @click="submitForm">保存</button>
</view> </view>
<uv-overlay :show="showPop" @click="showPop = false"> <uv-overlay :show="showPop" @click="showPop = false">
<view class="pop-cards"> <view class="pop-cards">
<view class="pop-cards-card" v-for="item in addWays" :key="item.id" @click.stop="addContent(item.id)"> <view class="pop-cards-card" v-for="item in addWays" :key="item.id" @click.stop="addContent(item.id)">
@ -60,424 +61,427 @@
</template> </template>
<script> <script>
import uploadImage from '../../utils/uploadImage' import uploadImage from '../../utils/uploadImage'
import permissionUtils from '../../utils/per' import permissionUtils from '../../utils/per'
import locationService from '../../utils/locationService'; import locationService from '../../utils/locationService';
export default { export default {
data() { data() {
return { return {
isReplace:false, isReplace: false,
crIndex: null, // crIndex: null, //
nowQer: 'xc', nowQer: 'xc',
isShowPer: false, isShowPer: false,
list: [], list: [],
textareaValue: "", textareaValue: "",
showPop: false, showPop: false,
showTextarea: false, showTextarea: false,
addWays: [{ addWays: [{
text: "分割线", text: "分割线",
id: 1, id: 1,
url: "/static/images/shop/imgAndText/fenge.png", url: "/static/images/shop/imgAndText/fenge.png",
}, },
{ {
text: "文本", text: "文本",
id: 2, id: 2,
url: "/static/images/shop/imgAndText/text.png", url: "/static/images/shop/imgAndText/text.png",
}, },
{ {
text: "图片", text: "图片",
id: 3, id: 3,
url: "/static/images/shop/imgAndText/bz.png", url: "/static/images/shop/imgAndText/bz.png",
}, },
{ {
text: "相机", text: "相机",
id: 4, id: 4,
url: "/static/images/shop/imgAndText/camera.png", url: "/static/images/shop/imgAndText/camera.png",
}, },
], ],
btns: [{ btns: [{
text: "删除", text: "删除",
id: 1, id: 1,
}, },
{ {
text: "上移", text: "上移",
id: 2, id: 2,
}, },
{ {
text: "下移", text: "下移",
id: 3, id: 3,
}, },
{ {
text: "替换", text: "替换",
id: 4, id: 4,
}, },
{ {
text: "插入", text: "插入",
id: 5, id: 5,
}, },
], ],
}; };
},
onLoad(options) {
console.log(options)
if (options.list && options.list !== 'null' && options.list !== 'undefined') {
try {
this.list = JSON.parse(options.list) || [];
} catch (e) {
this.list = [];
}
console.log(this.list)
}
},
methods: {
submitForm() {
uni.$emit('updateGraphicDetails', this.list);
uni.navigateBack()
}, },
onLoad(options) { //
console.log(options) insertAfterIndex(arr, index, data) {
if(options.list){ //
this.list = JSON.parse(options.list) if (index < 0 || index >= arr.length) {
console.log(this.list) console.error("索引无效");
return arr;
}
// 使spliceindex + 1
// 0
arr.splice(index + 1, 0, data);
this.crIndex = null
return arr;
},
//
replaceAtIndex(arr, index, data) {
// 0 <= index < arr.length
if (index < 0 || index >= arr.length) {
console.error("索引无效");
return arr; //
}
//
arr[index] = data;
// crIndex
this.crIndex = null;
this.isReplace = false
return arr; //
},
//
setTextarea() {
let obj = {
value: this.textareaValue,
type: 2,
};
if (this.textareaValue.length == 0) {
this.showTextarea = false
this.crIndex = null
return
}
if (this.crIndex != null) {
if (this.isReplace) {
this.replaceAtIndex(this.list, this.crIndex, obj);
} else {
this.insertAfterIndex(this.list, this.crIndex, obj);
}
} else {
this.list.push(obj);
}
this.textareaValue = ""
this.showTextarea = false
},
//
handleCard(id, index) {
switch (id) {
case 1:
this.list.splice(index, 1); // index 1
this.showPop = false
break;
case 2:
//
let temp = this.list[index - 1];
// splice index-1 index index temp
this.list.splice(index - 1, 1, this.list[index]); // index-1 index
this.list.splice(index, 1, temp); // index index-1
break;
case 3:
//
let temp2 = this.list[index + 1];
// splice index-1 index index temp
this.list.splice(index + 1, 1, this.list[index]); // index-1 index
this.list.splice(index, 1, temp2); // index index-1
break;
case 4:
this.crIndex = index
this.isReplace = true
this.showPop = true
break;
case 5:
this.crIndex = index
this.showPop = true
} }
}, },
methods: { addContent(id) {
submitForm(){ switch (id) {
uni.$emit('updateGraphicDetails', this.list); case 1:
uni.navigateBack() let obj = {
}, value: ``,
// type: 1,
insertAfterIndex(arr, index, data) { };
// if (this.crIndex != null) {
if (index < 0 || index >= arr.length) { if (this.isReplace) {
console.error("索引无效"); this.replaceAtIndex(this.list, this.crIndex, obj);
return arr; } else {
} this.insertAfterIndex(this.list, this.crIndex, obj);
}
// 使spliceindex + 1 } else {
// 0 this.list.push(obj);
arr.splice(index + 1, 0, data); }
this.crIndex = null this.showPop = false
return arr; break;
}, case 2:
// this.showTextarea = true
replaceAtIndex(arr, index, data) { this.showPop = false
// 0 <= index < arr.length break;
if (index < 0 || index >= arr.length) { case 3:
console.error("索引无效"); this.chooseAvatar(3)
return arr; // break;
} case 4:
this.chooseAvatar(4)
//
arr[index] = data; }
},
// crIndex async chooseAvatar(type) {
this.crIndex = null; try {
this.isReplace = false const systemInfo = uni.getSystemInfoSync()
if (systemInfo.platform === 'ios') {
return arr; // this.openCamera(type);
},
//
setTextarea() {
let obj = {
value: this.textareaValue,
type: 2,
};
if (this.textareaValue.length == 0) {
this.showTextarea = false
this.crIndex = null
return return
} }
if (this.crIndex!=null) { //
if(this.isReplace){ const permissionKey = type === 3 ? 'photo_library' : 'camera';
this.replaceAtIndex(this.list, this.crIndex, obj); const permissionAndroid = type === 3
}else{ ? 'android.permission.READ_EXTERNAL_STORAGE,android.permission.WRITE_EXTERNAL_STORAGE'
this.insertAfterIndex(this.list, this.crIndex, obj); : 'android.permission.CAMERA';
} const dialogTitle = type === 3 ? '相册权限申请' : '相机权限申请';
} else { const dialogContent = type === 3
this.list.push(obj); ? '我们需要访问您的相册以完成图片添加'
: '我们需要访问您的相机以完成图片拍摄';
const nowQer = type === 3 ? 'xc' : 'xj';
//
const firstRequest = !plus.storage.getItem(`perm_${permissionKey}`);
if (firstRequest) {
this.isShowPer = true;
this.nowQer = nowQer;
} }
this.textareaValue = "" // 1.
this.showTextarea = false const { granted } = await permissionUtils.checkPermission(permissionKey, dialogContent);
}, if (granted) {
//
handleCard(id, index) {
switch (id) {
case 1:
this.list.splice(index, 1); // index 1
this.showPop = false
break;
case 2:
//
let temp = this.list[index - 1];
// splice index-1 index index temp
this.list.splice(index - 1, 1, this.list[index]); // index-1 index
this.list.splice(index, 1, temp); // index index-1
break;
case 3:
//
let temp2 = this.list[index + 1];
// splice index-1 index index temp
this.list.splice(index + 1, 1, this.list[index]); // index-1 index
this.list.splice(index, 1, temp2); // index index-1
break;
case 4:
this.crIndex = index
this.isReplace = true
this.showPop = true
break;
case 5:
this.crIndex = index
this.showPop = true
}
},
addContent(id) {
switch (id) {
case 1:
let obj = {
value: ``,
type: 1,
};
if (this.crIndex!=null) {
if(this.isReplace){
this.replaceAtIndex(this.list, this.crIndex, obj);
}else{
this.insertAfterIndex(this.list, this.crIndex, obj);
}
} else {
this.list.push(obj);
}
this.showPop = false
break;
case 2:
this.showTextarea = true
this.showPop = false
break;
case 3:
this.chooseAvatar(3)
break;
case 4:
this.chooseAvatar(4)
}
},
async chooseAvatar(type) {
try {
const systemInfo = uni.getSystemInfoSync()
if(systemInfo.platform === 'ios') {
this.openCamera(type);
return
}
//
const permissionKey = type === 3 ? 'photo_library' : 'camera';
const permissionAndroid = type === 3
? 'android.permission.READ_EXTERNAL_STORAGE,android.permission.WRITE_EXTERNAL_STORAGE'
: 'android.permission.CAMERA';
const dialogTitle = type === 3 ? '相册权限申请' : '相机权限申请';
const dialogContent = type === 3
? '我们需要访问您的相册以完成图片添加'
: '我们需要访问您的相机以完成图片拍摄';
const nowQer = type === 3 ? 'xc' : 'xj';
//
const firstRequest = !plus.storage.getItem(`perm_${permissionKey}`);
if (firstRequest) {
this.isShowPer = true;
this.nowQer = nowQer;
}
// 1.
const { granted } = await permissionUtils.checkPermission(permissionKey, dialogContent);
if (granted) {
this.isShowPer = false;
this.openCamera(type);
return;
}
// 2.
this.isShowPer = false; this.isShowPer = false;
const confirm = await this.showPermissionDialog(dialogTitle, dialogContent); this.openCamera(type);
if (!confirm) return; return;
// 3.
const result = await permissionUtils.requestPermission(permissionKey, dialogContent);
if (result) {
this.openCamera(type);
} else {
locationService.openAppSettings();
}
} catch (error) {
console.error('权限处理出错:', error);
uni.showToast({
title: '权限检查失败',
icon: 'none'
});
} }
},
//
showPermissionDialog(title, content) {
return new Promise((resolve) => {
uni.showModal({
title,
content,
confirmText: '去开启',
success(res) {
resolve(res.confirm);
}
});
});
},
// /
async openCamera(type) {
this.isShowPer = false;
let result = null
if (type == 3) {
result = await uploadImage.handleUpload(this.$store.state.sjInfo.id ? this.$store.state.sjInfo.id :
Math.floor(Math.random() * 1000), 1, ['album']);
} else {
result = await uploadImage.handleUpload(this.$store.state.sjInfo.id ? this.$store.state.sjInfo.id :
Math.floor(Math.random() * 1000), 1, ['camera']);
}
uni.hideLoading();
// this.userInfo.head_photo = result;
let obj = {
value: result,
type: type,
};
if (this.crIndex!=null) {
if(this.isReplace){
this.replaceAtIndex(this.list, this.crIndex, obj);
}else{
this.insertAfterIndex(this.list, this.crIndex, obj);
}
} else {
this.list.push(obj);
}
this.showPop = false
},
// 2.
this.isShowPer = false;
const confirm = await this.showPermissionDialog(dialogTitle, dialogContent);
if (!confirm) return;
// 3.
const result = await permissionUtils.requestPermission(permissionKey, dialogContent);
if (result) {
this.openCamera(type);
} else {
locationService.openAppSettings();
}
} catch (error) {
console.error('权限处理出错:', error);
uni.showToast({
title: '权限检查失败',
icon: 'none'
});
}
}, },
}; //
showPermissionDialog(title, content) {
return new Promise((resolve) => {
uni.showModal({
title,
content,
confirmText: '去开启',
success(res) {
resolve(res.confirm);
}
});
});
},
// /
async openCamera(type) {
this.isShowPer = false;
let result = null
if (type == 3) {
result = await uploadImage.handleUpload(this.$store.state.sjInfo.id ? this.$store.state.sjInfo.id :
Math.floor(Math.random() * 1000), 1, ['album']);
} else {
result = await uploadImage.handleUpload(this.$store.state.sjInfo.id ? this.$store.state.sjInfo.id :
Math.floor(Math.random() * 1000), 1, ['camera']);
}
uni.hideLoading();
// this.userInfo.head_photo = result;
let obj = {
value: result,
type: type,
};
if (this.crIndex != null) {
if (this.isReplace) {
this.replaceAtIndex(this.list, this.crIndex, obj);
} else {
this.insertAfterIndex(this.list, this.crIndex, obj);
}
} else {
this.list.push(obj);
}
this.showPop = false
},
},
};
</script> </script>
<style lang="less"> <style lang="less">
.addTextAndImg { .addTextAndImg {
padding-bottom: 110rpx; padding-bottom: 110rpx;
.firstAdd {
position: fixed;
left: 50%;
top: 50%;
transform: translate3d(-50%, -50%, 0);
display: flex;
flex-wrap: wrap;
justify-content: center;
.firstAdd-garden { .firstAdd {
position: fixed;
left: 50%;
top: 50%;
transform: translate3d(-50%, -50%, 0);
display: flex;
flex-wrap: wrap;
justify-content: center;
.firstAdd-garden {
width: 200rpx;
height: 200rpx;
border: 3px solid #000;
border-radius: 50%;
margin-bottom: 100rpx;
.firstAdd-garden-img {
width: 200rpx; width: 200rpx;
height: 200rpx;
border: 3px solid #000;
border-radius: 50%;
margin-bottom: 100rpx;
.firstAdd-garden-img{
width: 200rpx;
}
}
.firstAdd-text {
font-size: 32rpx;
text-align: center;
white-space: nowrap;
width: 750rpx;
} }
} }
.addTextAndImg-content { .firstAdd-text {
.addTextAndImg-content-card { font-size: 32rpx;
text-align: center;
white-space: nowrap;
width: 750rpx;
}
}
.addTextAndImg-content-card-btns { .addTextAndImg-content {
.addTextAndImg-content-card {
.addTextAndImg-content-card-btns {
display: flex;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 10rpx;
background: #f5f5f5;
padding-bottom: 10rpx;
.addTextAndImg-content-card-btns-btn {
color: rgba(232, 16, 30, 1);
border: 2rpx solid rgba(232, 16, 30, 1);
width: 80rpx;
height: 50rpx;
display: flex; display: flex;
flex-wrap: nowrap; align-items: center;
justify-content: flex-end; justify-content: center;
gap: 10rpx;
background: #f5f5f5;
padding-bottom: 10rpx;
.addTextAndImg-content-card-btns-btn {
color: rgba(232, 16, 30, 1);
border: 2rpx solid rgba(232, 16, 30, 1);
width: 80rpx;
height: 50rpx;
display: flex;
align-items: center;
justify-content: center;
}
}
.addTextAndImg-content-card-text {
padding: 20rpx;
} }
} }
}
.pop-cards { .addTextAndImg-content-card-text {
z-index: 10; padding: 20rpx;
position: absolute;
left: 0;
right: 0;
bottom: 0;
background-color: #fff;
display: flex;
flex-wrap: nowrap;
gap: 63rpx;
padding: 68rpx 40rpx;
.pop-cards-card {
.pop-cards-card-img {
width: 120rpx;
height: 120rpx;
}
.pop-cards-card-text {
font-weight: 400;
font-size: 28rpx;
color: #333333;
line-height: 40rpx;
text-align: center;
font-style: normal;
}
} }
} }
.pop-textarea {
background-color: #fff;
margin: 200rpx 20rpx 20rpx 20rpx;
border-radius: 20rpx;
padding: 20rpx;
}
} }
.permission.transform { .pop-cards {
top: calc(var(--status-bar-height) + 88rpx + 20rpx); z-index: 10;
opacity: 1; position: absolute;
visibility: visible; left: 0;
right: 0;
bottom: 0;
background-color: #fff;
display: flex;
flex-wrap: nowrap;
gap: 63rpx;
padding: 68rpx 40rpx;
.pop-cards-card {
.pop-cards-card-img {
width: 120rpx;
height: 120rpx;
}
.pop-cards-card-text {
font-weight: 400;
font-size: 28rpx;
color: #333333;
line-height: 40rpx;
text-align: center;
font-style: normal;
}
}
} }
.bottom-buttons { .pop-textarea {
position: fixed; background-color: #fff;
left: 0; margin: 200rpx 20rpx 20rpx 20rpx;
right: 0; border-radius: 20rpx;
bottom: 0; padding: 20rpx;
background-color: #fff; }
height: 100rpx; }
padding: 40rpx 0 64rpx;
box-sizing: border-box; .permission.transform {
display: flex; top: calc(var(--status-bar-height) + 88rpx + 20rpx);
flex-flow: row nowrap; opacity: 1;
justify-content: space-between; visibility: visible;
align-items: center; }
.btn-submit {
.bottom-buttons {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: #fff;
height: 100rpx;
padding: 40rpx 0 64rpx;
box-sizing: border-box;
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
.btn-submit {
background-color: rgba(232, 16, 30, 1); background-color: rgba(232, 16, 30, 1);
color: #fff; color: #fff;
width: 334rpx; width: 334rpx;
height: 78rpx; height: 78rpx;
line-height: 78rpx; line-height: 78rpx;
text-align: center; text-align: center;
font-size: 28rpx; font-size: 28rpx;
border-radius: 78rpx; border-radius: 78rpx;
}
} }
}
</style> </style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -120,14 +120,14 @@ const request = async (options, isUpdate) => {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
baseURL = url; // 发布到生产环境时,此处代码会被摇树移除掉。 baseURL = url; // 发布到生产环境时,此处代码会被摇树移除掉。
// baseURL = "http://116.63.163.121:93"; // 发布到生产环境时,此处代码会被摇树移除掉。 baseURL = "http://116.63.163.121:93"; // 发布到生产环境时,此处代码会被摇树移除掉。
//baseURL = 'http://60.247.146.5:96'; // 发布到生产环境时,此处代码会被摇树移除掉。 // baseURL = "http://116.63.163.121:96"; // 发布到生产环境时,此处代码会被摇树移除掉。
baseURL = "https://app.mrrweb.com.cn"; // baseURL = "https://app.mrrweb.com.cn";
} else { } else {
baseURL = url; // 发布到生产环境时,此处代码会被摇树移除掉。 baseURL = url; // 发布到生产环境时,此处代码会被摇树移除掉。
// baseURL = "http://116.63.163.121:93"; // 发布到生产环境时,此处代码会被摇树移除掉。 baseURL = "http://116.63.163.121:93"; // 发布到生产环境时,此处代码会被摇树移除掉。
//baseURL = 'http://60.247.146.5:96'; // 发布到生产环境时,此处代码会被摇树移除掉。 // baseURL = "http://116.63.163.121:96"; // 发布到生产环境时,此处代码会被摇树移除掉。
baseURL = "https://app.mrrweb.com.cn"; // baseURL = "https://app.mrrweb.com.cn";
console.log("生产环境"); console.log("生产环境");
} }