279 lines
8.1 KiB
Vue
279 lines
8.1 KiB
Vue
<template>
|
||
<scroll-view class="panel-scroll" scroll-y>
|
||
<view class="toolbar">
|
||
<button
|
||
v-if="!recording"
|
||
class="btn-round"
|
||
type="primary"
|
||
:disabled="!recorderSupported"
|
||
@click="startRecord"
|
||
>
|
||
开始录音
|
||
</button>
|
||
<button v-else class="btn-round btn-stop" type="warn" @click="stopRecord">结束录音</button>
|
||
<text v-if="!recorderSupported" class="hint">当前环境不支持录音,请使用微信小程序或 App</text>
|
||
<text v-else-if="recording" class="hint recording">正在录音…</text>
|
||
</view>
|
||
|
||
<view class="section-title">录音列表</view>
|
||
<view v-if="!recordings.length" class="empty">暂无录音,点击「开始录音」记录相处时刻</view>
|
||
<view v-for="item in recordings" :key="item.id" class="rec-card">
|
||
<view class="rec-head">
|
||
<text class="rec-time">{{ formatDate(item.createdAt) }}</text>
|
||
<text class="rec-dur">{{ formatDuration(item.duration) }}</text>
|
||
</view>
|
||
<view class="rec-actions">
|
||
<button class="btn-mini" size="mini" @click="play(item)" :disabled="!item.tempFilePath">试听</button>
|
||
<button
|
||
class="btn-mini primary"
|
||
size="mini"
|
||
:disabled="item.analyzing"
|
||
@click="analyze(item)"
|
||
>
|
||
{{ item.analyzing ? '分析中…' : item.analysisText ? '重新分析' : '分析录音' }}
|
||
</button>
|
||
</view>
|
||
<text v-if="item.analysisText" class="rec-preview">{{ truncate(item.analysisText, 80) }}</text>
|
||
</view>
|
||
</scroll-view>
|
||
</template>
|
||
|
||
<script>
|
||
const KEY = 'xixi_recordings';
|
||
|
||
export default {
|
||
name: 'XixiRecord',
|
||
data() {
|
||
return {
|
||
recordings: [],
|
||
recording: false,
|
||
recorderSupported: false,
|
||
recorderManager: null,
|
||
innerAudio: null,
|
||
};
|
||
},
|
||
mounted() {
|
||
this.recorderSupported = typeof uni.getRecorderManager === 'function';
|
||
this.loadList();
|
||
if (this.recorderSupported) {
|
||
this.recorderManager = uni.getRecorderManager();
|
||
this.recorderManager.onStop((res) => {
|
||
this.recording = false;
|
||
const duration = res.duration || 0;
|
||
const tempFilePath = res.tempFilePath || '';
|
||
if (!tempFilePath) {
|
||
uni.showToast({ title: '未获取到录音文件', icon: 'none' });
|
||
return;
|
||
}
|
||
const item = {
|
||
id: `r_${Date.now()}`,
|
||
tempFilePath,
|
||
duration,
|
||
createdAt: Date.now(),
|
||
analysisText: '',
|
||
analyzing: false,
|
||
};
|
||
this.recordings.unshift(item);
|
||
this.persist();
|
||
uni.showToast({ title: '已保存录音', icon: 'success' });
|
||
this.$emit('recordings-changed');
|
||
});
|
||
this.recorderManager.onError(() => {
|
||
this.recording = false;
|
||
uni.showToast({ title: '录音出错', icon: 'none' });
|
||
});
|
||
}
|
||
},
|
||
beforeUnmount() {
|
||
this.stopAudio();
|
||
},
|
||
methods: {
|
||
loadList() {
|
||
try {
|
||
const list = uni.getStorageSync(KEY);
|
||
this.recordings = Array.isArray(list) ? list : [];
|
||
} catch (e) {
|
||
this.recordings = [];
|
||
}
|
||
},
|
||
persist() {
|
||
try {
|
||
uni.setStorageSync(KEY, this.recordings);
|
||
} catch (e) {
|
||
/* ignore */
|
||
}
|
||
},
|
||
startRecord() {
|
||
if (!this.recorderSupported || !this.recorderManager) return;
|
||
this.recording = true;
|
||
try {
|
||
this.recorderManager.start({
|
||
duration: 600000,
|
||
sampleRate: 44100,
|
||
numberOfChannels: 1,
|
||
encodeBitRate: 96000,
|
||
format: 'mp3',
|
||
});
|
||
} catch (e) {
|
||
this.recording = false;
|
||
uni.showToast({ title: '无法开始录音', icon: 'none' });
|
||
}
|
||
},
|
||
stopRecord() {
|
||
if (this.recorderManager && this.recording) {
|
||
this.recorderManager.stop();
|
||
}
|
||
},
|
||
play(item) {
|
||
if (!item.tempFilePath) return;
|
||
this.stopAudio();
|
||
const ctx = uni.createInnerAudioContext();
|
||
this.innerAudio = ctx;
|
||
ctx.src = item.tempFilePath;
|
||
ctx.onEnded(() => this.stopAudio());
|
||
ctx.onError(() => {
|
||
uni.showToast({ title: '播放失败', icon: 'none' });
|
||
this.stopAudio();
|
||
});
|
||
ctx.play();
|
||
},
|
||
stopAudio() {
|
||
if (this.innerAudio) {
|
||
try {
|
||
this.innerAudio.stop();
|
||
this.innerAudio.destroy();
|
||
} catch (e) {
|
||
/* ignore */
|
||
}
|
||
this.innerAudio = null;
|
||
}
|
||
},
|
||
analyze(item) {
|
||
const idx = this.recordings.findIndex((r) => r.id === item.id);
|
||
if (idx < 0) return;
|
||
const cur = this.recordings[idx];
|
||
this.recordings.splice(idx, 1, { ...cur, analyzing: true });
|
||
const who = uni.getStorageSync('xixi_who_am_i') || {};
|
||
const meet = uni.getStorageSync('xixi_first_meeting') || {};
|
||
const id = item.id;
|
||
setTimeout(() => {
|
||
const i = this.recordings.findIndex((r) => r.id === id);
|
||
if (i < 0) return;
|
||
const row = this.recordings[i];
|
||
const sec = Math.max(1, Math.round((row.duration || 0) / 1000));
|
||
const text = this.buildMockAnalysis(sec, who, meet);
|
||
this.recordings.splice(i, 1, { ...row, analysisText: text, analyzing: false });
|
||
this.persist();
|
||
uni.showToast({ title: '分析完成', icon: 'success' });
|
||
this.$emit('recordings-changed');
|
||
}, 900);
|
||
},
|
||
buildMockAnalysis(seconds, who, meet) {
|
||
const intro = who.selfIntro ? '你已填写「我是谁」,建议在真实对话中自然带出其中 1~2 点,避免像背稿。' : '尚未填写「我是谁」,补全后诊断可结合你的自我描述更贴切。';
|
||
const goal = meet.meetingGoal ? `结合你写的见面目标「${meet.meetingGoal.slice(0, 40)}${meet.meetingGoal.length > 40 ? '…' : ''}」,可回顾本次对话是否朝该方向推进。` : '可补充「本次见面要达到的结果」,便于对照复盘。';
|
||
return (
|
||
`【喜喜 · AI 相处诊断】(示例)\n\n` +
|
||
`· 录音时长约 ${seconds} 秒。\n` +
|
||
`· ${intro}\n` +
|
||
`· ${goal}\n\n` +
|
||
`沟通氛围:语气平稳、留白适中更易建立信任;若语速偏快,可适当停顿给对方回应空间。\n` +
|
||
`下一步:在「诊断结果」页查看完整记录;接入语音转写与大模型后,将替换为基于真实内容的分析。`
|
||
);
|
||
},
|
||
formatDate(ts) {
|
||
const d = new Date(ts);
|
||
const p = (n) => (n < 10 ? '0' + n : '' + n);
|
||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||
},
|
||
formatDuration(ms) {
|
||
const s = Math.round((ms || 0) / 1000);
|
||
const m = Math.floor(s / 60);
|
||
const r = s % 60;
|
||
return m > 0 ? `${m}分${r}秒` : `${r}秒`;
|
||
},
|
||
truncate(s, n) {
|
||
if (!s) return '';
|
||
return s.length <= n ? s : s.slice(0, n) + '…';
|
||
},
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
.panel-scroll {
|
||
height: 100%;
|
||
box-sizing: border-box;
|
||
padding: 24rpx 28rpx 32rpx;
|
||
}
|
||
.toolbar {
|
||
margin-bottom: 24rpx;
|
||
}
|
||
.btn-round {
|
||
border-radius: 999rpx;
|
||
background: linear-gradient(135deg, #ff6b9d, #e91e8c);
|
||
border: none;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
.btn-stop {
|
||
background: linear-gradient(135deg, #ff8a65, #f4511e);
|
||
}
|
||
.hint {
|
||
display: block;
|
||
font-size: 24rpx;
|
||
color: #888;
|
||
margin-top: 8rpx;
|
||
}
|
||
.hint.recording {
|
||
color: #e91e8c;
|
||
font-weight: 600;
|
||
}
|
||
.section-title {
|
||
font-size: 30rpx;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
.empty {
|
||
font-size: 26rpx;
|
||
color: #999;
|
||
padding: 40rpx 0;
|
||
text-align: center;
|
||
}
|
||
.rec-card {
|
||
background: #fff;
|
||
border-radius: 20rpx;
|
||
padding: 24rpx;
|
||
margin-bottom: 20rpx;
|
||
box-shadow: 0 4rpx 24rpx rgba(233, 30, 140, 0.06);
|
||
}
|
||
.rec-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
.rec-time {
|
||
font-size: 26rpx;
|
||
color: #333;
|
||
}
|
||
.rec-dur {
|
||
font-size: 24rpx;
|
||
color: #e91e8c;
|
||
}
|
||
.rec-actions {
|
||
display: flex;
|
||
gap: 20rpx;
|
||
}
|
||
.btn-mini.primary {
|
||
background: #fff0f5;
|
||
color: #e91e8c;
|
||
border: 1rpx solid #ffb6c1;
|
||
}
|
||
.rec-preview {
|
||
display: block;
|
||
margin-top: 16rpx;
|
||
font-size: 24rpx;
|
||
color: #666;
|
||
line-height: 1.5;
|
||
}
|
||
</style>
|