增加喜喜 概念框架

This commit is contained in:
zhonghua.li
2026-04-05 22:23:19 +08:00
parent b53195de68
commit b768d52f4b
11 changed files with 1363 additions and 1 deletions

View File

@@ -0,0 +1,235 @@
<template>
<scroll-view class="panel-scroll" scroll-y>
<view class="head-card">
<text class="head-title">今日约会建议</text>
<text class="head-date">{{ todayLabel }}</text>
<text class="head-note">以下内容根据你在我是谁约会准备中的填写自动生成仅供当日参考</text>
</view>
<view v-if="emptyHint" class="empty-wrap">
<text class="empty-title">还没有足够的信息</text>
<text class="empty-desc">请先在我是谁约会准备里填写内容保存后再来查看约会建议</text>
</view>
<view v-else class="list">
<view v-for="(line, idx) in lines" :key="idx" class="advice-item">
<text class="advice-index">{{ idx + 1 }}</text>
<text class="advice-text">{{ line }}</text>
</view>
</view>
</scroll-view>
</template>
<script>
const KEY_WHO = 'xixi_who_am_i';
const KEY_MEET = 'xixi_first_meeting';
function formatToday() {
const d = new Date();
const wk = ['日', '一', '二', '三', '四', '五', '六'];
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}日 星期${wk[d.getDay()]}`;
}
function trimSnippet(text, max) {
if (text == null || text === '') return '';
const s = String(text).trim().replace(/\s+/g, ' ');
if (!s) return '';
return s.length > max ? s.slice(0, max) + '…' : s;
}
function buildLines(who, meet) {
const w = who && typeof who === 'object' ? who : {};
const m = meet && typeof meet === 'object' ? meet : {};
const hasWho = !!(w.selfIntro && String(w.selfIntro).trim()) || !!(w.partnerExpect && String(w.partnerExpect).trim());
const hasMeet =
!!(m.relationshipStatus && String(m.relationshipStatus).trim()) || !!(m.meetingGoal && String(m.meetingGoal).trim());
if (!hasWho && !hasMeet) {
return { empty: true, lines: [] };
}
const lines = [];
lines.push('下面几条结合你在「我是谁」「约会准备」中的填写整理,方便你出门前快速过一遍思路。');
if (m.relationshipStatus) {
const sn = trimSnippet(m.relationshipStatus, 56);
lines.push(
`关系阶段你描述为「${sn}」:聊天节奏与对方投入程度尽量匹配这一阶段,不必急于定义关系,也避免过度冷淡让对方不安。`,
);
}
if (m.meetingGoal) {
const sn = trimSnippet(m.meetingGoal, 72);
lines.push(
`本次见面你希望「${sn}」:可以准备 12 个轻松的小话题或问题,自然带入即可,不必像完成任务一样逐项核对。`,
);
}
if (w.selfIntro) {
lines.push(
'「我是谁」里你写到的特点:今天选 12 点最想被对方感受到的,用小事或简短例子带过即可,留一点空间让对方慢慢了解你。',
);
}
if (w.partnerExpect) {
lines.push(
'关于对另一半的期待:更适合在互动中观察对方的言行是否合拍,用好奇和分享代替盘问,减少「考察感」。',
);
}
lines.push('见面时多倾听、适当复述对方话里的关键词,让对方感到被听懂;适当留白比一直找话题更重要。');
lines.push('若短暂冷场,可以从环境、共同经历或轻松八卦切入,不必为「必须有趣」而焦虑。');
lines.push('结束前可以自然聊聊今天的感受、是否愉快,再顺势看下次是否方便再约,不给对方过大压力。');
return { empty: false, lines };
}
export default {
name: 'XixiDatingAdvice',
props: {
refreshTick: {
type: Number,
default: 0,
},
},
data() {
return {
todayLabel: formatToday(),
emptyHint: true,
lines: [],
};
},
watch: {
refreshTick() {
this.regenerate();
},
},
mounted() {
this.regenerate();
},
methods: {
regenerate() {
this.todayLabel = formatToday();
let who = {};
let meet = {};
try {
const rawW = uni.getStorageSync(KEY_WHO);
if (rawW && typeof rawW === 'object') who = rawW;
} catch (e) {
/* ignore */
}
try {
const rawM = uni.getStorageSync(KEY_MEET);
if (rawM && typeof rawM === 'object') meet = rawM;
} catch (e) {
/* ignore */
}
const { empty, lines } = buildLines(who, meet);
this.emptyHint = empty;
this.lines = lines;
},
},
};
</script>
<style scoped>
.panel-scroll {
height: 100%;
box-sizing: border-box;
padding: 24rpx 28rpx 40rpx;
}
.head-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 24rpx rgba(233, 30, 140, 0.06);
}
.head-title {
display: block;
font-size: 34rpx;
font-weight: 600;
color: #333;
margin-bottom: 12rpx;
}
.head-date {
display: block;
font-size: 26rpx;
color: #e91e8c;
margin-bottom: 16rpx;
}
.head-note {
display: block;
font-size: 24rpx;
color: #888;
line-height: 1.55;
}
.empty-wrap {
background: #fff;
border-radius: 20rpx;
padding: 48rpx 32rpx;
text-align: center;
box-shadow: 0 4rpx 24rpx rgba(233, 30, 140, 0.04);
}
.empty-title {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
}
.empty-desc {
font-size: 26rpx;
color: #888;
line-height: 1.6;
}
.list {
display: flex;
flex-direction: column;
}
.advice-item {
display: flex;
flex-direction: row;
align-items: flex-start;
background: #fff;
border-radius: 20rpx;
padding: 24rpx 24rpx 24rpx 20rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(233, 30, 140, 0.06);
}
.advice-item:last-child {
margin-bottom: 0;
}
.advice-index {
flex-shrink: 0;
width: 44rpx;
height: 44rpx;
line-height: 44rpx;
text-align: center;
font-size: 24rpx;
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, #ff6b9d, #e91e8c);
border-radius: 12rpx;
margin-right: 16rpx;
margin-top: 4rpx;
}
.advice-text {
flex: 1;
font-size: 28rpx;
color: #444;
line-height: 1.65;
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<scroll-view class="panel-scroll" scroll-y>
<view v-if="!items.length" class="empty-wrap">
<text class="empty-title">暂无诊断结果</text>
<text class="empty-desc">请在幸福相遇中完成录音并点击分析录音</text>
</view>
<view v-for="(row, idx) in items" :key="row.id" class="diag-card">
<view class="diag-head">
<text class="diag-label">诊断 {{ items.length - idx }}</text>
<text class="diag-time">{{ formatDate(row.createdAt) }}</text>
</view>
<text class="diag-body">{{ row.analysisText }}</text>
</view>
</scroll-view>
</template>
<script>
const KEY = 'xixi_recordings';
export default {
name: 'XixiDiagnosis',
props: {
refreshTick: {
type: Number,
default: 0,
},
},
data() {
return {
items: [],
};
},
watch: {
refreshTick() {
this.load();
},
},
mounted() {
this.load();
},
methods: {
load() {
try {
const list = uni.getStorageSync(KEY);
const arr = Array.isArray(list) ? list : [];
this.items = arr.filter((r) => r && r.analysisText);
} catch (e) {
this.items = [];
}
},
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())}`;
},
},
};
</script>
<style scoped>
.panel-scroll {
height: 100%;
box-sizing: border-box;
padding: 24rpx 28rpx 32rpx;
}
.empty-wrap {
padding: 80rpx 32rpx;
text-align: center;
}
.empty-title {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
}
.empty-desc {
font-size: 26rpx;
color: #888;
line-height: 1.6;
}
.diag-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 24rpx rgba(233, 30, 140, 0.08);
border-left: 8rpx solid #ff6b9d;
}
.diag-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.diag-label {
font-size: 28rpx;
font-weight: 600;
color: #e91e8c;
}
.diag-time {
font-size: 24rpx;
color: #999;
}
.diag-body {
font-size: 28rpx;
color: #444;
line-height: 1.65;
white-space: pre-wrap;
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<scroll-view class="panel-scroll" scroll-y>
<view class="card">
<text class="label">现在两个人的关系</text>
<textarea
class="area area-sm"
v-model="local.relationshipStatus"
placeholder="例如:刚认识 / 暧昧期 / 已确定关系 / 异地恋…"
maxlength="800"
:show-confirm-bar="false"
/>
</view>
<view class="card">
<text class="label">本次见面要达到的结果</text>
<textarea
class="area"
v-model="local.meetingGoal"
placeholder="例如:更了解对方家庭观、约定下次约会、化解上次误会…"
maxlength="2000"
:show-confirm-bar="false"
/>
</view>
<button class="btn-save" type="primary" @click="save">保存</button>
</scroll-view>
</template>
<script>
const KEY = 'xixi_first_meeting';
export default {
name: 'XixiFirstMeeting',
data() {
return {
local: {
relationshipStatus: '',
meetingGoal: '',
},
};
},
mounted() {
this.load();
},
methods: {
load() {
try {
const raw = uni.getStorageSync(KEY);
if (raw && typeof raw === 'object') {
this.local.relationshipStatus = raw.relationshipStatus || '';
this.local.meetingGoal = raw.meetingGoal || '';
}
} catch (e) {
/* ignore */
}
},
save() {
try {
uni.setStorageSync(KEY, { ...this.local });
uni.showToast({ title: '已保存', icon: 'success' });
this.$emit('saved');
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' });
}
},
},
};
</script>
<style scoped>
.panel-scroll {
height: 100%;
box-sizing: border-box;
padding: 24rpx 28rpx 32rpx;
}
.card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 24rpx rgba(233, 30, 140, 0.06);
}
.label {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
}
.area {
width: 100%;
min-height: 200rpx;
font-size: 28rpx;
line-height: 1.6;
color: #333;
box-sizing: border-box;
padding: 16rpx;
background: #fff8fa;
border-radius: 12rpx;
}
.area-sm {
min-height: 120rpx;
}
.btn-save {
margin-top: 12rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #ff6b9d, #e91e8c);
border: none;
}
</style>

View File

@@ -0,0 +1,278 @@
<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 ? '你已填写「我是谁」,建议在真实对话中自然带出其中 12 点,避免像背稿。' : '尚未填写「我是谁」,补全后诊断可结合你的自我描述更贴切。';
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>

View File

@@ -0,0 +1,105 @@
<template>
<scroll-view class="panel-scroll" scroll-y>
<view class="card">
<text class="label">我是谁恋爱相关的自我描述</text>
<textarea
class="area"
v-model="local.selfIntro"
placeholder="例如:我的性格、价值观、在关系里看重什么…"
maxlength="2000"
:show-confirm-bar="false"
/>
</view>
<view class="card">
<text class="label">对另一半的要求</text>
<textarea
class="area"
v-model="local.partnerExpect"
placeholder="例如:希望对方具备的特质、相处方式、底线与期待…"
maxlength="2000"
:show-confirm-bar="false"
/>
</view>
<button class="btn-save" type="primary" @click="save">保存</button>
</scroll-view>
</template>
<script>
const KEY = 'xixi_who_am_i';
export default {
name: 'XixiWhoAmI',
data() {
return {
local: {
selfIntro: '',
partnerExpect: '',
},
};
},
mounted() {
this.load();
},
methods: {
load() {
try {
const raw = uni.getStorageSync(KEY);
if (raw && typeof raw === 'object') {
this.local.selfIntro = raw.selfIntro || '';
this.local.partnerExpect = raw.partnerExpect || '';
}
} catch (e) {
/* ignore */
}
},
save() {
try {
uni.setStorageSync(KEY, { ...this.local });
uni.showToast({ title: '已保存', icon: 'success' });
this.$emit('saved');
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' });
}
},
},
};
</script>
<style scoped>
.panel-scroll {
height: 100%;
box-sizing: border-box;
padding: 24rpx 28rpx 32rpx;
}
.card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 24rpx rgba(233, 30, 140, 0.06);
}
.label {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
}
.area {
width: 100%;
min-height: 220rpx;
font-size: 28rpx;
line-height: 1.6;
color: #333;
box-sizing: border-box;
padding: 16rpx;
background: #fff8fa;
border-radius: 12rpx;
}
.btn-save {
margin-top: 12rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #ff6b9d, #e91e8c);
border: none;
}
</style>