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:
@@ -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
Reference in New Issue
Block a user