feat(mobile): rebuild practice session as WeChat-style chat (batch 2)

对练进行中(practiceView=active)改为微信式会话,复用问页同款形态;
预习(场景卡/最近练过/每日三题)与评分/满意度视图保持不变,
start/turn/finish/求助/风格重生成/幂等/红线/满意度脚本逻辑零改动。

ChatComposer:新增 panelItems 属性(自定义⊕面板项+panel事件),
sop 页默认面板不受影响。

practice/index.vue:
- 会话视图:固定铺满窗口区;头部=场景名+第N/3轮+情绪/信任细条+红线提示
- 业主台词=语音优先气泡(TTS预合成+时长+未读红点+转文字/收起,
  TTS不可用回退文本);我的回复右侧粉色气泡;老师傅提示/求助SOP=
  蓝色教练气泡(带播报/引用来源);提交中「业主回应中…」系统气泡
- 风格chips(再严肃/再温柔/更细致/更专业)横排贴输入条上方
- 输入条:按住说话松开即发送(转写→带audioOssId直接提交回合),
  键盘切换,⊕面板=选音频转文字/求助SOP/结束评分(结束评分随
  canFinishPractice置灰)
- 错题重说(红线)、退避回滚(提交失败恢复对话)原逻辑保留

验证:
- 本地全链路:开始练习→业主语音气泡6"+红点→转文字→键盘回复→
  老师傅提示+4风格chips→风格调整→⊕求助SOP气泡→第二轮→
  ⊕结束评分→评分74+老师傅建议+满意度,8张截图目检
- 生产冒烟:语音气泡+红点+按住说话+老师傅提示+风格chips全就位
- H5已发布(备份 h5-before-20260718052458);冲突表已更新

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 05:26:28 +08:00
co-authored by Claude Fable 5
parent 95dfd653ba
commit 9d7d621300
3 changed files with 575 additions and 81 deletions
+36 -14
View File
@@ -69,20 +69,34 @@
</view>
<view v-if="panelOpen" class="composer-panel">
<button class="panel-item" :disabled="disabled" @click="panelEmit('pick-media')">
<text class="panel-item-icon">🖼</text>
<text class="panel-item-label">图片/视频</text>
</button>
<button class="panel-item" :disabled="disabled || transcribing" @click="panelEmit('pick-audio')">
<text class="panel-item-icon">🎵</text>
<text class="panel-item-label">选音频转文字</text>
</button>
<button v-if="toolLabel" class="panel-item" :disabled="disabled" @click="panelEmit('tool')">
<text class="panel-item-icon">📊</text>
<text class="panel-item-label">{{ toolLabel }}</text>
</button>
<template v-if="panelItems.length">
<button
v-for="item in panelItems"
:key="item.key"
class="panel-item"
:disabled="disabled || item.disabled"
@click="panelAction(item.key)"
>
<text class="panel-item-icon">{{ item.icon }}</text>
<text class="panel-item-label">{{ item.label }}</text>
</button>
</template>
<template v-else>
<button class="panel-item" :disabled="disabled" @click="panelEmit('pick-media')">
<text class="panel-item-icon">🖼</text>
<text class="panel-item-label">图片/视频</text>
</button>
<button class="panel-item" :disabled="disabled || transcribing" @click="panelEmit('pick-audio')">
<text class="panel-item-icon">🎵</text>
<text class="panel-item-label">选音频转文字</text>
</button>
<button v-if="toolLabel" class="panel-item" :disabled="disabled" @click="panelEmit('tool')">
<text class="panel-item-icon">📊</text>
<text class="panel-item-label">{{ toolLabel }}</text>
</button>
</template>
</view>
<text v-if="panelOpen" class="panel-hint">图片≤10MB,视频≤100MB;仅用于本次现场分析</text>
<text v-if="panelOpen && !panelItems.length" class="panel-hint">图片≤10MB,视频≤100MB;仅用于本次现场分析</text>
</view>
</template>
@@ -98,6 +112,7 @@ const props = withDefaults(defineProps<{
mediaName?: string;
mediaKind?: string;
toolLabel?: string;
panelItems?: Array<{ key: string; icon: string; label: string; disabled?: boolean }>;
}>(), {
disabled: false,
transcribing: false,
@@ -106,7 +121,8 @@ const props = withDefaults(defineProps<{
quickQuestions: () => [],
mediaName: '',
mediaKind: '',
toolLabel: ''
toolLabel: '',
panelItems: () => []
});
const emit = defineEmits<{
@@ -117,6 +133,7 @@ const emit = defineEmits<{
(e: 'clear-media'): void;
(e: 'quick', question: string): void;
(e: 'tool'): void;
(e: 'panel', key: string): void;
}>();
const draft = ref('');
@@ -150,6 +167,11 @@ const panelEmit = (event: 'pick-media' | 'pick-audio' | 'tool') => {
emit(event);
};
const panelAction = (key: string) => {
panelOpen.value = false;
emit('panel', key);
};
const stopTracks = () => {
stream?.getTracks().forEach((track) => track.stop());
stream = null;