feat(mobile): rebuild SOP ask page as WeChat-style chat (batch 1)

客户 2026-07-18 新要求:语音文字交互改为微信聊天式,替代表单式操作
(客户原话 > Figma,已记入版本对比冲突表)。

新增 components/chat/ChatComposer.vue(可复用,练页批次二直接使用):
- 微信式输入条:⊕功能面板(图片/视频、选音频转文字、训练概况)、
  按住说话(touch+mouse,松开即发送/滑出取消)、麦克风⇄键盘切换、
  文本发送;录音兜底:环境不支持自动转选音频
- 快捷问题 chips 横向滚动置于输入条上方;附件以可移除 chip 展示

pages/user/sop/index.vue 重构为消息流模型:
- user/master/system/pending 四类消息;答案气泡内含要点/提醒/无依据提示,
  操作行=播报、引用N条(折叠展开)、总结卡、有帮助/没解决(逐条反馈)
- 语音松开即发送(按用户决策):转写成功直接成为消息(带🎤标),
  失败以系统气泡提示;选音频同语义
- 登录守卫、pending query/tool、主管学员态、生成计数取消、
  onHide/onUnload 清理全部保留;总结卡弹窗(下载/播报)原样保留

验证:
- 本地(构建产物+生产API)9 张截图人工目检:空态/快捷词问答/引用展开/
  键盘输入发送/按住录音/语音失败系统气泡/⊕面板/总结卡/超大字号档
- 生产冒烟:composer 存在 + 完整问答回合渲染通过
- H5 已发布(备份 h5-before-20260718044611)

批次二(待办):练页会话化(复用 ChatComposer)、web-ai 页统一、
旧表单样式清理。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 04:47:33 +08:00
co-authored by Claude Fable 5
parent dfd82e1886
commit bf1e881a8c
3 changed files with 990 additions and 373 deletions
@@ -86,3 +86,9 @@ Figma 已完成全文件结构和视觉复核,当前实测为 81 个顶层页
- 阶段与产品定义:[银城员工端APP分阶段实施总纲.md](银城员工端APP分阶段实施总纲.md)、[20260708/数字师傅学练问报整合方案.md](20260708/数字师傅学练问报整合方案.md)。
- API 与运行边界:[API_INTEGRATION.md](API_INTEGRATION.md)、[DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md)。
- 历史可运行源码:`mobile/src/App.vue`;当前权威源码:`mobile-uni/`、`frontend/`、`backend/ruoyi-modules/ruoyi-aihr/`。
## 2026-07-18 追加:问答/对练交互形态(客户新要求)
| 功能 | 客户要求 | Figma | 当前代码 | 线上状态 | 处理决定 |
| --- | --- | --- | --- | --- | --- |
| 语音文字交互形态 | 2026-07-18 客户原话:凡语音文字交互处应做成微信聊天式(消息流+按住说话),现表单式操作太复杂 | 表单式布局(输入框+按钮组) | 问页已改会话流:ChatComposer(⊕面板/按住说话松开即发/键盘切换)+气泡消息流,引用/总结卡/反馈/播报收入答案气泡操作行 | 已发布并生产冒烟通过(2026-07-18) | 按客户要求执行,客户原话 > Figma;练页为第二批,web-ai 页随批次二统一 |
@@ -0,0 +1,408 @@
<template>
<view class="chat-composer-wrap">
<scroll-view v-if="quickQuestions.length" scroll-x class="chip-scroll" :show-scrollbar="false">
<view class="chip-row">
<button
v-for="item in quickQuestions"
:key="item"
class="chip"
:disabled="disabled"
@click="emit('quick', item)"
>
{{ item }}
</button>
</view>
</scroll-view>
<view v-if="mediaName" class="media-chip">
<text class="media-chip-kind">{{ mediaKind === 'video' ? '现场视频' : '现场图片' }}</text>
<text class="media-chip-name">{{ mediaName }}</text>
<button class="media-chip-remove" :disabled="disabled" aria-label="移除附件" @click="emit('clear-media')">×</button>
</view>
<view class="composer-bar">
<button class="composer-icon" :disabled="disabled || recording" aria-label="更多功能" @click="panelOpen = !panelOpen">
<text class="composer-icon-text">{{ panelOpen ? '×' : '+' }}</text>
</button>
<button
v-if="voiceMode"
class="hold-talk"
:class="{ recording }"
:disabled="disabled || transcribing"
@touchstart.prevent="startHold"
@touchend.prevent="endHold"
@touchcancel.prevent="cancelHold"
@mousedown.prevent="startHold"
@mouseup.prevent="endHold"
@mouseleave="cancelHold"
>
{{ recording ? '松开 发送' : transcribing ? '转写中…' : '按住 说话' }}
</button>
<textarea
v-else
v-model="draft"
class="composer-input"
:maxlength="maxlength"
:placeholder="placeholder"
:disabled="disabled"
auto-height
/>
<button
class="composer-icon"
:disabled="disabled || recording || transcribing"
:aria-label="voiceMode ? '切换键盘输入' : '切换语音输入'"
@click="toggleVoiceMode"
>
<text class="composer-icon-text">{{ voiceMode ? '⌨' : '🎤' }}</text>
</button>
<button
v-if="!voiceMode && (draft.trim() || mediaName)"
class="composer-send"
:disabled="disabled"
@click="sendDraft"
>
发送
</button>
</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>
</view>
<text v-if="panelOpen" class="panel-hint">图片≤10MB,视频≤100MB;仅用于本次现场分析</text>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const props = withDefaults(defineProps<{
disabled?: boolean;
transcribing?: boolean;
placeholder?: string;
maxlength?: number;
quickQuestions?: string[];
mediaName?: string;
mediaKind?: string;
toolLabel?: string;
}>(), {
disabled: false,
transcribing: false,
placeholder: '输入问题…',
maxlength: 200,
quickQuestions: () => [],
mediaName: '',
mediaKind: '',
toolLabel: ''
});
const emit = defineEmits<{
(e: 'send', text: string): void;
(e: 'voice-blob', blob: Blob): void;
(e: 'pick-audio'): void;
(e: 'pick-media'): void;
(e: 'clear-media'): void;
(e: 'quick', question: string): void;
(e: 'tool'): void;
}>();
const draft = ref('');
const voiceMode = ref(true);
const panelOpen = ref(false);
const recording = ref(false);
let recorder: MediaRecorder | null = null;
let stream: MediaStream | null = null;
let holdGeneration = 0;
let discardRecording = false;
const supportsRecording = () =>
typeof navigator !== 'undefined' && Boolean(navigator.mediaDevices?.getUserMedia) && typeof MediaRecorder !== 'undefined';
const toggleVoiceMode = () => {
voiceMode.value = !voiceMode.value;
panelOpen.value = false;
};
const sendDraft = () => {
const text = draft.value.trim();
if (!text && !props.mediaName) return;
draft.value = '';
panelOpen.value = false;
emit('send', text);
};
const panelEmit = (event: 'pick-media' | 'pick-audio' | 'tool') => {
panelOpen.value = false;
emit(event);
};
const stopTracks = () => {
stream?.getTracks().forEach((track) => track.stop());
stream = null;
};
const startHold = async () => {
if (props.disabled || props.transcribing || recording.value) return;
if (!supportsRecording()) {
emit('pick-audio');
return;
}
const generation = ++holdGeneration;
discardRecording = false;
try {
const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (generation !== holdGeneration) {
mediaStream.getTracks().forEach((track) => track.stop());
return;
}
stream = mediaStream;
const mediaRecorder = new MediaRecorder(mediaStream);
const chunks: Blob[] = [];
recorder = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data?.size) chunks.push(event.data);
};
mediaRecorder.onstop = () => {
recording.value = false;
recorder = null;
stopTracks();
if (discardRecording) return;
const blob = new Blob(chunks, { type: mediaRecorder.mimeType || 'audio/webm' });
if (blob.size) emit('voice-blob', blob);
};
mediaRecorder.start();
recording.value = true;
} catch {
stopTracks();
if (generation === holdGeneration) emit('pick-audio');
}
};
const endHold = () => {
if (!recording.value) return;
if (recorder && recorder.state !== 'inactive') recorder.stop();
};
const cancelHold = () => {
if (!recording.value) return;
discardRecording = true;
if (recorder && recorder.state !== 'inactive') recorder.stop();
};
const teardown = () => {
holdGeneration += 1;
discardRecording = true;
if (recorder && recorder.state !== 'inactive') {
recorder.ondataavailable = null;
recorder.onstop = null;
recorder.stop();
}
recorder = null;
stopTracks();
recording.value = false;
};
defineExpose({ teardown });
</script>
<style scoped>
.chat-composer-wrap {
border-top: 1px solid #ebedf0;
background: #f7f8fa;
padding: 8px 10px calc(10px + env(safe-area-inset-bottom));
}
.chip-scroll {
width: 100%;
margin-bottom: 8px;
white-space: nowrap;
}
.chip-row {
display: inline-flex;
gap: 8px;
}
.chip {
flex-shrink: 0;
margin: 0;
padding: 5px 12px;
border: 1px solid #f0c0c5;
border-radius: 999px;
background: #ffffff;
color: #c8001a;
font-size: 13px;
line-height: 1.4;
}
.media-chip {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding: 6px 10px;
border: 1px solid #e3e6ea;
border-radius: 10px;
background: #ffffff;
}
.media-chip-kind {
flex-shrink: 0;
color: #c8001a;
font-size: 12px;
font-weight: 600;
}
.media-chip-name {
flex: 1;
overflow: hidden;
color: #4d5560;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-chip-remove {
flex-shrink: 0;
width: 24px;
height: 24px;
margin: 0;
padding: 0;
border-radius: 50%;
background: #eef0f3;
color: #6b7480;
font-size: 15px;
line-height: 24px;
}
.composer-bar {
display: flex;
align-items: flex-end;
gap: 8px;
}
.composer-icon {
flex-shrink: 0;
width: 38px;
height: 38px;
margin: 0;
padding: 0;
border: 1px solid #dfe3e8;
border-radius: 50%;
background: #ffffff;
line-height: 36px;
}
.composer-icon-text {
color: #3a4350;
font-size: 19px;
}
.hold-talk {
flex: 1;
min-height: 38px;
margin: 0;
border: 1px solid #dfe3e8;
border-radius: 10px;
background: #ffffff;
color: #232a33;
font-size: 16px;
font-weight: 600;
user-select: none;
-webkit-user-select: none;
}
.hold-talk.recording {
background: #e70014;
border-color: #e70014;
color: #ffffff;
}
.composer-input {
flex: 1;
box-sizing: border-box;
width: auto;
min-height: 38px;
max-height: 110px;
padding: 9px 12px;
border: 1px solid #dfe3e8;
border-radius: 10px;
background: #ffffff;
font-size: 15px;
line-height: 1.4;
}
.composer-send {
flex-shrink: 0;
min-height: 38px;
margin: 0;
padding: 0 16px;
border-radius: 10px;
background: #e70014;
color: #ffffff;
font-size: 15px;
font-weight: 600;
}
.composer-panel {
display: flex;
gap: 18px;
margin-top: 12px;
padding: 4px 6px 0;
}
.panel-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin: 0;
padding: 0;
background: transparent;
border: none;
line-height: 1.2;
}
.panel-item::after {
border: none;
}
.panel-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border: 1px solid #e6e9ed;
border-radius: 14px;
background: #ffffff;
font-size: 24px;
}
.panel-item-label {
color: #5a6270;
font-size: 12px;
}
.panel-hint {
display: block;
margin-top: 8px;
padding-left: 6px;
color: #98a1ad;
font-size: 11px;
}
</style>
File diff suppressed because it is too large Load Diff