Files
msh-system/msh_single_uniapp/pages/tool/checkin-publish.vue

945 lines
20 KiB
Vue
Raw Normal View History

<template>
<view class="checkin-publish-page">
<!-- 内容区域 -->
<scroll-view class="content-scroll" scroll-y>
<!-- 上传照片卡片 -->
<view class="card">
<view class="card-header">
<text class="card-title">上传照片</text>
<text class="photo-count">{{ selectedImages.length }}/6</text>
</view>
<view class="photo-grid">
<view
class="photo-item"
v-for="(image, index) in selectedImages"
:key="index"
>
<image class="photo-image" :src="image" mode="aspectFill"></image>
<view class="photo-delete" @click="removeImage(index)">
<text class="delete-icon">×</text>
</view>
</view>
<view
class="photo-add-btn"
v-if="selectedImages.length < 6"
@click="chooseImage"
>
<text class="add-icon">+</text>
<text class="add-text">添加照片</text>
</view>
</view>
<view class="photo-tip">
<!-- <text class="tip-icon">📷</text> -->
<text class="tip-text">拍摄清晰的照片能帮您更准确地识别食物营养成分</text>
</view>
</view>
<!-- 选择餐次卡片 -->
<view class="card">
<view class="card-header">
<text class="card-title">选择餐次</text>
</view>
<view class="meal-type-list">
<view
class="meal-type-item"
v-for="(meal, index) in mealTypes"
:key="index"
:class="{ active: selectedMealType === meal.value }"
@click="selectMealType(meal.value)"
>
<text>{{ meal.label }}</text>
</view>
</view>
</view>
<!-- 备注说明卡片 -->
<view class="card">
<view class="card-header">
<text class="card-title">一句话说明可选</text>
</view>
<textarea
class="remark-input"
v-model="remark"
placeholder="记录今天的饮食感受、烹饪方法、食材用量等..."
maxlength="500"
@input="onRemarkInput"
></textarea>
<view class="remark-footer">
<text class="char-count">{{ remark.length }}/500</text>
<view class="voice-btn" :class="{ recording: isRecording }" @click="handleVoiceRemark">
<text class="voice-icon">{{ isRecording ? '⏹️' : '🎤' }}</text>
<text class="voice-text">{{ isRecording ? `停止录音 (${recordDuration}s)` : '语音录入' }}</text>
</view>
</view>
</view>
<!-- 生成AI营养分析视频卡片 -->
<view class="card">
<view class="card-header">
<view class="ai-header-left">
<text class="ai-icon">🎬</text>
<text class="card-title">生成打卡视频分享到社区</text>
</view>
<switch
class="ai-switch"
:checked="enableAIVideo"
@change="toggleAIVideo"
color="#ff6b35"
/>
</view>
<view class="ai-features" v-if="enableAIVideo">
<view class="feature-item">
<text class="feature-check"></text>
<text class="feature-text">AI分析营养成分</text>
</view>
<view class="feature-item">
<text class="feature-check"></text>
<text class="feature-text">生成专业营养分析视频</text>
</view>
<view class="feature-item">
<text class="feature-check"></text>
<text class="feature-text">分享到社区获得更多积分</text>
</view>
</view>
</view>
<!-- 底部安全距离 -->
<view class="safe-bottom"></view>
</scroll-view>
<!-- 底部操作栏 -->
<view class="bottom-bar">
<view class="cancel-btn" @click="handleCancel">
<text>取消</text>
</view>
<view class="publish-btn" @click="handlePublish">
<text>发布打卡</text>
</view>
</view>
</view>
</template>
<script>
import api from '@/api/models-api.js';
import { submitCheckin } from '@/api/tool.js';
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters(['userInfo','uid'])
},
data() {
return {
selectedImages: [],
selectedMealType: 'breakfast',
mealTypes: [
{ label: '早餐', value: 'breakfast' },
{ label: '午餐', value: 'lunch' },
{ label: '晚餐', value: 'dinner' },
{ label: '加餐', value: 'snack' }
],
remark: '',
enableAIVideo: false,
// 语音输入相关
isRecording: false,
recorderManager: null,
recordDuration: 0,
recordTimer: null
}
},
onLoad(options) {
// 初始化录音管理器
this.initRecorder();
// 根据参数处理
if (options.type === 'video') {
this.enableAIVideo = true;
}
// 处理一键复制/借鉴打卡
if (options.mode === 'copy') {
try {
const copyData = uni.getStorageSync('checkin_copy_data');
if (copyData) {
this.selectedImages = copyData.images || [];
this.selectedMealType = copyData.mealType || 'breakfast';
this.remark = copyData.notes || '';
// 清除缓存
uni.removeStorageSync('checkin_copy_data');
uni.showToast({
title: '已加载打卡内容',
icon: 'success'
});
}
} catch (e) {
console.error('读取复制数据失败:', e);
}
}
},
onUnload() {
// 清理录音计时器
this.stopRecordTimer();
// 如果正在录音,停止录音
if (this.isRecording && this.recorderManager) {
this.recorderManager.stop();
}
},
methods: {
// 初始化录音管理器
initRecorder() {
// #ifdef MP-WEIXIN || APP-PLUS
this.recorderManager = uni.getRecorderManager();
// 录音开始事件
this.recorderManager.onStart(() => {
console.log('录音开始');
this.recordDuration = 0;
this.startRecordTimer();
});
// 录音结束事件
this.recorderManager.onStop((res) => {
console.log('录音结束', res);
this.stopRecordTimer();
if (res.duration < 1000) {
uni.showToast({
title: '录音时间太短',
icon: 'none'
});
return;
}
// 处理录音结果
this.handleRecordResult(res);
});
// 录音错误事件
this.recorderManager.onError((err) => {
console.error('录音错误:', err);
this.isRecording = false;
this.stopRecordTimer();
uni.showToast({
title: '录音失败,请重试',
icon: 'none'
});
});
// #endif
// #ifdef H5
console.log('H5环境暂不支持录音功能');
// #endif
},
// 开始录音计时
startRecordTimer() {
this.recordTimer = setInterval(() => {
this.recordDuration++;
if (this.recordDuration >= 60) {
// 录音超过60秒自动停止
this.stopVoiceInput();
}
}, 1000);
},
// 停止录音计时
stopRecordTimer() {
if (this.recordTimer) {
clearInterval(this.recordTimer);
this.recordTimer = null;
}
this.recordDuration = 0;
},
// 处理语音备注按钮点击
handleVoiceRemark() {
if (this.isRecording) {
this.stopVoiceInput();
} else {
this.startVoiceInput();
}
},
// 开始语音输入
startVoiceInput() {
// #ifdef H5
uni.showModal({
title: '提示',
content: 'H5环境暂不支持语音输入请使用小程序或APP',
showCancel: false
});
return;
// #endif
// #ifdef MP-WEIXIN || APP-PLUS
if (!this.recorderManager) {
uni.showToast({
title: '录音功能初始化失败',
icon: 'none'
});
return;
}
// 检查录音权限
uni.authorize({
scope: 'scope.record',
success: () => {
this.startRecording();
},
fail: () => {
uni.showModal({
title: '需要录音权限',
content: '请在设置中开启录音权限',
confirmText: '去设置',
success: (res) => {
if (res.confirm) {
uni.openSetting();
}
}
});
}
});
// #endif
},
// 开始录音
startRecording() {
this.isRecording = true;
uni.showToast({
title: '开始录音',
icon: 'none',
duration: 1000
});
// 开始录音
this.recorderManager.start({
duration: 60000, // 最长录音60秒
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 96000,
format: 'mp3'
});
},
// 停止语音输入
stopVoiceInput() {
if (!this.isRecording) {
return;
}
this.isRecording = false;
// #ifdef MP-WEIXIN || APP-PLUS
if (this.recorderManager) {
this.recorderManager.stop();
}
// #endif
uni.showToast({
title: '正在识别...',
icon: 'loading',
duration: 2000
});
},
// 处理录音结果
async handleRecordResult(res) {
console.log('录音文件路径:', res.tempFilePath);
try {
// 1. 上传录音文件
// 按照 oneclick.vue 的逻辑,使用 api.uploadFile 上传音频
const uploadRes = await api.uploadFile(res.tempFilePath, {
model: 'audio',
pid: '8'
});
if (!uploadRes || uploadRes.code !== 200) {
throw new Error('录音文件上传失败');
}
const audioUrl = uploadRes.data.fullUrl;
console.log('录音文件上传成功URL:', audioUrl);
// 2. 识别语音
const recognizedText = await this.recognizeSpeech(audioUrl);
if (recognizedText) {
// 将识别结果添加到备注中
if (this.remark) {
this.remark += ' ' + recognizedText;
} else {
this.remark = recognizedText;
}
uni.showToast({
title: '识别成功',
icon: 'success',
duration: 1500
});
}
} catch (error) {
console.error('语音识别流程失败:', error);
uni.showToast({
title: '识别失败,请重试',
icon: 'none',
duration: 2000
});
}
},
// 语音识别(对接腾讯云语音识别服务)
async recognizeSpeech(audioUrl) {
try {
// 创建语音识别任务
console.log('创建语音识别任务, URL:', audioUrl);
const createTaskRes = await api.createAsrTask({
url: audioUrl,
engineModelType: '16k_zh',
channelNum: 1,
resTextFormat: 0,
sourceType: 0
});
if (!createTaskRes || createTaskRes.code !== 200) {
throw new Error('创建识别任务失败');
}
const taskId = createTaskRes.data.taskId;
console.log('识别任务创建成功任务ID:', taskId);
// 轮询查询识别结果
const result = await this.pollAsrResult(taskId);
// 解析识别文本
return this.parseAsrResult(result);
} catch (error) {
console.error('语音识别请求失败:', error);
throw error;
}
},
// 轮询查询ASR识别结果
async pollAsrResult(taskId, maxAttempts = 30, interval = 2000) {
let attempts = 0;
while (attempts < maxAttempts) {
attempts++;
try {
const statusRes = await api.queryAsrStatus(taskId);
if (!statusRes || statusRes.code !== 200) {
throw new Error('查询识别状态失败');
}
const status = statusRes.data.status;
// status: 0-等待处理, 1-处理中, 2-识别成功, 3-识别失败
if (status === 2) {
return statusRes.data;
} else if (status === 3) {
throw new Error(statusRes.data.errorMsg || '识别失败');
}
// 等待一段时间后继续查询
await new Promise(resolve => setTimeout(resolve, interval));
} catch (error) {
console.error('查询识别状态出错:', error);
if (attempts >= maxAttempts) throw error;
await new Promise(resolve => setTimeout(resolve, interval));
}
}
throw new Error('识别超时,请重试');
},
// 解析ASR识别结果文本
parseAsrResult(data) {
if (!data || !data.result) {
return '';
}
let text = data.result;
// 去除时间戳标记
text = text.replace(/\[\d+:\d+\.\d+,\d+:\d+\.\d+\]\s*/g, '');
// 去除多余的空格和换行
text = text.replace(/\n+/g, ' ').trim();
text = text.replace(/\s+/g, ' ');
return text;
},
async chooseImage() {
const maxCount = 6 - this.selectedImages.length;
if (maxCount <= 0) return;
try {
const res = await new Promise((resolve, reject) => {
uni.chooseImage({
count: maxCount,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: resolve,
fail: reject
});
});
uni.showLoading({
title: '正在上传...',
mask: true
});
for (const filePath of res.tempFilePaths) {
try {
const uploadResult = await api.uploadFile(filePath, {
model: 'checkin',
pid: '7'
});
if (uploadResult && uploadResult.code === 200) {
// 直接保存完整的URL
this.selectedImages.push(uploadResult.data.fullUrl);
} else {
console.error('上传失败:', uploadResult);
uni.showToast({
title: '部分图片上传失败',
icon: 'none'
});
}
} catch (uploadError) {
console.error('上传异常:', uploadError);
}
}
uni.hideLoading();
} catch (error) {
uni.hideLoading();
console.error('选择图片失败', error);
if (!error.errMsg || !error.errMsg.includes('cancel')) {
uni.showToast({
title: '选择图片失败',
icon: 'none'
});
}
}
},
removeImage(index) {
this.selectedImages.splice(index, 1)
},
selectMealType(value) {
this.selectedMealType = value
},
onRemarkInput(e) {
this.remark = e.detail.value
},
toggleAIVideo(e) {
this.enableAIVideo = e.detail.value
},
handleCancel() {
uni.showModal({
title: '提示',
content: '确定要取消发布吗?',
success: (res) => {
if (res.confirm) {
uni.navigateBack()
}
}
})
},
async handlePublish() {
if (this.selectedImages.length === 0) {
uni.showToast({
title: '请至少上传一张照片',
icon: 'none'
})
return
}
// 防止重复提交
if (this._publishing) return
this._publishing = true
uni.showLoading({ title: '发布中...' });
try {
const imageUrls = this.selectedImages;
const today = new Date().toISOString().split('T')[0];
const result = await submitCheckin({
mealType: this.selectedMealType,
date: today,
photosJson: JSON.stringify(imageUrls),
notes: this.remark,
enableAIVideo: this.enableAIVideo,
enableAIAnalysis: false
});
uni.hideLoading();
const points = result.data?.points || 0;
const taskId = result.data?.taskId;
const successMsg = (this.enableAIVideo && taskId)
? '打卡成功!视频生成中...'
: `打卡成功!获得${points}积分`;
uni.showToast({
title: successMsg,
icon: 'success',
duration: 2000
});
// 延迟跳转
setTimeout(() => {
uni.redirectTo({
url: '/pages/tool/dietary-records'
});
}, 1000);
} catch (error) {
uni.hideLoading();
console.error('发布失败:', error);
const msg = typeof error === 'string' ? error : (error && (error.message || error.msg));
uni.showToast({
title: msg || '发布失败,请重试',
icon: 'none',
duration: msg ? 2500 : 2000
});
} finally {
this._publishing = false
}
}
}
}
</script>
<style lang="scss" scoped>
.checkin-publish-page {
min-height: 100vh;
background: #f4f5f7;
display: flex;
flex-direction: column;
}
/* 内容滚动区域 */
.content-scroll {
flex: 1;
margin-bottom: 120rpx;
padding: 32rpx 0;
}
/* 卡片样式 */
.card {
background: #ffffff;
border: 1rpx solid #d0dbea;
border-radius: 24rpx;
margin: 0 32rpx 32rpx;
padding: 32rpx;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
.card-title {
font-size: 28rpx;
color: #2e3e5c;
font-weight: 500;
}
.photo-count {
font-size: 24rpx;
color: #9fa5c0;
}
.ai-header-left {
display: flex;
align-items: center;
gap: 16rpx;
.ai-icon {
font-size: 32rpx;
}
}
}
/* 照片网格 */
.photo-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-bottom: 24rpx;
}
.photo-item {
width: 195rpx;
height: 195rpx;
border-radius: 24rpx;
overflow: hidden;
position: relative;
background: #f4f5f7;
.photo-image {
width: 100%;
height: 100%;
}
.photo-delete {
position: absolute;
right: 8rpx;
top: 8rpx;
width: 48rpx;
height: 48rpx;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.delete-icon {
font-size: 32rpx;
color: #ffffff;
line-height: 1;
}
}
}
.photo-add-btn {
width: 195rpx;
height: 195rpx;
border: 2rpx solid #d0dbea;
border-radius: 24rpx;
background: #f4f5f7;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12rpx;
.add-icon {
font-size: 48rpx;
color: #9fa5c0;
line-height: 1;
}
.add-text {
font-size: 24rpx;
color: #9fa5c0;
}
}
.photo-tip {
background: #e3f2fd;
border-radius: 16rpx;
padding: 16rpx;
display: flex;
align-items: center;
gap: 16rpx;
.tip-icon {
font-size: 32rpx;
}
.tip-text {
flex: 1;
font-size: 24rpx;
color: #1976d2;
line-height: 1.5;
}
}
/* 餐次选择 */
.meal-type-list {
display: flex;
gap: 16rpx;
}
.meal-type-item {
flex: 1;
height: 70rpx;
border: 2rpx solid #d0dbea;
border-radius: 50rpx;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
color: #9fa5c0;
transition: all 0.3s;
&.active {
background: #fff5f0;
border-color: #ff6b35;
color: #ff6b35;
}
}
/* 备注说明 */
.remark-input {
width: 100%;
min-height: 200rpx;
background: #ffffff;
border: 1rpx solid #d0dbea;
border-radius: 12rpx;
padding: 16rpx;
font-size: 28rpx;
color: #2e3e5c;
line-height: 1.6;
margin-bottom: 16rpx;
}
.remark-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.char-count {
font-size: 24rpx;
color: #9fa5c0;
}
.voice-btn {
background: linear-gradient(135deg, #ff8c52 0%, #ff6b35 100%);
border-radius: 50rpx;
padding: 12rpx 32rpx;
display: flex;
align-items: center;
gap: 12rpx;
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.3);
transition: all 0.3s;
.voice-icon {
font-size: 32rpx;
color: #ffffff;
}
.voice-text {
font-size: 26rpx;
color: #ffffff;
font-weight: 500;
}
&:active {
transform: scale(0.95);
opacity: 0.9;
}
&.recording {
background: linear-gradient(135deg, #ff4d4f 0%, #f5222d 100%);
box-shadow: 0 4rpx 12rpx rgba(245, 34, 45, 0.3);
.voice-text {
color: #ffffff;
}
.voice-icon {
animation: pulse 1s infinite;
color: #ffffff;
}
}
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/* AI视频功能 */
.ai-switch {
transform: scale(0.8);
}
.ai-features {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 24rpx;
}
.feature-item {
display: flex;
align-items: center;
gap: 16rpx;
.feature-check {
font-size: 24rpx;
color: #ff6b35;
width: 24rpx;
height: 24rpx;
display: flex;
align-items: center;
justify-content: center;
}
.feature-text {
font-size: 24rpx;
color: #3e5481;
}
}
/* 底部安全距离 */
.safe-bottom {
height: 40rpx;
}
/* 底部操作栏 */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 120rpx;
background: #ffffff;
border-top: 1rpx solid #d0dbea;
padding: 24rpx 32rpx;
display: flex;
align-items: center;
gap: 24rpx;
z-index: 1000;
}
.cancel-btn {
flex: 1;
height: 96rpx;
border: 2rpx solid #d0dbea;
border-radius: 40rpx;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: 28rpx;
color: #3e5481;
font-weight: 500;
}
}
.publish-btn {
flex: 1;
height: 96rpx;
background: #ff6b35;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.3);
text {
font-size: 32rpx;
color: #ffffff;
font-weight: 500;
}
}
</style>