feat: restore learning feedback and knowledge capabilities
This commit is contained in:
@@ -8,6 +8,11 @@
|
||||
<text class="toolbar-tip">先把问题问清楚,再按需生成总结卡,不伪造分类能力。</text>
|
||||
</view>
|
||||
|
||||
<view class="source-switch employee-card" aria-label="问答来源">
|
||||
<button class="source-option active" aria-current="page">企业问师傅</button>
|
||||
<button class="source-option" @click="openWebAi">问 AI(全网)</button>
|
||||
</view>
|
||||
|
||||
<view class="panel query-panel employee-card">
|
||||
<view class="query-hero">
|
||||
<view class="query-copy">
|
||||
@@ -25,13 +30,47 @@
|
||||
maxlength="200"
|
||||
:placeholder="quickQuestionPlaceholder"
|
||||
/>
|
||||
<view class="voice-query-actions">
|
||||
<button
|
||||
class="secondary-button voice-query-button"
|
||||
:class="{ 'recording-active': voiceRecording }"
|
||||
:disabled="loading || voiceTranscribing"
|
||||
@click="toggleVoiceRecording"
|
||||
>
|
||||
{{ voiceRecording ? '停止录音' : voiceTranscribing ? '转写中' : '语音提问' }}
|
||||
</button>
|
||||
<button
|
||||
class="ghost-button voice-query-button"
|
||||
:disabled="loading || voiceRecording || voiceTranscribing"
|
||||
@click="pickVoiceAudio"
|
||||
>
|
||||
{{ voiceTranscribing ? '转写中' : '选择音频' }}
|
||||
</button>
|
||||
</view>
|
||||
<text class="voice-query-hint">录音会先转成文字,确认问题后再提交;麦克风不可用时可选择音频。</text>
|
||||
<view class="media-actions">
|
||||
<button class="ghost-button media-button" :disabled="loading" @click="pickMedia">
|
||||
+ 上传图片或视频
|
||||
</button>
|
||||
<text class="media-hint">图片≤10MB,视频≤100MB;仅用于本次现场分析</text>
|
||||
</view>
|
||||
<view v-if="selectedMedia" class="media-file">
|
||||
<view class="media-file-copy">
|
||||
<text class="media-kind">{{ selectedMedia.kind === 'video' ? '现场视频' : '现场图片' }}</text>
|
||||
<text class="media-name">{{ selectedMedia.name }}</text>
|
||||
</view>
|
||||
<button class="media-remove" :disabled="loading" aria-label="移除附件" @click="clearMedia">×</button>
|
||||
</view>
|
||||
<text class="query-caption">常见问题可以直接点下面快捷问法,结果会保留引用依据。</text>
|
||||
<view class="chip-row">
|
||||
<button v-for="item in quickQuestions" :key="item" class="chip" :disabled="loading" @click="useQuickQuestion(item)">
|
||||
{{ item }}
|
||||
</button>
|
||||
</view>
|
||||
<button class="primary-button query-submit" :disabled="loading" @click="search">
|
||||
<button class="secondary-button data-tool-button" :disabled="loading" @click="runDataTool(defaultToolCode)">
|
||||
{{ defaultToolCode === 'TEAM_PRACTICE_SUMMARY' ? '团队训练概况' : '我的训练概况' }}
|
||||
</button>
|
||||
<button class="primary-button query-submit" :disabled="loading || voiceRecording || voiceTranscribing" @click="search">
|
||||
{{ loading ? '检索中' : '问老师傅' }}
|
||||
</button>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
@@ -61,7 +100,12 @@
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel answer-panel employee-card">
|
||||
<text class="panel-title">师傅回答</text>
|
||||
<view class="answer-title-row">
|
||||
<text class="panel-title">师傅回答</text>
|
||||
<button class="voice-button" :disabled="speechState.status === 'loading' && speechState.key !== 'answer'" @click="toggleAnswerSpeech">
|
||||
{{ speechButtonLabel('answer') }}
|
||||
</button>
|
||||
</view>
|
||||
<view class="question-card">
|
||||
<text class="question-label">我的问题</text>
|
||||
<text class="question-text">{{ query }}</text>
|
||||
@@ -69,6 +113,7 @@
|
||||
<view class="answer-copy-card">
|
||||
<text class="answer-text">{{ result.answer || '未生成直接答案,请查看下方引用。' }}</text>
|
||||
</view>
|
||||
<text v-if="result.noEvidence" class="status-message">当前授权空间内没有足够依据。</text>
|
||||
|
||||
<view v-if="result.keyPoints?.length" class="keypoints">
|
||||
<text v-for="(point, index) in result.keyPoints" :key="point" class="keypoint">
|
||||
@@ -80,7 +125,7 @@
|
||||
<text v-for="item in result.cautions" :key="item">{{ item }}</text>
|
||||
</view>
|
||||
|
||||
<view class="feedback-actions">
|
||||
<view v-if="currentToolCode === ''" class="feedback-actions">
|
||||
<button
|
||||
class="secondary-button"
|
||||
:class="{ active: feedbackVerdict === 'up' }"
|
||||
@@ -107,12 +152,13 @@
|
||||
<text class="panel-title">引用依据</text>
|
||||
<view v-for="(snippet, index) in result.snippets" :key="`${snippet.docTitle}-${index}`" class="snippet">
|
||||
<text class="snippet-title">{{ snippet.docTitle }}</text>
|
||||
<text v-if="snippet.spaceCode" class="snippet-id">知识空间:{{ snippet.spaceCode }}</text>
|
||||
<text class="snippet-content">{{ snippetExcerpt(snippet.content) }}</text>
|
||||
<text v-if="snippet.fragmentId" class="snippet-id">片段 #{{ snippet.fragmentId }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel summary-panel employee-card">
|
||||
<view v-if="result && currentToolCode === ''" class="panel summary-panel employee-card">
|
||||
<text class="panel-title">总结卡</text>
|
||||
<text class="muted summary-copy">需要时再生成,适合把一段 SOP 整理成步骤卡、话术卡和提醒卡。</text>
|
||||
<button class="ghost-button" :disabled="summaryLoading" @click="buildSummaryCard">
|
||||
@@ -185,7 +231,13 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="modal-footer">
|
||||
<button class="primary-button footer-btn" @click="closeSummary">我知道了</button>
|
||||
<button class="secondary-button footer-btn" :disabled="downloadingCard" @click="downloadSummary">
|
||||
{{ downloadingCard ? '生成中' : '下载图片' }}
|
||||
</button>
|
||||
<button class="secondary-button footer-btn" @click="toggleSummarySpeech">
|
||||
{{ speechButtonLabel('summary') }}
|
||||
</button>
|
||||
<button class="primary-button footer-btn footer-close" @click="closeSummary">我知道了</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -194,20 +246,39 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type {
|
||||
AnswerFeedbackVerdict,
|
||||
KnowledgeMediaSelectedFile,
|
||||
KnowledgeSearchResponse,
|
||||
SpeechPlaybackStatus,
|
||||
SummaryCardResponse
|
||||
} from '@/types/api';
|
||||
import {
|
||||
chooseKnowledgeMedia,
|
||||
generateSummaryCard,
|
||||
queryKnowledgeTool,
|
||||
searchKnowledge,
|
||||
searchKnowledgeWithMedia,
|
||||
submitAnswerFeedback
|
||||
} from '@/services/knowledge';
|
||||
import { getSelectedPosition, getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import {
|
||||
getSelectedPosition,
|
||||
getSelectedPositionProfile,
|
||||
isLoggedIn,
|
||||
isSupervisorLearnerMode,
|
||||
rememberLoginRedirect,
|
||||
roleKey
|
||||
} from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { ensureEmployeePosition } from '@/services/position';
|
||||
import {
|
||||
chooseSpeechAudio,
|
||||
createSpeechPlaybackController,
|
||||
transcribeSpeechBlob,
|
||||
transcribeSpeechFile
|
||||
} from '@/services/speech';
|
||||
import { downloadSummaryCardImage } from '@/services/summary-card-image';
|
||||
|
||||
const positionProfile = ref(getSelectedPositionProfile());
|
||||
const positionLabel = ref(getSelectedPosition());
|
||||
@@ -218,23 +289,62 @@ const result = ref<KnowledgeSearchResponse | null>(null);
|
||||
const summaryCard = ref<SummaryCardResponse | null>(null);
|
||||
const loading = ref(false);
|
||||
const summaryLoading = ref(false);
|
||||
const downloadingCard = ref(false);
|
||||
const selectedMedia = ref<KnowledgeMediaSelectedFile | null>(null);
|
||||
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
||||
const voiceRecording = ref(false);
|
||||
const voiceTranscribing = ref(false);
|
||||
const voiceRecorder = ref<MediaRecorder | null>(null);
|
||||
const voiceStream = ref<MediaStream | null>(null);
|
||||
const voiceStarting = ref(false);
|
||||
const feedbackSubmitting = ref(false);
|
||||
const feedbackVerdict = ref<AnswerFeedbackVerdict | ''>('');
|
||||
const message = ref('');
|
||||
const loggedIn = ref(false);
|
||||
const pendingSopQueryKey = 'aihr_sop_pending_query';
|
||||
const pendingToolKey = 'aihr_sop_pending_tool';
|
||||
const currentToolCode = ref<'' | 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY'>('');
|
||||
const currentRole = ref(String(uni.getStorageSync(roleKey) || ''));
|
||||
const supervisorLearner = ref(isSupervisorLearnerMode());
|
||||
let voiceCaptureGeneration = 0;
|
||||
let voiceTranscriptionGeneration = 0;
|
||||
let abortVoiceTranscription: (() => void) | null = null;
|
||||
let knowledgeSearchGeneration = 0;
|
||||
let activeMediaUploadTask: ReturnType<typeof uni.uploadFile> | null = null;
|
||||
const defaultToolCode = computed<'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY'>(() =>
|
||||
currentRole.value === 'supervisor' && !supervisorLearner.value
|
||||
? 'TEAM_PRACTICE_SUMMARY'
|
||||
: 'MY_PRACTICE_SUMMARY'
|
||||
);
|
||||
const speechPlayback = createSpeechPlaybackController(
|
||||
(state) => { speechState.value = state; },
|
||||
(text) => {
|
||||
message.value = text;
|
||||
uni.showToast({ title: text, icon: 'none' });
|
||||
}
|
||||
);
|
||||
|
||||
const resetResultState = () => {
|
||||
result.value = null;
|
||||
summaryCard.value = null;
|
||||
feedbackVerdict.value = '';
|
||||
feedbackSubmitting.value = false;
|
||||
currentToolCode.value = '';
|
||||
};
|
||||
|
||||
const refreshAuthState = () => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
positionLabel.value = getSelectedPosition();
|
||||
positionProfile.value = getSelectedPositionProfile();
|
||||
currentRole.value = String(uni.getStorageSync(roleKey) || '');
|
||||
supervisorLearner.value = isSupervisorLearnerMode();
|
||||
};
|
||||
|
||||
const cancelPendingMediaSearch = () => {
|
||||
knowledgeSearchGeneration += 1;
|
||||
activeMediaUploadTask?.abort();
|
||||
activeMediaUploadTask = null;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = loggedIn.value) => {
|
||||
@@ -249,12 +359,13 @@ const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = logg
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
if (loading.value) return;
|
||||
const value = query.value.trim();
|
||||
if (loading.value || voiceRecording.value || voiceTranscribing.value) return;
|
||||
const value = query.value.trim() || (selectedMedia.value ? '请根据附件现场情况,告诉我应该按什么 SOP 处理' : '');
|
||||
if (!value) {
|
||||
message.value = '请先输入问题';
|
||||
return;
|
||||
}
|
||||
query.value = value;
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
uni.setStorageSync(pendingSopQueryKey, value);
|
||||
@@ -267,25 +378,224 @@ const search = async () => {
|
||||
positionProfile.value = getSelectedPositionProfile();
|
||||
positionLabel.value = getSelectedPosition();
|
||||
loading.value = true;
|
||||
const generation = ++knowledgeSearchGeneration;
|
||||
message.value = '';
|
||||
resetResultState();
|
||||
try {
|
||||
result.value = await searchKnowledge(value);
|
||||
const nextResult = selectedMedia.value
|
||||
? await searchKnowledgeWithMedia(selectedMedia.value, value, getSelectedPosition(), (task) => {
|
||||
if (generation === knowledgeSearchGeneration) activeMediaUploadTask = task;
|
||||
})
|
||||
: await searchKnowledge(value);
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
result.value = nextResult;
|
||||
if (!result.value.answer && !result.value.snippets?.length) {
|
||||
message.value = '未检索到可引用内容';
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
handleRequestError(error, '检索失败', true);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (generation === knowledgeSearchGeneration) {
|
||||
activeMediaUploadTask = null;
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const runDataTool = async (toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY') => {
|
||||
if (loading.value) return;
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
uni.setStorageSync(pendingToolKey, toolCode);
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
const effectiveToolCode = supervisorLearner.value && toolCode === 'TEAM_PRACTICE_SUMMARY'
|
||||
? 'MY_PRACTICE_SUMMARY'
|
||||
: toolCode;
|
||||
const generation = ++knowledgeSearchGeneration;
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
resetResultState();
|
||||
selectedMedia.value = null;
|
||||
currentToolCode.value = effectiveToolCode;
|
||||
const question = effectiveToolCode === 'TEAM_PRACTICE_SUMMARY' ? '查询我的团队训练概况' : '查询我的训练概况';
|
||||
query.value = question;
|
||||
try {
|
||||
const nextResult = await queryKnowledgeTool(effectiveToolCode, question);
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
result.value = nextResult;
|
||||
} catch (error) {
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
handleRequestError(error, '训练概况查询失败', true);
|
||||
} finally {
|
||||
if (generation === knowledgeSearchGeneration) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const useQuickQuestion = (value: string) => {
|
||||
selectedMedia.value = null;
|
||||
query.value = value;
|
||||
void search();
|
||||
};
|
||||
|
||||
const pickMedia = async () => {
|
||||
if (loading.value) return;
|
||||
try {
|
||||
selectedMedia.value = await chooseKnowledgeMedia();
|
||||
message.value = selectedMedia.value.kind === 'video'
|
||||
? '已选择视频,提交后会提取语音、画面文字和场景信息'
|
||||
: '已选择图片,可补充一句现场问题后提交';
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : '附件选择失败';
|
||||
if (text !== '未选择附件') message.value = text;
|
||||
}
|
||||
};
|
||||
|
||||
const clearMedia = () => {
|
||||
if (loading.value) return;
|
||||
selectedMedia.value = null;
|
||||
};
|
||||
|
||||
const stopVoiceTracks = () => {
|
||||
voiceStream.value?.getTracks().forEach((track) => track.stop());
|
||||
voiceStream.value = null;
|
||||
};
|
||||
|
||||
const cancelVoiceTranscription = () => {
|
||||
voiceTranscriptionGeneration += 1;
|
||||
abortVoiceTranscription?.();
|
||||
abortVoiceTranscription = null;
|
||||
voiceTranscribing.value = false;
|
||||
};
|
||||
|
||||
const cancelVoiceRecording = () => {
|
||||
voiceCaptureGeneration += 1;
|
||||
cancelVoiceTranscription();
|
||||
voiceStarting.value = false;
|
||||
const recorder = voiceRecorder.value;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
recorder.ondataavailable = null;
|
||||
recorder.onstop = null;
|
||||
recorder.stop();
|
||||
}
|
||||
voiceRecorder.value = null;
|
||||
stopVoiceTracks();
|
||||
voiceRecording.value = false;
|
||||
};
|
||||
|
||||
const applyVoiceTranscription = (text?: string) => {
|
||||
const value = (text || '').trim();
|
||||
if (!value) throw new Error('语音没有识别到文字');
|
||||
const current = query.value.trim();
|
||||
query.value = current ? `${current}\n${value}` : value;
|
||||
message.value = '语音已转成文字,请确认问题后提交';
|
||||
};
|
||||
|
||||
const transcribeVoiceBlob = async (blob: Blob) => {
|
||||
if (voiceTranscribing.value) return;
|
||||
const generation = ++voiceTranscriptionGeneration;
|
||||
voiceTranscribing.value = true;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
const result = await transcribeSpeechBlob(blob, 'sop-question.webm', (abort) => {
|
||||
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||||
});
|
||||
if (generation !== voiceTranscriptionGeneration) return;
|
||||
applyVoiceTranscription(result.text);
|
||||
} catch (error) {
|
||||
if (generation !== voiceTranscriptionGeneration) return;
|
||||
message.value = error instanceof Error ? error.message : '语音转写失败,请改用文字输入';
|
||||
} finally {
|
||||
if (generation === voiceTranscriptionGeneration) {
|
||||
abortVoiceTranscription = null;
|
||||
voiceTranscribing.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pickVoiceAudio = async () => {
|
||||
if (loading.value || voiceRecording.value || voiceTranscribing.value) return;
|
||||
const generation = ++voiceTranscriptionGeneration;
|
||||
voiceTranscribing.value = true;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
const file = await chooseSpeechAudio();
|
||||
if (generation !== voiceTranscriptionGeneration) return;
|
||||
const result = await transcribeSpeechFile(file, (abort) => {
|
||||
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||||
});
|
||||
if (generation !== voiceTranscriptionGeneration) return;
|
||||
applyVoiceTranscription(result.text);
|
||||
} catch (error) {
|
||||
if (generation !== voiceTranscriptionGeneration) return;
|
||||
const text = error instanceof Error ? error.message : '语音转写失败,请改用文字输入';
|
||||
if (text !== '未选择音频') message.value = text;
|
||||
} finally {
|
||||
if (generation === voiceTranscriptionGeneration) {
|
||||
abortVoiceTranscription = null;
|
||||
voiceTranscribing.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startVoiceRecording = async () => {
|
||||
if (loading.value || voiceStarting.value || voiceRecording.value || voiceTranscribing.value) return;
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
await pickVoiceAudio();
|
||||
return;
|
||||
}
|
||||
const generation = ++voiceCaptureGeneration;
|
||||
voiceStarting.value = true;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (generation !== voiceCaptureGeneration) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
voiceStream.value = stream;
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: Blob[] = [];
|
||||
voiceRecorder.value = recorder;
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data?.size) chunks.push(event.data);
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
voiceRecording.value = false;
|
||||
voiceRecorder.value = null;
|
||||
stopVoiceTracks();
|
||||
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
|
||||
if (!blob.size) {
|
||||
message.value = '未录到音频,请改用文字输入';
|
||||
return;
|
||||
}
|
||||
void transcribeVoiceBlob(blob);
|
||||
};
|
||||
recorder.start();
|
||||
voiceRecording.value = true;
|
||||
message.value = '正在录音,再点一次结束';
|
||||
} catch {
|
||||
stopVoiceTracks();
|
||||
if (generation === voiceCaptureGeneration) await pickVoiceAudio();
|
||||
} finally {
|
||||
if (generation === voiceCaptureGeneration) voiceStarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleVoiceRecording = () => {
|
||||
if (voiceStarting.value) return;
|
||||
if (voiceRecording.value) {
|
||||
const recorder = voiceRecorder.value;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
message.value = '录音结束,正在转写';
|
||||
recorder.stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
void startVoiceRecording();
|
||||
};
|
||||
|
||||
const snippetExcerpt = (text = '') => {
|
||||
const value = text.trim();
|
||||
return value.length > 260 ? `${value.slice(0, 260)}...` : value;
|
||||
@@ -350,14 +660,67 @@ const buildSummaryCard = async () => {
|
||||
};
|
||||
|
||||
const closeSummary = () => {
|
||||
if (speechState.value.key === 'summary') speechPlayback.stop();
|
||||
summaryCard.value = null;
|
||||
};
|
||||
|
||||
const speechButtonLabel = (key: string) => {
|
||||
if (speechState.value.key !== key) return '语音播报';
|
||||
if (speechState.value.status === 'loading') return '生成语音中';
|
||||
if (speechState.value.status === 'playing') return '停止播报';
|
||||
return '语音播报';
|
||||
};
|
||||
|
||||
const answerSpeechText = () => {
|
||||
if (!result.value) return '';
|
||||
return [
|
||||
result.value.answer,
|
||||
...(result.value.keyPoints || []).map((item, index) => `第${index + 1}点,${cleanOrderedText(item)}`),
|
||||
...(result.value.cautions || []).map((item) => `注意,${item}`)
|
||||
].filter(Boolean).join('。');
|
||||
};
|
||||
|
||||
const summarySpeechText = () => {
|
||||
if (!summaryCard.value) return '';
|
||||
return [
|
||||
summaryCard.value.title,
|
||||
...(summaryCard.value.steps || []).map((item, index) => `第${index + 1}步,${item.label},${item.desc}`),
|
||||
...(summaryCard.value.reminders || []).map((item) => `师傅提醒,${item}`)
|
||||
].filter(Boolean).join('。');
|
||||
};
|
||||
|
||||
const toggleAnswerSpeech = () => {
|
||||
void speechPlayback.toggle('answer', answerSpeechText());
|
||||
};
|
||||
|
||||
const toggleSummarySpeech = () => {
|
||||
void speechPlayback.toggle('summary', summarySpeechText());
|
||||
};
|
||||
|
||||
const downloadSummary = async () => {
|
||||
if (!summaryCard.value || downloadingCard.value) return;
|
||||
downloadingCard.value = true;
|
||||
try {
|
||||
await downloadSummaryCardImage(summaryCard.value);
|
||||
uni.showToast({ title: '总结卡图片已下载', icon: 'none' });
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : '总结卡下载失败';
|
||||
message.value = text;
|
||||
uni.showToast({ title: text, icon: 'none' });
|
||||
} finally {
|
||||
downloadingCard.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect('/pages/user/sop/index');
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const openWebAi = () => {
|
||||
uni.navigateTo({ url: '/pages/user/web-ai/index' });
|
||||
};
|
||||
|
||||
const handleShow = async () => {
|
||||
resetPageScroll();
|
||||
refreshAuthState();
|
||||
@@ -369,6 +732,17 @@ const handleShow = async () => {
|
||||
resetResultState();
|
||||
}
|
||||
const pending = uni.getStorageSync(pendingSopQueryKey);
|
||||
const pendingTool = uni.getStorageSync(pendingToolKey);
|
||||
if (pendingTool === 'MY_PRACTICE_SUMMARY' || pendingTool === 'TEAM_PRACTICE_SUMMARY') {
|
||||
if (!loggedIn.value) {
|
||||
message.value = '登录后继续查询训练概况';
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
uni.removeStorageSync(pendingToolKey);
|
||||
void runDataTool(pendingTool);
|
||||
return;
|
||||
}
|
||||
if (!pending) {
|
||||
if (loggedIn.value && query.value.trim() && message.value === '登录后继续查询该问题') {
|
||||
void search();
|
||||
@@ -387,6 +761,17 @@ const handleShow = async () => {
|
||||
onShow(() => {
|
||||
void handleShow();
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
speechPlayback.stop();
|
||||
cancelVoiceRecording();
|
||||
cancelPendingMediaSearch();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
speechPlayback.destroy();
|
||||
cancelVoiceRecording();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -414,6 +799,26 @@ onShow(() => {
|
||||
content: '';
|
||||
}
|
||||
|
||||
.source-switch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
margin-top: 16px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.source-option {
|
||||
min-height: 44px;
|
||||
background: transparent;
|
||||
color: var(--employee-muted);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.source-option.active {
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.sop-eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 12px;
|
||||
@@ -798,7 +1203,7 @@ onShow(() => {
|
||||
background: #168243;
|
||||
color: #ffffff;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -814,13 +1219,13 @@ onShow(() => {
|
||||
/* Steps Timeline */
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
left: 8px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 2px;
|
||||
@@ -840,16 +1245,16 @@ onShow(() => {
|
||||
|
||||
.timeline-node {
|
||||
position: absolute;
|
||||
left: -14px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
left: -18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #168243;
|
||||
color: #ffffff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@@ -921,9 +1326,9 @@ onShow(() => {
|
||||
|
||||
.q-badge,
|
||||
.a-badge {
|
||||
flex: 0 0 18px;
|
||||
height: 18px;
|
||||
font-size: 10px;
|
||||
flex: 0 0 22px;
|
||||
height: 22px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
@@ -1283,4 +1688,131 @@ onShow(() => {
|
||||
.query-panel .query-caption {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.media-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.voice-query-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.voice-query-button {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.voice-query-button.recording-active {
|
||||
border-color: var(--employee-primary);
|
||||
background: #fff0f1;
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.voice-query-hint {
|
||||
display: block;
|
||||
margin-top: -2px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.media-button {
|
||||
min-height: 42px;
|
||||
margin: 0;
|
||||
padding: 0 14px;
|
||||
border-color: #cfe0ff;
|
||||
color: var(--employee-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.media-hint {
|
||||
flex: 1;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.media-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #cfe0ff;
|
||||
border-radius: 14px;
|
||||
background: var(--employee-primary-soft);
|
||||
}
|
||||
|
||||
.media-file-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.media-kind {
|
||||
color: var(--employee-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.media-name {
|
||||
overflow: hidden;
|
||||
color: var(--employee-text);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-remove {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
color: var(--employee-muted);
|
||||
font-size: 21px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.answer-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.voice-button {
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #f3b7bd;
|
||||
border-radius: 999px;
|
||||
background: #fff7f8;
|
||||
color: var(--employee-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal-footer .footer-btn {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-footer .footer-close {
|
||||
grid-column: span 2;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user