topfans/frontend/pages/discover/create-feed.vue
2026-04-07 23:08:49 +08:00

1060 lines
22 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="create-feed">
<!-- 梦幻渐变背景 -->
<view class="gradient-background"></view>
<!-- 散布的元素 -->
<view v-for="item in scatteredItems" :key="item.id" class="scattered-item" :style="getItemStyle(item)"
@click="handleItemClick(item)">
<!-- 图片卡片 -->
<view class="item-card">
<image class="card-image" :src="item.image" mode="aspectFill" />
</view>
<!-- 标签文字在图片下方 -->
<text class="card-label">{{ item.label }}</text>
</view>
<!-- 底部输入框 -->
<view class="bottom-hint">
<view class="input-wrapper">
<!-- 已选标签 -->
<view v-if="selectedTags.length > 0" class="selected-tags">
<view v-for="(tag, index) in selectedTags" :key="index" class="selected-tag" @click.stop="removeTag(index)">
<text class="selected-tag-text">{{ tag }}</text>
<text class="selected-tag-close">×</text>
</view>
</view>
<!-- 输入框和按钮 -->
<view class="input-row">
<input
class="hint-input"
v-model="dreamInput"
placeholder="点击描述你的愿景"
placeholder-class="hint-placeholder"
@focus="handleInputFocus"
@blur="handleInputBlur"
@confirm="handleInputConfirm"
/>
<view class="input-actions">
<!-- 语音输入按钮 - 支持长按 -->
<view
class="action-icon-btn voice-btn"
:class="{ 'recording': isRecording }"
@touchstart.stop.prevent="handleTouchStart"
@touchend.stop.prevent="handleTouchEnd"
@touchcancel.stop.prevent="handleTouchCancel"
>
<!-- 录音状态显示 -->
<view v-if="!isRecording" class="voice-normal" style="pointer-events: none;">
<!-- <image class="action-icon" src="/static/icon/voice.png" mode="aspectFit" /> -->
<!-- <text class="action-text">长按说话</text> -->
</view>
<!-- 录音中显示波形 -->
<view v-else class="voice-recording" style="pointer-events: none;">
<view class="waveform">
<view
v-for="(height, index) in waveformBars"
:key="index"
class="waveform-bar"
:style="{ height: (height * 100) + '%' }"
></view>
</view>
<text class="action-text recording-text">{{ recordingDuration }}s 松开识别</text>
</view>
</view>
<view class="action-icon-btn send-btn" @click="handleInputConfirm">
<image class="action-icon" src="/static/icon/send.png" mode="aspectFit" />
<text class="action-text">发送</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue';
// 梦境输入
const dreamInput = ref('');
// 已选标签
const selectedTags = ref([]);
// 从 CastloveContent 获取的表单数据
const castloveFormData = ref(null);
// 语音识别相关
const recorderManager = ref(null);
const isRecording = ref(false);
const recordingDuration = ref(0);
let recordingTimer = null;
let recognizedText = ''; // 保存识别结果
let isRecognizing = false; // 标记是否正在识别
// 波形动画相关
const waveformBars = ref([1, 1, 1, 1, 1]); // 5个波形条的高度
let waveformTimer = null;
// 散布的元素数据 - 动态生成,图片路径后续会随机生成
// 3列2行布局统一尺寸高低错落
const scatteredItems = ref([
{
id: 1,
type: 'image',
image: '', // 后续随机生成
label: '自律小卡',
x: 6,
y: 18, // 第一行第一张,最低
width: 220,
height: 400
},
{
id: 2,
type: 'image',
image: '', // 后续随机生成
label: '未来艺术',
x: 38,
y: 10, // 第一行中间,最高
width: 220,
height: 400
},
{
id: 3,
type: 'image',
image: '', // 后续随机生成
label: '真人表情包',
x: 70,
y: 14, // 第一行第三张,中等
width: 220,
height: 400
},
{
id: 4,
type: 'image',
image: '', // 后续随机生成
label: '海报风',
x: 6,
y: 48, // 第二行第一张,最低
width: 220,
height: 400
},
{
id: 5,
type: 'image',
image: '', // 后续随机生成
label: '艺术插画',
x: 38,
y: 40, // 第二行中间,最高
width: 220,
height: 400
},
{
id: 6,
type: 'image',
image: '', // 后续随机生成
label: '二次元',
x: 70,
y: 44, // 第二行第三张,中等
width: 220,
height: 400
}
]);
// 随机生成图片路径的函数
const generateRandomImages = () => {
const totalImages = 16; // static/sucai 中有 16 张图片
const usedIndexes = new Set();
scatteredItems.value.forEach(item => {
let randomIndex;
do {
randomIndex = Math.floor(Math.random() * totalImages) + 1;
} while (usedIndexes.has(randomIndex));
usedIndexes.add(randomIndex);
const imageNumber = String(randomIndex).padStart(2, '0');
item.image = `/static/sucai/image-${imageNumber}.png`;
});
};
// 获取元素样式
const getItemStyle = (item) => {
if (item.type === 'image') {
return {
left: `${item.x}%`,
top: `${item.y}%`,
width: `${item.width}rpx`,
height: `${item.height}rpx`
};
}
return {
left: `${item.x}%`,
top: `${item.y}%`,
width: `${item.size}rpx`,
height: `${item.size}rpx`
};
};
// 处理元素点击
const handleItemClick = (item) => {
// 添加标签到已选列表
const index = selectedTags.value.indexOf(item.label);
if (index > -1) {
// 已存在,则移除
selectedTags.value.splice(index, 1);
} else {
// 不存在,则添加
selectedTags.value.push(item.label);
}
};
// 初始化录音管理器
const initRecorderManager = () => {
// #ifdef APP-PLUS
try {
recorderManager.value = uni.getRecorderManager();
// 录音开始事件
recorderManager.value.onStart(() => {
isRecording.value = true;
recordingDuration.value = 0;
// 开始计时
recordingTimer = setInterval(() => {
recordingDuration.value += 1;
if (recordingDuration.value >= 60) {
// 最长录音60秒
stopRecording();
}
}, 1000);
// 开始波形动画
startWaveformAnimation();
});
// 录音结束事件
recorderManager.value.onStop((res) => {
// 使用保存的录音时长(因为 recordingDuration 已经被重置为 0
const duration = savedDuration;
console.log('[CreateFeed] onStop 回调,使用保存的时长:', duration);
// 重置保存的时长
savedDuration = 0;
// 立即停止定时器
if (recordingTimer) {
clearInterval(recordingTimer);
recordingTimer = null;
}
// 停止波形动画
stopWaveformAnimation();
// 使用 setTimeout 确保状态更新能触发界面刷新
setTimeout(() => {
isRecording.value = false;
recordingDuration.value = 0;
// 检查录音时长
if (duration < 1) {
uni.showToast({
title: '录音时间太短',
icon: 'none',
duration: 1500
});
return;
}
// 显示录音成功提示
uni.showToast({
title: `✅ 录音已停止 (${duration}秒)`,
icon: 'success',
duration: 1500
});
}, 0);
});
// 录音错误事件
recorderManager.value.onError((err) => {
console.error('[CreateFeed] 录音错误:', err);
isRecording.value = false;
recordingDuration.value = 0; // 重置录音时长
if (recordingTimer) {
clearInterval(recordingTimer);
recordingTimer = null;
}
// 根据错误类型显示不同提示
let errorMsg = '录音失败,请重试';
if (err.errMsg && err.errMsg.includes('permission')) {
errorMsg = '请授予录音权限';
// 显示权限设置提示
uni.showModal({
title: '需要录音权限',
content: '请在设置中开启录音权限后重试',
confirmText: '去设置',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
plus.runtime.openURL('app-settings:');
}
}
});
} else {
uni.showToast({
title: errorMsg,
icon: 'none'
});
}
// 停止波形动画
stopWaveformAnimation();
});
console.log('[CreateFeed] 录音管理器初始化成功');
} catch (error) {
console.error('[CreateFeed] 录音管理器初始化失败:', error);
}
// #endif
};
// 语音识别(支持两种模式)
// 将识别的文字追加到输入框
const appendTextToInput = (text) => {
if (!text) return;
if (dreamInput.value) {
dreamInput.value += ' ' + text;
} else {
dreamInput.value = text;
}
};
// 开始录音
const startRecording = () => {
// #ifdef APP-PLUS
// 防止重复调用
if (isRecording.value) {
return;
}
// 清空之前的识别结果
recognizedText = '';
// 启动实时语音识别
startSpeechRecognition();
// 同时启动录音(用于显示波形和时长)
if (!recorderManager.value) {
initRecorderManager();
}
try {
recorderManager.value.start({
duration: 60000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 96000,
format: 'mp3'
});
} catch (error) {
console.error('[CreateFeed] 开始录音失败:', error);
stopSpeechRecognition();
uni.showModal({
title: '录音失败',
content: '请确保已授予录音权限',
confirmText: '去设置',
success: (res) => {
if (res.confirm) {
// #ifdef APP-PLUS
plus.runtime.openURL('app-settings:');
// #endif
}
}
});
}
// #endif
};
// 启动实时语音识别
const startSpeechRecognition = () => {
// #ifdef APP-PLUS
if (!plus.speech) {
console.error('[CreateFeed] 语音识别模块不可用');
uni.showToast({
title: '语音识别模块不可用',
icon: 'none',
duration: 2000
});
return;
}
// 如果正在识别,先停止
if (isRecognizing) {
console.log('[CreateFeed] 检测到正在识别,先停止');
try {
plus.speech.stopRecognize();
} catch (e) {
console.error('[CreateFeed] 停止识别失败:', e);
}
isRecognizing = false;
// 等待一下再启动
setTimeout(() => {
startSpeechRecognition();
}, 300);
return;
}
// 清空之前的识别结果
recognizedText = '';
isRecognizing = true;
console.log('[CreateFeed] 开始语音识别');
// 使用官方 demo 的简化配置
const options = {
engine: 'baidu'
};
plus.speech.startRecognize(options, function(result) {
console.log('[CreateFeed] 识别结果:', result);
// 累积识别结果
recognizedText += result;
}, function(error) {
isRecognizing = false;
console.error('[CreateFeed] 语音识别失败:', JSON.stringify(error));
// 根据错误代码显示提示
let errorMsg = '识别失败';
if (error.code === 2225220) {
errorMsg = '认证失败,请检查配置';
} else if (error.code === 2625535) {
errorMsg = '引擎忙,请稍后重试';
} else if (error.code === 10001) {
errorMsg = '网络连接失败';
}
// 只在非引擎忙的情况下显示提示
if (error.code !== 2625535) {
uni.showToast({
title: errorMsg,
icon: 'none',
duration: 2000
});
}
});
// #endif
};
// 停止实时语音识别
const stopSpeechRecognition = () => {
// #ifdef APP-PLUS
if (plus.speech && isRecognizing) {
console.log('[CreateFeed] 停止实时语音识别');
try {
plus.speech.stopRecognize();
isRecognizing = false;
} catch (e) {
console.error('[CreateFeed] 停止识别异常:', e);
isRecognizing = false;
}
// 如果有识别结果,添加到输入框
if (recognizedText) {
console.log('[CreateFeed] 最终识别结果:', recognizedText);
appendTextToInput(recognizedText);
uni.showToast({
title: '识别成功',
icon: 'success',
duration: 1500
});
} else {
console.log('[CreateFeed] 未识别到内容');
}
}
// #endif
};
// 停止录音
const stopRecording = () => {
// #ifdef APP-PLUS
if (recorderManager.value && isRecording.value) {
try {
recorderManager.value.stop();
// 停止语音识别
stopSpeechRecognition();
} catch (error) {
console.error('[CreateFeed] 停止录音异常:', error);
// 如果停止失败,手动重置状态
isRecording.value = false;
recordingDuration.value = 0;
if (recordingTimer) {
clearInterval(recordingTimer);
recordingTimer = null;
}
stopWaveformAnimation();
stopSpeechRecognition();
}
}
// #endif
};
// 开始波形动画
const startWaveformAnimation = () => {
if (waveformTimer) {
clearInterval(waveformTimer);
}
waveformTimer = setInterval(() => {
// 随机生成波形高度,模拟音频波动
waveformBars.value = waveformBars.value.map(() => {
return Math.random() * 0.7 + 0.3; // 0.3 到 1.0 之间
});
}, 100); // 每100ms更新一次
};
// 停止波形动画
const stopWaveformAnimation = () => {
if (waveformTimer) {
clearInterval(waveformTimer);
waveformTimer = null;
}
// 重置波形高度
waveformBars.value = [1, 1, 1, 1, 1];
};
// 长按开始录音(使用 longpress 事件更可靠)
const handleLongPress = () => {
// #ifdef APP-PLUS
startRecording();
// #endif
// #ifndef APP-PLUS
uni.showModal({
title: '提示',
content: '语音输入功能仅支持 App 端使用',
showCancel: false
});
// #endif
};
// 触摸开始(直接开始录音,不需要长按)
const handleTouchStart = () => {
console.log('[CreateFeed] touchstart 触发');
// #ifdef APP-PLUS
startRecording();
// #endif
// #ifndef APP-PLUS
uni.showModal({
title: '提示',
content: '语音输入功能仅支持 App 端使用',
showCancel: false
});
// #endif
};
// 防止重复触发的标志
let isStopping = false;
// 保存录音时长用于 onStop 回调
let savedDuration = 0;
// 松开停止录音
const handleTouchEnd = () => {
console.log('[CreateFeed] ========== touchend 触发 ==========');
console.log('[CreateFeed] touchend 触发, isRecording:', isRecording.value, 'isStopping:', isStopping);
// #ifdef APP-PLUS
// 防止重复调用
if (isStopping) {
console.log('[CreateFeed] 正在停止中,忽略重复调用');
return;
}
// 立即重置界面状态(不等待 onStop 回调)
const wasRecording = isRecording.value;
if (wasRecording) {
isStopping = true;
// 保存当前录音时长,供 onStop 回调使用
savedDuration = recordingDuration.value;
console.log('[CreateFeed] 保存录音时长:', savedDuration);
isRecording.value = false;
recordingDuration.value = 0;
if (recordingTimer) {
clearInterval(recordingTimer);
recordingTimer = null;
}
stopWaveformAnimation();
// 停止语音识别
stopSpeechRecognition();
}
// 立即调用停止录音
if (wasRecording && recorderManager.value) {
try {
recorderManager.value.stop();
} catch (error) {
console.error('[CreateFeed] 停止录音失败:', error);
}
// 延迟重置标志,防止过快的重复调用
setTimeout(() => {
isStopping = false;
}, 500);
}
console.log('[CreateFeed] ========== touchend 处理完成 ==========');
// #endif
};
// 取消录音(手指移出按钮区域)
const handleTouchCancel = () => {
console.log('[CreateFeed] touchcancel 触发');
// #ifdef APP-PLUS
if (isRecording.value) {
// 取消录音,不进行识别
if (recorderManager.value) {
recorderManager.value.stop();
}
// 停止语音识别
stopSpeechRecognition();
isRecording.value = false;
recordingDuration.value = 0;
if (recordingTimer) {
clearInterval(recordingTimer);
recordingTimer = null;
}
stopWaveformAnimation();
uni.showToast({
title: '已取消录音',
icon: 'none',
duration: 1000
});
}
// #endif
};
// 语音输入(保留点击方式作为备用)
const handleVoiceInput = () => {
// #ifdef APP-PLUS
if (isRecording.value) {
// 正在录音,点击停止
stopRecording();
} else {
// 未录音,点击开始
startRecording();
}
// #endif
// #ifndef APP-PLUS
uni.showModal({
title: '提示',
content: '语音输入功能仅支持 App 端使用',
showCancel: false
});
// #endif
};
// 移除标签
const removeTag = (index) => {
const tag = selectedTags.value[index];
selectedTags.value.splice(index, 1);
uni.showToast({
title: `已移除"${tag}"`,
icon: 'none',
duration: 1000
});
};
// 输入框聚焦
const handleInputFocus = () => {
// 输入框聚焦处理
};
// 输入框失焦
const handleInputBlur = () => {
// 输入框失焦处理
};
// 输入确认
const handleInputConfirm = () => {
// 检查是否有铸造表单数据
if (!castloveFormData.value) {
uni.showToast({
title: '未找到铸造数据',
icon: 'none',
duration: 2000
});
return;
}
// 组合标签和输入文字
const tagsText = selectedTags.value.join('');
const fullText = tagsText ? `${tagsText}${dreamInput.value.trim()}` : dreamInput.value.trim();
if (!fullText) {
uni.showToast({
title: '请选择标签或输入描述',
icon: 'none'
});
return;
}
// 显示确认信息
uni.showModal({
title: '确认发送',
content: `藏品名称: ${castloveFormData.value.name}\nAI描述: ${fullText}`,
success: (res) => {
if (res.confirm) {
// 这里调用后端接口
sendToBackend(fullText);
}
}
});
};
// 发送数据到后端
const sendToBackend = (aiDescription) => {
// 构建后端需要的数据格式不包含base64图片
const requestData = {
prompt: aiDescription, // AI描述文本
model: "image-01",
aspect_ratio: "16:9",
subject_reference: [
{
type: "character",
image_file: "" // 占位符实际使用时从castlove_form_data读取
}
],
"n": 4
};
// 将轻量数据存储到本地
uni.setStorageSync('generation_request_data', JSON.stringify(requestData));
// base64图片已经在castlove_form_data中不需要重复存储
// 跳转到加载页面
uni.navigateTo({
url: '/pages/discover/generation-loading'
});
};
onMounted(() => {
// 随机生成图片
generateRandomImages();
// 初始化录音管理器
initRecorderManager();
// 尝试从存储中读取表单数据
try {
const storedData = uni.getStorageSync('castlove_form_data');
if (storedData) {
castloveFormData.value = JSON.parse(storedData);
}
} catch (e) {
console.error('[CreateFeed] 读取表单数据失败:', e);
}
});
</script>
<style scoped>
.create-feed {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
/* 背景层(移除,使用父组件背景) */
.gradient-background {
display: none;
}
/* 顶部用户 */
.top-user {
position: absolute;
top: 120rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
z-index: 10;
}
.user-avatar {
width: 140rpx;
height: 140rpx;
border-radius: 50%;
border: 6rpx solid rgba(255, 255, 255, 0.8);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
}
.user-name-wrapper {
display: flex;
align-items: center;
margin-top: 16rpx;
}
.user-name {
font-size: 28rpx;
color: #333;
font-weight: 600;
}
.verified-icon {
width: 32rpx;
height: 32rpx;
margin-left: 8rpx;
}
/* 散布的元素 */
.scattered-item {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
z-index: 5;
animation: float 3s ease-in-out infinite;
overflow: visible;
}
.scattered-item:nth-child(odd) {
animation-delay: 0.5s;
}
.scattered-item:nth-child(even) {
animation-delay: 1s;
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10rpx);
}
}
/* 图片卡片 */
.item-card {
width: 100%;
height: 100%;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.2);
}
.card-image {
width: 100%;
height: 100%;
display: block;
}
.card-label {
margin-top: 12rpx;
font-size: 24rpx;
color: #ffffff;
font-weight: 600;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5);
text-align: center;
width: 100%;
}
/* 底部输入框 */
.bottom-hint {
position: absolute;
bottom: 200rpx;
left: 48rpx;
right: 48rpx;
display: flex;
justify-content: center;
z-index: 10;
}
.input-wrapper {
width: 100%;
min-height: 88rpx;
background: rgba(255, 255, 255, 0.15);
border-radius: 44rpx;
backdrop-filter: blur(10rpx);
border: 2rpx solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
padding: 16rpx 24rpx;
gap: 12rpx;
}
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
width: 100%;
}
.selected-tag {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 20rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 32rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.selected-tag-text {
font-size: 26rpx;
color: #333;
font-weight: 500;
}
.selected-tag-close {
font-size: 32rpx;
color: #999;
font-weight: bold;
line-height: 1;
}
.input-row {
display: flex;
align-items: center;
gap: 16rpx;
width: 100%;
}
.hint-input {
flex: 1;
height: 64rpx;
font-size: 32rpx;
color: rgba(255, 255, 255, 0.95);
padding: 0 20rpx;
background: transparent;
border: none;
text-align: left;
}
.hint-placeholder {
color: rgba(255, 255, 255, 0.6);
font-size: 32rpx;
}
.input-actions {
display: flex;
align-items: center;
gap: 16rpx;
}
.action-icon-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
padding: 8rpx 12rpx;
transition: all 0.3s ease;
}
.voice-btn {
min-width: 120rpx;
}
.voice-normal,
.voice-recording {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.action-icon-btn.recording {
background: rgba(255, 68, 68, 0.1);
border-radius: 12rpx;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.9;
transform: scale(1.05);
}
}
/* 波形动画 */
.waveform {
display: flex;
align-items: center;
justify-content: center;
gap: 4rpx;
height: 40rpx;
width: 80rpx;
}
.waveform-bar {
width: 6rpx;
background: linear-gradient(to top, #ff4444, #ff6666);
border-radius: 3rpx;
transition: height 0.1s ease;
min-height: 8rpx;
}
.waveform-bar:nth-child(1) {
animation-delay: 0s;
}
.waveform-bar:nth-child(2) {
animation-delay: 0.1s;
}
.waveform-bar:nth-child(3) {
animation-delay: 0.2s;
}
.waveform-bar:nth-child(4) {
animation-delay: 0.1s;
}
.waveform-bar:nth-child(5) {
animation-delay: 0s;
}
.action-icon {
width: 40rpx;
height: 40rpx;
}
.action-text {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.8);
white-space: nowrap;
}
.recording-text {
color: #ff4444;
font-weight: 600;
}
.send-btn .action-text {
color: rgba(255, 255, 255, 0.95);
font-weight: 600;
}
</style>