2025-08-18 20:47:50 +08:00
|
|
|
<template>
|
|
|
|
|
<view class="dm-ps-component">
|
|
|
|
|
<!-- 现场拍照 -->
|
|
|
|
|
<view class="section-card mb-15">
|
|
|
|
|
<view class="section-title">{{ photoTitle || '现场拍照' }}</view>
|
|
|
|
|
<view class="photo-section">
|
|
|
|
|
<view class="photo-preview" v-if="photoList.length > 0">
|
|
|
|
|
<view
|
|
|
|
|
v-for="(photo, index) in photoList"
|
|
|
|
|
:key="index"
|
|
|
|
|
class="photo-item"
|
|
|
|
|
>
|
|
|
|
|
<image
|
|
|
|
|
:src="photo.url"
|
|
|
|
|
class="photo-image"
|
|
|
|
|
mode="aspectFill"
|
|
|
|
|
@click="previewPhoto(photo.url)"
|
|
|
|
|
/>
|
|
|
|
|
<view class="photo-actions">
|
|
|
|
|
<view class="action-btn delete-btn" @click="deletePhoto(index)">
|
|
|
|
|
<u-icon name="trash" size="16" color="#fff"></u-icon>
|
|
|
|
|
</view>
|
|
|
|
|
</view>
|
|
|
|
|
</view>
|
|
|
|
|
</view>
|
|
|
|
|
<view class="photo-upload" @click="takePhoto">
|
|
|
|
|
<u-icon name="camera" size="32" color="#999"></u-icon>
|
|
|
|
|
<text class="upload-text">{{ photoUploadText || '点击拍照' }}</text>
|
|
|
|
|
</view>
|
|
|
|
|
</view>
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
<!-- 现场视频 -->
|
|
|
|
|
<view class="section-card mb-15">
|
|
|
|
|
<view class="section-title">{{ videoTitle || '现场视频' }}</view>
|
|
|
|
|
<view class="video-section">
|
|
|
|
|
<view class="video-preview" v-if="videoList.length > 0">
|
|
|
|
|
<view
|
|
|
|
|
v-for="(video, index) in videoList"
|
|
|
|
|
:key="index"
|
|
|
|
|
class="video-item"
|
|
|
|
|
>
|
|
|
|
|
<video
|
|
|
|
|
:src="video.url"
|
|
|
|
|
class="video-player"
|
|
|
|
|
controls
|
|
|
|
|
show-center-play-btn
|
|
|
|
|
></video>
|
|
|
|
|
<view class="video-actions">
|
|
|
|
|
<view class="action-btn delete-btn" @click="deleteVideo(index)">
|
|
|
|
|
<u-icon name="trash" size="16" color="#fff"></u-icon>
|
|
|
|
|
</view>
|
|
|
|
|
</view>
|
|
|
|
|
</view>
|
|
|
|
|
</view>
|
|
|
|
|
<view class="video-upload" @click="recordVideo">
|
2025-08-19 01:48:06 +08:00
|
|
|
<u-icon name="camera" size="32" color="#999"></u-icon>
|
2025-08-18 20:47:50 +08:00
|
|
|
<text class="upload-text">{{ videoUploadText || '点击录制' }}</text>
|
|
|
|
|
</view>
|
|
|
|
|
</view>
|
|
|
|
|
</view>
|
|
|
|
|
</view>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script lang="ts" setup>
|
|
|
|
|
import { ref, watch } from "vue";
|
|
|
|
|
import { attachmentUpload } from "@/api/system/upload";
|
|
|
|
|
import { showLoading, hideLoading, showToast } from "@/utils/uniapp";
|
|
|
|
|
|
|
|
|
|
// 定义组件属性
|
|
|
|
|
interface Props {
|
|
|
|
|
// 照片相关
|
|
|
|
|
photoTitle?: string;
|
|
|
|
|
photoUploadText?: string;
|
|
|
|
|
maxPhotoCount?: number;
|
|
|
|
|
|
|
|
|
|
// 视频相关
|
|
|
|
|
videoTitle?: string;
|
|
|
|
|
videoUploadText?: string;
|
|
|
|
|
maxVideoCount?: number;
|
|
|
|
|
maxVideoDuration?: number;
|
|
|
|
|
|
|
|
|
|
// 数据
|
|
|
|
|
modelValue?: {
|
|
|
|
|
photoList: Array<{url: string, path: string}>;
|
|
|
|
|
videoList: Array<{url: string, path: string}>;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 定义组件事件
|
|
|
|
|
interface Emits {
|
|
|
|
|
(e: 'update:modelValue', value: {photoList: Array<{url: string, path: string}>, videoList: Array<{url: string, path: string}>}): void;
|
|
|
|
|
(e: 'photoChange', photoList: Array<{url: string, path: string}>): void;
|
|
|
|
|
(e: 'videoChange', videoList: Array<{url: string, path: string}>): void;
|
|
|
|
|
(e: 'photoAdd', photo: {url: string, path: string}): void;
|
|
|
|
|
(e: 'photoDelete', index: number): void;
|
|
|
|
|
(e: 'videoAdd', video: {url: string, path: string}): void;
|
|
|
|
|
(e: 'videoDelete', index: number): void;
|
|
|
|
|
(e: 'uploadComplete', result: {photoUrls: string, videoUrls: string}): void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
|
|
|
photoTitle: '现场拍照',
|
|
|
|
|
photoUploadText: '点击拍照',
|
|
|
|
|
maxPhotoCount: 9,
|
|
|
|
|
videoTitle: '现场视频',
|
|
|
|
|
videoUploadText: '点击录制',
|
|
|
|
|
maxVideoCount: 3,
|
|
|
|
|
maxVideoDuration: 60
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<Emits>();
|
|
|
|
|
|
|
|
|
|
// 照片和视频列表
|
|
|
|
|
const photoList = ref<Array<{url: string, path: string}>>([]);
|
|
|
|
|
const videoList = ref<Array<{url: string, path: string}>>([]);
|
|
|
|
|
|
|
|
|
|
// 监听外部数据变化
|
|
|
|
|
watch(() => props.modelValue, (newValue) => {
|
|
|
|
|
if (newValue) {
|
|
|
|
|
photoList.value = newValue.photoList || [];
|
|
|
|
|
videoList.value = newValue.videoList || [];
|
|
|
|
|
}
|
|
|
|
|
}, { immediate: true, deep: true });
|
|
|
|
|
|
|
|
|
|
// 监听内部数据变化,向外部发送更新
|
|
|
|
|
watch([photoList, videoList], ([newPhotoList, newVideoList]) => {
|
|
|
|
|
const value = {
|
|
|
|
|
photoList: newPhotoList,
|
|
|
|
|
videoList: newVideoList
|
|
|
|
|
};
|
|
|
|
|
emit('update:modelValue', value);
|
|
|
|
|
emit('photoChange', newPhotoList);
|
|
|
|
|
emit('videoChange', newVideoList);
|
|
|
|
|
}, { deep: true });
|
|
|
|
|
|
|
|
|
|
// 拍照功能
|
|
|
|
|
const takePhoto = () => {
|
|
|
|
|
if (photoList.value.length >= props.maxPhotoCount) {
|
|
|
|
|
uni.showToast({
|
|
|
|
|
title: `最多只能拍摄${props.maxPhotoCount}张照片`,
|
|
|
|
|
icon: 'none'
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uni.chooseImage({
|
|
|
|
|
count: 1,
|
|
|
|
|
sizeType: ['compressed'],
|
|
|
|
|
sourceType: ['camera'],
|
|
|
|
|
success: (res) => {
|
|
|
|
|
const tempFilePath = res.tempFilePaths[0];
|
|
|
|
|
const photo = {
|
|
|
|
|
url: tempFilePath,
|
|
|
|
|
path: tempFilePath
|
|
|
|
|
};
|
|
|
|
|
photoList.value.push(photo);
|
|
|
|
|
emit('photoAdd', photo);
|
|
|
|
|
},
|
|
|
|
|
fail: (err) => {
|
|
|
|
|
console.error('拍照失败:', err);
|
|
|
|
|
uni.showToast({
|
|
|
|
|
title: '拍照失败',
|
|
|
|
|
icon: 'none'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 录制视频功能
|
|
|
|
|
const recordVideo = () => {
|
|
|
|
|
if (videoList.value.length >= props.maxVideoCount) {
|
|
|
|
|
uni.showToast({
|
|
|
|
|
title: `最多只能录制${props.maxVideoCount}个视频`,
|
|
|
|
|
icon: 'none'
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uni.chooseVideo({
|
|
|
|
|
sourceType: ['camera'],
|
|
|
|
|
maxDuration: props.maxVideoDuration,
|
|
|
|
|
camera: 'back',
|
|
|
|
|
success: (res) => {
|
|
|
|
|
const tempFilePath = res.tempFilePath;
|
|
|
|
|
const video = {
|
|
|
|
|
url: tempFilePath,
|
|
|
|
|
path: tempFilePath
|
|
|
|
|
};
|
|
|
|
|
videoList.value.push(video);
|
|
|
|
|
emit('videoAdd', video);
|
|
|
|
|
},
|
|
|
|
|
fail: (err) => {
|
|
|
|
|
console.error('录制视频失败:', err);
|
|
|
|
|
uni.showToast({
|
|
|
|
|
title: '录制视频失败',
|
|
|
|
|
icon: 'none'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 删除照片
|
|
|
|
|
const deletePhoto = (index: number) => {
|
|
|
|
|
const deletedPhoto = photoList.value[index];
|
|
|
|
|
photoList.value.splice(index, 1);
|
|
|
|
|
emit('photoDelete', index);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 删除视频
|
|
|
|
|
const deleteVideo = (index: number) => {
|
|
|
|
|
const deletedVideo = videoList.value[index];
|
|
|
|
|
videoList.value.splice(index, 1);
|
|
|
|
|
emit('videoDelete', index);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 预览照片
|
|
|
|
|
const previewPhoto = (url: string) => {
|
|
|
|
|
const urls = photoList.value.map(photo => photo.url);
|
|
|
|
|
uni.previewImage({
|
|
|
|
|
current: url,
|
|
|
|
|
urls: urls
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 上传媒体文件到服务器
|
|
|
|
|
const uploadMedia = async (): Promise<{photoUrls: string, videoUrls: string}> => {
|
|
|
|
|
const photoUrls: string[] = [];
|
|
|
|
|
const videoUrls: string[] = [];
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 上传照片
|
|
|
|
|
if (photoList.value.length > 0) {
|
|
|
|
|
showLoading({ title: '正在上传照片...' });
|
|
|
|
|
for (let i = 0; i < photoList.value.length; i++) {
|
|
|
|
|
const photo = photoList.value[i];
|
|
|
|
|
try {
|
|
|
|
|
const res: any = await attachmentUpload(photo.path);
|
|
|
|
|
if (res && res.result && res.result[0]) {
|
|
|
|
|
photoUrls.push(res.result[0].filePath);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('照片上传失败:', error);
|
|
|
|
|
throw new Error(`第${i + 1}张照片上传失败`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
hideLoading();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 上传视频
|
|
|
|
|
if (videoList.value.length > 0) {
|
|
|
|
|
showLoading({ title: '正在上传视频...' });
|
|
|
|
|
for (let i = 0; i < videoList.value.length; i++) {
|
|
|
|
|
const video = videoList.value[i];
|
|
|
|
|
try {
|
|
|
|
|
const res: any = await attachmentUpload(video.path);
|
|
|
|
|
if (res && res.result && res.result[0]) {
|
|
|
|
|
videoUrls.push(res.result[0].filePath);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('视频上传失败:', error);
|
|
|
|
|
throw new Error(`第${i + 1}个视频上传失败`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
hideLoading();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = {
|
|
|
|
|
photoUrls: photoUrls.join(','),
|
|
|
|
|
videoUrls: videoUrls.join(',')
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 发送上传完成事件
|
|
|
|
|
emit('uploadComplete', result);
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
hideLoading();
|
|
|
|
|
showToast({
|
|
|
|
|
title: error instanceof Error ? error.message : '上传失败',
|
|
|
|
|
icon: 'none'
|
|
|
|
|
});
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 暴露方法给父组件
|
|
|
|
|
defineExpose({
|
|
|
|
|
photoList,
|
|
|
|
|
videoList,
|
|
|
|
|
takePhoto,
|
|
|
|
|
recordVideo,
|
|
|
|
|
deletePhoto,
|
|
|
|
|
deleteVideo,
|
|
|
|
|
uploadMedia,
|
|
|
|
|
clearAll: () => {
|
|
|
|
|
photoList.value = [];
|
|
|
|
|
videoList.value = [];
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
.dm-ps-component {
|
|
|
|
|
.section-card {
|
|
|
|
|
background: white;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
padding: 15px;
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.section-title {
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: #333;
|
|
|
|
|
margin-bottom: 15px;
|
|
|
|
|
padding-left: 8px;
|
|
|
|
|
border-left: 3px solid #007aff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.photo-section, .video-section {
|
|
|
|
|
.photo-preview, .video-preview {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(3, 1fr);
|
|
|
|
|
gap: 10px;
|
|
|
|
|
margin-bottom: 15px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.photo-item, .video-item {
|
|
|
|
|
position: relative;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
|
|
|
|
|
|
|
|
.photo-image, .video-player {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100px;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.video-player {
|
|
|
|
|
background-color: #000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.photo-actions, .video-actions {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 5px;
|
|
|
|
|
right: 5px;
|
|
|
|
|
|
|
|
|
|
.action-btn {
|
|
|
|
|
width: 24px;
|
|
|
|
|
height: 24px;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
|
|
&.delete-btn {
|
|
|
|
|
background-color: rgba(255, 0, 0, 0.8);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.photo-upload, .video-upload {
|
|
|
|
|
width: 100px;
|
|
|
|
|
height: 100px;
|
|
|
|
|
border: 2px dashed #ddd;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: all 0.3s ease;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
border-color: #007aff;
|
|
|
|
|
background-color: rgba(0, 122, 255, 0.05);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.upload-text {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #999;
|
|
|
|
|
margin-top: 5px;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 工具类
|
|
|
|
|
.mb-15 { margin-bottom: 15px; }
|
|
|
|
|
</style>
|