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

999 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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: this.enableAIVideo ? '正在创建视频任务...' : '发布中...'
});
try {
const imageUrls = this.selectedImages;
// 如果开启了AI视频生成创建视频任务
let videoTaskId = '';
if (this.enableAIVideo) {
try {
// 构建视频生成提示词
const mealLabel = this.mealTypes.find(m => m.value === this.selectedMealType)?.label || '饮食';
const videoPrompt = this.remark || `健康${mealLabel}打卡`;
const taskParams = {
uid: String(this.uid || ''),
prompt: videoPrompt,
image_urls: imageUrls,
remove_watermark: true
};
let videoTaskRes;
if (imageUrls.length > 0) {
// 图生视频:使用第一张图片作为参考图
videoTaskRes = await api.createImageToVideoTask({
...taskParams,
imageUrl: imageUrls[0]
});
} else {
// 文生视频
videoTaskRes = await api.createTextToVideoTask(taskParams);
}
if (videoTaskRes && videoTaskRes.code === 200 && videoTaskRes.data) {
videoTaskId = videoTaskRes.data;
console.log('视频生成任务已提交taskId:', videoTaskId);
} else {
console.warn('视频任务创建返回异常:', videoTaskRes);
}
} catch (videoError) {
console.error('创建视频任务失败:', videoError);
// 视频任务失败不阻断打卡提交,只提示
uni.showToast({
title: '视频任务创建失败,打卡将继续提交',
icon: 'none',
duration: 2000
});
// 等待toast显示
await new Promise(resolve => setTimeout(resolve, 1500));
}
}
// 更新loading提示
uni.showLoading({ title: '提交打卡中...' });
// 提交打卡
const today = new Date().toISOString().split('T')[0];
const result = await submitCheckin({
mealType: this.selectedMealType,
date: today,
photosJson: JSON.stringify(imageUrls),
notes: this.remark,
taskId: videoTaskId,
enableAIVideo: this.enableAIVideo,
enableAIAnalysis: false
});
uni.hideLoading();
// 显示成功提示
const points = result.data?.points || 0;
const successMsg = videoTaskId
? `打卡成功!视频生成中...`
: `打卡成功!获得${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>