641 lines
14 KiB
Plaintext
641 lines
14 KiB
Plaintext
<template>
|
|
<view class="apex-camera">
|
|
<view class="top-bar">
|
|
<text class="nav-btn" @click="handleBack"><</text>
|
|
<text class="title">{{ props.title }}</text>
|
|
<text class="badge" :class="initialized ? 'badge-ready' : 'badge-pending'">
|
|
{{ initialized ? '就绪' : '初始化中' }}
|
|
</text>
|
|
</view>
|
|
|
|
<view class="preview-area">
|
|
<camera id="apex-camera-preview" class="preview-camera" mode="normal" resolution="high"
|
|
:device-position="cameraPosition" :flash="flashMode" @initdone="handleCameraReady"
|
|
@ready="handleCameraReady" @stop="handleCameraStop" @error="handleCameraNativeError" />
|
|
|
|
<view v-if="!initialized" class="preview-mask">
|
|
<text class="main-copy">{{ props.subTitle }}</text>
|
|
<text class="minor-copy">相机正在准备中,请确认系统相机权限已允许</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="status-panel">
|
|
<text class="status-line">摄像头:{{ cameraPosition == 'front' ? '前置' : '后置' }}</text>
|
|
<text class="status-line">闪光灯:{{ flashModeLabel }}</text>
|
|
<text class="status-line">保存到相册:{{ saveToAlbum ? '是' : '否' }}</text>
|
|
<text class="status-line">{{ statusText }}</text>
|
|
<text v-if="lastPhotoPath != ''" class="status-line">最后照片:{{ lastPhotoPath }}</text>
|
|
<text v-if="lastVideoPath != ''" class="status-line">最后视频:{{ lastVideoPath }}</text>
|
|
</view>
|
|
|
|
<view class="bottom-panel">
|
|
<view class="action-row">
|
|
<view class="ghost-btn" @click="toggleCamera">
|
|
<text class="ghost-btn-text">切换</text>
|
|
</view>
|
|
|
|
<view class="capture-btn" @click="takePhoto">
|
|
<view class="capture-btn-inner">
|
|
<text class="capture-btn-text">拍照</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="record-btn" @click="recordVideo">
|
|
<text class="record-btn-text">{{ isRecording ? '停止' : '录像' }}</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="tool-row">
|
|
<view class="tool-card">
|
|
<text class="tool-title">保存到相册</text>
|
|
<switch :checked="saveToAlbum" color="#1677ff" @change="handleSaveToAlbumChange" />
|
|
</view>
|
|
|
|
<view class="tool-card flash-card">
|
|
<text class="tool-title">闪光灯</text>
|
|
<view class="flash-options">
|
|
<text class="flash-option" :class="flashMode == 'off' ? 'flash-option-active' : ''"
|
|
@click="changeFlashMode('off')">关</text>
|
|
<text class="flash-option" :class="flashMode == 'on' ? 'flash-option-active' : ''"
|
|
@click="changeFlashMode('on')">开</text>
|
|
<text class="flash-option" :class="flashMode == 'auto' ? 'flash-option-active' : ''"
|
|
@click="changeFlashMode('auto')">自动</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, nextTick, onMounted, ref } from 'vue'
|
|
import {
|
|
ApexCameraFlashMode,
|
|
ApexCameraPhotoResult,
|
|
ApexCameraPosition,
|
|
ApexCameraProps,
|
|
ApexCameraVideoResult
|
|
} from './type.uts'
|
|
|
|
defineOptions({
|
|
name: 'apex-camera',
|
|
styleIsolation: 'app'
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
ready : []
|
|
photo : [res: ApexCameraPhotoResult]
|
|
video : [res: ApexCameraVideoResult]
|
|
error : [message: string]
|
|
}>()
|
|
|
|
const props = withDefaults(defineProps<ApexCameraProps>(), {
|
|
title: 'UTS 相机组件',
|
|
subTitle: '支持 Android + iOS + 鸿蒙',
|
|
autoInit: true,
|
|
autoBack: true,
|
|
maxDuration: 60,
|
|
defaultPosition: 'back',
|
|
defaultFlashMode: 'off',
|
|
defaultSaveToAlbum: false,
|
|
tipLines: () : string[] => ([])
|
|
})
|
|
|
|
type ApexCameraErrorDetail = {
|
|
errMsg ?: string
|
|
message ?: string
|
|
msg ?: string
|
|
}
|
|
|
|
type ApexCameraErrorEvent = {
|
|
detail ?: ApexCameraErrorDetail
|
|
errMsg ?: string
|
|
}
|
|
|
|
const initialized = ref(false)
|
|
const busy = ref(false)
|
|
const isRecording = ref(false)
|
|
const cameraPosition = ref<ApexCameraPosition>(props.defaultPosition)
|
|
const flashMode = ref<ApexCameraFlashMode>(props.defaultFlashMode)
|
|
const saveToAlbum = ref(props.defaultSaveToAlbum)
|
|
const statusText = ref('等待相机初始化')
|
|
const lastPhotoPath = ref('')
|
|
const lastVideoPath = ref('')
|
|
|
|
const flashModeLabel = computed(() : string => {
|
|
if (flashMode.value == 'on') {
|
|
return '开'
|
|
}
|
|
if (flashMode.value == 'auto') {
|
|
return '自动'
|
|
}
|
|
return '关'
|
|
})
|
|
|
|
const showToast = (title : string) => {
|
|
uni.showToast({
|
|
title,
|
|
icon: 'none'
|
|
})
|
|
}
|
|
|
|
const getErrorMessage = (event : any, fallback : string) : string => {
|
|
const errorEvent = event as ApexCameraErrorEvent | null
|
|
if (errorEvent != null) {
|
|
const detail = errorEvent.detail
|
|
if (detail != null) {
|
|
if (detail.errMsg != null && detail.errMsg != '') {
|
|
return detail.errMsg
|
|
}
|
|
if (detail.message != null && detail.message != '') {
|
|
return detail.message
|
|
}
|
|
if (detail.msg != null && detail.msg != '') {
|
|
return detail.msg
|
|
}
|
|
}
|
|
if (errorEvent.errMsg != null && errorEvent.errMsg != '') {
|
|
return errorEvent.errMsg
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
const ensureCameraContext = () => {
|
|
try {
|
|
return uni.createCameraContext()
|
|
} catch (e) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const saveImageIfNeeded = (path : string) : Promise<boolean> => {
|
|
if (!saveToAlbum.value) {
|
|
return Promise.resolve(false)
|
|
}
|
|
return new Promise((resolve) => {
|
|
uni.saveImageToPhotosAlbum({
|
|
filePath: path,
|
|
success: () => resolve(true),
|
|
fail: () => resolve(false)
|
|
})
|
|
})
|
|
}
|
|
|
|
const saveVideoIfNeeded = (path : string) : Promise<boolean> => {
|
|
if (!saveToAlbum.value) {
|
|
return Promise.resolve(false)
|
|
}
|
|
return new Promise((resolve) => {
|
|
uni.saveVideoToPhotosAlbum({
|
|
filePath: path,
|
|
success: () => resolve(true),
|
|
fail: () => resolve(false)
|
|
})
|
|
})
|
|
}
|
|
|
|
const bootCamera = async () => {
|
|
await nextTick()
|
|
const context = ensureCameraContext()
|
|
if (context == null) {
|
|
const message = '创建相机上下文失败'
|
|
statusText.value = message
|
|
emit('error', message)
|
|
return
|
|
}
|
|
initialized.value = false
|
|
statusText.value = '相机视图已创建,等待初始化完成'
|
|
}
|
|
|
|
const handleCameraReady = () => {
|
|
initialized.value = true
|
|
statusText.value = isRecording.value ? '录像中...' : '相机已就绪'
|
|
emit('ready')
|
|
}
|
|
|
|
const handleCameraStop = () => {
|
|
initialized.value = false
|
|
isRecording.value = false
|
|
statusText.value = '相机预览已停止'
|
|
}
|
|
|
|
const handleCameraNativeError = (event : any) => {
|
|
const message = getErrorMessage(event, 'camera error')
|
|
initialized.value = false
|
|
isRecording.value = false
|
|
statusText.value = message
|
|
emit('error', message)
|
|
showToast(message)
|
|
}
|
|
|
|
const changeFlashMode = (mode : ApexCameraFlashMode) => {
|
|
if (busy.value || flashMode.value == mode) {
|
|
return
|
|
}
|
|
flashMode.value = mode
|
|
statusText.value = `闪光灯已切换为${flashModeLabel.value}`
|
|
}
|
|
|
|
const toggleCamera = async () => {
|
|
if (busy.value || isRecording.value) {
|
|
return
|
|
}
|
|
cameraPosition.value = cameraPosition.value == 'front' ? 'back' : 'front'
|
|
initialized.value = false
|
|
statusText.value = `正在切换到${cameraPosition.value == 'front' ? '前置' : '后置'}摄像头...`
|
|
await bootCamera()
|
|
}
|
|
|
|
const takePhoto = () => {
|
|
if (busy.value) {
|
|
return
|
|
}
|
|
const context = ensureCameraContext()
|
|
if (context == null) {
|
|
const message = '相机上下文不可用'
|
|
statusText.value = message
|
|
emit('error', message)
|
|
return
|
|
}
|
|
busy.value = true
|
|
statusText.value = '正在拍照...'
|
|
context.takePhoto({
|
|
quality: 'high',
|
|
success: (res) => {
|
|
let imagePath = ''
|
|
if (res.tempImagePath != null && res.tempImagePath != '') {
|
|
imagePath = res.tempImagePath
|
|
}
|
|
if (imagePath == '') {
|
|
const message = '拍照失败,未返回图片路径'
|
|
statusText.value = message
|
|
emit('error', message)
|
|
showToast(message)
|
|
busy.value = false
|
|
return
|
|
}
|
|
saveImageIfNeeded(imagePath).then((saved : boolean) => {
|
|
lastPhotoPath.value = imagePath
|
|
lastVideoPath.value = ''
|
|
statusText.value = saved ? '拍照成功,已保存到相册' : '拍照成功'
|
|
emit('photo', {
|
|
path: imagePath,
|
|
width: 0,
|
|
height: 0,
|
|
savedToAlbum: saved
|
|
} as ApexCameraPhotoResult)
|
|
showToast(saved ? '拍照成功并已保存' : '拍照成功')
|
|
busy.value = false
|
|
}).catch(() => {
|
|
lastPhotoPath.value = imagePath
|
|
lastVideoPath.value = ''
|
|
statusText.value = '拍照成功'
|
|
emit('photo', {
|
|
path: imagePath,
|
|
width: 0,
|
|
height: 0,
|
|
savedToAlbum: false
|
|
} as ApexCameraPhotoResult)
|
|
busy.value = false
|
|
})
|
|
},
|
|
fail: (err) => {
|
|
const message = getErrorMessage(err, '拍照失败')
|
|
statusText.value = message
|
|
emit('error', message)
|
|
showToast(message)
|
|
busy.value = false
|
|
}
|
|
})
|
|
}
|
|
|
|
const recordVideo = () => {
|
|
if (busy.value) {
|
|
return
|
|
}
|
|
const context = ensureCameraContext()
|
|
if (context == null) {
|
|
const message = '相机上下文不可用'
|
|
statusText.value = message
|
|
emit('error', message)
|
|
return
|
|
}
|
|
busy.value = true
|
|
|
|
if (isRecording.value) {
|
|
statusText.value = '正在结束录像...'
|
|
context.stopRecord({
|
|
success: (res) => {
|
|
let videoPath = ''
|
|
if (res.tempVideoPath != null && res.tempVideoPath != '') {
|
|
videoPath = res.tempVideoPath
|
|
}
|
|
const duration = 0
|
|
const size = 0
|
|
saveVideoIfNeeded(videoPath).then((saved : boolean) => {
|
|
isRecording.value = false
|
|
lastVideoPath.value = videoPath
|
|
lastPhotoPath.value = ''
|
|
statusText.value = saved ? '录像完成,已保存到相册' : '录像完成'
|
|
emit('video', {
|
|
path: videoPath,
|
|
duration: duration,
|
|
size: size,
|
|
savedToAlbum: saved
|
|
} as ApexCameraVideoResult)
|
|
busy.value = false
|
|
}).catch(() => {
|
|
isRecording.value = false
|
|
lastVideoPath.value = videoPath
|
|
lastPhotoPath.value = ''
|
|
statusText.value = '录像完成'
|
|
emit('video', {
|
|
path: videoPath,
|
|
duration: duration,
|
|
size: size,
|
|
savedToAlbum: false
|
|
} as ApexCameraVideoResult)
|
|
busy.value = false
|
|
})
|
|
},
|
|
fail: (err) => {
|
|
const message = getErrorMessage(err, '结束录像失败')
|
|
statusText.value = message
|
|
emit('error', message)
|
|
showToast(message)
|
|
busy.value = false
|
|
}
|
|
})
|
|
return
|
|
}
|
|
|
|
statusText.value = '开始录像...'
|
|
context.startRecord({
|
|
timeout: props.maxDuration,
|
|
success: () => {
|
|
isRecording.value = true
|
|
statusText.value = '录像中,再点一次停止'
|
|
busy.value = false
|
|
},
|
|
fail: (err) => {
|
|
const message = getErrorMessage(err, '开始录像失败')
|
|
statusText.value = message
|
|
emit('error', message)
|
|
showToast(message)
|
|
busy.value = false
|
|
}
|
|
})
|
|
}
|
|
|
|
const handleSaveToAlbumChange = (event : UniSwitchChangeEvent) => {
|
|
saveToAlbum.value = event.detail.value
|
|
statusText.value = saveToAlbum.value ? '已开启保存到相册' : '已关闭保存到相册'
|
|
}
|
|
|
|
const handleBack = () => {
|
|
if (!props.autoBack) {
|
|
return
|
|
}
|
|
uni.navigateBack({
|
|
fail: () => { }
|
|
})
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (props.autoInit) {
|
|
bootCamera()
|
|
}
|
|
})
|
|
|
|
defineExpose({
|
|
bootCamera,
|
|
takePhoto,
|
|
recordVideo,
|
|
toggleCamera,
|
|
changeFlashMode
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.apex-camera {
|
|
flex: 1;
|
|
width: 100%;
|
|
height: 100%;
|
|
flex-direction: column;
|
|
background-color: #000;
|
|
}
|
|
|
|
.top-bar {
|
|
height: 88px;
|
|
padding-top: 32px;
|
|
padding-left: 16px;
|
|
padding-right: 16px;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
background-color: rgba(0, 0, 0, 0.95);
|
|
}
|
|
|
|
.preview-area {
|
|
flex: 1;
|
|
min-height: 240px;
|
|
position: relative;
|
|
background-color: #0b0b0b;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.preview-camera {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.preview-mask {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding-left: 28px;
|
|
padding-right: 28px;
|
|
background-color: rgba(0, 0, 0, 0.22);
|
|
}
|
|
|
|
.main-copy {
|
|
color: #fff;
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
text-align: center;
|
|
}
|
|
|
|
.minor-copy {
|
|
color: #f8e53d;
|
|
font-size: 14px;
|
|
margin-top: 12px;
|
|
text-align: center;
|
|
}
|
|
|
|
.nav-btn {
|
|
color: #fff;
|
|
font-size: 28px;
|
|
width: 32px;
|
|
text-align: center;
|
|
}
|
|
|
|
.title {
|
|
color: #fff;
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.badge {
|
|
min-width: 54px;
|
|
padding-top: 5px;
|
|
padding-bottom: 5px;
|
|
padding-left: 10px;
|
|
padding-right: 10px;
|
|
border-radius: 999px;
|
|
color: #fff;
|
|
font-size: 13px;
|
|
text-align: center;
|
|
}
|
|
|
|
.badge-ready {
|
|
background-color: #3cc064;
|
|
}
|
|
|
|
.badge-pending {
|
|
background-color: #ffb020;
|
|
}
|
|
|
|
.status-panel {
|
|
padding: 12px 14px;
|
|
background-color: #111214;
|
|
}
|
|
|
|
.status-line {
|
|
color: #fff;
|
|
font-size: 13px;
|
|
line-height: 20px;
|
|
}
|
|
|
|
.bottom-panel {
|
|
background-color: #0f0f11;
|
|
padding-top: 20px;
|
|
padding-bottom: 18px;
|
|
padding-left: 16px;
|
|
padding-right: 16px;
|
|
}
|
|
|
|
.action-row {
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.ghost-btn,
|
|
.record-btn {
|
|
width: 92px;
|
|
height: 50px;
|
|
border-radius: 25px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.ghost-btn {
|
|
background-color: #2c2c2e;
|
|
}
|
|
|
|
.record-btn {
|
|
background-color: #ff6b73;
|
|
}
|
|
|
|
.ghost-btn-text,
|
|
.record-btn-text {
|
|
color: #fff;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.capture-btn {
|
|
width: 98px;
|
|
height: 98px;
|
|
border-radius: 49px;
|
|
background-color: #e8e8e8;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.capture-btn-inner {
|
|
width: 82px;
|
|
height: 82px;
|
|
border-radius: 41px;
|
|
background-color: #fff;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-width: 1px;
|
|
border-style: solid;
|
|
border-color: #d9d9d9;
|
|
}
|
|
|
|
.capture-btn-text {
|
|
color: #222;
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.tool-row {
|
|
flex-direction: row;
|
|
justify-content: space-between;
|
|
margin-top: 18px;
|
|
}
|
|
|
|
.tool-card {
|
|
width: 48%;
|
|
min-height: 74px;
|
|
border-radius: 14px;
|
|
padding: 12px 14px;
|
|
background-color: #1b1b1d;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.flash-card {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
justify-content: center;
|
|
}
|
|
|
|
.tool-title {
|
|
color: #fff;
|
|
font-size: 15px;
|
|
}
|
|
|
|
.flash-options {
|
|
flex-direction: row;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.flash-option {
|
|
min-width: 38px;
|
|
height: 30px;
|
|
padding-left: 10px;
|
|
padding-right: 10px;
|
|
border-radius: 15px;
|
|
color: #fff;
|
|
font-size: 14px;
|
|
text-align: center;
|
|
line-height: 30px;
|
|
background-color: #2e2e32;
|
|
margin-right: 8px;
|
|
}
|
|
|
|
.flash-option-active {
|
|
color: #111;
|
|
background-color: #f5d667;
|
|
}
|
|
</style>
|