fix(mobile-uni): 极简压缩“问”模块顶部Header与项目栏,大幅释放对话框垂直空间
This commit is contained in:
@@ -1,28 +1,39 @@
|
||||
<template>
|
||||
<view class="ask-source-head">
|
||||
<view class="head-row">
|
||||
<text class="head-title">问 · 数字师傅</text>
|
||||
<view class="head-actions">
|
||||
<text class="head-context">{{ context }}</text>
|
||||
<button class="new-chat-button" :disabled="loading" @click="emit('newConversation')">新对话</button>
|
||||
<view class="ask-source-head glass-header">
|
||||
<view class="compact-header-row">
|
||||
<!-- Left: Title & Status Badge -->
|
||||
<view class="brand-box">
|
||||
<text class="brand-name">数字师傅</text>
|
||||
<view class="status-pill">
|
||||
<view class="green-dot" />
|
||||
<text class="status-text">AI在线</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="source-switch" aria-label="问答来源">
|
||||
<button
|
||||
class="source-option"
|
||||
:class="{ active: active === 'assistant' }"
|
||||
:aria-current="active === 'assistant' ? 'page' : undefined"
|
||||
@click="selectAssistant"
|
||||
>
|
||||
工作助手
|
||||
</button>
|
||||
<button
|
||||
class="source-option"
|
||||
:class="{ active: active === 'web' }"
|
||||
:aria-current="active === 'web' ? 'page' : undefined"
|
||||
@click="selectWeb"
|
||||
>
|
||||
查全网
|
||||
|
||||
<!-- Center: Switcher Capsule -->
|
||||
<view class="source-switch" aria-label="问答来源">
|
||||
<button
|
||||
class="source-option"
|
||||
:class="{ active: active === 'assistant' }"
|
||||
:aria-current="active === 'assistant' ? 'page' : undefined"
|
||||
@click="selectAssistant"
|
||||
>
|
||||
工作助手
|
||||
</button>
|
||||
<button
|
||||
class="source-option"
|
||||
:class="{ active: active === 'web' }"
|
||||
:aria-current="active === 'web' ? 'page' : undefined"
|
||||
@click="selectWeb"
|
||||
>
|
||||
查全网
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- Right: Compact New Chat Button -->
|
||||
<button class="new-chat-btn" :disabled="loading" title="开启新对话" @click="emit('newConversation')">
|
||||
<uni-icons type="plusempty" size="13" color="#e60012" />
|
||||
<text class="new-chat-text">新对话</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -31,9 +42,10 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
active: 'assistant' | 'web';
|
||||
context: string;
|
||||
context?: string;
|
||||
loading?: boolean;
|
||||
}>(), {
|
||||
context: '',
|
||||
loading: false
|
||||
});
|
||||
|
||||
@@ -55,102 +67,111 @@ const selectWeb = () => {
|
||||
<style scoped>
|
||||
.ask-source-head {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px 6px;
|
||||
border-bottom: 1px solid #ebedf0;
|
||||
padding: 6px 12px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.head-row {
|
||||
.compact-header-row {
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.head-title {
|
||||
flex-shrink: 0;
|
||||
color: #18202b;
|
||||
.brand-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
color: #1a1a1a;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
.status-pill {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.head-context {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #7b8492;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.new-chat-button {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--employee-primary, #e60012);
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--employee-primary, #e60012);
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
background: #edf7f0;
|
||||
}
|
||||
|
||||
.new-chat-button[disabled] {
|
||||
opacity: 0.6;
|
||||
.green-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 6px rgba(16, 185, 129, 0.6);
|
||||
}
|
||||
|
||||
.new-chat-button::after,
|
||||
.source-option::after {
|
||||
border: none;
|
||||
.status-text {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.source-switch {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
align-items: center;
|
||||
padding: 3px;
|
||||
border-radius: 999px;
|
||||
background: #f2f3f5;
|
||||
background: #f1f3f7;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.source-option {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #5a6270;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.source-option.active {
|
||||
background: #ffffff;
|
||||
color: #c8001a;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 1px 4px rgba(20, 24, 31, 0.08);
|
||||
color: #e60012;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.source-option::after,
|
||||
.new-chat-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(230, 0, 18, 0.2);
|
||||
border-radius: 999px;
|
||||
background: #fff5f5;
|
||||
flex: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.new-chat-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.new-chat-text {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: #e60012;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,29 +3,44 @@
|
||||
<AskSourceHeader
|
||||
active="assistant"
|
||||
:context="headerContext"
|
||||
:loading="loading || voiceTranscribing"
|
||||
:loading="loading || voiceTranscribing || broadcastContextLoading"
|
||||
@select-web="openWebAi"
|
||||
@new-conversation="startNewConversation"
|
||||
/>
|
||||
|
||||
<view v-if="loggedIn" class="project-context-bar">
|
||||
<view v-if="broadcastContextLoading" class="broadcast-context-loading" role="status" aria-live="polite">
|
||||
正在确认消息咨询…
|
||||
</view>
|
||||
<view v-else-if="broadcastContext" class="broadcast-context-bar" role="status" aria-live="polite">
|
||||
<view class="broadcast-context-copy">
|
||||
<text class="broadcast-context-kicker">正在咨询这条消息</text>
|
||||
<text class="broadcast-context-title">{{ broadcastContext.title }}</text>
|
||||
<text class="broadcast-context-note">可用文字或语音追问;图片、视频和数据查询在本次咨询中不可用。</text>
|
||||
</view>
|
||||
<view class="broadcast-context-actions">
|
||||
<button class="broadcast-context-action" @click="viewBroadcastMessage">查看原消息</button>
|
||||
<button class="broadcast-context-action end" @click="endBroadcastConsultation">结束咨询</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loggedIn && !conversationStarted" class="compact-project-strip">
|
||||
<button
|
||||
class="project-context-button"
|
||||
class="project-pill-btn"
|
||||
:class="{ missing: availableProjects.length > 1 && !currentProject }"
|
||||
:disabled="projectLoading || loading"
|
||||
@click="openProjectPicker"
|
||||
>
|
||||
<view class="project-context-copy">
|
||||
<text class="project-context-label">当前项目</text>
|
||||
<text class="project-context-name">{{ projectContextLabel }}</text>
|
||||
</view>
|
||||
<uni-icons v-if="availableProjects.length > 1" type="down" size="15" color="#596474" />
|
||||
<uni-icons type="location-filled" size="13" color="#e60012" />
|
||||
<text class="project-pill-name">{{ projectContextLabel }}</text>
|
||||
<uni-icons v-if="availableProjects.length > 1" type="down" size="11" color="#64748b" />
|
||||
</button>
|
||||
<button class="personal-lib-btn" @click="openPersonalAssistant">
|
||||
<uni-icons type="folder-add" size="13" color="#64748b" />
|
||||
<text class="personal-lib-text">我的资料</text>
|
||||
</button>
|
||||
<text v-if="!projectLoading && !availableProjects.length" class="project-context-note">未绑定项目,可继续查制度;项目工作请联系管理员</text>
|
||||
<button class="personal-library-entry" @click="openPersonalAssistant">我的资料</button>
|
||||
</view>
|
||||
|
||||
<button v-if="loggedIn && currentProject" class="today-result-entry" @click="openTodayResult">
|
||||
<button v-if="loggedIn && currentProject && !conversationStarted" class="today-result-entry" @click="openTodayResult">
|
||||
<view class="today-result-copy">
|
||||
<text class="today-result-title">今日工作成果</text>
|
||||
<text class="today-result-note">按当前项目汇总已确认记录</text>
|
||||
@@ -41,7 +56,7 @@
|
||||
:scroll-with-animation="true"
|
||||
>
|
||||
<view class="thread-inner">
|
||||
<view class="bubble-row left">
|
||||
<view v-if="!conversationStarted" class="bubble-row left">
|
||||
<image class="avatar master-avatar" src="/static/mentor-female-avatar.png" mode="aspectFill" />
|
||||
<view class="bubble master">
|
||||
<text class="bubble-text">工作里的问题直接问我,我会查制度、SOP、项目记录和经你确认保存的事项;也可以按住下面的按钮说话。</text>
|
||||
@@ -118,10 +133,10 @@
|
||||
<button v-if="msg.result.snippets?.length" class="bubble-action" @click="msg.citationsOpen = !msg.citationsOpen">
|
||||
引用 {{ msg.result.snippets.length }} 条 {{ msg.citationsOpen ? '▲' : '▼' }}
|
||||
</button>
|
||||
<button v-if="msg.toolCode === ''" class="bubble-action" :disabled="summaryLoading" @click="buildSummaryCard(msg)">
|
||||
<button v-if="msg.toolCode === '' && !broadcastContext" class="bubble-action" :disabled="summaryLoading" @click="buildSummaryCard(msg)">
|
||||
{{ summaryLoading ? '生成中' : '总结卡' }}
|
||||
</button>
|
||||
<button v-if="msg.result.noEvidence" class="bubble-action primary" @click="openWebAi">去查全网</button>
|
||||
<button v-if="msg.result.noEvidence && !broadcastContext" class="bubble-action primary" @click="openWebAi">去查全网</button>
|
||||
</view>
|
||||
<view v-if="msg.toolCode === ''" class="bubble-actions feedback-row">
|
||||
<button
|
||||
@@ -220,14 +235,15 @@
|
||||
|
||||
<ChatComposer
|
||||
ref="composerRef"
|
||||
:disabled="!loggedIn"
|
||||
:disabled="!loggedIn || broadcastContextLoading"
|
||||
:transcribing="voiceTranscribing"
|
||||
:placeholder="quickQuestionPlaceholder"
|
||||
:maxlength="200"
|
||||
:quick-questions="quickQuestions"
|
||||
:quick-questions="conversationStarted ? [] : quickQuestions"
|
||||
:media-name="selectedMedia?.name || ''"
|
||||
:media-kind="selectedMedia?.kind || ''"
|
||||
:tool-label="defaultToolCode === 'TEAM_PRACTICE_SUMMARY' ? '团队训练概况' : '我的训练概况'"
|
||||
:tool-label="broadcastContext ? '' : defaultToolCode === 'TEAM_PRACTICE_SUMMARY' ? '团队训练概况' : '我的训练概况'"
|
||||
:panel-items="composerPanelItems"
|
||||
@send="handleComposerSend"
|
||||
@voice-blob="handleVoiceBlob"
|
||||
@pick-audio="pickVoiceAudio"
|
||||
@@ -235,6 +251,7 @@
|
||||
@clear-media="clearMedia"
|
||||
@quick="useQuickQuestion"
|
||||
@tool="runDataTool(defaultToolCode)"
|
||||
@panel="handleComposerPanel"
|
||||
/>
|
||||
|
||||
<view v-if="projectPickerOpen" class="project-picker-mask" @click="closeProjectPicker">
|
||||
@@ -353,6 +370,7 @@ import { computed, nextTick, ref } from 'vue';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type {
|
||||
AnswerFeedbackVerdict,
|
||||
BroadcastKnowledgeContext,
|
||||
KnowledgeConversationState,
|
||||
KnowledgeMediaSelectedFile,
|
||||
KnowledgeResource,
|
||||
@@ -383,12 +401,19 @@ import {
|
||||
saveCurrentProject
|
||||
} from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
|
||||
import { ApiError, authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
|
||||
import { ensureEmployeePosition, fetchMobileIdentity } from '@/services/position';
|
||||
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
import { downloadSummaryCardImage } from '@/services/summary-card-image';
|
||||
import { getMemoryCandidates } from '@/services/personal-assistant';
|
||||
import { currentAccountKey } from '@/services/request-scope';
|
||||
import {
|
||||
clearBroadcastQuestionContext,
|
||||
getBroadcastMessage,
|
||||
readBroadcastQuestionContext,
|
||||
saveBroadcastQuestionContext,
|
||||
type BroadcastQuestionContext
|
||||
} from '@/services/broadcast';
|
||||
|
||||
type ToolCode = '' | 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY';
|
||||
|
||||
@@ -434,6 +459,13 @@ const quickQuestions = computed(() => positionProfile.value.quickQuestions);
|
||||
const quickQuestionPlaceholder = computed(() => `例如: ${quickQuestions.value[0] || '岗位SOP怎么处理?'}`);
|
||||
|
||||
const messages = ref<ChatMsg[]>([]);
|
||||
const broadcastContext = ref<BroadcastKnowledgeContext | null>(null);
|
||||
const broadcastContextLoading = ref(false);
|
||||
const broadcastContextExpiresAt = ref(0);
|
||||
const broadcastContextPrompting = ref(false);
|
||||
const conversationStarted = computed(() => messages.value.some((message) =>
|
||||
message.role === 'user' || message.role === 'master' || message.role === 'pending'
|
||||
) || Boolean(broadcastContext.value) || broadcastContextLoading.value);
|
||||
const threadScrollTop = ref(0);
|
||||
const threadScrollIntoView = ref('');
|
||||
const composerRef = ref<InstanceType<typeof ChatComposer> | null>(null);
|
||||
@@ -454,7 +486,12 @@ const pendingSopQueryKey = 'aihr_sop_pending_query';
|
||||
const pendingToolKey = 'aihr_sop_pending_tool';
|
||||
const currentRole = ref(String(uni.getStorageSync(roleKey) || ''));
|
||||
const supervisorLearner = ref(isSupervisorLearnerMode());
|
||||
const headerContext = computed(() => `${positionLabel.value} · ${currentProject.value?.projectName || '住宅项目'}`);
|
||||
const headerContext = computed(() => broadcastContext.value
|
||||
? `消息咨询 · ${broadcastContext.value.title}`
|
||||
: `${positionLabel.value} · ${currentProject.value?.projectName || '住宅项目'}`);
|
||||
const composerPanelItems = computed(() => broadcastContext.value
|
||||
? [{ key: 'pick-audio', icon: 'sound', label: '选音频转文字' }]
|
||||
: []);
|
||||
const projectContextLabel = computed(() => {
|
||||
if (projectLoading.value) return '正在读取…';
|
||||
if (currentProject.value) return currentProject.value.projectName;
|
||||
@@ -489,6 +526,7 @@ let activeMediaUploadTask: ReturnType<typeof uni.uploadFile> | null = null;
|
||||
let candidateRefreshGeneration = 0;
|
||||
let showGeneration = 0;
|
||||
let visibleAccountKey = '';
|
||||
let broadcastContextGeneration = 0;
|
||||
|
||||
const defaultToolCode = computed<'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY'>(() =>
|
||||
currentRole.value === 'supervisor' && !supervisorLearner.value
|
||||
@@ -538,6 +576,126 @@ const replaceMsg = (id: number, next: ChatMsg | null) => {
|
||||
|
||||
const pushSystem = (text: string) => pushMsg<SystemMsg>({ role: 'system', text });
|
||||
|
||||
const isBroadcastMessageUnavailable = (error: unknown) =>
|
||||
error instanceof ApiError && (error.statusCode === 404 || error.bizCode === 404);
|
||||
|
||||
const clearBroadcastConsultation = () => {
|
||||
broadcastContextGeneration += 1;
|
||||
broadcastContextLoading.value = false;
|
||||
broadcastContextExpiresAt.value = 0;
|
||||
broadcastContext.value = null;
|
||||
clearBroadcastQuestionContext();
|
||||
};
|
||||
|
||||
const preserveCurrentBroadcastConsultation = () => {
|
||||
if (!broadcastContext.value) return;
|
||||
saveBroadcastQuestionContext(broadcastContext.value.messageId, currentAccountKey());
|
||||
};
|
||||
|
||||
const activateBroadcastConsultation = async (
|
||||
pending: BroadcastQuestionContext,
|
||||
accountKey: string,
|
||||
pageGeneration: number
|
||||
) => {
|
||||
const generation = ++broadcastContextGeneration;
|
||||
const isCurrent = () => generation === broadcastContextGeneration
|
||||
&& pageGeneration === showGeneration
|
||||
&& accountKey === currentAccountKey();
|
||||
broadcastContextLoading.value = true;
|
||||
// The user has confirmed the switch. Clear any unsent attachment/draft and
|
||||
// generate a new conversation before this ID can be bound server-side.
|
||||
broadcastContext.value = null;
|
||||
broadcastContextExpiresAt.value = 0;
|
||||
resetConversation(false);
|
||||
try {
|
||||
// Re-read under the current employee identity. Only title/time are retained in memory.
|
||||
const message = await getBroadcastMessage(pending.messageId);
|
||||
if (!isCurrent()) return;
|
||||
broadcastContext.value = {
|
||||
messageId: message.id,
|
||||
title: message.title,
|
||||
publishedAt: message.publishedAt
|
||||
};
|
||||
broadcastContextExpiresAt.value = pending.expiresAt;
|
||||
preserveCurrentBroadcastConsultation();
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return;
|
||||
clearBroadcastConsultation();
|
||||
resetConversation(false);
|
||||
pushSystem(isBroadcastMessageUnavailable(error)
|
||||
? '这条消息已撤回,无法继续咨询。'
|
||||
: requestErrorText(error, '消息咨询打开失败'));
|
||||
} finally {
|
||||
if (isCurrent()) broadcastContextLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const restoreBroadcastConsultation = async (pageGeneration: number, accountKey: string) => {
|
||||
if (broadcastContextLoading.value || broadcastContextPrompting.value) return;
|
||||
if (broadcastContext.value && broadcastContextExpiresAt.value > 0 && broadcastContextExpiresAt.value <= Date.now()) {
|
||||
clearBroadcastConsultation();
|
||||
resetConversation(false);
|
||||
pushSystem('消息咨询已过期,请从消息详情重新进入。');
|
||||
return;
|
||||
}
|
||||
const pending = readBroadcastQuestionContext(accountKey);
|
||||
if (!pending) {
|
||||
if (broadcastContext.value) {
|
||||
clearBroadcastConsultation();
|
||||
resetConversation(false);
|
||||
pushSystem('消息咨询已结束,请从消息详情重新进入。');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (broadcastContext.value?.messageId === pending.messageId) {
|
||||
broadcastContextExpiresAt.value = pending.expiresAt;
|
||||
return;
|
||||
}
|
||||
|
||||
const start = () => void activateBroadcastConsultation(pending, accountKey, pageGeneration);
|
||||
if (!conversationStarted.value) {
|
||||
start();
|
||||
return;
|
||||
}
|
||||
|
||||
broadcastContextPrompting.value = true;
|
||||
uni.showModal({
|
||||
title: '开始新的消息咨询?',
|
||||
content: '当前对话将被清空,并改为围绕这条消息咨询。',
|
||||
confirmText: '开始咨询',
|
||||
cancelText: '保留当前',
|
||||
success: (result) => {
|
||||
broadcastContextPrompting.value = false;
|
||||
if (pageGeneration !== showGeneration || accountKey !== currentAccountKey()) return;
|
||||
if (result.confirm) {
|
||||
start();
|
||||
return;
|
||||
}
|
||||
// A detail page may have written a newer context over an active one. Restore the
|
||||
// active ID on cancellation so the current account never resumes the wrong message.
|
||||
if (broadcastContext.value) preserveCurrentBroadcastConsultation();
|
||||
else clearBroadcastQuestionContext();
|
||||
},
|
||||
fail: () => {
|
||||
broadcastContextPrompting.value = false;
|
||||
if (broadcastContext.value) preserveCurrentBroadcastConsultation();
|
||||
else clearBroadcastQuestionContext();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const viewBroadcastMessage = () => {
|
||||
if (!broadcastContext.value) return;
|
||||
uni.navigateTo({ url: `/pages/user/broadcast/detail?id=${broadcastContext.value.messageId}` });
|
||||
};
|
||||
|
||||
const endBroadcastConsultation = () => {
|
||||
if (!broadcastContext.value && !broadcastContextLoading.value) return;
|
||||
clearBroadcastConsultation();
|
||||
resetConversation(false);
|
||||
uni.showToast({ title: '已结束消息咨询', icon: 'none' });
|
||||
};
|
||||
|
||||
const conversationState = (): KnowledgeConversationState => ({
|
||||
conversationId: conversationId.value,
|
||||
contextVersion: contextVersion.value
|
||||
@@ -582,7 +740,10 @@ const resetConversation = (notify: boolean) => {
|
||||
if (notify) pushSystem('已开始新对话,后续问题将从新的上下文理解');
|
||||
};
|
||||
|
||||
const startNewConversation = () => resetConversation(true);
|
||||
const startNewConversation = () => {
|
||||
clearBroadcastConsultation();
|
||||
resetConversation(true);
|
||||
};
|
||||
|
||||
const openProjectPicker = () => {
|
||||
if (projectLoading.value) return;
|
||||
@@ -614,6 +775,7 @@ const applyProject = (project: MobileProject) => {
|
||||
projectPickerOpen.value = false;
|
||||
projectSearch.value = '';
|
||||
if (changed) {
|
||||
clearBroadcastConsultation();
|
||||
resetConversation(false);
|
||||
pushSystem(`已切换到“${project.projectName}”,后续记录和对话从新的项目上下文开始`);
|
||||
}
|
||||
@@ -644,7 +806,7 @@ const loadProjectContext = async () => {
|
||||
const identity = await fetchMobileIdentity();
|
||||
availableProjects.value = identity.projects || [];
|
||||
currentProject.value = reconcileCurrentProject(availableProjects.value);
|
||||
if (!currentProject.value && availableProjects.value.length > 1) {
|
||||
if (!currentProject.value && availableProjects.value.length > 1 && !broadcastContext.value && !broadcastContextPrompting.value) {
|
||||
projectPickerOpen.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -695,7 +857,13 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
|
||||
uni.showToast({ title: '上一条还在检索中,请稍候', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const broadcastMessageId = broadcastContext.value?.messageId;
|
||||
const media = selectedMedia.value;
|
||||
if (broadcastMessageId && media) {
|
||||
selectedMedia.value = null;
|
||||
uni.showToast({ title: '消息咨询暂不支持图片或视频,请改用文字或语音', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const text = rawText.trim() || (media ? '请根据附件现场情况,告诉我应该按什么 SOP 处理' : '');
|
||||
if (!text) {
|
||||
uni.showToast({ title: '请先输入问题', icon: 'none' });
|
||||
@@ -704,7 +872,8 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
|
||||
const requestShowGeneration = showGeneration;
|
||||
const requestAccountKey = currentAccountKey();
|
||||
const isCurrentQuestion = () => requestShowGeneration === showGeneration
|
||||
&& requestAccountKey === currentAccountKey();
|
||||
&& requestAccountKey === currentAccountKey()
|
||||
&& broadcastMessageId === broadcastContext.value?.messageId;
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
uni.setStorageSync(pendingSopQueryKey, text);
|
||||
@@ -714,7 +883,7 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
|
||||
if (!(await ensureEmployeePosition('/pages/user/sop/index')) || !isCurrentQuestion()) {
|
||||
return;
|
||||
}
|
||||
if (!ensureCurrentProject()) return;
|
||||
if (!broadcastMessageId && !ensureCurrentProject()) return;
|
||||
refreshAuthState();
|
||||
if (!isCurrentQuestion()) return;
|
||||
pushMsg<UserMsg>({
|
||||
@@ -737,11 +906,15 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
|
||||
text,
|
||||
getSelectedPosition(),
|
||||
conversationState(),
|
||||
currentProject.value?.projectCode,
|
||||
options.voice ? 'mobile_uni_voice' : 'mobile_uni_sop'
|
||||
broadcastMessageId ? undefined : currentProject.value?.projectCode,
|
||||
options.voice ? 'mobile_uni_voice' : 'mobile_uni_sop',
|
||||
broadcastMessageId
|
||||
);
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
applyConversationState(nextResult);
|
||||
if (broadcastMessageId && nextResult.broadcastContext?.messageId === broadcastMessageId) {
|
||||
broadcastContext.value = nextResult.broadcastContext;
|
||||
}
|
||||
retireEarlierMemoryCandidate(nextResult.memoryCandidate?.id);
|
||||
const spoken = (nextResult.answer || '').trim();
|
||||
replaceMsg(pending.id, {
|
||||
@@ -763,6 +936,12 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== knowledgeSearchGeneration) return;
|
||||
if (broadcastMessageId && isBroadcastMessageUnavailable(error) && broadcastContext.value?.messageId === broadcastMessageId) {
|
||||
clearBroadcastConsultation();
|
||||
resetConversation(false);
|
||||
pushSystem('这条消息已撤回,无法继续咨询。');
|
||||
return;
|
||||
}
|
||||
replaceMsg(pending.id, null);
|
||||
pushSystem(requestErrorText(error, '检索失败,请稍后重试'));
|
||||
} finally {
|
||||
@@ -784,6 +963,10 @@ const useQuickQuestion = (value: string) => {
|
||||
|
||||
const runDataTool = async (toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY') => {
|
||||
if (loading.value) return;
|
||||
if (broadcastContext.value) {
|
||||
uni.showToast({ title: '消息咨询不支持数据查询,请先结束咨询', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
uni.setStorageSync(pendingToolKey, toolCode);
|
||||
@@ -828,6 +1011,10 @@ const runDataTool = async (toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMM
|
||||
|
||||
const pickMedia = async () => {
|
||||
if (loading.value) return;
|
||||
if (broadcastContext.value) {
|
||||
uni.showToast({ title: '消息咨询暂不支持图片或视频,请改用文字或语音', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
selectedMedia.value = await chooseKnowledgeMedia();
|
||||
uni.showToast({
|
||||
@@ -845,6 +1032,10 @@ const clearMedia = () => {
|
||||
selectedMedia.value = null;
|
||||
};
|
||||
|
||||
const handleComposerPanel = (key: string) => {
|
||||
if (key === 'pick-audio') void pickVoiceAudio();
|
||||
};
|
||||
|
||||
const cancelVoiceTranscription = () => {
|
||||
voiceTranscriptionGeneration += 1;
|
||||
abortVoiceTranscription?.();
|
||||
@@ -1036,6 +1227,10 @@ const feedback = async (verdict: AnswerFeedbackVerdict, msg: MasterMsg) => {
|
||||
};
|
||||
|
||||
const buildSummaryCard = async (msg: MasterMsg) => {
|
||||
if (broadcastContext.value) {
|
||||
uni.showToast({ title: '消息咨询不支持生成总结卡,请先结束咨询', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (summaryLoading.value) return;
|
||||
const value = (msg.result.queryText || '').trim();
|
||||
if (!value) return;
|
||||
@@ -1112,6 +1307,10 @@ const goLogin = () => {
|
||||
};
|
||||
|
||||
const openWebAi = () => {
|
||||
if (broadcastContext.value) {
|
||||
uni.showToast({ title: '消息咨询仅使用公司消息上下文,请先结束咨询', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({ url: '/pages/user/web-ai/index' });
|
||||
};
|
||||
|
||||
@@ -1145,22 +1344,33 @@ const handleShow = async () => {
|
||||
resetPageScroll();
|
||||
const accountKey = currentAccountKey();
|
||||
const isCurrentShow = () => generation === showGeneration && accountKey === currentAccountKey();
|
||||
if (visibleAccountKey && visibleAccountKey !== accountKey) resetConversation(false);
|
||||
if (visibleAccountKey && visibleAccountKey !== accountKey) {
|
||||
clearBroadcastConsultation();
|
||||
resetConversation(false);
|
||||
}
|
||||
visibleAccountKey = accountKey;
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value && messages.value.length) {
|
||||
resetConversation(false);
|
||||
if (!loggedIn.value) {
|
||||
clearBroadcastConsultation();
|
||||
if (messages.value.length) resetConversation(false);
|
||||
}
|
||||
if (!isCurrentShow()) return;
|
||||
if (loggedIn.value) {
|
||||
const hasEmployeePosition = await ensureEmployeePosition('/pages/user/sop/index');
|
||||
if (!isCurrentShow() || !hasEmployeePosition) return;
|
||||
await restoreBroadcastConsultation(generation, accountKey);
|
||||
if (!isCurrentShow()) return;
|
||||
await loadProjectContext();
|
||||
if (!isCurrentShow()) return;
|
||||
}
|
||||
positionProfile.value = getSelectedPositionProfile();
|
||||
scrollToBottom();
|
||||
if (!isCurrentShow()) return;
|
||||
if (broadcastContext.value || broadcastContextLoading.value || broadcastContextPrompting.value) {
|
||||
uni.removeStorageSync(pendingSopQueryKey);
|
||||
uni.removeStorageSync(pendingToolKey);
|
||||
return;
|
||||
}
|
||||
const pending = uni.getStorageSync(pendingSopQueryKey);
|
||||
if (!isCurrentShow()) return;
|
||||
const pendingTool = uni.getStorageSync(pendingToolKey);
|
||||
@@ -1211,6 +1421,77 @@ onUnload(() => {
|
||||
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
|
||||
}
|
||||
|
||||
.broadcast-context-loading,
|
||||
.broadcast-context-bar {
|
||||
margin-top: 12px;
|
||||
border: 1px solid #f1c7cd;
|
||||
border-radius: 16px;
|
||||
background: #fff7f7;
|
||||
}
|
||||
|
||||
.broadcast-context-loading {
|
||||
padding: 13px 14px;
|
||||
color: #9a2633;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.broadcast-context-bar {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.broadcast-context-copy {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.broadcast-context-kicker {
|
||||
color: #c8001a;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.broadcast-context-title {
|
||||
overflow: hidden;
|
||||
color: #25282d;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.broadcast-context-note {
|
||||
color: #7b5258;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.broadcast-context-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.broadcast-context-action {
|
||||
min-height: 38px;
|
||||
margin: 0;
|
||||
border: 1px solid #e7aeb7;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
color: #b51c2b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.broadcast-context-action.end {
|
||||
border-color: transparent;
|
||||
background: #f5e9eb;
|
||||
color: #76545a;
|
||||
}
|
||||
|
||||
.sop-head {
|
||||
position: relative;
|
||||
margin: 0 -16px;
|
||||
@@ -2277,74 +2558,66 @@ onUnload(() => {
|
||||
.today-result-note { color: #8c929d; font-size: 12px; }
|
||||
.today-result-arrow { color: #d9081c; font-size: 13px; }
|
||||
|
||||
.project-context-button {
|
||||
.compact-project-strip {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid #e1e5ea;
|
||||
border-radius: 13px;
|
||||
background: #f8f9fb;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.project-context-button.missing {
|
||||
border-color: #efb4bb;
|
||||
background: #fff7f8;
|
||||
}
|
||||
|
||||
.project-context-button::after,
|
||||
.project-picker-close::after,
|
||||
.project-option::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.project-context-copy {
|
||||
.project-pill-btn {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.project-context-label {
|
||||
color: #8a93a0;
|
||||
font-size: 11px;
|
||||
.project-pill-btn.missing {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.project-context-name {
|
||||
overflow: hidden;
|
||||
color: #29313c;
|
||||
font-size: 15px;
|
||||
.project-pill-name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-context-note {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #9a6b24;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.personal-library-entry {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
margin: 7px 0 0 auto;
|
||||
padding: 2px 0;
|
||||
border: 0;
|
||||
color: #c90012;
|
||||
.personal-lib-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.personal-library-entry::after { border: 0; }
|
||||
.personal-lib-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.project-pill-btn::after,
|
||||
.personal-lib-btn::after,
|
||||
.project-picker-close::after,
|
||||
.project-option::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.project-picker-mask {
|
||||
position: fixed;
|
||||
|
||||
Reference in New Issue
Block a user