服务发布

This commit is contained in:
丁杰 2026-06-23 09:55:51 +08:00
parent 3e39ff1140
commit 573aef3210
19 changed files with 1165 additions and 490 deletions

View File

@ -15,7 +15,6 @@
</view> </view>
</template> </template>
<script>
<script> <script>
// spark-md5 // spark-md5
import SparkMD5 from 'spark-md5' import SparkMD5 from 'spark-md5'
@ -42,7 +41,7 @@ export default {
maxSize: { maxSize: {
type: Number, type: Number,
default: 500 default: 500
} },
}, },
data() { data() {
return { return {
@ -53,7 +52,7 @@ export default {
fileMd5: '', fileMd5: '',
cancelToken: null cancelToken: null
}; };
} },
methods: { methods: {
// //
async chooseVideo() { async chooseVideo() {
@ -319,7 +318,7 @@ export default {
chunkIndex, chunkIndex,
fileMd5: this.fileMd5, fileMd5: this.fileMd5,
chunkSize: this.chunkSize, chunkSize: this.chunkSize,
totalChunks: Math.ceil(this.file.size / this.chunkSize)) totalChunks: Math.ceil(this.file.size / this.chunkSize)
}, },
header: { header: {
'Content-Type': 'multipart/form-data' 'Content-Type': 'multipart/form-data'

View File

@ -50,6 +50,7 @@
v-for="(file, index) in videoList" v-for="(file, index) in videoList"
:key="file || index" :key="file || index"
class="preview-item" class="preview-item"
@click="previewVideo(file)"
> >
<!-- <video <!-- <video
:src="file" :src="file"
@ -57,6 +58,11 @@
@click="handlePreview(index)" @click="handlePreview(index)"
/> --> /> -->
<image class="preview-image" :src="getVideoPoster(file)" mode="aspectFill"></image> <image class="preview-image" :src="getVideoPoster(file)" mode="aspectFill"></image>
<view class="video-play-mask">
<view class="video-play-circle">
<view class="video-play-icon"></view>
</view>
</view>
<image <image
v-if="deletable" v-if="deletable"
class="delete-icon" class="delete-icon"
@ -81,6 +87,14 @@
</view> </view>
</view> </view>
</view> </view>
<view v-if="showVideoPlayer" class="video-player-mask" @click="closeVideoPlayer">
<view class="video-player-box" @click.stop>
<video :src="previewVideoUrl" class="video-player" controls autoplay show-fullscreen-btn></video>
<view class="video-player-close" @click="closeVideoPlayer">
<text class="video-player-close-text">&times;</text>
</view>
</view>
</view>
<!-- 提示文字 --> <!-- 提示文字 -->
<!-- <text v-if="tips" class="tips-text">{{ tips }}</text> --> <!-- <text v-if="tips" class="tips-text">{{ tips }}</text> -->
@ -97,12 +111,12 @@ export default {
props: { props: {
// //
value: { value: {
type: Array, type: [Array, String, Object],
default: () => [] default: () => []
}, },
// //
fileString: { fileString: {
type: String, type: [String, Object, Array],
default: '' default: ''
}, },
// //
@ -176,7 +190,9 @@ export default {
loading: false, loading: false,
dragIndex: -1, dragIndex: -1,
isDragging: false, isDragging: false,
suppressPreview: false suppressPreview: false,
showVideoPlayer: false,
previewVideoUrl: ''
} }
}, },
@ -190,19 +206,57 @@ export default {
value: { value: {
immediate: true, immediate: true,
handler(newVal) { handler(newVal) {
this.fileList = [...newVal] if (this.accept === 'video/*') {
const videoValue = this.normalizeVideoValue(newVal)
this.oneFile = videoValue
this.videoList = videoValue ? [videoValue] : []
return
}
this.fileList = Array.isArray(newVal) ? [...newVal] : []
} }
}, },
fileString: { fileString: {
immediate: true, immediate: true,
handler(newVal) { handler(newVal) {
this.oneFile = newVal const videoValue = this.normalizeVideoValue(newVal)
this.videoList = newVal ? [newVal] : [] this.oneFile = videoValue
this.videoList = videoValue ? [videoValue] : []
} }
} }
}, },
methods: { methods: {
normalizeVideoValue(value) {
if (!value) return ''
if (typeof value === 'string') {
const str = value.trim()
if (!str) return ''
if ((str.startsWith('{') && str.endsWith('}')) || (str.startsWith('[') && str.endsWith(']'))) {
try {
return this.normalizeVideoValue(JSON.parse(str))
} catch (error) {
return str
}
}
return str
}
if (Array.isArray(value)) {
for (const item of value) {
const url = this.normalizeVideoValue(item)
if (url) return url
}
return ''
}
if (typeof value === 'object') {
const candidates = [value.url, value.path, value.filePath, value.tempFilePath, value.src, value.value]
for (const item of candidates) {
const url = this.normalizeVideoValue(item)
if (url) return url
}
return ''
}
return String(value)
},
// //
showPermissionDialog(title, content) { showPermissionDialog(title, content) {
@ -319,8 +373,9 @@ export default {
}, },
// OSS // OSS
async uploadFile(file) { async uploadFile(file) {
const uploadFile = await this.prepareUploadFile(file)
// //
if (file.size > this.maxSize * 1024 * 1024) { if (uploadFile.size > this.maxSize * 1024 * 1024) {
uni.showToast({ uni.showToast({
title: `文件大小不能超过${this.maxSize}MB`, title: `文件大小不能超过${this.maxSize}MB`,
icon: 'none' icon: 'none'
@ -328,7 +383,7 @@ export default {
return Promise.reject(new Error('文件大小超出限制')) return Promise.reject(new Error('文件大小超出限制'))
} }
let date = `${new Date().getTime()}_${Math.floor(Math.random() * 100000)}` let date = `${new Date().getTime()}_${Math.floor(Math.random() * 100000)}`
const fileExt = this.getFileExt(file) const fileExt = this.getFileExt(uploadFile)
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}.${fileExt}` let name = `${this.userId}_${artisan}_${date}.${fileExt}`
@ -337,7 +392,7 @@ export default {
// 1. OSS // 1. OSS
const ossConfig = await this.getOssConfig() const ossConfig = await this.getOssConfig()
// 2. // 2.
const fileUrl = await this.uploadToOss(file, ossConfig, name) const fileUrl = await this.uploadToOss(uploadFile, ossConfig, name)
// 3. // 3.
if(this.accept == 'image/*') { if(this.accept == 'image/*') {
const newFile = [`${fileUrl}`] const newFile = [`${fileUrl}`]
@ -379,10 +434,90 @@ export default {
getFileExt(file) { getFileExt(file) {
const defaultExt = this.accept === 'video/*' ? 'mp4' : 'jpg' const defaultExt = this.accept === 'video/*' ? 'mp4' : 'jpg'
const source = file && (file.name || file.tempFilePath || file.path || file.url || '') const sources = this.accept === 'video/*'
const cleanPath = String(source).split('?')[0].split('#')[0] ? [file && file.tempFilePath, file && file.path, file && file.url, file && file.name]
: [file && file.path, file && file.tempFilePath, file && file.url, file && file.name]
for (const source of sources) {
const cleanPath = String(source || '').split('?')[0].split('#')[0]
const ext = cleanPath.includes('.') ? cleanPath.split('.').pop().toLowerCase() : '' const ext = cleanPath.includes('.') ? cleanPath.split('.').pop().toLowerCase() : ''
return /^[a-z0-9]+$/.test(ext) ? ext : defaultExt if (/^[a-z0-9]+$/.test(ext)) return ext
}
return defaultExt
},
getFilePath(file) {
if (!file) return ''
return file.tempFilePath || file.path || file.url || file.name || ''
},
getMimeType(file, name) {
const ext = this.getFileExt({ tempFilePath: name, path: name, name })
const mimeTypes = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
mp4: 'video/mp4',
m4v: 'video/x-m4v',
mov: 'video/quicktime',
qt: 'video/quicktime',
webm: 'video/webm',
mpeg: 'video/mpeg',
mpg: 'video/mpeg',
avi: 'video/x-msvideo',
'3gp': 'video/3gpp',
mkv: 'video/x-matroska',
}
return mimeTypes[ext] || (file && file.type) || ''
},
buildOssFormData(ossConfig, objectKey, mimeType) {
const formData = {
key: objectKey,
policy: ossConfig.policy,
OSSAccessKeyId: ossConfig.ossAccessKeyId,
success_action_status: '200',
signature: ossConfig.signature,
}
if (mimeType) {
formData['Content-Type'] = mimeType
formData['x-oss-meta-content-type'] = mimeType
}
return formData
},
async prepareUploadFile(file) {
if (this.accept !== 'video/*') return file
const filePath = this.getFilePath(file)
const fileExt = this.getFileExt(file)
if (!filePath || fileExt === 'mp4' || typeof uni.compressVideo !== 'function') {
return file
}
try {
const res = await uni.compressVideo({
src: filePath,
quality: 'medium'
})
const tempFilePath = res.tempFilePath || res.path
if (!tempFilePath) return file
return {
...file,
tempFilePath,
path: tempFilePath,
size: res.size || file.size,
name: this.replaceFileExt(file.name, 'mp4'),
type: 'video/mp4'
}
} catch (error) {
console.warn('视频压缩失败,使用原文件上传:', error)
return file
}
},
replaceFileExt(name, ext) {
if (!name) return ''
return String(name).replace(/\.[^.]+$/, `.${ext}`)
}, },
buildFileUrl(ossConfig, name) { buildFileUrl(ossConfig, name) {
@ -403,6 +538,21 @@ export default {
}, },
chooseVideoFiles(sourceType, count) { chooseVideoFiles(sourceType, count) {
if (count <= 1 && typeof uni.chooseVideo === 'function') {
return new Promise((resolve, reject) => {
uni.chooseVideo({
sourceType: [sourceType],
compressed: true,
maxDuration: 60,
success: (res) => resolve([{
...res,
tempFilePath: res.tempFilePath || res.path,
path: res.tempFilePath || res.path,
}]),
fail: reject,
});
});
}
if (uni.chooseMedia) { if (uni.chooseMedia) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
uni.chooseMedia({ uni.chooseMedia({
@ -438,14 +588,20 @@ export default {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const objectKey = this.buildObjectKey(ossConfig, name) const objectKey = this.buildObjectKey(ossConfig, name)
const fileUrl = this.buildFileUrl(ossConfig, name) const fileUrl = this.buildFileUrl(ossConfig, name)
const mimeType = this.getMimeType(file, name)
let formData;
// #ifdef H5 // #ifdef H5
const formData = new FormData() formData = new FormData()
formData.append('name',name); formData.append('name',name);
formData.append('policy', ossConfig.policy); formData.append('policy', ossConfig.policy);
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', objectKey); formData.append('key', objectKey);
if (mimeType) {
formData.append('Content-Type', mimeType);
formData.append('x-oss-meta-content-type', mimeType);
}
// filefile // filefile
formData.append('file', file.file || file.tempFile || 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) => {
@ -460,23 +616,17 @@ export default {
// #endif // #endif
// #ifdef APP-PLUS || MP-WEIXIN // #ifdef APP-PLUS || MP-WEIXIN
let formData = {}; formData = this.buildOssFormData(ossConfig, objectKey, mimeType);
formData.name = name; formData.name = name;
formData.policy = ossConfig.policy;
formData.OSSAccessKeyId = ossConfig.ossAccessKeyId;
formData.success_action_status = '200';
formData.signature = ossConfig.signature;
formData.key = objectKey;
formData.file = file;
let path; let path;
if(this.accept == 'image/*') { if(this.accept == 'image/*') {
path = file.path path = this.getFilePath(file)
}else if(this.accept == 'video/*') { }else if(this.accept == 'video/*') {
// #ifdef H5 // #ifdef H5
path = file.name path = this.getFilePath(file)
// #endif // #endif
// #ifdef APP-PLUS || MP-WEIXIN // #ifdef APP-PLUS || MP-WEIXIN
path = file.tempFilePath path = this.getFilePath(file)
// #endif // #endif
} }
// 使 uni.uploadFile // 使 uni.uploadFile
@ -484,13 +634,7 @@ export default {
url: ossConfig.host, url: ossConfig.host,
filePath: path, // filePath: path, //
name: 'file', name: 'file',
formData: { formData,
key: objectKey, //
policy: ossConfig.policy, //
OSSAccessKeyId: ossConfig.ossAccessKeyId, // ID
success_action_status: 200, // 200,204
signature: ossConfig.signature //
},
success: (res) => { success: (res) => {
const statusCode = Number(res.statusCode) const statusCode = Number(res.statusCode)
if (statusCode >= 200 && statusCode < 300) { if (statusCode >= 200 && statusCode < 300) {
@ -517,6 +661,17 @@ export default {
}) })
}, },
previewVideo(url) {
if (!url) return
this.previewVideoUrl = url
this.showVideoPlayer = true
},
closeVideoPlayer() {
this.showVideoPlayer = false
this.previewVideoUrl = ''
},
startDrag(index) { startDrag(index) {
if (!this.sortable || this.accept !== 'image/*' || this.fileList.length < 2) return if (!this.sortable || this.accept !== 'image/*' || this.fileList.length < 2) return
this.dragIndex = index this.dragIndex = index
@ -662,6 +817,38 @@ export default {
border-radius: 8rpx; border-radius: 8rpx;
} }
.video-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.18);
border-radius: 8rpx;
}
.video-play-circle {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
}
.video-play-icon {
width: 0;
height: 0;
border-top: 14rpx solid transparent;
border-bottom: 14rpx solid transparent;
border-left: 22rpx solid #fff;
margin-left: 6rpx;
}
.delete-icon { .delete-icon {
position: absolute; position: absolute;
top: -10rpx; top: -10rpx;
@ -670,4 +857,50 @@ export default {
height: 42rpx; height: 42rpx;
z-index: 5; z-index: 5;
} }
.video-player-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 9999;
background-color: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
}
.video-player-box {
position: relative;
width: 690rpx;
background-color: #000;
border-radius: 8rpx;
overflow: hidden;
}
.video-player {
width: 690rpx;
height: 388rpx;
background-color: #000;
}
.video-player-close {
position: absolute;
top: 12rpx;
right: 12rpx;
width: 52rpx;
height: 52rpx;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
}
.video-player-close-text {
color: #fff;
font-size: 42rpx;
line-height: 52rpx;
}
</style> </style>

View File

@ -2,7 +2,7 @@
<view class="form-checkbox"> <view class="form-checkbox">
<view v-for="(item, index) in options" :key="index" class="checkbox-item" @click="onToggle(item)"> <view v-for="(item, index) in options" :key="index" class="checkbox-item" @click="onToggle(item)">
<view class="checkbox-dot" :class="{ active: isChecked(item) }"></view> <view class="checkbox-dot" :class="{ active: isChecked(item) }"></view>
<text class="checkbox-label">{{ item }}</text> <text class="checkbox-label">{{ getOptionLabel(item) }}</text>
</view> </view>
</view> </view>
</template> </template>
@ -16,12 +16,37 @@ export default {
maxCount: { type: Number, default: 0 } maxCount: { type: Number, default: 0 }
}, },
methods: { methods: {
isChecked: function (val) { getOptionValue: function (item) {
return this.value && this.value.indexOf(val) !== -1; if (item && typeof item === 'object') {
if (item.value !== undefined) return item.value;
if (item.id !== undefined) return item.id;
if (item.key !== undefined) return item.key;
if (item.code !== undefined) return item.code;
return item.label;
}
return item;
}, },
onToggle: function (val) { getOptionLabel: function (item) {
if (item && typeof item === 'object') {
var label = item.label || item.title || item.name || item.text;
if (label !== undefined && label !== null) return label;
return String(this.getOptionValue(item));
}
return item;
},
findValueIndex: function (list, value) {
for (var i = 0; i < list.length; i++) {
if (list[i] == value) return i;
}
return -1;
},
isChecked: function (item) {
return this.findValueIndex(this.value || [], this.getOptionValue(item)) !== -1;
},
onToggle: function (item) {
var val = this.getOptionValue(item);
var newValue = (this.value || []).slice(); var newValue = (this.value || []).slice();
var index = newValue.indexOf(val); var index = this.findValueIndex(newValue, val);
if (index === -1) { if (index === -1) {
if (this.maxCount > 0 && newValue.length >= this.maxCount) { if (this.maxCount > 0 && newValue.length >= this.maxCount) {
uni.showToast({ uni.showToast({

View File

@ -1,6 +1,6 @@
<template> <template>
<ali-oss-uploader v-model="currentValue" :max-count="maxCount" :max-size="maxSize" accept="image/*" :userId="userId" <ali-oss-uploader v-model="currentValue" :max-count="maxCount" :max-size="maxSize" accept="image/*" :userId="computedUserId"
:type="ossType" width="100%" :sortable="true" @input="onInput" @change="onChange" /> :type="computedOssType" width="100%" :sortable="true" @input="onInput" @change="onChange" />
</template> </template>
<script> <script>
@ -12,7 +12,9 @@ export default {
props: { props: {
value: { type: Array, default: function () { return []; } }, value: { type: Array, default: function () { return []; } },
maxCount: { type: Number, default: 9 }, maxCount: { type: Number, default: 9 },
maxSize: { type: Number, default: 5 } maxSize: { type: Number, default: 5 },
userId: { type: [Number, String], default: null },
ossType: { type: [Number, String], default: null }
}, },
data: function () { data: function () {
return { return {
@ -20,11 +22,13 @@ export default {
}; };
}, },
computed: { computed: {
userId: function () { computedUserId: function () {
if (this.userId) return this.userId;
return this.$store && this.$store.state && this.$store.state.sjInfo return this.$store && this.$store.state && this.$store.state.sjInfo
? this.$store.state.sjInfo.id : null; ? this.$store.state.sjInfo.id : null;
}, },
ossType: function () { computedOssType: function () {
if (this.ossType) return this.ossType;
var artisanType = getApp().globalData ? getApp().globalData.artisanType : 0; var artisanType = getApp().globalData ? getApp().globalData.artisanType : 0;
return (artisanType || 0) + 1; return (artisanType || 0) + 1;
} }

View File

@ -1,8 +1,8 @@
<template> <template>
<view class="form-radio"> <view class="form-radio">
<view v-for="(item, index) in options" :key="index" class="radio-item" @click="onSelect(item)"> <view v-for="(item, index) in options" :key="index" class="radio-item" @click="onSelect(item)">
<view class="radio-dot" :class="{ active: value === item }"></view> <view class="radio-dot" :class="{ active: isActive(item) }"></view>
<text class="radio-label">{{ item }}</text> <text class="radio-label">{{ getOptionLabel(item) }}</text>
</view> </view>
</view> </view>
</template> </template>
@ -15,8 +15,29 @@ export default {
options: { type: Array, default: function () { return []; } } options: { type: Array, default: function () { return []; } }
}, },
methods: { methods: {
onSelect: function (val) { getOptionValue: function (item) {
this.$emit('input', val); if (item && typeof item === 'object') {
if (item.value !== undefined) return item.value;
if (item.id !== undefined) return item.id;
if (item.key !== undefined) return item.key;
if (item.code !== undefined) return item.code;
return item.label;
}
return item;
},
getOptionLabel: function (item) {
if (item && typeof item === 'object') {
var label = item.label || item.title || item.name || item.text;
if (label !== undefined && label !== null) return label;
return String(this.getOptionValue(item));
}
return item;
},
isActive: function (item) {
return this.value == this.getOptionValue(item);
},
onSelect: function (item) {
this.$emit('input', this.getOptionValue(item));
} }
} }
}; };

View File

@ -1,8 +1,8 @@
<template> <template>
<picker :range="options" @change="onChange"> <picker :range="displayOptions" @change="onChange">
<view class="form-select"> <view class="form-select">
<text :class="['select-text', value ? '' : 'placeholder']"> <text :class="['select-text', hasValue ? '' : 'placeholder']">
{{ value || placeholder }} {{ hasValue ? selectedLabel : placeholder }}
</text> </text>
<image src="https://mrrplus.oss-cn-beijing.aliyuncs.com/wxstaic/images/arrow_right.png" class="arrow-right" <image src="https://mrrplus.oss-cn-beijing.aliyuncs.com/wxstaic/images/arrow_right.png" class="arrow-right"
mode="aspectFit" /> mode="aspectFit" />
@ -18,12 +18,48 @@ export default {
options: { type: Array, default: function () { return []; } }, options: { type: Array, default: function () { return []; } },
placeholder: { type: String, default: '请选择' } placeholder: { type: String, default: '请选择' }
}, },
computed: {
displayOptions: function () {
var self = this;
return this.options.map(function (item) {
return self.getOptionLabel(item);
});
},
hasValue: function () {
return this.value !== undefined && this.value !== null && this.value !== '';
},
selectedLabel: function () {
var self = this;
var selected = this.options.find(function (item) {
return self.getOptionValue(item) == self.value;
});
return selected !== undefined ? this.getOptionLabel(selected) : this.value;
}
},
methods: { methods: {
getOptionValue: function (item) {
if (item && typeof item === 'object') {
if (item.value !== undefined) return item.value;
if (item.id !== undefined) return item.id;
if (item.key !== undefined) return item.key;
if (item.code !== undefined) return item.code;
return item.label;
}
return item;
},
getOptionLabel: function (item) {
if (item && typeof item === 'object') {
var label = item.label || item.title || item.name || item.text;
if (label !== undefined && label !== null) return label;
return String(this.getOptionValue(item));
}
return item;
},
onChange: function (e) { onChange: function (e) {
var index = e.detail.value; var index = e.detail.value;
var selected = this.options[index]; var selected = this.options[index];
if (selected !== undefined) { if (selected !== undefined) {
this.$emit('input', selected); this.$emit('input', this.getOptionValue(selected));
} }
} }
} }

View File

@ -20,6 +20,37 @@
<text class="price-tip-card">{{ group.field.tip }}</text> <text class="price-tip-card">{{ group.field.tip }}</text>
</view> </view>
</view> </view>
<view v-else-if="group.field.type === 'textarea'" class="detail-row">
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
<view class="detail-textarea-wrap">
<textarea class="detail-textarea" :value="formData[group.field.key]" :placeholder="group.field.placeholder"
placeholder-class="placeholder" maxlength="200" @input="onInput(group.field.key, $event)" />
<text class="detail-count">{{ (formData[group.field.key] || "").length }}/200</text>
</view>
</view>
<view v-else-if="group.field.type === 'select'" class="form-item">
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
<form-item-select :value="formData[group.field.key]" :options="group.field.options || []"
:placeholder="group.field.placeholder" @input="onInput(group.field.key, $event)" />
</view>
<view v-else-if="group.field.type === 'single_select'" class="form-item" style="flex-wrap: wrap;">
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
<view class="select-options">
<form-item-radio :value="formData[group.field.key]" :options="group.field.options || []"
@input="onInput(group.field.key, $event)" />
</view>
</view>
<view v-else-if="group.field.type === 'multi_select'" class="form-item" style="flex-wrap: wrap;">
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
}}</text>
<view class="select-options">
<form-item-checkbox :value="formData[group.field.key]" :options="group.field.options || []"
:maxCount="group.field.maxCount || 0" @input="onInput(group.field.key, $event)" />
</view>
</view>
<view v-else-if="group.field.type === 'image_multi'" class="form-item-up upload-item"> <view v-else-if="group.field.type === 'image_multi'" class="form-item-up upload-item">
<view> <view>
<text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label <text :class="['form-label', group.field.is_required === true ? 'required' : '']">{{ group.field.label
@ -28,7 +59,7 @@
</view> </view>
<view class="upload-content"> <view class="upload-content">
<form-item-image :value="formData[group.field.key]" :maxCount="group.field.maxCount || 9" <form-item-image :value="formData[group.field.key]" :maxCount="group.field.maxCount || 9"
@input="onInput(group.field.key, $event)" /> :userId="uploadUserId" @input="onInput(group.field.key, $event)" />
</view> </view>
</view> </view>
<view v-else-if="group.field.type === 'video'" class="form-item-up upload-item"> <view v-else-if="group.field.type === 'video'" class="form-item-up upload-item">
@ -109,7 +140,7 @@
</view> </view>
<view class="upload-content"> <view class="upload-content">
<form-item-image :value="formData[field.key]" :maxCount="field.maxCount || 9" <form-item-image :value="formData[field.key]" :maxCount="field.maxCount || 9"
@input="onInput(field.key, $event)" /> :userId="uploadUserId" @input="onInput(field.key, $event)" />
</view> </view>
</view> </view>
<view v-else-if="field.type === 'video'" class="form-item-up upload-item no-bg"> <view v-else-if="field.type === 'video'" class="form-item-up upload-item no-bg">

View File

@ -22,7 +22,6 @@
</view> </view>
</view> </view>
</template> </template>
<script src="https://gosspublic.alicdn.com/aliyun-oss-sdk-6.18.0.min.js"></script>
<script> <script>
import request from '../../utils/request'; import request from '../../utils/request';
export default { export default {

View File

@ -200,11 +200,12 @@ import sparkMD5 from 'spark-md5'; // 引入MD5计算库
const loadNext = () => { const loadNext = () => {
const start = currentChunk * chunkSize; const start = currentChunk * chunkSize;
const end = start + chunkSize >= file.size ? file.size : start + chunkSize; const end = start + chunkSize >= file.size ? file.size : start + chunkSize;
let reader;
// #ifdef H5 // #ifdef H5
const reader = new FileReader(); reader = new FileReader();
// #endif // #endif
// #ifndef H5 // #ifndef H5
const reader = uni.getfilesystem; reader = uni.getfilesystem;
// #endif // #endif
reader.onload = (e) => { reader.onload = (e) => {
spark.append(e.target.result); spark.append(e.target.result);
@ -226,11 +227,12 @@ import sparkMD5 from 'spark-md5'; // 引入MD5计算库
try { try {
// ArrayBuffer // ArrayBuffer
const arrayBuffer = await new Promise((resolve) => { const arrayBuffer = await new Promise((resolve) => {
let reader;
// #ifdef H5 // #ifdef H5
const reader = new FileReader(); reader = new FileReader();
// #endif // #endif
// #ifndef H5 // #ifndef H5
const reader = uni.getfilesystem; reader = uni.getfilesystem;
// #endif // #endif
reader.onload = () => resolve(reader.result); reader.onload = () => resolve(reader.result);
reader.readAsArrayBuffer(chunk); reader.readAsArrayBuffer(chunk);

View File

@ -270,14 +270,50 @@ export default {
}); });
}, },
viewEdit(item) { viewEdit(item) {
uni.navigateTo({ let url = `/pages/shop/add-service?id=${item.id}&state=${item.state}`;
url: `/pages/shop/add-service?id=${item.id}&state=${item.state}&first_class_id=${item.first_class_id}`, if (item.first_class_id) {
}); url += `&first_class_id=${item.first_class_id}`;
}
if (item.second_class_id) {
url += `&second_class_id=${item.second_class_id}`;
}
uni.navigateTo({ url });
}, },
addService() { async getCurrentSjId() {
const userId = uni.getStorageSync('userId') || ''; const sjInfo = this.$store && this.$store.state && this.$store.state.sjInfo;
request.post("/sj/firstclass", { id: userId }).then((res) => { if (sjInfo && sjInfo.id) {
return sjInfo.id;
}
const storageSjId = uni.getStorageSync("sjId");
if (storageSjId) {
return storageSjId;
}
const res = await request.post("/sj/user/getUser");
if (res && res.data && res.data.id) {
if (this.$store && this.$store.commit) {
this.$store.commit("setSjInfo", res.data);
}
uni.setStorageSync("sjId", res.data.id);
return res.data.id;
}
return "";
},
async addService() {
try {
const sjId = await this.getCurrentSjId();
if (!sjId) {
uni.showToast({
title: "未获取到商家信息",
icon: "none",
});
return;
}
const res = await request.post("/sj/firstclass", { id: sjId });
if (res.data && res.data.length > 0) { if (res.data && res.data.length > 0) {
this.firstClassList = res.data; this.firstClassList = res.data;
this.selectedFirstIndex = 0; this.selectedFirstIndex = 0;
@ -291,7 +327,13 @@ export default {
icon: "none", icon: "none",
}); });
} }
} catch (error) {
console.error("获取商家服务分类失败:", error);
uni.showToast({
title: "获取分类失败",
icon: "none",
}); });
}
}, },
loadSecondClass(firstId) { loadSecondClass(firstId) {
request.post("/user/secondclass", { request.post("/user/secondclass", {

View File

@ -285,13 +285,14 @@ export default {
sourceType: ['album', 'camera'], sourceType: ['album', 'camera'],
compressed: false compressed: false
}) })
let selectedFile;
// #ifdef H5 // #ifdef H5
const file = res.tempFile; selectedFile = res.tempFile;
// #endif // #endif
// #ifdef APP-PLUS || MP-WEIXIN // #ifdef APP-PLUS || MP-WEIXIN
const file = res; selectedFile = res;
// #endif // #endif
await this.uploadFile(file) await this.uploadFile(selectedFile)
} }
} catch (error) { } catch (error) {
console.error('上传出错:', error) console.error('上传出错:', error)
@ -369,8 +370,9 @@ export default {
// OSS // OSS
uploadToOss(file, ossConfig, name) { uploadToOss(file, ossConfig, name) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let formData;
// #ifdef H5 // #ifdef H5
const formData = new FormData() formData = new FormData()
formData.append('name',name); formData.append('name',name);
formData.append('policy', ossConfig.policy); formData.append('policy', ossConfig.policy);
formData.append('OSSAccessKeyId', ossConfig.ossAccessKeyId); formData.append('OSSAccessKeyId', ossConfig.ossAccessKeyId);
@ -387,7 +389,7 @@ export default {
// #endif // #endif
// #ifdef APP-PLUS || MP-WEIXIN // #ifdef APP-PLUS || MP-WEIXIN
let formData = {}; formData = {};
formData.name = name; formData.name = name;
formData.policy = ossConfig.policy; formData.policy = ossConfig.policy;
formData.OSSAccessKeyId = ossConfig.ossAccessKeyId; formData.OSSAccessKeyId = ossConfig.ossAccessKeyId;

View File

@ -145,9 +145,6 @@
<view class="per-cont">当您需要联系商家或平台客服的时候需要获取拨打电话权限</view> <view class="per-cont">当您需要联系商家或平台客服的时候需要获取拨打电话权限</view>
</view> </view>
</view>
</view> </view>
</template> </template>

View File

@ -67,12 +67,10 @@
<view class="form-upimg"> <view class="form-upimg">
<view class="upload-box" @click="chooseImage('idcard_negative')"> <view class="upload-box" @click="chooseImage('idcard_negative')">
</image>
<view class="upload-camera img2"> <view class="upload-camera img2">
<image v-if="formData.idcard_negative" :src="formData.idcard_negative" mode="aspectFill" <image v-if="formData.idcard_negative" :src="formData.idcard_negative" mode="aspectFill"
class="preview-image"> class="preview-image"></image>
<image v-else src="/static/images/camera.png" mode="aspectFit" class="camera-icon"> <image v-else src="/static/images/camera.png" mode="aspectFit" class="camera-icon"></image>
</image>
</view> </view>
</view> </view>
<text class="label required">请上传身份证反面(国徽面)</text> <text class="label required">请上传身份证反面(国徽面)</text>
@ -126,12 +124,10 @@
<view class="form-upimg"> <view class="form-upimg">
<view class="upload-box" @click="chooseImage('legal_idcard_negative')"> <view class="upload-box" @click="chooseImage('legal_idcard_negative')">
</image>
<view class="upload-camera img2"> <view class="upload-camera img2">
<image v-if="formData.legal_idcard_negative" :src="formData.legal_idcard_negative" <image v-if="formData.legal_idcard_negative" :src="formData.legal_idcard_negative"
mode="aspectFill" class="preview-image"> mode="aspectFill" class="preview-image"></image>
<image v-else src="/static/images/camera.png" mode="aspectFit" class="camera-icon"> <image v-else src="/static/images/camera.png" mode="aspectFit" class="camera-icon"></image>
</image>
</view> </view>
</view> </view>
<text class="label required">请上传法人身份证反面(国徽面)</text> <text class="label required">请上传法人身份证反面(国徽面)</text>

View File

@ -45,7 +45,6 @@
<span class="detail-info-user__company__name__line"> | </span> <span class="detail-info-user__company__name__line"> | </span>
<view class="detail-info-user__company__name__text2"> <view class="detail-info-user__company__name__text2">
{{ item.for_shop }} {{ item.for_shop }}
</image>
</view> </view>
</view> </view>

View File

@ -166,12 +166,12 @@
</view> --> </view> -->
<!-- 项目说明 --> <!-- 项目说明 -->
<view class="notice-section"> <view class="notice-section" v-if="hasProjectInfo">
<view class="section-title"> <view class="section-title">
<text class="section-tit">项目说明</text> <text class="section-tit">项目说明</text>
</view> </view>
<view class="notice-content"> <view class="notice-content">
<view class="notice-cont"> <view class="notice-cont" v-if="serviceInfo.server_time">
<text class="notice-title"> <text class="notice-title">
服务时长: 服务时长:
</text> </text>
@ -179,7 +179,7 @@
{{serviceInfo.server_time}}分钟 {{serviceInfo.server_time}}分钟
</text> </text>
</view> </view>
<view class="notice-cont" v-for="(item,i) in serviceInfo.detail"> <view class="notice-cont" v-for="(item,i) in visibleDetailList" :key="'detail-' + i">
<text class="notice-title"> <text class="notice-title">
{{item.title}} {{item.title}}
</text> </text>
@ -189,16 +189,38 @@
</view> </view>
</view> </view>
</view> </view>
<view class="notice-section" v-for="(section, sectionIndex) in dynamicIntroSections"
:key="'dynamic-section-' + sectionIndex">
<view class="section-title">
<text class="section-tit">{{section.title}}</text>
</view>
<view class="notice-content">
<view class="notice-cont dynamic-notice-cont" v-for="(item, itemIndex) in section.fields"
:key="'dynamic-field-' + sectionIndex + '-' + itemIndex">
<text class="notice-title">{{item.title}}</text>
<view class="dynamic-media-list" v-if="item.images.length">
<image class="dynamic-image" v-for="(img, imgIndex) in item.images"
:key="'dynamic-img-' + imgIndex" :src="img" mode="aspectFill"
@click="previewImage(item.images, imgIndex)"></image>
</view>
<view class="dynamic-media-list" v-else-if="item.videos.length">
<video class="dynamic-video" v-for="(video, videoIndex) in item.videos"
:key="'dynamic-video-' + videoIndex" :src="video" controls></video>
</view>
<text class="notice-text" v-else>{{item.displayValue}}</text>
</view>
</view>
</view>
<!-- 图文详情 --> <!-- 图文详情 -->
<imgAndText v-if="serviceInfo.graphic_details" :info="serviceInfo.graphic_details"></imgAndText> <imgAndText v-if="serviceInfo.graphic_details" :info="serviceInfo.graphic_details"></imgAndText>
<!-- 步骤 --> <!-- 步骤 -->
<view class="notice-section"> <view class="notice-section" v-if="visibleProcessList.length">
<view class="section-title"> <view class="section-title">
<text class="section-tit">项目步骤</text> <text class="section-tit">项目步骤</text>
</view> </view>
<view class="notice-content"> <view class="notice-content">
<view class="notice-cont" v-for="(item,i) in serviceInfo.process"> <view class="notice-cont" v-for="(item,i) in visibleProcessList" :key="'process-' + i">
<text class="notice-title"> <text class="notice-title">
{{item.title}} {{item.title}}
</text> </text>
@ -209,7 +231,7 @@
</view> </view>
</view> </view>
<!-- 订购须知 --> <!-- 订购须知 -->
<view class="notice-section"> <view class="notice-section" v-if="purchaseNotesText">
<view class="section-title"> <view class="section-title">
<text class="section-tit">订购须知</text> <text class="section-tit">订购须知</text>
<!-- <view class="section-tit-right"> <!-- <view class="section-tit-right">
@ -219,7 +241,7 @@
</view> </view>
<view class="notice-content"> <view class="notice-content">
<text class="notice-text"> <text class="notice-text">
{{serviceInfo.purchasenotes}} {{purchaseNotesText}}
</text> </text>
</view> </view>
</view> </view>
@ -327,6 +349,25 @@
computed: { computed: {
userAdrees() { userAdrees() {
return uni.getStorageSync("userAdrees").addressRes || getApp().globalData.addressRes; return uni.getStorageSync("userAdrees").addressRes || getApp().globalData.addressRes;
},
visibleDetailList() {
return (this.serviceInfo.detail || []).filter((item) => {
return item && item.title && item.text;
});
},
visibleProcessList() {
return (this.serviceInfo.process || []).filter((item) => {
return item && item.text;
});
},
hasProjectInfo() {
return !!this.serviceInfo.server_time || this.visibleDetailList.length > 0;
},
purchaseNotesText() {
return this.serviceInfo.purchasenotes || this.serviceInfo.purchase_notes || '';
},
dynamicIntroSections() {
return this.buildDynamicIntroSections(this.serviceInfo.intro);
} }
}, },
onLoad(option) { onLoad(option) {
@ -368,6 +409,86 @@
this.showVideo = false; this.showVideo = false;
}, },
methods: { methods: {
parseMaybeJson(raw) {
if (!raw) return [];
if (typeof raw !== 'string') return raw;
try {
return JSON.parse(raw);
} catch (e) {
return [];
}
},
getDynamicPlainValue(value) {
if (value === undefined || value === null) return '';
if (typeof value !== 'object') return String(value);
return value.label || value.title || value.name || value.text || value.value || '';
},
normalizeDynamicIntroField(item) {
if (!item) return null;
const type = item.type || '';
const value = item.value;
const images = [];
const videos = [];
let displayValue = '';
if (value === undefined || value === null || value === '') return null;
if (Array.isArray(value) && value.length === 0) return null;
if (type === 'image_multi') {
(value || []).forEach((img) => {
const url = typeof img === 'string' ? img : (img && (img.url || img.path));
if (url) images.push(url);
});
} else if (type === 'video') {
const list = Array.isArray(value) ? value : [value];
list.forEach((video) => {
const url = typeof video === 'string' ? video : (video && (video.url || video.path));
if (url) videos.push(url);
});
} else if (type === 'service_step' && Array.isArray(value)) {
displayValue = value.map((step, index) => {
const text = this.getDynamicPlainValue(step);
return text ? `${index + 1}. ${text}` : '';
}).filter(Boolean).join('');
} else if (Array.isArray(value)) {
displayValue = value.map((val) => this.getDynamicPlainValue(val)).filter(Boolean).join('、');
} else if (typeof value === 'object') {
displayValue = this.getDynamicPlainValue(value);
} else {
displayValue = String(value);
}
if (!displayValue && images.length === 0 && videos.length === 0) return null;
return {
title: item.title || '补充信息',
type,
displayValue,
images,
videos,
sectionTitle: item.sectionTitle || '',
sectionId: item.sectionId || '',
};
},
buildDynamicIntroSections(rawIntro) {
const intro = this.parseMaybeJson(rawIntro);
if (!Array.isArray(intro)) return [];
const groups = [];
const groupMap = {};
intro.forEach((item) => {
const field = this.normalizeDynamicIntroField(item);
if (!field) return;
const title = field.sectionTitle || (field.type === 'service_step' ? '项目步骤' : '更多说明');
const key = field.sectionId || title;
if (!groupMap[key]) {
groupMap[key] = {
title,
fields: [],
};
groups.push(groupMap[key]);
}
groupMap[key].fields.push(field);
});
return groups.filter((section) => section.fields.length > 0);
},
async goInvite() { async goInvite() {
let imageUrl = let imageUrl =
'https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/yh/1756713709528-717.png?x-oss-process=image/resize,p_40' 'https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/yh/1756713709528-717.png?x-oss-process=image/resize,p_40'
@ -1142,6 +1263,26 @@
color: #666; color: #666;
} }
.dynamic-notice-cont {
flex-wrap: wrap;
}
.dynamic-media-list {
flex: 1;
display: flex;
flex-wrap: wrap;
}
.dynamic-image,
.dynamic-video {
width: 160rpx;
height: 160rpx;
margin-right: 12rpx;
margin-bottom: 12rpx;
border-radius: 8rpx;
background-color: #F6F6F6;
}
/* 底部按钮 */ /* 底部按钮 */
.bottom-bar { .bottom-bar {
position: fixed; position: fixed;

View File

@ -21,7 +21,7 @@
ref="dynamicForm" /> ref="dynamicForm" />
<!-- 服务流程 --> <!-- 服务流程 -->
<view class="form-step"> <view class="form-step" v-if="!hasDynamicServiceStepField">
<view class="form-item"> <view class="form-item">
<text class="form-label">服务流程</text> <text class="form-label">服务流程</text>
<text @click="addStep" style="color: #FF4767;">添加步骤</text> <text @click="addStep" style="color: #FF4767;">添加步骤</text>
@ -42,8 +42,9 @@
</view> </view>
<ali-oss-uploader :max-count="1" :max-size="100" :type="artisanType + 1" <ali-oss-uploader :max-count="1" :max-size="100" :type="artisanType + 1"
:user-id="formData.publish_user_id" :width="'654rpx'" :kind="2" :file-string="formData.video" :user-id="formData.publish_user_id" :width="'654rpx'" :kind="2" :file-string="formData.video"
accept="video/*" button-text="上传视频" @success="uploadVideoSuc" @error="uploadVideoErr" accept="video/*" button-text="上传视频" @input="setVideoValue" @change="setVideoValue"
@delete="uploadVideoDel" @ckeckisShowPer="ckeckisShowPer" @ckecknowQer="ckecknowQer"> @success="uploadVideoSuc" @error="uploadVideoErr" @delete="uploadVideoDel"
@ckeckisShowPer="ckeckisShowPer" @ckecknowQer="ckecknowQer">
</ali-oss-uploader> </ali-oss-uploader>
</view> </view>
</view> </view>
@ -200,6 +201,17 @@ export default {
return group.type === 'video'; return group.type === 'video';
}); });
}, },
hasDynamicServiceStepField() {
return this.formFields.some((group) => {
if (group.type === 'single' && group.field) {
return group.field.type === 'service_step';
}
if (group.type === 'section' && Array.isArray(group.fields)) {
return group.fields.some((field) => field.type === 'service_step');
}
return group.type === 'service_step';
});
},
serviceClassName() { serviceClassName() {
return [this.firstClassName, this.secondClassName].filter(Boolean).join('/'); return [this.firstClassName, this.secondClassName].filter(Boolean).join('/');
}, },
@ -212,15 +224,47 @@ export default {
'number': 'number', 'number': 'number',
'image_multi': 'image_multi', 'image_multi': 'image_multi',
'video': 'video', 'video': 'video',
'service_step': 'service_step',
'richtext_editor': 'richtext_editor', 'richtext_editor': 'richtext_editor',
'select': 'select', 'select': 'select',
'single_select': 'single_select', 'single_select': 'single_select',
'radio': 'single_select',
'switch': 'single_select',
'boolean': 'single_select',
'multi_select': 'multi_select', 'multi_select': 'multi_select',
'textarea': 'textarea', 'textarea': 'textarea',
}; };
return typeMap[fieldType] || 'text'; return typeMap[fieldType] || 'text';
}, },
normalizeFieldOptions(options) {
if (!Array.isArray(options)) return [];
return options.map((item) => {
if (item && typeof item === 'object') {
const value = item.value !== undefined ? item.value :
(item.id !== undefined ? item.id :
(item.key !== undefined ? item.key :
(item.code !== undefined ? item.code : item.label)));
const label = item.label || item.title || item.name || item.text || String(value);
return {
...item,
label,
value,
};
}
return {
label: String(item),
value: item,
};
});
},
applyFieldHint(field, rawConfig) {
if (!rawConfig || !rawConfig.hint) return;
if (rawConfig.showHint === false || rawConfig.showHint === 0 || rawConfig.showHint === '0') return;
field.tip = rawConfig.hint;
},
// config_datadynamic-form // config_datadynamic-form
transformConfigToFields(configData) { transformConfigToFields(configData) {
const groups = []; const groups = [];
@ -239,13 +283,22 @@ export default {
placeholder: preset.placeholder || `请输入${preset.title}`, placeholder: preset.placeholder || `请输入${preset.title}`,
}; };
if (preset.showHint && preset.hint) { if (preset.options && Array.isArray(preset.options) && preset.options.length > 0) {
field.tip = preset.hint; field.options = this.normalizeFieldOptions(preset.options);
} }
if (preset.default_value !== undefined) {
field.default_value = preset.default_value;
} else if (preset.defaultValue !== undefined) {
field.default_value = preset.defaultValue;
}
this.applyFieldHint(field, preset);
if (preset.key === 'line_price' || preset.key === 'server_price') { if (preset.key === 'line_price' || preset.key === 'server_price') {
field.tips = '(元)'; field.tips = '(元)';
if (!field.tip) {
field.tip = preset.key === 'line_price' ? '服务的基础定价,未参与任何优惠的价格' : '服务参与单品优惠后的价格'; field.tip = preset.key === 'line_price' ? '服务的基础定价,未参与任何优惠的价格' : '服务参与单品优惠后的价格';
}
} else if (preset.key === 'server_time') { } else if (preset.key === 'server_time') {
field.placeholder = '请输入服务时长'; field.placeholder = '请输入服务时长';
field.tips = '分钟'; field.tips = '分钟';
@ -294,9 +347,16 @@ export default {
field.tips = '分钟'; field.tips = '分钟';
} }
this.applyFieldHint(field, sectionField);
if (sectionField.options && Array.isArray(sectionField.options) && if (sectionField.options && Array.isArray(sectionField.options) &&
sectionField.options.length > 0) { sectionField.options.length > 0) {
field.options = sectionField.options; field.options = this.normalizeFieldOptions(sectionField.options);
}
if (sectionField.default_value !== undefined) {
field.default_value = sectionField.default_value;
} else if (sectionField.defaultValue !== undefined) {
field.default_value = sectionField.defaultValue;
} }
if (sectionField.maxCount && (sectionField.fieldType === 'multi_select' || if (sectionField.maxCount && (sectionField.fieldType === 'multi_select' ||
@ -326,6 +386,9 @@ export default {
sectionType: section.sectionType, sectionType: section.sectionType,
userDefined: sectionField.userDefined, userDefined: sectionField.userDefined,
}; };
field._sectionTitle = section.sectionTitle || '';
field._sectionId = section.id;
field._sectionType = section.sectionType;
sectionFields.push(field); sectionFields.push(field);
}); });
@ -444,16 +507,9 @@ export default {
let that = this; let that = this;
let value = e.detail.value; let value = e.detail.value;
// 1.
value = value.replace(/[^\d.]/g, '');
// 1. // 1.
value = value.replace(/[^\d.]/g, ''); value = value.replace(/[^\d.]/g, '');
// 2.
const parts = value.split('.');
if (parts.length > 2) {
value = parts[0] + '.' + parts.slice(1).join('');
}
// 2. // 2.
const parts = value.split('.'); const parts = value.split('.');
if (parts.length > 2) { if (parts.length > 2) {
@ -471,27 +527,6 @@ export default {
that.$set(that.formData, 'line_price', value); that.$set(that.formData, 'line_price', value);
}, },
// //
initDetailForm(detail = []) {
// detailForm
this.detailForm = {
effect: "",
crowd: "",
product: "",
scope: "",
tips: "",
};
this.detailOtherList = [];
// 3.
if (parts.length > 1) {
value = parts[0] + '.' + parts[1].slice(0, 2);
}
// 4. 0
if (value.startsWith('.')) {
value = '0' + value;
}
that.$set(that.formData, 'line_price', value);
},
//
initDetailForm(detail = []) { initDetailForm(detail = []) {
// detailForm // detailForm
this.detailForm = { this.detailForm = {
@ -594,7 +629,41 @@ export default {
this.nowQer = value; this.nowQer = value;
}, },
addVideo(value) { addVideo(value) {
this.formData.video = value; this.setVideoValue(value);
},
normalizeVideoValue(value) {
if (!value) return "";
if (typeof value === "string") {
const str = value.trim();
if (!str) return "";
if ((str.startsWith("{") && str.endsWith("}")) || (str.startsWith("[") && str.endsWith("]"))) {
try {
return this.normalizeVideoValue(JSON.parse(str));
} catch (error) {
return str;
}
}
return str;
}
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const url = this.normalizeVideoValue(value[i]);
if (url) return url;
}
return "";
}
if (typeof value === "object") {
const candidates = [value.url, value.path, value.filePath, value.tempFilePath, value.src, value.value];
for (let i = 0; i < candidates.length; i++) {
const url = this.normalizeVideoValue(candidates[i]);
if (url) return url;
}
return "";
}
return String(value);
},
setVideoValue(value) {
this.formData.video = this.normalizeVideoValue(value);
}, },
// addImg(arr) { // addImg(arr) {
// this.formData.photo = this.formData.photo.concat(arr); // this.formData.photo = this.formData.photo.concat(arr);
@ -616,7 +685,7 @@ export default {
console.log("删除文件:", this.formData.photo, index); console.log("删除文件:", this.formData.photo, index);
}, },
uploadVideoSuc(file) { uploadVideoSuc(file) {
this.formData.video = file; this.setVideoValue(file);
}, },
uploadVideoErr() { }, uploadVideoErr() { },
uploadVideoDel() { uploadVideoDel() {
@ -627,12 +696,16 @@ export default {
.post("/sj/servers/detail", { .post("/sj/servers/detail", {
id: this.formData.id id: this.formData.id
}) })
.then((res) => { .then(async (res) => {
// //
this.formData = { this.formData = {
...this.formData, ...this.formData,
...res.data ...res.data
}; };
this.setVideoValue(this.formData.video);
if (this.formData.first_class_id && this.formFields.length === 0) {
await this.getTemplate();
}
// process // process
if (!Array.isArray(this.formData.process)) { if (!Array.isArray(this.formData.process)) {
this.formData.process = []; this.formData.process = [];
@ -752,16 +825,9 @@ export default {
let that = this; let that = this;
let value = e.detail.value; let value = e.detail.value;
// 1.
value = value.replace(/[^\d.]/g, "");
// 1. // 1.
value = value.replace(/[^\d.]/g, ""); value = value.replace(/[^\d.]/g, "");
// 2.
const parts = value.split(".");
if (parts.length > 2) {
value = parts[0] + "." + parts.slice(1).join("");
}
// 2. // 2.
const parts = value.split("."); const parts = value.split(".");
if (parts.length > 2) { if (parts.length > 2) {
@ -792,7 +858,7 @@ export default {
}, },
// //
uploadVideoSuc(file) { uploadVideoSuc(file) {
this.formData.video = file; this.setVideoValue(file);
}, },
uploadVideoErr() { }, uploadVideoErr() { },
uploadVideoDel() { uploadVideoDel() {
@ -849,49 +915,6 @@ export default {
} }
return !value; return !value;
}); });
// 稿
saveDraft: debounce(function () {
if (this.isDraft) return;
this.syncDetailForm();
const {
title,
server_price,
line_price,
server_time,
first_class_id,
second_class_id,
detail,
process,
video,
photo,
} = this.formData;
let item = {
title,
server_price,
line_price,
server_time,
first_class_id,
second_class_id,
detail,
process,
video,
photo,
};
//
const isEmpty = Object.values(item).every((value) => {
if (Array.isArray(value)) {
return value.length === 0;
}
return !value;
});
if (isEmpty) {
uni.showToast({
title: "请至少填写一项内容",
icon: "none",
});
return;
}
if (isEmpty) { if (isEmpty) {
uni.showToast({ uni.showToast({
title: "请至少填写一项内容", title: "请至少填写一项内容",
@ -962,7 +985,9 @@ export default {
if (isEmpty) { if (isEmpty) {
// //
const prefix = (field.type === 'image_multi' || field.type === 'video') ? '请上传' : '请输入'; const prefix = (field.type === 'image_multi' || field.type === 'video') ? '请上传' :
(field.type === 'select' || field.type === 'single_select' || field.type === 'multi_select') ?
'请选择' : '请输入';
uni.showToast({ uni.showToast({
title: `${prefix}${field.label}`, title: `${prefix}${field.label}`,
icon: "none", icon: "none",
@ -970,6 +995,17 @@ export default {
return false; return false;
} }
if (field.type === 'service_step' && Array.isArray(value)) {
const hasEmptyStep = value.some((item) => !item || !item.text || !item.text.trim());
if (hasEmptyStep) {
uni.showToast({
title: "请完善服务流程内容",
icon: "none",
});
return false;
}
}
// //
if (field.key === 'server_time') { if (field.key === 'server_time') {
if (value < 10) { if (value < 10) {
@ -1008,8 +1044,9 @@ export default {
} }
// //
if (this.formData.video) { const videoUrl = this.normalizeVideoValue(this.formData.video);
submitData.video = this.formData.video; if (videoUrl) {
submitData.video = videoUrl;
} }
// //
@ -1027,6 +1064,27 @@ export default {
if (value === undefined || value === null || value === '') continue; if (value === undefined || value === null || value === '') continue;
if (Array.isArray(value) && value.length === 0) continue; if (Array.isArray(value) && value.length === 0) continue;
if (field.type === 'service_step' && Array.isArray(value)) {
const processArr = value
.filter(item => item && item.text && item.text.trim())
.map(item => ({ text: item.text.trim() }));
if (processArr.length === 0) continue;
if (field._hasOriginalKey) {
submitData[field.key] = JSON.stringify(processArr);
} else {
introArr.push({
id: field._original ? field._original.id : field.key,
title: field.label,
type: field._fieldType || field.type,
value: processArr,
sectionId: field._sectionId || (field._original && field._original.sectionId),
sectionTitle: field._sectionTitle || '',
sectionType: field._sectionType || (field._original && field._original.sectionType),
});
}
continue;
}
if (field._hasOriginalKey) { if (field._hasOriginalKey) {
// key // key
// URL // URL
@ -1044,6 +1102,9 @@ export default {
title: field.label, title: field.label,
type: field._fieldType || field.type, type: field._fieldType || field.type,
value: value, value: value,
sectionId: field._sectionId || (field._original && field._original.sectionId),
sectionTitle: field._sectionTitle || '',
sectionType: field._sectionType || (field._original && field._original.sectionType),
}); });
} }
} }
@ -1054,7 +1115,7 @@ export default {
} }
// keykeyintro // keykeyintro
if (this.formData.process && this.formData.process.length > 0) { if (!this.hasDynamicServiceStepField && this.formData.process && this.formData.process.length > 0) {
const processArr = this.formData.process const processArr = this.formData.process
.filter(item => item.text && item.text.trim()) .filter(item => item.text && item.text.trim())
.map(item => ({ text: item.text.trim() })); .map(item => ({ text: item.text.trim() }));
@ -1071,6 +1132,7 @@ export default {
introList.push({ introList.push({
id: 'service_step', id: 'service_step',
title: '服务流程', title: '服务流程',
type: 'service_step',
value: processArr, value: processArr,
}); });
submitData.intro = JSON.stringify(introList); submitData.intro = JSON.stringify(introList);
@ -1088,6 +1150,14 @@ export default {
// formData // formData
this.syncDetailForm(); this.syncDetailForm();
if (!this.formData.first_class_id) {
uni.showToast({
title: "请选择服务分类",
icon: "none",
});
return;
}
// //
if (!this.validateDynamicForm()) { if (!this.validateDynamicForm()) {
return; return;
@ -1603,15 +1673,6 @@ export default {
align-items: center; align-items: center;
} }
height: 100rpx;
padding: 40rpx 0 64rpx;
box-sizing: border-box;
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
}
.btn-draft, .btn-draft,
.btn-submit { .btn-submit {
width: 334rpx; width: 334rpx;
@ -1942,14 +2003,6 @@ align-items: center;
/* 平台特定样式 */ /* 平台特定样式 */
/* #ifdef H5 */ /* #ifdef H5 */
/* 平台特定样式 */
/* #ifdef H5 */
.form-input,
.form-textarea {
outline: none;
}
.form-input, .form-input,
.form-textarea { .form-textarea {
outline: none; outline: none;
@ -1958,18 +2011,7 @@ align-items: center;
.upload-btn { .upload-btn {
cursor: pointer; cursor: pointer;
} }
.upload-btn {
cursor: pointer;
}
/* #endif */ /* #endif */
/* #endif */
/* #ifdef APP-PLUS */
.form-input {
height: 90rpx;
}
/* #ifdef APP-PLUS */ /* #ifdef APP-PLUS */
.form-input { .form-input {
@ -1981,20 +2023,7 @@ align-items: center;
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
} }
.permission.transform {
top: calc(var(--status-bar-height) + 88rpx + 30rpx);
opacity: 1;
visibility: visible;
}
/* #endif */ /* #endif */
/* #endif */
/* #ifdef MP-WEIXIN */
.form-input {
height: 80rpx;
}
/* #ifdef MP-WEIXIN */ /* #ifdef MP-WEIXIN */
.form-input { .form-input {
@ -2004,12 +2033,8 @@ align-items: center;
.bottom-buttons { .bottom-buttons {
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
} }
.bottom-buttons {
padding-bottom: env(safe-area-inset-bottom);
}
/* #endif */ /* #endif */
.tip { .tip {
padding: 20rpx 30rpx; padding: 20rpx 30rpx;
background: #feefec; background: #feefec;
@ -2017,30 +2042,8 @@ align-items: center;
font-size: 30rpx; font-size: 30rpx;
color: #f31d2f; color: #f31d2f;
line-height: 42rpx; line-height: 42rpx;
text-align: left;
font-style: normal;
/* #endif */
.tip {
padding: 20rpx 30rpx;
background: #feefec;
font-weight: 400;
font-size: 30rpx;
color: #f31d2f;
line-height: 42rpx;
text-align: left;
font-style: normal;
text-align: center; text-align: center;
text-align: center; font-style: normal;
.tip-icon {
width: 34rpx;
height: 34rpx;
margin-right: 10rpx;
transform: translateY(6rpx);
}
}
.tip-icon { .tip-icon {
width: 34rpx; width: 34rpx;

View File

@ -152,12 +152,12 @@
</view> </view>
<!-- 项目说明 --> <!-- 项目说明 -->
<view class="notice-section"> <view class="notice-section" v-if="hasProjectInfo">
<view class="section-title"> <view class="section-title">
<text class="section-tit">项目说明</text> <text class="section-tit">项目说明</text>
</view> </view>
<view class="notice-content"> <view class="notice-content">
<view class="notice-cont"> <view class="notice-cont" v-if="serviceInfo.server_time">
<text class="notice-title"> <text class="notice-title">
服务时长: 服务时长:
</text> </text>
@ -165,7 +165,7 @@
{{serviceInfo.server_time}}分钟 {{serviceInfo.server_time}}分钟
</text> </text>
</view> </view>
<view class="notice-cont" v-for="(item,i) in serviceInfo.detail"> <view class="notice-cont" v-for="(item,i) in visibleDetailList" :key="'detail-' + i">
<text class="notice-title"> <text class="notice-title">
{{item.title}} {{item.title}}
</text> </text>
@ -175,13 +175,35 @@
</view> </view>
</view> </view>
</view> </view>
<view class="notice-section" v-for="(section, sectionIndex) in dynamicIntroSections"
:key="'dynamic-section-' + sectionIndex">
<view class="section-title">
<text class="section-tit">{{section.title}}</text>
</view>
<view class="notice-content">
<view class="notice-cont dynamic-notice-cont" v-for="(item, itemIndex) in section.fields"
:key="'dynamic-field-' + sectionIndex + '-' + itemIndex">
<text class="notice-title">{{item.title}}</text>
<view class="dynamic-media-list" v-if="item.images.length">
<image class="dynamic-image" v-for="(img, imgIndex) in item.images"
:key="'dynamic-img-' + imgIndex" :src="img" mode="aspectFill"
@click="previewImage(item.images, imgIndex)"></image>
</view>
<view class="dynamic-media-list" v-else-if="item.videos.length">
<video class="dynamic-video" v-for="(video, videoIndex) in item.videos"
:key="'dynamic-video-' + videoIndex" :src="video" controls></video>
</view>
<text class="notice-text" v-else>{{item.displayValue}}</text>
</view>
</view>
</view>
<!-- 步骤 --> <!-- 步骤 -->
<view class="notice-section"> <view class="notice-section" v-if="visibleProcessList.length">
<view class="section-title"> <view class="section-title">
<text class="section-tit">项目步骤</text> <text class="section-tit">项目步骤</text>
</view> </view>
<view class="notice-content"> <view class="notice-content">
<view class="notice-cont" v-for="(item,i) in serviceInfo.process"> <view class="notice-cont" v-for="(item,i) in visibleProcessList" :key="'process-' + i">
<text class="notice-title"> <text class="notice-title">
{{item.title}} {{item.title}}
</text> </text>
@ -192,7 +214,7 @@
</view> </view>
</view> </view>
<!-- 订购须知 --> <!-- 订购须知 -->
<view class="notice-section"> <view class="notice-section" v-if="purchaseNotesText">
<view class="section-title"> <view class="section-title">
<text class="section-tit">订购须知</text> <text class="section-tit">订购须知</text>
<!-- <view class="section-tit-right"> <!-- <view class="section-tit-right">
@ -202,7 +224,7 @@
</view> </view>
<view class="notice-content"> <view class="notice-content">
<text class="notice-text"> <text class="notice-text">
{{serviceInfo.purchasenotes}} {{purchaseNotesText}}
</text> </text>
</view> </view>
</view> </view>
@ -285,6 +307,27 @@
}] }]
} }
}, },
computed: {
visibleDetailList() {
return (this.serviceInfo.detail || []).filter((item) => {
return item && item.title && item.text;
});
},
visibleProcessList() {
return (this.serviceInfo.process || []).filter((item) => {
return item && item.text;
});
},
hasProjectInfo() {
return !!this.serviceInfo.server_time || this.visibleDetailList.length > 0;
},
purchaseNotesText() {
return this.serviceInfo.purchasenotes || this.serviceInfo.purchase_notes || '';
},
dynamicIntroSections() {
return this.buildDynamicIntroSections(this.serviceInfo.intro);
}
},
onLoad(option) { onLoad(option) {
// Android可能需要延迟加载 // Android可能需要延迟加载
request.post('/sj/serverdetail', { request.post('/sj/serverdetail', {
@ -316,6 +359,86 @@
this.showVideo = false; this.showVideo = false;
}, },
methods: { methods: {
parseMaybeJson(raw) {
if (!raw) return [];
if (typeof raw !== 'string') return raw;
try {
return JSON.parse(raw);
} catch (e) {
return [];
}
},
getDynamicPlainValue(value) {
if (value === undefined || value === null) return '';
if (typeof value !== 'object') return String(value);
return value.label || value.title || value.name || value.text || value.value || '';
},
normalizeDynamicIntroField(item) {
if (!item) return null;
const type = item.type || '';
const value = item.value;
const images = [];
const videos = [];
let displayValue = '';
if (value === undefined || value === null || value === '') return null;
if (Array.isArray(value) && value.length === 0) return null;
if (type === 'image_multi') {
(value || []).forEach((img) => {
const url = typeof img === 'string' ? img : (img && (img.url || img.path));
if (url) images.push(url);
});
} else if (type === 'video') {
const list = Array.isArray(value) ? value : [value];
list.forEach((video) => {
const url = typeof video === 'string' ? video : (video && (video.url || video.path));
if (url) videos.push(url);
});
} else if (type === 'service_step' && Array.isArray(value)) {
displayValue = value.map((step, index) => {
const text = this.getDynamicPlainValue(step);
return text ? `${index + 1}. ${text}` : '';
}).filter(Boolean).join('');
} else if (Array.isArray(value)) {
displayValue = value.map((val) => this.getDynamicPlainValue(val)).filter(Boolean).join('、');
} else if (typeof value === 'object') {
displayValue = this.getDynamicPlainValue(value);
} else {
displayValue = String(value);
}
if (!displayValue && images.length === 0 && videos.length === 0) return null;
return {
title: item.title || '补充信息',
type,
displayValue,
images,
videos,
sectionTitle: item.sectionTitle || '',
sectionId: item.sectionId || '',
};
},
buildDynamicIntroSections(rawIntro) {
const intro = this.parseMaybeJson(rawIntro);
if (!Array.isArray(intro)) return [];
const groups = [];
const groupMap = {};
intro.forEach((item) => {
const field = this.normalizeDynamicIntroField(item);
if (!field) return;
const title = field.sectionTitle || (field.type === 'service_step' ? '项目步骤' : '更多说明');
const key = field.sectionId || title;
if (!groupMap[key]) {
groupMap[key] = {
title,
fields: [],
};
groups.push(groupMap[key]);
}
groupMap[key].fields.push(field);
});
return groups.filter((section) => section.fields.length > 0);
},
// 获取手艺人详情 // 获取手艺人详情
getSyrDetail(id) { getSyrDetail(id) {
request.post('/sj/syrdetail', { request.post('/sj/syrdetail', {
@ -814,6 +937,26 @@
color: #666; color: #666;
} }
.dynamic-notice-cont {
flex-wrap: wrap;
}
.dynamic-media-list {
flex: 1;
display: flex;
flex-wrap: wrap;
}
.dynamic-image,
.dynamic-video {
width: 160rpx;
height: 160rpx;
margin-right: 12rpx;
margin-bottom: 12rpx;
border-radius: 8rpx;
background-color: #F6F6F6;
}
/* 底部按钮 */ /* 底部按钮 */
.bottom-bar { .bottom-bar {
position: fixed; position: fixed;

View File

@ -228,8 +228,9 @@ const getOssConfig = async (type) => {
const uploadToOss = (file, ossConfig, name, uploadType) => { const uploadToOss = (file, ossConfig, name, uploadType) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let formData;
// #ifdef H5 // #ifdef H5
const formData = new FormData() formData = new FormData()
formData.append('name', name); formData.append('name', name);
formData.append('policy', ossConfig.policy); formData.append('policy', ossConfig.policy);
formData.append('OSSAccessKeyId', ossConfig.ossAccessKeyId); formData.append('OSSAccessKeyId', ossConfig.ossAccessKeyId);
@ -248,7 +249,7 @@ const uploadToOss = (file, ossConfig, name, uploadType) => {
}) })
// #endif // #endif
// #ifdef APP-PLUS || MP-WEIXIN // #ifdef APP-PLUS || MP-WEIXIN
let formData = {}; formData = {};
formData.name = name; formData.name = name;
formData.policy = ossConfig.policy; formData.policy = ossConfig.policy;
formData.OSSAccessKeyId = ossConfig.ossAccessKeyId; formData.OSSAccessKeyId = ossConfig.ossAccessKeyId;

View File

@ -148,8 +148,9 @@ const getOssConfig = async (type)=> {
const uploadToOss = (file, ossConfig, name)=> { const uploadToOss = (file, ossConfig, name)=> {
console.log(ossConfig) console.log(ossConfig)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let formData;
// #ifdef H5 // #ifdef H5
const formData = new FormData() formData = new FormData()
formData.append('name',name); formData.append('name',name);
formData.append('policy', ossConfig.policy); formData.append('policy', ossConfig.policy);
formData.append('OSSAccessKeyId', ossConfig.ossAccessKeyId); formData.append('OSSAccessKeyId', ossConfig.ossAccessKeyId);
@ -165,7 +166,7 @@ const uploadToOss = (file, ossConfig, name)=> {
}) })
// #endif // #endif
// #ifdef APP-PLUS || MP-WEIXIN // #ifdef APP-PLUS || MP-WEIXIN
let formData = {}; formData = {};
formData.name = name; formData.name = name;
formData.policy = ossConfig.policy; formData.policy = ossConfig.policy;
formData.OSSAccessKeyId = ossConfig.ossAccessKeyId; formData.OSSAccessKeyId = ossConfig.ossAccessKeyId;