3557 lines
96 KiB
Vue
3557 lines
96 KiB
Vue
<template>
|
||
<view class="page sop-chat-page">
|
||
<AskSourceHeader
|
||
active="assistant"
|
||
:context="headerContext"
|
||
:loading="loading || voiceTranscribing || broadcastContextLoading"
|
||
@select-web="openWebAi"
|
||
@new-conversation="startNewConversation"
|
||
/>
|
||
|
||
<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-pill-btn"
|
||
:class="{ missing: availableProjects.length > 1 && !currentProject }"
|
||
:disabled="projectLoading || loading"
|
||
@click="openProjectPicker"
|
||
>
|
||
<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>
|
||
</view>
|
||
|
||
<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>
|
||
</view>
|
||
<text class="today-result-arrow">查看 ›</text>
|
||
</button>
|
||
|
||
<scroll-view
|
||
scroll-y
|
||
class="chat-thread"
|
||
:scroll-top="threadScrollTop"
|
||
:scroll-into-view="threadScrollIntoView"
|
||
:scroll-with-animation="true"
|
||
>
|
||
<view class="thread-inner">
|
||
<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>
|
||
</view>
|
||
</view>
|
||
|
||
<template v-for="msg in messages" :key="msg.id">
|
||
<view v-if="msg.role === 'user'" class="bubble-row right">
|
||
<view class="bubble user">
|
||
<view v-if="msg.voice" class="bubble-tag">
|
||
<uni-icons type="mic-filled" size="12" color="#ffffff" />
|
||
<text>语音转文字</text>
|
||
</view>
|
||
<view v-if="msg.mediaName" class="bubble-tag">
|
||
<uni-icons type="paperclip" size="12" color="#ffffff" />
|
||
<text>{{ msg.mediaKind === 'video' ? '现场视频' : '现场图片' }} · {{ msg.mediaName }}</text>
|
||
</view>
|
||
<text class="bubble-text">{{ msg.text }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else-if="msg.role === 'system'" class="system-row">
|
||
<text class="system-text">{{ msg.text }}</text>
|
||
</view>
|
||
|
||
<view v-else-if="msg.role === 'pending'" class="bubble-row left">
|
||
<image class="avatar master-avatar" src="/static/mentor-female-avatar.png" mode="aspectFill" />
|
||
<view class="bubble master pending-bubble">
|
||
<text class="bubble-text">师傅思考中…</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else :id="`chat-message-${msg.id}`" class="bubble-row left">
|
||
<image class="avatar master-avatar" src="/static/mentor-female-avatar.png" mode="aspectFill" />
|
||
<view class="master-col">
|
||
<view v-if="msg.voiceStatus !== 'unavailable'" class="voice-line">
|
||
<view
|
||
class="bubble master voice-bubble"
|
||
:class="{ playing: isVoicePlaying(msg) }"
|
||
@click="playVoice(msg)"
|
||
>
|
||
<uni-icons
|
||
class="voice-icon"
|
||
:class="{ playing: isVoicePlaying(msg) }"
|
||
type="sound"
|
||
size="17"
|
||
:color="isVoicePlaying(msg) ? '#e70014' : '#5a6270'"
|
||
/>
|
||
<text class="voice-duration">{{ voiceLabel(msg) }}</text>
|
||
</view>
|
||
<view v-if="msg.unread" class="voice-dot" />
|
||
<button class="transcript-toggle" @click="toggleTranscript(msg)">
|
||
{{ msg.transcriptOpen ? '收起' : '转文字' }}
|
||
</button>
|
||
</view>
|
||
|
||
<view
|
||
v-if="(msg.voiceStatus === 'unavailable' || msg.transcriptOpen) && (msg.result.answer || msg.result.snippets?.length || !msg.result.memoryCandidate)"
|
||
class="bubble master transcript-bubble"
|
||
>
|
||
<view class="answer-evidence-label">
|
||
<uni-icons type="info" size="13" color="#2f6bff" />
|
||
<text>AI 参考回答 · 下方引用为原文证据</text>
|
||
</view>
|
||
<text
|
||
v-if="msg.result.rewrittenQuery && msg.result.rewrittenQuery !== msg.result.queryText"
|
||
class="context-used"
|
||
>已结合本次对话理解</text>
|
||
<view v-if="msg.result.sourceSummary?.length" class="agent-source-row">
|
||
<text
|
||
v-for="source in msg.result.sourceSummary"
|
||
:key="`${source.type}-${source.label}`"
|
||
class="agent-source-chip"
|
||
>{{ source.label }}</text>
|
||
</view>
|
||
<text class="bubble-text">{{ msg.result.answer || '未生成直接答案,请查看引用依据。' }}</text>
|
||
<view
|
||
v-if="msg.result.agentStatus === 'NEEDS_INPUT' && msg.result.clarification"
|
||
class="agent-clarification"
|
||
>
|
||
<text class="agent-clarification-text">{{ msg.result.clarification.prompt }}</text>
|
||
<button
|
||
v-if="msg.result.agentIntent === 'WEB_RESEARCH'"
|
||
class="bubble-action primary"
|
||
:disabled="loading"
|
||
@click="consentWebResearch(msg)"
|
||
>同意查询全网</button>
|
||
</view>
|
||
<view v-if="msg.result.keyPoints?.length" class="bubble-keypoints">
|
||
<text v-for="(point, index) in msg.result.keyPoints" :key="point" class="bubble-keypoint">
|
||
{{ index + 1 }}. {{ cleanOrderedText(point) }}
|
||
</text>
|
||
</view>
|
||
<view v-if="msg.result.cautions?.length" class="bubble-cautions">
|
||
<view v-for="item in msg.result.cautions" :key="item" class="bubble-caution">
|
||
<uni-icons type="warn-filled" size="12" color="#e60012" />
|
||
<text>{{ item }}</text>
|
||
</view>
|
||
</view>
|
||
<text v-if="msg.result.noEvidence" class="bubble-noevidence">{{ noEvidenceText(msg.result) }}</text>
|
||
|
||
<view class="bubble-actions">
|
||
<button v-if="msg.voiceStatus === 'unavailable'" class="bubble-action" @click="toggleAnswerSpeech(msg)">
|
||
{{ speechButtonLabel(`answer-${msg.id}`) }}
|
||
</button>
|
||
<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.result.agentIntent === 'KNOWLEDGE_QA' && summaryFragmentIds(msg).length && !broadcastContext" class="bubble-action" :disabled="summaryLoading" @click="buildSummaryCard(msg)">
|
||
{{ summaryLoading ? '生成中' : '总结卡' }}
|
||
</button>
|
||
<button v-if="msg.result.noEvidence && msg.result.agentIntent === 'KNOWLEDGE_QA' && !broadcastContext" class="bubble-action primary" @click="openWebAi">去查全网</button>
|
||
</view>
|
||
<view v-if="msg.result.snippets?.length" class="bubble-actions feedback-row">
|
||
<button
|
||
class="bubble-action"
|
||
:class="{ active: msg.feedbackVerdict === 'up' }"
|
||
:disabled="msg.feedbackSubmitting || Boolean(msg.feedbackVerdict)"
|
||
@click="feedback('up', msg)"
|
||
>
|
||
有帮助
|
||
</button>
|
||
<button
|
||
class="bubble-action"
|
||
:class="{ negative: msg.feedbackVerdict === 'down' }"
|
||
:disabled="msg.feedbackSubmitting || Boolean(msg.feedbackVerdict)"
|
||
@click="openNegativeFeedback(msg)"
|
||
>
|
||
没解决
|
||
</button>
|
||
<text v-if="msg.feedbackVerdict" class="feedback-note">
|
||
{{ msg.feedbackVerdict === 'up' ? '已记录' : '已进入复核' }}
|
||
</text>
|
||
<button
|
||
v-if="msg.feedbackVerdict === 'down' && !msg.helpQuestionId"
|
||
class="bubble-action primary"
|
||
:disabled="msg.helpSubmitting || !msg.result.requestId"
|
||
@click="submitHumanHelp(msg)"
|
||
>
|
||
{{ msg.helpSubmitting ? '提交中' : '转人工求助' }}
|
||
</button>
|
||
<button
|
||
v-if="msg.helpQuestionId"
|
||
class="bubble-action"
|
||
@click="openHelpQuestion(msg.helpQuestionId)"
|
||
>查看待审核问题</button>
|
||
</view>
|
||
<view v-if="msg.feedbackPanelOpen && !msg.feedbackVerdict" class="negative-feedback-panel">
|
||
<text class="negative-feedback-title">哪里还没解决?(可多选)</text>
|
||
<view class="negative-feedback-options">
|
||
<button
|
||
v-for="option in feedbackReasonOptions"
|
||
:key="option.code"
|
||
class="negative-feedback-option"
|
||
:class="{ active: msg.feedbackReasonCodes.includes(option.code) }"
|
||
:disabled="msg.feedbackSubmitting"
|
||
@click="toggleNegativeFeedbackReason(msg, option.code)"
|
||
>{{ option.label }}</button>
|
||
</view>
|
||
<textarea
|
||
v-model="msg.feedbackComment"
|
||
class="textarea negative-feedback-comment"
|
||
maxlength="200"
|
||
:disabled="msg.feedbackSubmitting"
|
||
placeholder="可补充具体问题,方便复核"
|
||
/>
|
||
<text class="negative-feedback-hint">请至少选择一项原因后提交</text>
|
||
<button
|
||
class="bubble-action primary"
|
||
:disabled="msg.feedbackSubmitting || !msg.feedbackReasonCodes.length"
|
||
@click="submitNegativeFeedback(msg)"
|
||
>
|
||
{{ msg.feedbackSubmitting ? '提交中' : '提交复核' }}
|
||
</button>
|
||
</view>
|
||
|
||
<view v-if="msg.citationsOpen && msg.result.snippets?.length" class="bubble-citations">
|
||
<component
|
||
:is="snippet.detailRef && snippet.sourceType === 'DOCUMENT' ? 'button' : 'view'"
|
||
v-for="(snippet, index) in msg.result.snippets"
|
||
:key="`${snippet.docTitle}-${index}`"
|
||
class="citation"
|
||
:class="{ 'citation-action': Boolean(snippet.detailRef && snippet.sourceType === 'DOCUMENT') }"
|
||
@click="openCitationDetail(snippet)"
|
||
>
|
||
<text class="citation-domain" :class="{ project: snippet.domain === 'PROJECT_SERVICE' }">
|
||
{{ citationDomainLabel(snippet) }}
|
||
</text>
|
||
<text class="citation-title">{{ snippet.docTitle }}</text>
|
||
<text v-if="snippet.domain === 'PROJECT_SERVICE'" class="citation-meta">
|
||
{{ serviceMemoryStatusLabel(snippet.status) }} · 发生 {{ displayCitationDate(snippet.occurredAt) }} · 更新 {{ displayCitationDate(snippet.updatedAt) }}
|
||
</text>
|
||
<text v-else-if="snippet.spaceCode" class="citation-meta">知识空间:{{ snippet.spaceCode }}</text>
|
||
<text class="citation-content">{{ snippetExcerpt(snippet.content) }}</text>
|
||
<view v-if="snippet.detailRef && snippet.sourceType === 'DOCUMENT'" class="citation-open-row">
|
||
<text class="citation-location">{{ citationLocationLabel(snippet) }}</text>
|
||
<text class="citation-open">查看出处 ›</text>
|
||
</view>
|
||
</component>
|
||
</view>
|
||
|
||
<view v-if="msg.voiceStatus !== 'unavailable'" class="transcript-collapse" @click="collapseTranscript(msg)">
|
||
<text class="transcript-collapse-text">收起全文 ▲</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="msg.result.resources?.length" class="resource-list">
|
||
<view
|
||
v-for="resource in msg.result.resources"
|
||
:key="resource.attachmentId"
|
||
class="resource-card"
|
||
:class="{ video: resource.type === 'VIDEO' }"
|
||
@click="openKnowledgeResource(resource)"
|
||
>
|
||
<view class="resource-icon">{{ resource.type === 'VIDEO' ? '视' : '文' }}</view>
|
||
<view class="resource-copy">
|
||
<text class="resource-title">{{ resource.title }}</text>
|
||
<text class="resource-meta">
|
||
{{ resource.loading ? (resource.type === 'VIDEO' ? '正在安全读取…' : '正在生成安全下载链接…') : resource.type === 'VIDEO' ? (resource.localUrl ? '已加载,可直接播放' : '点击加载操作视频') : '点击下载原文件' }}
|
||
</text>
|
||
</view>
|
||
<text class="resource-arrow">›</text>
|
||
<video
|
||
v-if="resource.type === 'VIDEO' && resource.localUrl"
|
||
class="resource-video"
|
||
:src="resource.localUrl"
|
||
controls
|
||
@click.stop
|
||
/>
|
||
</view>
|
||
</view>
|
||
|
||
<MemoryCandidateCard
|
||
v-if="msg.result.memoryCandidate"
|
||
:candidate="msg.result.memoryCandidate"
|
||
:project-name="currentProject?.projectName || ''"
|
||
@dismissed="hideMemoryCandidate(msg)"
|
||
@refresh="refreshMemoryCandidate(msg, $event)"
|
||
/>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<view v-if="!loggedIn" 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">登录后才能使用工作助手、确认保存事项并查询内部资料。</text>
|
||
<view class="bubble-actions">
|
||
<button class="bubble-action primary" @click="goLogin">去登录</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view :style="{ height: '12px' }" />
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<view class="scope-note">资料范围:自动 · 仅查询当前有权限的内部资料</view>
|
||
|
||
<ChatComposer
|
||
ref="composerRef"
|
||
:disabled="!loggedIn || broadcastContextLoading"
|
||
:transcribing="voiceTranscribing"
|
||
:placeholder="quickQuestionPlaceholder"
|
||
:maxlength="200"
|
||
:quick-questions="conversationStarted ? [] : quickQuestions"
|
||
:media-name="selectedMedia?.name || ''"
|
||
:media-kind="selectedMedia?.kind || ''"
|
||
:tool-label="broadcastContext ? '' : defaultToolCode === 'TEAM_PRACTICE_SUMMARY' ? '团队训练概况' : '我的训练概况'"
|
||
:panel-items="composerPanelItems"
|
||
@send="handleComposerSend"
|
||
@voice-capture="handleVoiceCapture"
|
||
@pick-audio="pickVoiceAudio"
|
||
@pick-media="pickMedia"
|
||
@clear-media="clearMedia"
|
||
@quick="useQuickQuestion"
|
||
@tool="runDataTool(defaultToolCode)"
|
||
@panel="handleComposerPanel"
|
||
/>
|
||
|
||
<view v-if="projectPickerOpen" class="project-picker-mask" @click="closeProjectPicker">
|
||
<view class="project-picker-sheet" role="dialog" aria-label="选择当前项目" @click.stop>
|
||
<view class="project-picker-head">
|
||
<view>
|
||
<text class="project-picker-title">选择当前项目</text>
|
||
<text class="project-picker-subtitle">后续工作记录和对话只归入这个项目</text>
|
||
</view>
|
||
<button class="project-picker-close" aria-label="关闭项目选择" @click="closeProjectPicker">×</button>
|
||
</view>
|
||
<input
|
||
v-if="availableProjects.length > 6"
|
||
v-model.trim="projectSearch"
|
||
class="project-search"
|
||
maxlength="40"
|
||
placeholder="搜索项目名称"
|
||
/>
|
||
<scroll-view scroll-y class="project-picker-list">
|
||
<button
|
||
v-for="project in filteredProjects"
|
||
:key="project.projectCode"
|
||
class="project-option"
|
||
:class="{ selected: currentProject?.projectCode === project.projectCode }"
|
||
@click="selectProject(project)"
|
||
>
|
||
<text class="project-option-name">{{ project.projectName }}</text>
|
||
<text v-if="currentProject?.projectCode === project.projectCode" class="project-option-status">当前</text>
|
||
</button>
|
||
<text v-if="!filteredProjects.length" class="project-empty">没有匹配的项目</text>
|
||
</scroll-view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="summaryCard" class="modal-mask" @click="closeSummary">
|
||
<view class="modal-card" @click.stop>
|
||
<view class="modal-header">
|
||
<text class="modal-title">{{ summaryCard.title }}</text>
|
||
<button class="modal-close" aria-label="关闭总结卡" title="关闭总结卡" @click="closeSummary">×</button>
|
||
</view>
|
||
<scroll-view scroll-y class="modal-body">
|
||
<view v-if="summaryCard.steps?.length" class="info-section">
|
||
<view class="section-title-row">
|
||
<text class="badge-num">1</text>
|
||
<text class="info-section-title">核心步骤</text>
|
||
</view>
|
||
<view class="timeline">
|
||
<view v-for="(step, index) in summaryCard.steps" :key="index" class="timeline-item">
|
||
<view class="timeline-node">{{ index + 1 }}</view>
|
||
<view class="timeline-content">
|
||
<text class="timeline-label">{{ step.label }}</text>
|
||
<text class="timeline-desc">{{ step.desc }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view v-if="summaryCard.scripts?.length" class="info-section speech-section">
|
||
<view class="section-title-row">
|
||
<text class="badge-num">2</text>
|
||
<text class="info-section-title">推荐话术</text>
|
||
</view>
|
||
<view class="speech-bubbles">
|
||
<view v-for="(script, index) in summaryCard.scripts" :key="index" class="speech-bubble">
|
||
<text class="quote-mark">“</text>
|
||
<text class="speech-text">{{ script }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view v-if="summaryCard.objections?.length" class="info-section objection-section">
|
||
<view class="section-title-row">
|
||
<text class="badge-num">3</text>
|
||
<text class="info-section-title">常见异议与回应</text>
|
||
</view>
|
||
<view class="objection-list">
|
||
<view v-for="(obj, index) in summaryCard.objections" :key="index" class="objection-card">
|
||
<view class="objection-q">
|
||
<text class="q-badge">问</text>
|
||
<text class="q-text">{{ obj.concern }}</text>
|
||
</view>
|
||
<view class="objection-a">
|
||
<text class="a-badge">答</text>
|
||
<text class="a-text">{{ obj.response }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view v-if="summaryCard.reminders?.length" class="info-section reminder-section">
|
||
<view class="section-title-row">
|
||
<text class="badge-num">4</text>
|
||
<text class="info-section-title">师傅提醒</text>
|
||
</view>
|
||
<view class="reminder-list">
|
||
<view v-for="(rem, index) in summaryCard.reminders" :key="index" class="reminder-item">
|
||
<text class="reminder-text">{{ rem }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
<view class="modal-footer">
|
||
<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="secondary-button footer-btn" @click="practiceFromSummary">去练一遍</button>
|
||
<button class="primary-button footer-btn footer-close" @click="closeSummary">我知道了</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, ref } from 'vue';
|
||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
|
||
import type {
|
||
AnswerFeedbackVerdict,
|
||
BroadcastKnowledgeContext,
|
||
KnowledgeConversationState,
|
||
KnowledgeMediaSelectedFile,
|
||
KnowledgeResource,
|
||
KnowledgeSearchResponse,
|
||
MobileProject,
|
||
SpeechPlaybackStatus,
|
||
SummaryCardResponse
|
||
} from '@/types/api';
|
||
import ChatComposer from '@/components/chat/ChatComposer.vue';
|
||
import AskSourceHeader from '@/components/chat/AskSourceHeader.vue';
|
||
import MemoryCandidateCard from '@/components/chat/MemoryCandidateCard.vue';
|
||
import {
|
||
chooseKnowledgeMedia,
|
||
generateSummaryCard,
|
||
requestKnowledgeResourceDownloadLink,
|
||
submitAnswerFeedback
|
||
} from '@/services/knowledge';
|
||
import { askAgent, askAgentWithMedia } from '@/services/agent';
|
||
import {
|
||
getSelectedPosition,
|
||
getSelectedPositionProfile,
|
||
reconcileCurrentProject,
|
||
isLoggedIn,
|
||
isSupervisorLearnerMode,
|
||
rememberLoginRedirect,
|
||
roleKey,
|
||
saveCurrentProject
|
||
} from '@/services/auth';
|
||
import { resetPageScroll } from '@/services/navigation';
|
||
import { ApiError, authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
|
||
import { ensureEmployeePosition, fetchMobileIdentity } from '@/services/position';
|
||
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, MENTOR_VOICE_PROFILE, transcribeSpeechCapture, transcribeSpeechFile } from '@/services/speech';
|
||
import { isNativeAppRuntime, type SpeechCapture } from '@/services/speech-capture';
|
||
import { downloadSummaryCardImage } from '@/services/summary-card-image';
|
||
import { getMemoryCandidates } from '@/services/personal-assistant';
|
||
import { publishQuestionFromAnswerFeedback } from '@/services/questions';
|
||
import { currentAccountKey } from '@/services/request-scope';
|
||
import { isFileSelectionCancelled } from '@/utils/file-selection';
|
||
import {
|
||
clearBroadcastQuestionContext,
|
||
getBroadcastMessage,
|
||
readBroadcastQuestionContext,
|
||
saveBroadcastQuestionContext,
|
||
type BroadcastQuestionContext
|
||
} from '@/services/broadcast';
|
||
|
||
type ToolCode = '' | 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY';
|
||
|
||
interface UserMsg {
|
||
id: number;
|
||
role: 'user';
|
||
text: string;
|
||
voice?: boolean;
|
||
mediaKind?: string;
|
||
mediaName?: string;
|
||
}
|
||
|
||
interface MasterMsg {
|
||
id: number;
|
||
role: 'master';
|
||
result: KnowledgeSearchResponse;
|
||
toolCode: ToolCode;
|
||
feedbackVerdict: AnswerFeedbackVerdict | '';
|
||
feedbackSubmitting: boolean;
|
||
feedbackPanelOpen: boolean;
|
||
feedbackReasonCodes: string[];
|
||
feedbackComment: string;
|
||
helpSubmitting: boolean;
|
||
helpQuestionId: number | null;
|
||
helpIdempotencyKey: string;
|
||
citationsOpen: boolean;
|
||
voiceStatus: 'preparing' | 'ready' | 'unavailable';
|
||
voiceDuration: number | null;
|
||
unread: boolean;
|
||
transcriptOpen: boolean;
|
||
/** Set once the reader toggles the transcript, so preparing voice never
|
||
* collapses text the reader chose to keep open. */
|
||
transcriptPinned: boolean;
|
||
}
|
||
|
||
interface SystemMsg {
|
||
id: number;
|
||
role: 'system';
|
||
text: string;
|
||
}
|
||
|
||
interface PendingMsg {
|
||
id: number;
|
||
role: 'pending';
|
||
}
|
||
|
||
type ChatMsg = UserMsg | MasterMsg | SystemMsg | PendingMsg;
|
||
|
||
const positionProfile = ref(getSelectedPositionProfile());
|
||
const positionLabel = ref(getSelectedPosition());
|
||
const policyQuickQuestion = '问制度:请说明公司请假和考勤的适用规定';
|
||
const quickQuestions = computed(() => [
|
||
policyQuickQuestion,
|
||
...positionProfile.value.quickQuestions.filter((item) => item !== policyQuickQuestion)
|
||
].slice(0, 4));
|
||
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);
|
||
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 voiceTranscribing = ref(false);
|
||
const loggedIn = ref(false);
|
||
const projectLoading = ref(false);
|
||
const availableProjects = ref<MobileProject[]>([]);
|
||
const currentProject = ref<MobileProject | null>(null);
|
||
const projectPickerOpen = ref(false);
|
||
const projectSearch = ref('');
|
||
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(() => 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;
|
||
if (availableProjects.value.length > 1) return '请选择项目';
|
||
return '未绑定项目';
|
||
});
|
||
const filteredProjects = computed(() => {
|
||
const keyword = projectSearch.value.trim().toLowerCase();
|
||
const rows = keyword
|
||
? availableProjects.value.filter((project) => project.projectName.toLowerCase().includes(keyword))
|
||
: availableProjects.value;
|
||
return [...rows].sort((left, right) => {
|
||
if (left.projectCode === currentProject.value?.projectCode) return -1;
|
||
if (right.projectCode === currentProject.value?.projectCode) return 1;
|
||
return left.projectName.localeCompare(right.projectName, 'zh-CN');
|
||
});
|
||
});
|
||
const createConversationId = () => {
|
||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||
return crypto.randomUUID();
|
||
}
|
||
return `chat_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`;
|
||
};
|
||
const conversationId = ref(createConversationId());
|
||
const contextVersion = ref(0);
|
||
let msgSeq = 0;
|
||
let scrollSeq = 0;
|
||
let voiceTranscriptionGeneration = 0;
|
||
let abortVoiceTranscription: (() => void) | null = null;
|
||
let knowledgeSearchGeneration = 0;
|
||
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
|
||
? 'TEAM_PRACTICE_SUMMARY'
|
||
: 'MY_PRACTICE_SUMMARY'
|
||
);
|
||
|
||
const speechPlayback = createSpeechPlaybackController(
|
||
(state) => { speechState.value = state; },
|
||
(text) => { uni.showToast({ title: text, icon: 'none' }); },
|
||
MENTOR_VOICE_PROFILE
|
||
);
|
||
|
||
const scrollToBottom = () => {
|
||
void nextTick(() => {
|
||
threadScrollIntoView.value = '';
|
||
threadScrollTop.value = 100000 + (++scrollSeq * 200);
|
||
});
|
||
};
|
||
|
||
const scrollToMessageStart = (messageId: number) => {
|
||
void nextTick(() => {
|
||
threadScrollIntoView.value = '';
|
||
void nextTick(() => {
|
||
threadScrollIntoView.value = `chat-message-${messageId}`;
|
||
});
|
||
});
|
||
};
|
||
|
||
const pushMsg = <T extends ChatMsg>(msg: Omit<T, 'id'>): T => {
|
||
const withId = { ...msg, id: ++msgSeq } as T;
|
||
messages.value = [...messages.value, withId];
|
||
scrollToBottom();
|
||
return withId;
|
||
};
|
||
|
||
const replaceMsg = (id: number, next: ChatMsg | null) => {
|
||
messages.value = next
|
||
? messages.value.map((item) => (item.id === id ? next : item))
|
||
: messages.value.filter((item) => item.id !== id);
|
||
if (next?.role === 'master' && next.result.memoryCandidate) {
|
||
scrollToMessageStart(id);
|
||
return;
|
||
}
|
||
scrollToBottom();
|
||
};
|
||
|
||
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();
|
||
};
|
||
|
||
// A message consultation is re-authorized on every show. When the page is
|
||
// hidden while that request (or its confirmation dialog) is still pending,
|
||
// invalidate only the in-flight work. Keep the account-bound pending message
|
||
// ID so the next onShow can restart the authorization instead of leaving the
|
||
// composer locked in a stale loading state.
|
||
const cancelPendingBroadcastConsultation = () => {
|
||
broadcastContextGeneration += 1;
|
||
broadcastContextLoading.value = false;
|
||
broadcastContextPrompting.value = false;
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
if (!conversationStarted.value) {
|
||
// Do not start project selection until the message context has been
|
||
// resolved. Otherwise the asynchronous handoff can briefly look like an
|
||
// ordinary empty conversation and incorrectly open the project picker.
|
||
await activateBroadcastConsultation(pending, accountKey, pageGeneration);
|
||
return;
|
||
}
|
||
|
||
const start = () => void activateBroadcastConsultation(pending, accountKey, pageGeneration);
|
||
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
|
||
});
|
||
|
||
const applyConversationState = (result: KnowledgeSearchResponse) => {
|
||
if (result.conversationId === conversationId.value && typeof result.contextVersion === 'number') {
|
||
contextVersion.value = result.contextVersion;
|
||
}
|
||
};
|
||
|
||
const retireEarlierMemoryCandidate = (candidateId?: number) => {
|
||
if (!candidateId) return;
|
||
messages.value.forEach((item) => {
|
||
if (item.role === 'master' && item.result.memoryCandidate?.id === candidateId) {
|
||
item.result.memoryCandidate = null;
|
||
}
|
||
});
|
||
};
|
||
|
||
const releaseMessageResources = () => {
|
||
messages.value.forEach((msg) => {
|
||
if (msg.role !== 'master') return;
|
||
(msg.result.resources || []).forEach((resource) => {
|
||
if (resource.localUrl) releaseAuthenticatedFileUrl(resource.localUrl);
|
||
resource.localUrl = undefined;
|
||
});
|
||
});
|
||
};
|
||
|
||
const resetConversation = (notify: boolean) => {
|
||
cancelPendingMediaSearch();
|
||
candidateRefreshGeneration += 1;
|
||
releaseMessageResources();
|
||
speechPlayback.stop();
|
||
messages.value = [];
|
||
summaryCard.value = null;
|
||
selectedMedia.value = null;
|
||
composerRef.value?.clearDraft();
|
||
conversationId.value = createConversationId();
|
||
contextVersion.value = 0;
|
||
if (notify) pushSystem('已开始新对话,后续问题将从新的上下文理解');
|
||
};
|
||
|
||
const startNewConversation = () => {
|
||
clearBroadcastConsultation();
|
||
resetConversation(true);
|
||
};
|
||
|
||
const openProjectPicker = () => {
|
||
if (projectLoading.value) return;
|
||
if (!availableProjects.value.length) {
|
||
uni.showToast({ title: '当前账号未绑定项目', icon: 'none' });
|
||
return;
|
||
}
|
||
if (availableProjects.value.length === 1) return;
|
||
projectSearch.value = '';
|
||
projectPickerOpen.value = true;
|
||
};
|
||
|
||
const closeProjectPicker = () => {
|
||
if (!currentProject.value && availableProjects.value.length > 1) return;
|
||
projectPickerOpen.value = false;
|
||
projectSearch.value = '';
|
||
};
|
||
|
||
const hasUnfinishedProjectInput = () => Boolean(
|
||
selectedMedia.value
|
||
|| composerRef.value?.hasDraft()
|
||
|| messages.value.some((message) => message.role === 'master' && message.result.memoryCandidate)
|
||
);
|
||
|
||
const applyProject = (project: MobileProject) => {
|
||
const changed = currentProject.value?.projectCode !== project.projectCode;
|
||
saveCurrentProject(project);
|
||
currentProject.value = project;
|
||
projectPickerOpen.value = false;
|
||
projectSearch.value = '';
|
||
if (changed) {
|
||
clearBroadcastConsultation();
|
||
resetConversation(false);
|
||
pushSystem(`已切换到“${project.projectName}”,后续记录和对话从新的项目上下文开始`);
|
||
}
|
||
};
|
||
|
||
const selectProject = (project: MobileProject) => {
|
||
if (project.projectCode === currentProject.value?.projectCode) {
|
||
projectPickerOpen.value = false;
|
||
return;
|
||
}
|
||
if (!hasUnfinishedProjectInput()) {
|
||
applyProject(project);
|
||
return;
|
||
}
|
||
uni.showModal({
|
||
title: '切换当前项目?',
|
||
content: '未发送内容或待确认记录会被清空,已保存记录不受影响。',
|
||
confirmText: '确认切换',
|
||
success: (result) => {
|
||
if (result.confirm) applyProject(project);
|
||
}
|
||
});
|
||
};
|
||
|
||
const loadProjectContext = async () => {
|
||
projectLoading.value = true;
|
||
try {
|
||
const identity = await fetchMobileIdentity();
|
||
availableProjects.value = identity.projects || [];
|
||
currentProject.value = reconcileCurrentProject(availableProjects.value);
|
||
if (!currentProject.value && availableProjects.value.length > 1
|
||
&& !broadcastContext.value && !broadcastContextLoading.value && !broadcastContextPrompting.value) {
|
||
projectPickerOpen.value = true;
|
||
}
|
||
} catch (error) {
|
||
availableProjects.value = [];
|
||
currentProject.value = null;
|
||
uni.showToast({ title: requestErrorText(error, '项目列表读取失败'), icon: 'none' });
|
||
} finally {
|
||
projectLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const ensureCurrentProject = () => {
|
||
if (projectLoading.value) {
|
||
uni.showToast({ title: '正在读取项目,请稍候', icon: 'none' });
|
||
return false;
|
||
}
|
||
if (availableProjects.value.length > 1 && !currentProject.value) {
|
||
projectPickerOpen.value = true;
|
||
uni.showToast({ title: '请先选择当前项目', icon: 'none' });
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
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 requestErrorText = (error: unknown, fallback: string) => {
|
||
refreshAuthState();
|
||
if (!loggedIn.value) return '登录已过期,请重新登录';
|
||
return error instanceof Error ? error.message : fallback;
|
||
};
|
||
|
||
const sendQuestion = async (
|
||
rawText: string,
|
||
options: { voice?: boolean; externalConsent?: boolean } = {}
|
||
) => {
|
||
if (loading.value) {
|
||
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 ? '请分析附件内容并回答我的问题' : '');
|
||
if (!text) {
|
||
uni.showToast({ title: '请先输入问题', icon: 'none' });
|
||
return;
|
||
}
|
||
const requestShowGeneration = showGeneration;
|
||
const requestAccountKey = currentAccountKey();
|
||
const isCurrentQuestion = () => requestShowGeneration === showGeneration
|
||
&& requestAccountKey === currentAccountKey()
|
||
&& broadcastMessageId === broadcastContext.value?.messageId;
|
||
refreshAuthState();
|
||
if (!loggedIn.value) {
|
||
uni.setStorageSync(pendingSopQueryKey, text);
|
||
goLogin();
|
||
return;
|
||
}
|
||
if (!(await ensureEmployeePosition('/pages/user/sop/index')) || !isCurrentQuestion()) {
|
||
return;
|
||
}
|
||
if (!broadcastMessageId && !ensureCurrentProject()) return;
|
||
refreshAuthState();
|
||
if (!isCurrentQuestion()) return;
|
||
pushMsg<UserMsg>({
|
||
role: 'user',
|
||
text,
|
||
voice: options.voice,
|
||
mediaKind: media?.kind,
|
||
mediaName: media?.name
|
||
});
|
||
selectedMedia.value = null;
|
||
const pending = pushMsg<PendingMsg>({ role: 'pending' });
|
||
loading.value = true;
|
||
const generation = ++knowledgeSearchGeneration;
|
||
try {
|
||
const agentOptions = {
|
||
conversation: conversationState(),
|
||
projectCode: broadcastMessageId ? undefined : currentProject.value?.projectCode,
|
||
broadcastMessageId,
|
||
externalConsent: Boolean(options.externalConsent)
|
||
};
|
||
const nextResult = media
|
||
? await askAgentWithMedia(media, text, agentOptions, (task) => {
|
||
if (generation === knowledgeSearchGeneration) activeMediaUploadTask = task;
|
||
})
|
||
: await askAgent(text, agentOptions);
|
||
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();
|
||
const shouldPreloadVoice = Boolean(spoken);
|
||
replaceMsg(pending.id, {
|
||
id: pending.id,
|
||
role: 'master',
|
||
result: nextResult,
|
||
toolCode: '',
|
||
feedbackVerdict: '',
|
||
feedbackSubmitting: false,
|
||
feedbackPanelOpen: false,
|
||
feedbackReasonCodes: [],
|
||
feedbackComment: '',
|
||
helpSubmitting: false,
|
||
helpQuestionId: null,
|
||
helpIdempotencyKey: '',
|
||
citationsOpen: false,
|
||
voiceStatus: shouldPreloadVoice ? 'preparing' : 'unavailable',
|
||
voiceDuration: null,
|
||
unread: Boolean(spoken),
|
||
// Keep the answer readable while the voice is still synthesizing; it
|
||
// collapses on its own once the voice bubble can show a duration.
|
||
transcriptOpen: true,
|
||
transcriptPinned: false
|
||
});
|
||
if (shouldPreloadVoice) void prepareVoice(pending.id, spoken);
|
||
if (!nextResult.answer && !nextResult.snippets?.length && !nextResult.memoryCandidate) {
|
||
pushSystem('未检索到可引用内容,换个问法试试');
|
||
}
|
||
} 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 {
|
||
if (generation === knowledgeSearchGeneration) {
|
||
activeMediaUploadTask = null;
|
||
loading.value = false;
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleComposerSend = (text: string) => {
|
||
void sendQuestion(text);
|
||
};
|
||
|
||
const useQuickQuestion = (value: string) => {
|
||
selectedMedia.value = null;
|
||
void sendQuestion(value);
|
||
};
|
||
|
||
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);
|
||
goLogin();
|
||
return;
|
||
}
|
||
const effectiveToolCode = supervisorLearner.value && toolCode === 'TEAM_PRACTICE_SUMMARY'
|
||
? 'MY_PRACTICE_SUMMARY'
|
||
: toolCode;
|
||
const question = effectiveToolCode === 'TEAM_PRACTICE_SUMMARY' ? '查询我的团队训练概况' : '查询我的训练概况';
|
||
selectedMedia.value = null;
|
||
void sendQuestion(question);
|
||
};
|
||
|
||
const pickMedia = async () => {
|
||
if (loading.value) return;
|
||
if (broadcastContext.value) {
|
||
uni.showToast({ title: '消息咨询暂不支持图片或视频,请改用文字或语音', icon: 'none' });
|
||
return;
|
||
}
|
||
try {
|
||
selectedMedia.value = await chooseKnowledgeMedia();
|
||
uni.showToast({
|
||
title: selectedMedia.value.kind === 'video' ? '已选择视频,发送时一起分析' : '已选择图片,发送时一起分析',
|
||
icon: 'none'
|
||
});
|
||
} catch (error) {
|
||
if (!isFileSelectionCancelled(error)) {
|
||
const text = error instanceof Error ? error.message : '附件选择失败';
|
||
uni.showToast({ title: text, icon: 'none' });
|
||
}
|
||
}
|
||
};
|
||
|
||
const clearMedia = () => {
|
||
if (loading.value) return;
|
||
selectedMedia.value = null;
|
||
};
|
||
|
||
const handleComposerPanel = (key: string) => {
|
||
if (key === 'pick-audio') void pickVoiceAudio();
|
||
};
|
||
|
||
const cancelVoiceTranscription = () => {
|
||
voiceTranscriptionGeneration += 1;
|
||
abortVoiceTranscription?.();
|
||
abortVoiceTranscription = null;
|
||
voiceTranscribing.value = false;
|
||
};
|
||
|
||
const handleVoiceCapture = async (capture: SpeechCapture) => {
|
||
if (voiceTranscribing.value) return;
|
||
const generation = ++voiceTranscriptionGeneration;
|
||
voiceTranscribing.value = true;
|
||
try {
|
||
const result = await transcribeSpeechCapture(capture, 'sop-question.webm', (abort) => {
|
||
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||
});
|
||
if (generation !== voiceTranscriptionGeneration) return;
|
||
const text = (result.text || '').trim();
|
||
if (!text) {
|
||
pushSystem('语音没有识别到文字,请再说一遍');
|
||
return;
|
||
}
|
||
void sendQuestion(text, { voice: true });
|
||
} catch (error) {
|
||
if (generation !== voiceTranscriptionGeneration) return;
|
||
pushSystem(error instanceof Error ? error.message : '语音转写失败,请改用文字输入');
|
||
} finally {
|
||
if (generation === voiceTranscriptionGeneration) {
|
||
abortVoiceTranscription = null;
|
||
voiceTranscribing.value = false;
|
||
}
|
||
}
|
||
};
|
||
|
||
const pickVoiceAudio = async () => {
|
||
if (loading.value || voiceTranscribing.value) return;
|
||
const generation = ++voiceTranscriptionGeneration;
|
||
voiceTranscribing.value = true;
|
||
try {
|
||
const file = await chooseSpeechAudio();
|
||
if (generation !== voiceTranscriptionGeneration) return;
|
||
const result = await transcribeSpeechFile(file, (abort) => {
|
||
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||
});
|
||
if (generation !== voiceTranscriptionGeneration) return;
|
||
const text = (result.text || '').trim();
|
||
if (!text) {
|
||
pushSystem('语音没有识别到文字,请再试一次');
|
||
return;
|
||
}
|
||
void sendQuestion(text, { voice: true });
|
||
} catch (error) {
|
||
if (generation !== voiceTranscriptionGeneration) return;
|
||
if (!isFileSelectionCancelled(error)) {
|
||
const text = error instanceof Error ? error.message : '语音转写失败,请改用文字输入';
|
||
pushSystem(text);
|
||
}
|
||
} finally {
|
||
if (generation === voiceTranscriptionGeneration) {
|
||
abortVoiceTranscription = null;
|
||
voiceTranscribing.value = false;
|
||
}
|
||
}
|
||
};
|
||
|
||
const findMasterMsg = (id: number): MasterMsg | null => {
|
||
const found = messages.value.find((item) => item.id === id);
|
||
return found && found.role === 'master' ? found : null;
|
||
};
|
||
|
||
/** Upper bound on the `preparing` voice bubble. Remote TTS has been seen to
|
||
* hang well past its own timeout; after this the reply degrades to the plain
|
||
* text bubble instead of showing an endless placeholder duration. */
|
||
const VOICE_PREPARE_TIMEOUT_MS = 20000;
|
||
|
||
const spokenTextOf = (msg: MasterMsg) => (msg.result.answer || '').trim();
|
||
|
||
/** Pre-synthesize the reply audio so the voice bubble can show a duration
|
||
* before the first tap, WeChat-style. Falls back to a plain text bubble
|
||
* when remote TTS is unavailable. */
|
||
const prepareVoice = async (id: number, spoken: string) => {
|
||
const source = await Promise.race([
|
||
speechPlayback.preload(spoken),
|
||
new Promise<null>((resolve) => {
|
||
setTimeout(() => resolve(null), VOICE_PREPARE_TIMEOUT_MS);
|
||
})
|
||
]);
|
||
const msg = findMasterMsg(id);
|
||
if (!msg) return;
|
||
if (!source) {
|
||
// No voice to offer: leave the text bubble and its play button in place.
|
||
msg.voiceStatus = 'unavailable';
|
||
msg.unread = false;
|
||
return;
|
||
}
|
||
msg.voiceDuration = await measureAudioDuration(source);
|
||
msg.voiceStatus = 'ready';
|
||
// Deliberately not collapsing here: synthesis often finishes in 2-4s, which
|
||
// would pull the answer away mid-sentence. The transcript collapses when the
|
||
// reader starts the voice instead.
|
||
};
|
||
|
||
/** Pin on any manual toggle so a later `ready` voice keeps the reader's choice. */
|
||
const toggleTranscript = (msg: MasterMsg) => {
|
||
msg.transcriptOpen = !msg.transcriptOpen;
|
||
msg.transcriptPinned = true;
|
||
};
|
||
|
||
const collapseTranscript = (msg: MasterMsg) => {
|
||
msg.transcriptOpen = false;
|
||
msg.transcriptPinned = true;
|
||
};
|
||
|
||
const isVoicePlaying = (msg: MasterMsg) =>
|
||
speechState.value.key === `answer-${msg.id}` && speechState.value.status === 'playing';
|
||
|
||
const voiceLabel = (msg: MasterMsg) => {
|
||
if (speechState.value.key === `answer-${msg.id}` && speechState.value.status === 'loading') return '…';
|
||
if (msg.voiceStatus === 'preparing') return '…';
|
||
return msg.voiceDuration ? `${Math.min(msg.voiceDuration, 60)}"` : '语音';
|
||
};
|
||
|
||
const playVoice = (msg: MasterMsg) => {
|
||
if (msg.voiceStatus === 'preparing') {
|
||
uni.showToast({ title: '语音生成中,请稍候', icon: 'none' });
|
||
return;
|
||
}
|
||
msg.unread = false;
|
||
// Listening replaces reading, so fold the transcript away unless the reader
|
||
// explicitly asked to keep it open.
|
||
if (!msg.transcriptPinned) msg.transcriptOpen = false;
|
||
void speechPlayback.toggle(`answer-${msg.id}`, spokenTextOf(msg));
|
||
};
|
||
|
||
const openKnowledgeResource = async (resource: KnowledgeResource) => {
|
||
if (resource.loading) return;
|
||
if (resource.type === 'VIDEO' && resource.localUrl) return;
|
||
resource.loading = true;
|
||
try {
|
||
if (resource.type === 'FILE') {
|
||
const downloadUrl = await requestKnowledgeResourceDownloadLink(resource.attachmentId);
|
||
if (!isNativeAppRuntime() && typeof document !== 'undefined') {
|
||
const link = document.createElement('a');
|
||
link.href = downloadUrl;
|
||
link.download = resource.title || '资料文件';
|
||
link.rel = 'noreferrer';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
return;
|
||
}
|
||
uni.downloadFile({
|
||
url: downloadUrl,
|
||
success: (response) => {
|
||
if (response.statusCode >= 200 && response.statusCode < 300 && response.tempFilePath) {
|
||
uni.openDocument({ filePath: response.tempFilePath, showMenu: true });
|
||
return;
|
||
}
|
||
uni.showToast({ title: '文件下载失败,请稍后重试', icon: 'none' });
|
||
},
|
||
fail: () => uni.showToast({ title: '文件下载失败,请稍后重试', icon: 'none' })
|
||
});
|
||
return;
|
||
}
|
||
const localUrl = await authenticatedFileUrl(resource.contentUrl);
|
||
if (resource.localUrl && resource.localUrl !== localUrl) {
|
||
releaseAuthenticatedFileUrl(resource.localUrl);
|
||
}
|
||
resource.localUrl = localUrl;
|
||
} catch (error) {
|
||
uni.showToast({ title: requestErrorText(error, '资料读取失败'), icon: 'none' });
|
||
} finally {
|
||
resource.loading = false;
|
||
}
|
||
};
|
||
|
||
const snippetExcerpt = (text = '') => {
|
||
const value = text.trim();
|
||
return value.length > 260 ? `${value.slice(0, 260)}...` : value;
|
||
};
|
||
|
||
const cleanOrderedText = (text = '') =>
|
||
text
|
||
.trim()
|
||
.replace(/^(?:[一二三四五六七八九十]+[、..]|[0-9]+[.)、..])\s*/, '')
|
||
.trim();
|
||
|
||
const citationDomainLabel = (snippet: KnowledgeSearchResponse['snippets'][number]) => {
|
||
if (snippet.domain === 'PROJECT_SERVICE') return '项目记录';
|
||
if (snippet.domain === 'PERSONAL') return '我的资料';
|
||
if (snippet.sourceType === 'DATA_TOOL') return '数据工具';
|
||
return ({
|
||
FORMAL_POLICY: '正式制度',
|
||
OPERATING_MANUAL: '操作手册',
|
||
APPROVED_SOP: '已批准 SOP',
|
||
EXPERIENCE: '经验材料',
|
||
CASE: '案例'
|
||
}[snippet.sourceKind || ''] || '企业知识');
|
||
};
|
||
|
||
const serviceMemoryStatusLabel = (status?: string | null) => ({
|
||
OPEN: '待跟进',
|
||
IN_PROGRESS: '跟进中',
|
||
RESOLVED: '已完成',
|
||
VOID: '已作废'
|
||
}[status || ''] || '状态未知');
|
||
|
||
const displayCitationDate = (value?: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未记录';
|
||
|
||
const formatCitationTime = (milliseconds?: number | null) => {
|
||
if (typeof milliseconds !== 'number' || milliseconds < 0) return '';
|
||
const seconds = Math.floor(milliseconds / 1000);
|
||
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
||
};
|
||
|
||
const citationLocationLabel = (snippet: KnowledgeSearchResponse['snippets'][number]) => {
|
||
const locator = snippet.locatorSummary;
|
||
const at = locator?.frameMs ?? locator?.startMs;
|
||
if (snippet.mediaType === 'VIDEO' && typeof at === 'number') return `视频 ${formatCitationTime(at)}`;
|
||
if (locator?.pageNumber) return `第 ${locator.pageNumber} 页`;
|
||
if (locator?.slideNumber) return `第 ${locator.slideNumber} 页`;
|
||
if (locator?.sheetName && locator?.rowStart) return `${locator.sheetName} · 第 ${locator.rowStart} 行`;
|
||
if (locator?.paragraphStart) return `第 ${locator.paragraphStart} 段`;
|
||
return snippet.mediaType === 'IMAGE' ? '原图' : '来源全文';
|
||
};
|
||
|
||
const openCitationDetail = (snippet: KnowledgeSearchResponse['snippets'][number]) => {
|
||
if (!snippet.detailRef || snippet.sourceType !== 'DOCUMENT') return;
|
||
uni.navigateTo({ url: `/pages/user/sop/citation-detail?ref=${encodeURIComponent(snippet.detailRef)}` });
|
||
};
|
||
|
||
const noEvidenceText = (result: KnowledgeSearchResponse) => {
|
||
if (result.agentIntent === 'WEB_RESEARCH') return '公开网络中没有找到可核验来源。';
|
||
if (result.agentIntent === 'MEDIA_UNDERSTANDING') return '已分析附件,但当前资料中没有补充依据。';
|
||
return '当前授权空间内没有足够依据。';
|
||
};
|
||
|
||
const feedbackReasonOptions = [
|
||
{ code: 'NO_ANSWER', label: '缺少制度或SOP依据' },
|
||
{ code: 'NOT_SPECIFIC', label: '处理步骤不够具体' },
|
||
{ code: 'WRONG_REFERENCE', label: '答案或引用不正确' },
|
||
{ code: 'NOT_APPLICABLE', label: '不适用于当前现场' }
|
||
];
|
||
|
||
const openNegativeFeedback = (msg: MasterMsg) => {
|
||
if (msg.feedbackSubmitting || msg.feedbackVerdict) return;
|
||
msg.feedbackPanelOpen = !msg.feedbackPanelOpen;
|
||
};
|
||
|
||
const toggleNegativeFeedbackReason = (msg: MasterMsg, code: string) => {
|
||
if (msg.feedbackSubmitting) return;
|
||
msg.feedbackReasonCodes = msg.feedbackReasonCodes.includes(code)
|
||
? msg.feedbackReasonCodes.filter((item) => item !== code)
|
||
: [...msg.feedbackReasonCodes, code];
|
||
};
|
||
|
||
const submitNegativeFeedback = async (msg: MasterMsg) => {
|
||
if (!msg.feedbackReasonCodes.length) {
|
||
uni.showToast({ title: '请至少选择一项未解决原因', icon: 'none' });
|
||
return;
|
||
}
|
||
await feedback('down', msg);
|
||
};
|
||
|
||
const feedback = async (verdict: AnswerFeedbackVerdict, msg: MasterMsg) => {
|
||
if (msg.feedbackSubmitting || msg.feedbackVerdict) return;
|
||
const fragmentIds = Array.from(
|
||
new Set(
|
||
(msg.result.snippets || [])
|
||
.map((item) => item.fragmentId)
|
||
.filter((id): id is number => typeof id === 'number' && id > 0)
|
||
)
|
||
);
|
||
if (!fragmentIds.length) {
|
||
uni.showToast({ title: '暂无可反馈依据', icon: 'none' });
|
||
return;
|
||
}
|
||
msg.feedbackSubmitting = true;
|
||
try {
|
||
await submitAnswerFeedback({
|
||
queryText: msg.result.queryText || '',
|
||
requestId: msg.result.requestId,
|
||
fragmentIds,
|
||
verdict,
|
||
reviewId: msg.result.reviewId,
|
||
reasonCodes: verdict === 'down' ? msg.feedbackReasonCodes : undefined,
|
||
comment: verdict === 'down' ? msg.feedbackComment.trim() : undefined
|
||
});
|
||
msg.feedbackVerdict = verdict;
|
||
msg.feedbackPanelOpen = false;
|
||
uni.showToast({ title: verdict === 'down' ? '已进入复核' : '已记录', icon: 'none' });
|
||
} catch (error) {
|
||
uni.showToast({ title: requestErrorText(error, '反馈失败'), icon: 'none' });
|
||
} finally {
|
||
msg.feedbackSubmitting = false;
|
||
}
|
||
};
|
||
|
||
const confirmHumanHelp = () => new Promise<boolean>((resolve) => {
|
||
uni.showModal({
|
||
title: '提交人工求助',
|
||
content: '将使用你刚才的问题创建问题榜条目。审核通过后其他员工可见,请确认问题中不含住户姓名、电话、房号等隐私信息。',
|
||
confirmText: '确认提交',
|
||
cancelText: '暂不提交',
|
||
success: (result) => resolve(Boolean(result.confirm)),
|
||
fail: () => resolve(false)
|
||
});
|
||
});
|
||
|
||
const submitHumanHelp = async (msg: MasterMsg) => {
|
||
if (msg.helpSubmitting || msg.helpQuestionId || !msg.result.requestId) return;
|
||
if (!await confirmHumanHelp()) return;
|
||
msg.helpSubmitting = true;
|
||
if (!msg.helpIdempotencyKey) {
|
||
msg.helpIdempotencyKey = `feedback-help-${createConversationId()}`;
|
||
}
|
||
try {
|
||
const question = await publishQuestionFromAnswerFeedback({
|
||
requestId: msg.result.requestId,
|
||
idempotencyKey: msg.helpIdempotencyKey
|
||
});
|
||
msg.helpQuestionId = question.id;
|
||
uni.showToast({ title: '已提交审核', icon: 'none' });
|
||
} catch (error) {
|
||
uni.showToast({ title: requestErrorText(error, '人工求助提交失败'), icon: 'none' });
|
||
} finally {
|
||
msg.helpSubmitting = false;
|
||
}
|
||
};
|
||
|
||
const openHelpQuestion = (questionId: number) => {
|
||
uni.navigateTo({ url: `/pages/user/questions/detail?id=${questionId}` });
|
||
};
|
||
|
||
const buildSummaryCard = async (msg: MasterMsg) => {
|
||
if (broadcastContext.value) {
|
||
uni.showToast({ title: '消息咨询不支持生成总结卡,请先结束咨询', icon: 'none' });
|
||
return;
|
||
}
|
||
if (summaryLoading.value) return;
|
||
const value = (msg.result.queryText || '').trim();
|
||
const fragmentIds = summaryFragmentIds(msg);
|
||
if (!value || !fragmentIds.length) {
|
||
pushSystem('当前回答没有可用于总结卡的引用片段,请重新提问');
|
||
return;
|
||
}
|
||
summaryLoading.value = true;
|
||
summaryCard.value = null;
|
||
try {
|
||
summaryCard.value = await generateSummaryCard({ queryText: value, fragmentIds });
|
||
} catch (error) {
|
||
pushSystem(requestErrorText(error, '总结卡生成失败'));
|
||
} finally {
|
||
summaryLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const summaryFragmentIds = (msg: MasterMsg) => Array.from(new Set(
|
||
(msg.result.snippets || [])
|
||
.filter((item) => !item.sourceType || item.sourceType === 'DOCUMENT')
|
||
.map((item) => item.fragmentId)
|
||
.filter((id): id is number => typeof id === 'number' && Number.isSafeInteger(id) && id > 0)
|
||
)).slice(0, 5);
|
||
|
||
const closeSummary = () => {
|
||
if (speechState.value.key === 'summary') speechPlayback.stop();
|
||
summaryCard.value = null;
|
||
};
|
||
|
||
/** 学练闭环(旧 H5 逻辑保留):看完总结卡直接去练一遍。 */
|
||
const practiceFromSummary = () => {
|
||
closeSummary();
|
||
uni.switchTab({ url: '/pages/user/practice/index' });
|
||
};
|
||
|
||
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 = (msg: MasterMsg) =>
|
||
[
|
||
msg.result.answer,
|
||
...(msg.result.keyPoints || []).map((item, index) => `第${index + 1}点,${cleanOrderedText(item)}`),
|
||
...(msg.result.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 = (msg: MasterMsg) => {
|
||
void speechPlayback.toggle(`answer-${msg.id}`, answerSpeechText(msg));
|
||
};
|
||
|
||
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 : '总结卡下载失败';
|
||
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 = () => {
|
||
if (broadcastContext.value) {
|
||
uni.showToast({ title: '消息咨询仅使用公司消息上下文,请先结束咨询', icon: 'none' });
|
||
return;
|
||
}
|
||
uni.navigateTo({ url: '/pages/user/web-ai/index' });
|
||
};
|
||
|
||
const consentWebResearch = (msg: MasterMsg) => {
|
||
if (loading.value || msg.result.agentIntent !== 'WEB_RESEARCH') return;
|
||
void sendQuestion(msg.result.queryText, { externalConsent: true });
|
||
};
|
||
|
||
const openTodayResult = () => {
|
||
uni.navigateTo({ url: '/pages/user/work-results/index' });
|
||
};
|
||
|
||
const openPersonalAssistant = () => {
|
||
uni.navigateTo({ url: '/pages/user/assistant/index' });
|
||
};
|
||
|
||
const hideMemoryCandidate = (msg: MasterMsg) => {
|
||
retireEarlierMemoryCandidate(msg.result.memoryCandidate?.id);
|
||
};
|
||
|
||
const refreshMemoryCandidate = async (msg: MasterMsg, candidateId: number) => {
|
||
const generation = ++candidateRefreshGeneration;
|
||
const accountKey = currentAccountKey();
|
||
try {
|
||
const candidates = await getMemoryCandidates('DRAFT');
|
||
if (generation !== candidateRefreshGeneration || accountKey !== currentAccountKey()) return;
|
||
const refreshed = candidates.find((item) => item.id === candidateId);
|
||
msg.result.memoryCandidate = refreshed
|
||
? { ...refreshed, agentDraftId: msg.result.memoryCandidate?.agentDraftId }
|
||
: null;
|
||
} catch (error) {
|
||
if (generation !== candidateRefreshGeneration || accountKey !== currentAccountKey()) return;
|
||
uni.showToast({ title: requestErrorText(error, '确认卡刷新失败'), icon: 'none' });
|
||
}
|
||
};
|
||
|
||
const handleShow = async () => {
|
||
const generation = ++showGeneration;
|
||
resetPageScroll();
|
||
const accountKey = currentAccountKey();
|
||
const isCurrentShow = () => generation === showGeneration && accountKey === currentAccountKey();
|
||
if (visibleAccountKey && visibleAccountKey !== accountKey) {
|
||
clearBroadcastConsultation();
|
||
resetConversation(false);
|
||
}
|
||
visibleAccountKey = accountKey;
|
||
refreshAuthState();
|
||
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);
|
||
if (pendingTool === 'MY_PRACTICE_SUMMARY' || pendingTool === 'TEAM_PRACTICE_SUMMARY') {
|
||
if (!loggedIn.value) {
|
||
goLogin();
|
||
return;
|
||
}
|
||
if (!isCurrentShow()) return;
|
||
uni.removeStorageSync(pendingToolKey);
|
||
if (!isCurrentShow()) return;
|
||
void runDataTool(pendingTool);
|
||
return;
|
||
}
|
||
if (pending && loggedIn.value) {
|
||
if (!isCurrentShow()) return;
|
||
uni.removeStorageSync(pendingSopQueryKey);
|
||
if (!isCurrentShow()) return;
|
||
void sendQuestion(String(pending));
|
||
}
|
||
};
|
||
|
||
onShow(() => {
|
||
void handleShow();
|
||
});
|
||
|
||
onHide(() => {
|
||
showGeneration += 1;
|
||
candidateRefreshGeneration += 1;
|
||
cancelPendingBroadcastConsultation();
|
||
speechPlayback.stop();
|
||
if (!isPlatformNativeActivityActive()) {
|
||
composerRef.value?.teardown();
|
||
cancelVoiceTranscription();
|
||
}
|
||
cancelPendingMediaSearch();
|
||
});
|
||
|
||
onUnload(() => {
|
||
showGeneration += 1;
|
||
cancelPendingBroadcastConsultation();
|
||
releaseMessageResources();
|
||
speechPlayback.destroy();
|
||
composerRef.value?.teardown();
|
||
cancelVoiceTranscription();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.sop-page {
|
||
padding: 0 16px 108px;
|
||
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 {
|
||
display: flex;
|
||
min-height: 38px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: 0;
|
||
padding: 0;
|
||
border: 1px solid #e7aeb7;
|
||
border-radius: 10px;
|
||
background: #ffffff;
|
||
color: #b51c2b;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
}
|
||
|
||
.broadcast-context-action.end {
|
||
border-color: transparent;
|
||
background: #f5e9eb;
|
||
color: #76545a;
|
||
}
|
||
|
||
.sop-head {
|
||
position: relative;
|
||
margin: 0 -16px;
|
||
padding: 30px 20px 24px;
|
||
border-bottom: 1px solid var(--employee-line);
|
||
background: linear-gradient(180deg, #eef4ff 0%, #f5f8fd 100%);
|
||
}
|
||
|
||
.sop-head::after {
|
||
position: absolute;
|
||
right: 20px;
|
||
bottom: 20px;
|
||
width: 42px;
|
||
height: 4px;
|
||
border-radius: 4px;
|
||
background: var(--employee-primary);
|
||
content: '';
|
||
}
|
||
|
||
.sop-eyebrow {
|
||
display: inline-flex;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.sop-head .page-title {
|
||
color: #1f2b3d;
|
||
font-size: 31px;
|
||
line-height: 1.12;
|
||
}
|
||
|
||
.sop-head .page-subtitle {
|
||
max-width: 292px;
|
||
margin-top: 10px;
|
||
color: #607086;
|
||
font-size: 14px;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.toolbar-tip {
|
||
display: block;
|
||
max-width: 286px;
|
||
margin-top: 14px;
|
||
color: #315b8f;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.query-panel,
|
||
.answer-panel,
|
||
.summary-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.query-panel {
|
||
margin-top: 16px;
|
||
padding: 20px;
|
||
border-top: 0;
|
||
box-shadow: var(--employee-card-shadow);
|
||
}
|
||
|
||
.sop-query-textarea {
|
||
min-height: 112px;
|
||
border-color: #d6deea;
|
||
background: #f9fbff;
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||
}
|
||
|
||
.query-caption {
|
||
color: #6f7b8f;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.query-panel .textarea {
|
||
min-height: 104px;
|
||
height: 104px;
|
||
border-color: #d9dade;
|
||
border-radius: 7px;
|
||
background: #fbfbfb;
|
||
color: #25282d;
|
||
}
|
||
|
||
.query-panel .chip-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 7px;
|
||
}
|
||
|
||
.query-panel .chip {
|
||
min-height: 38px;
|
||
padding: 0 10px;
|
||
border-color: #dbe5f4;
|
||
border-radius: 999px;
|
||
background: #f7faff;
|
||
color: #47607f;
|
||
font-size: 13px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.query-submit {
|
||
width: 100%;
|
||
margin-top: 2px;
|
||
min-height: 50px;
|
||
border-radius: 18px;
|
||
background: linear-gradient(135deg, #2f6bff, #5a8cff);
|
||
box-shadow: 0 14px 28px rgba(47, 107, 255, 0.22);
|
||
}
|
||
|
||
.login-panel {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 14px;
|
||
margin-top: 22px;
|
||
border-left: 4px solid #e70014;
|
||
box-shadow: var(--employee-card-shadow);
|
||
}
|
||
|
||
.block {
|
||
display: block;
|
||
margin-top: 8px;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.login-button {
|
||
width: 96px;
|
||
min-width: 96px;
|
||
flex: 0 0 96px;
|
||
padding: 0 8px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.answer-text {
|
||
color: #243246;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
line-height: 1.65;
|
||
}
|
||
|
||
.answer-panel {
|
||
border-color: var(--employee-line);
|
||
border-left: 4px solid var(--employee-primary);
|
||
box-shadow: var(--employee-card-shadow);
|
||
}
|
||
|
||
.answer-panel .panel-title {
|
||
color: var(--employee-primary);
|
||
}
|
||
|
||
.question-card,
|
||
.answer-copy-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
margin-top: 12px;
|
||
padding: 14px 16px;
|
||
border-radius: 14px;
|
||
}
|
||
|
||
.question-card {
|
||
background: #fff1f1;
|
||
}
|
||
|
||
.question-label {
|
||
color: #e34b4b;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.question-text {
|
||
color: #25282d;
|
||
font-size: 16px;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.answer-copy-card {
|
||
background: #fff;
|
||
}
|
||
|
||
.keypoints {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.keypoint {
|
||
padding: 10px 12px;
|
||
border-left: 3px solid #168243;
|
||
border-radius: 5px;
|
||
background: #f2f8f4;
|
||
color: #45484e;
|
||
font-size: 15px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.cautions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
padding: 10px 12px;
|
||
border-left: 3px solid #d98200;
|
||
border-radius: 5px;
|
||
background: #fff8e9;
|
||
color: #855000;
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.feedback-actions {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 10px;
|
||
}
|
||
|
||
.feedback-actions .active {
|
||
border-color: var(--employee-primary);
|
||
background: var(--employee-primary-soft);
|
||
color: var(--employee-primary);
|
||
}
|
||
|
||
.feedback-actions .negative {
|
||
border-color: var(--employee-danger);
|
||
background: #fff1f0;
|
||
color: #c34d45;
|
||
}
|
||
|
||
.feedback-status {
|
||
display: block;
|
||
padding: 10px 12px;
|
||
border-radius: 12px;
|
||
background: var(--employee-primary-soft);
|
||
color: var(--employee-primary);
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.sources-panel {
|
||
padding: 0;
|
||
border: 0;
|
||
background: transparent;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.sources-panel .panel-title {
|
||
display: block;
|
||
margin-top: 26px;
|
||
color: #25282d;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.snippet {
|
||
display: flex;
|
||
margin-top: 12px;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
padding: 14px;
|
||
border: 1px solid #e3e4e7;
|
||
border-color: var(--employee-line);
|
||
border-radius: 18px;
|
||
background: #ffffff;
|
||
box-shadow: 0 5px 14px rgba(37, 40, 45, 0.04);
|
||
}
|
||
|
||
.snippet-title {
|
||
color: #34373c;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.snippet-content {
|
||
color: #60636a;
|
||
font-size: 14px;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.snippet-id {
|
||
color: #96999f;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.summary-block {
|
||
display: flex;
|
||
margin-top: 14px;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.summary-panel {
|
||
border-color: #f4e4c4;
|
||
background: #fffaf2;
|
||
box-shadow: var(--employee-card-shadow);
|
||
}
|
||
|
||
.summary-panel .muted {
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.summary-copy {
|
||
max-width: 278px;
|
||
}
|
||
|
||
.summary-panel .ghost-button {
|
||
min-height: 48px;
|
||
border: 1px solid #d7e1f5;
|
||
border-radius: 18px;
|
||
background: #f6f9ff;
|
||
color: #315b8f;
|
||
}
|
||
|
||
/* Modal mask overlay */
|
||
.modal-mask {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(15, 23, 42, 0.6);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 999;
|
||
padding: 20px;
|
||
}
|
||
|
||
.modal-card {
|
||
width: 100%;
|
||
max-width: 400px;
|
||
max-height: 80vh;
|
||
background: #ffffff;
|
||
border-radius: 16px;
|
||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
border-top: 5px solid #168243;
|
||
}
|
||
|
||
.modal-header {
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid #f1f5f9;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.modal-title {
|
||
font-size: 18px;
|
||
font-weight: 900;
|
||
color: #0f172a;
|
||
}
|
||
|
||
.modal-close {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: transparent;
|
||
border: none;
|
||
font-size: 24px;
|
||
color: #94a3b8;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
line-height: 1;
|
||
width: 32px;
|
||
height: 32px;
|
||
min-height: 32px;
|
||
}
|
||
|
||
.modal-body {
|
||
flex: 1;
|
||
padding: 20px;
|
||
overflow-y: auto;
|
||
max-height: 55vh;
|
||
}
|
||
|
||
.modal-footer {
|
||
padding: 16px 20px;
|
||
border-top: 1px solid #f1f5f9;
|
||
}
|
||
|
||
.footer-btn {
|
||
width: 100%;
|
||
background: var(--employee-primary);
|
||
box-shadow: 0 8px 18px rgba(47, 107, 255, 0.18);
|
||
color: #ffffff;
|
||
}
|
||
|
||
/* Infographic Sections */
|
||
.info-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.section-title-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.badge-num {
|
||
width: 20px;
|
||
height: 20px;
|
||
background: #168243;
|
||
color: #ffffff;
|
||
border-radius: 50%;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.info-section-title {
|
||
font-size: 15px;
|
||
font-weight: 900;
|
||
color: #1e293b;
|
||
}
|
||
|
||
/* Steps Timeline */
|
||
.timeline {
|
||
position: relative;
|
||
padding-left: 18px;
|
||
}
|
||
|
||
.timeline::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 8px;
|
||
top: 6px;
|
||
bottom: 6px;
|
||
width: 2px;
|
||
background: #e2e8f0;
|
||
}
|
||
|
||
.timeline-item {
|
||
position: relative;
|
||
padding-bottom: 16px;
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
|
||
.timeline-item:last-child {
|
||
padding-bottom: 0;
|
||
}
|
||
|
||
.timeline-node {
|
||
position: absolute;
|
||
left: -18px;
|
||
width: 18px;
|
||
height: 18px;
|
||
background: #168243;
|
||
color: #ffffff;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.timeline-content {
|
||
flex: 1;
|
||
}
|
||
|
||
.timeline-label {
|
||
display: block;
|
||
font-size: 14px;
|
||
font-weight: 800;
|
||
color: #334155;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.timeline-desc {
|
||
display: block;
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
/* Speech bubbles/scripts */
|
||
.speech-bubble {
|
||
position: relative;
|
||
background: #f2f8f4;
|
||
border-left: 3px solid #168243;
|
||
border-radius: 8px;
|
||
padding: 12px 14px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.quote-mark {
|
||
position: absolute;
|
||
left: 6px;
|
||
top: -2px;
|
||
font-size: 24px;
|
||
color: rgba(22, 130, 67, 0.15);
|
||
font-family: Georgia, serif;
|
||
line-height: 1;
|
||
}
|
||
|
||
.speech-text {
|
||
font-size: 13.5px;
|
||
color: #1e293b;
|
||
line-height: 1.5;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
/* Objections Q&A */
|
||
.objection-card {
|
||
background: #f8fafc;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.objection-q,
|
||
.objection-a {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.objection-q {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.q-badge,
|
||
.a-badge {
|
||
flex: 0 0 22px;
|
||
height: 22px;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
border-radius: 4px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.q-badge {
|
||
background: #fee2e2;
|
||
color: #b91c1c;
|
||
}
|
||
|
||
.a-badge {
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
}
|
||
|
||
.q-text {
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
color: #334155;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.a-text {
|
||
font-size: 13px;
|
||
color: #475569;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
/* Reminders alert box */
|
||
.reminder-item {
|
||
display: flex;
|
||
gap: 8px;
|
||
background: #fffbeb;
|
||
border-radius: 8px;
|
||
padding: 10px 12px;
|
||
margin-bottom: 8px;
|
||
align-items: flex-start;
|
||
border-left: 3px solid #d97706;
|
||
}
|
||
|
||
.reminder-text {
|
||
font-size: 13px;
|
||
color: #78350f;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
/* 视觉稿对齐:与员工端练页统一的蓝白轻卡片体系 */
|
||
.sop-page {
|
||
padding: 0 16px 108px;
|
||
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
|
||
}
|
||
|
||
.sop-head {
|
||
margin: 0 -16px;
|
||
padding: 30px 20px 24px;
|
||
border-bottom: 1px solid var(--employee-line);
|
||
background: linear-gradient(180deg, #eef4ff 0%, #f5f8fd 100%);
|
||
text-align: left;
|
||
}
|
||
|
||
.sop-eyebrow,
|
||
.toolbar-tip {
|
||
display: block;
|
||
}
|
||
|
||
.sop-head .page-title {
|
||
color: var(--employee-text);
|
||
font-size: 30px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.sop-head .page-subtitle {
|
||
max-width: none;
|
||
margin-top: 10px;
|
||
color: var(--employee-muted);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.sop-context {
|
||
display: inline-flex;
|
||
margin-top: 14px;
|
||
padding: 7px 14px;
|
||
border: 1px solid #cfe0ff;
|
||
border-radius: 999px;
|
||
background: var(--employee-primary-soft);
|
||
color: var(--employee-primary);
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.query-panel {
|
||
margin-top: 16px;
|
||
padding: 22px 18px 18px;
|
||
border: 0;
|
||
border-radius: var(--employee-card-radius);
|
||
background: #ffffff;
|
||
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.06);
|
||
}
|
||
|
||
.query-panel .textarea,
|
||
.sop-query-textarea {
|
||
min-height: 126px;
|
||
height: 126px;
|
||
border-color: #dedfe2;
|
||
border-radius: 16px;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.query-panel .chip {
|
||
min-height: 40px;
|
||
border-color: #cfe0ff;
|
||
border-radius: 999px;
|
||
background: #ffffff;
|
||
color: var(--employee-primary);
|
||
}
|
||
|
||
.query-submit {
|
||
min-height: 54px;
|
||
margin-top: 8px;
|
||
border-radius: 18px;
|
||
background: linear-gradient(135deg, #2f6bff, #5a8cff);
|
||
box-shadow: 0 8px 18px rgba(47, 107, 255, 0.2);
|
||
}
|
||
|
||
.knowledge-tip-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-top: 14px;
|
||
padding: 16px;
|
||
border: 0;
|
||
border-radius: 18px;
|
||
border: 1px solid var(--employee-line);
|
||
border-radius: var(--employee-card-radius);
|
||
background: #ffffff;
|
||
box-shadow: var(--employee-card-shadow);
|
||
}
|
||
|
||
.knowledge-tip-icon {
|
||
display: flex;
|
||
width: 46px;
|
||
height: 46px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 14px;
|
||
background: var(--employee-primary-soft);
|
||
color: var(--employee-primary);
|
||
font-size: 28px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.knowledge-tip-copy {
|
||
display: flex;
|
||
min-width: 0;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.knowledge-tip-title {
|
||
color: #25282d;
|
||
font-size: 16px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.knowledge-tip-desc {
|
||
color: #858991;
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.knowledge-tip-arrow {
|
||
color: #8c9098;
|
||
font-size: 26px;
|
||
}
|
||
|
||
.answer-empty {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-top: 14px;
|
||
padding: 28px 18px;
|
||
flex-direction: column;
|
||
border: 0;
|
||
border-radius: var(--employee-card-radius);
|
||
background: #ffffff;
|
||
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.04);
|
||
}
|
||
|
||
.answer-empty-icon {
|
||
color: #f2b4b6;
|
||
font-size: 22px;
|
||
letter-spacing: 5px;
|
||
}
|
||
|
||
.answer-empty-title {
|
||
margin-top: 12px;
|
||
color: #666a72;
|
||
font-size: 18px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.answer-empty-desc {
|
||
margin-top: 6px;
|
||
color: #9a9da3;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.answer-panel {
|
||
border-color: var(--employee-line);
|
||
border-left-color: var(--employee-primary);
|
||
}
|
||
|
||
.answer-panel .panel-title,
|
||
.result-score {
|
||
color: var(--employee-primary);
|
||
}
|
||
|
||
.keypoint,
|
||
.speech-bubble {
|
||
border-left-color: var(--employee-primary);
|
||
background: var(--employee-primary-soft);
|
||
}
|
||
|
||
.cautions,
|
||
.reminder-item {
|
||
border-left-color: var(--employee-warning);
|
||
background: #fff8ec;
|
||
color: #8b5a1d;
|
||
}
|
||
|
||
.feedback-actions .active {
|
||
border-color: var(--employee-primary);
|
||
background: var(--employee-primary-soft);
|
||
color: var(--employee-primary);
|
||
}
|
||
|
||
.feedback-actions .negative {
|
||
border-color: var(--employee-danger);
|
||
background: #fff1f0;
|
||
color: #c34d45;
|
||
}
|
||
|
||
.summary-panel {
|
||
border-color: #f4e4c4;
|
||
border-radius: var(--employee-card-radius);
|
||
background: #ffffff;
|
||
}
|
||
|
||
.summary-panel .ghost-button {
|
||
border-color: #cfe0ff;
|
||
background: var(--employee-primary-soft);
|
||
color: var(--employee-primary);
|
||
}
|
||
|
||
.modal-card {
|
||
border-top-color: var(--employee-primary);
|
||
}
|
||
|
||
.badge-num,
|
||
.timeline-node {
|
||
background: var(--employee-primary);
|
||
}
|
||
|
||
.a-badge {
|
||
background: var(--employee-primary-soft);
|
||
color: var(--employee-primary);
|
||
}
|
||
|
||
.reminder-text {
|
||
color: #8b5a1d;
|
||
}
|
||
|
||
/* 原型对齐:问师傅主卡的标题与对话插画 */
|
||
.query-hero {
|
||
position: relative;
|
||
min-height: 84px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.query-copy {
|
||
position: relative;
|
||
z-index: 1;
|
||
display: flex;
|
||
max-width: 66%;
|
||
flex-direction: column;
|
||
gap: 7px;
|
||
}
|
||
|
||
.query-title {
|
||
color: var(--employee-text);
|
||
font-size: 30px;
|
||
font-weight: 900;
|
||
line-height: 1.1;
|
||
}
|
||
|
||
.query-subtitle {
|
||
color: var(--employee-muted);
|
||
font-size: 15px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.query-visual {
|
||
position: absolute;
|
||
top: -6px;
|
||
right: -4px;
|
||
width: 42%;
|
||
height: 96px;
|
||
border-radius: 50%;
|
||
background: var(--employee-primary-soft);
|
||
}
|
||
|
||
.query-bubble {
|
||
position: absolute;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 50%;
|
||
letter-spacing: 3px;
|
||
}
|
||
|
||
.query-bubble.primary {
|
||
top: 7px;
|
||
right: 26px;
|
||
width: 92px;
|
||
height: 56px;
|
||
border-radius: 28px 28px 28px 10px;
|
||
background: #7ba4ff;
|
||
color: #ffffff;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.query-bubble.primary::after {
|
||
position: absolute;
|
||
bottom: -8px;
|
||
left: 18px;
|
||
width: 16px;
|
||
height: 16px;
|
||
background: #7ba4ff;
|
||
content: '';
|
||
transform: rotate(28deg);
|
||
}
|
||
|
||
.query-bubble.secondary {
|
||
right: 2px;
|
||
bottom: 5px;
|
||
width: 70px;
|
||
height: 42px;
|
||
border-radius: 22px 22px 8px 22px;
|
||
background: #ffffff;
|
||
box-shadow: 0 5px 14px rgba(47, 107, 255, 0.1);
|
||
color: #9bb8ff;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.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 {
|
||
display: flex;
|
||
width: 32px;
|
||
min-width: 32px;
|
||
height: 32px;
|
||
min-height: 32px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0;
|
||
border-radius: 50%;
|
||
background: #ffffff;
|
||
color: var(--employee-muted);
|
||
font-size: 21px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* ===== chat layout (WeChat-style rebuild) ===== */
|
||
.sop-chat-page {
|
||
position: fixed;
|
||
top: var(--window-top, 0px);
|
||
right: 0;
|
||
bottom: var(--window-bottom, 0px);
|
||
left: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
height: auto;
|
||
padding: 0;
|
||
background: #f2f3f5;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.project-context-bar {
|
||
flex-shrink: 0;
|
||
padding: 7px 12px;
|
||
border-bottom: 1px solid #ebedf0;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.today-result-entry {
|
||
display: flex;
|
||
flex-shrink: 0;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin: 0;
|
||
padding: 9px 18px;
|
||
border: 0;
|
||
border-bottom: 1px solid #ebedf0;
|
||
border-radius: 0;
|
||
background: #fff8f8;
|
||
text-align: left;
|
||
}
|
||
|
||
.today-result-entry::after { border: 0; }
|
||
.today-result-copy { display: flex; flex-direction: column; gap: 2px; }
|
||
.today-result-title { color: #c90012; font-size: 15px; font-weight: 700; }
|
||
.today-result-note { color: #8c929d; font-size: 12px; }
|
||
.today-result-arrow { color: #d9081c; font-size: 13px; }
|
||
|
||
.compact-project-strip {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
padding: 6px 12px;
|
||
background: #f8fafc;
|
||
border-bottom: 1px solid #edf2f7;
|
||
}
|
||
|
||
.project-pill-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
height: 28px;
|
||
padding: 0 10px;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 999px;
|
||
background: #ffffff;
|
||
margin: 0;
|
||
}
|
||
|
||
.project-pill-btn.missing {
|
||
border-color: #fecaca;
|
||
background: #fef2f2;
|
||
}
|
||
|
||
.project-pill-name {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: #1e293b;
|
||
max-width: 180px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.personal-lib-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
height: 28px;
|
||
padding: 0 8px;
|
||
border: none;
|
||
background: transparent;
|
||
margin: 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;
|
||
z-index: 1100;
|
||
inset: 0 0 var(--window-bottom, 0px);
|
||
display: flex;
|
||
align-items: flex-end;
|
||
background: rgba(15, 23, 42, 0.46);
|
||
}
|
||
|
||
.project-picker-sheet {
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
max-height: 72vh;
|
||
padding: 18px 16px max(18px, env(safe-area-inset-bottom));
|
||
border-radius: 22px 22px 0 0;
|
||
background: #ffffff;
|
||
box-shadow: 0 -10px 36px rgba(15, 23, 42, 0.16);
|
||
}
|
||
|
||
.project-picker-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
}
|
||
|
||
.project-picker-title,
|
||
.project-picker-subtitle {
|
||
display: block;
|
||
}
|
||
|
||
.project-picker-title {
|
||
color: #18202b;
|
||
font-size: 19px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.project-picker-subtitle {
|
||
margin-top: 5px;
|
||
color: #7b8492;
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.project-picker-close {
|
||
display: flex;
|
||
flex: 0 0 40px;
|
||
width: 40px;
|
||
height: 40px;
|
||
min-height: 40px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: 0;
|
||
padding: 0;
|
||
border: none;
|
||
border-radius: 50%;
|
||
background: #f1f3f5;
|
||
color: #596474;
|
||
font-size: 22px;
|
||
}
|
||
|
||
.project-search {
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
height: 46px;
|
||
margin-top: 16px;
|
||
padding: 0 14px;
|
||
border: 1px solid #dde2e8;
|
||
border-radius: 12px;
|
||
background: #f8f9fb;
|
||
color: #29313c;
|
||
font-size: 15px;
|
||
}
|
||
|
||
.project-picker-list {
|
||
max-height: 48vh;
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.project-option {
|
||
display: flex;
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
min-height: 54px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin: 0 0 9px;
|
||
padding: 0 15px;
|
||
border: 1px solid #e4e7eb;
|
||
border-radius: 13px;
|
||
background: #ffffff;
|
||
text-align: left;
|
||
}
|
||
|
||
.project-option.selected {
|
||
border-color: #e60012;
|
||
background: #fff7f8;
|
||
}
|
||
|
||
.project-option-name {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
color: #29313c;
|
||
font-size: 16px;
|
||
font-weight: 650;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.project-option-status {
|
||
flex-shrink: 0;
|
||
margin-left: 12px;
|
||
color: #d70016;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.project-empty {
|
||
display: block;
|
||
padding: 28px 0;
|
||
color: #8a93a0;
|
||
font-size: 14px;
|
||
text-align: center;
|
||
}
|
||
|
||
.chat-thread {
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
.thread-inner {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
padding: 14px 12px 6px;
|
||
}
|
||
|
||
.bubble-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
}
|
||
|
||
.bubble-row.right {
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.avatar {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 8px;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.master-avatar {
|
||
overflow: hidden;
|
||
background: #f3f4f6;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.bubble {
|
||
max-width: 82%;
|
||
padding: 10px 12px;
|
||
border-radius: 12px;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.bubble.master {
|
||
background: #ffffff;
|
||
border-top-left-radius: 4px;
|
||
box-shadow: 0 1px 3px rgba(20, 24, 31, 0.06);
|
||
}
|
||
|
||
.bubble.user {
|
||
background: #ffd8dc;
|
||
border-top-right-radius: 4px;
|
||
}
|
||
|
||
.bubble-text {
|
||
display: block;
|
||
color: #232a33;
|
||
font-size: 15px;
|
||
line-height: 1.55;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.bubble-tag {
|
||
display: inline-block;
|
||
margin-bottom: 6px;
|
||
padding: 2px 8px;
|
||
border-radius: 6px;
|
||
background: rgba(200, 0, 26, 0.08);
|
||
color: #a04a00;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.bubble.user .bubble-tag {
|
||
background: rgba(255, 255, 255, 0.7);
|
||
}
|
||
|
||
.bubble-keypoints {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
margin-top: 8px;
|
||
padding-top: 8px;
|
||
border-top: 1px dashed #eceff2;
|
||
}
|
||
|
||
.bubble-keypoint {
|
||
color: #38424e;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.bubble-cautions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.bubble-caution {
|
||
color: #a04a00;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.bubble-noevidence {
|
||
display: block;
|
||
margin-top: 8px;
|
||
color: #c8001a;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.context-used {
|
||
display: block;
|
||
margin-bottom: 6px;
|
||
color: #74839a;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.agent-source-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.agent-source-chip {
|
||
padding: 3px 8px;
|
||
border-radius: 999px;
|
||
background: #eef3f8;
|
||
color: #5b6b7f;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.agent-clarification {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px dashed #eceff2;
|
||
}
|
||
|
||
.agent-clarification-text {
|
||
color: #38424e;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.bubble-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.bubble-action {
|
||
display: flex;
|
||
min-height: 28px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: 0;
|
||
padding: 0 12px;
|
||
border: 1px solid #e3e6ea;
|
||
border-radius: 999px;
|
||
background: #f7f8fa;
|
||
color: #38424e;
|
||
font-size: 12px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.bubble-action.active {
|
||
border-color: #1f9d55;
|
||
color: #168243;
|
||
}
|
||
|
||
.bubble-action.negative {
|
||
border-color: #e07f88;
|
||
color: #bd0010;
|
||
}
|
||
|
||
.bubble-action.primary {
|
||
border-color: #e70014;
|
||
background: #e70014;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.feedback-note {
|
||
color: #8a93a0;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.negative-feedback-panel {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
padding: 10px;
|
||
border: 1px solid #f1c5ca;
|
||
border-radius: 12px;
|
||
background: #fff8f8;
|
||
}
|
||
|
||
.negative-feedback-title {
|
||
color: #5b3136;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.negative-feedback-options {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.negative-feedback-option {
|
||
min-height: 28px;
|
||
margin: 0;
|
||
padding: 0 9px;
|
||
border: 1px solid #f0d6d9;
|
||
border-radius: 999px;
|
||
background: #ffffff;
|
||
color: #6b7280;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.negative-feedback-option.active {
|
||
border-color: #e70014;
|
||
background: #fff0f1;
|
||
color: #bd0010;
|
||
}
|
||
|
||
.negative-feedback-comment {
|
||
min-height: 58px;
|
||
padding: 8px;
|
||
border-color: #f0d6d9;
|
||
background: #ffffff;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.negative-feedback-hint {
|
||
color: #8a5960;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.bubble-citations {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px dashed #eceff2;
|
||
}
|
||
|
||
.resource-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
width: min(280px, 100%);
|
||
}
|
||
|
||
.resource-card {
|
||
display: grid;
|
||
grid-template-columns: 38px minmax(0, 1fr) 16px;
|
||
gap: 9px;
|
||
align-items: center;
|
||
width: 100%;
|
||
padding: 10px;
|
||
border: 1px solid #e3e7ed;
|
||
border-radius: 12px;
|
||
background: #ffffff;
|
||
box-shadow: 0 1px 3px rgba(20, 24, 31, 0.06);
|
||
}
|
||
|
||
.resource-card.video {
|
||
border-color: #f1cbd0;
|
||
}
|
||
|
||
.resource-icon {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 38px;
|
||
height: 38px;
|
||
border-radius: 9px;
|
||
background: #fff0f2;
|
||
color: #c8001a;
|
||
font-size: 14px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.resource-copy {
|
||
min-width: 0;
|
||
}
|
||
|
||
.resource-title,
|
||
.resource-meta {
|
||
display: block;
|
||
}
|
||
|
||
.resource-title {
|
||
color: #232a33;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
line-height: 1.4;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.resource-meta {
|
||
margin-top: 3px;
|
||
color: #8a93a0;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.resource-arrow {
|
||
color: #a4abb5;
|
||
font-size: 22px;
|
||
}
|
||
|
||
.resource-video {
|
||
grid-column: 1 / -1;
|
||
width: 100%;
|
||
height: 156px;
|
||
margin-top: 2px;
|
||
border-radius: 8px;
|
||
background: #111111;
|
||
}
|
||
|
||
.citation {
|
||
padding: 8px 10px;
|
||
border-left: 3px solid #f0c0c5;
|
||
border-radius: 6px;
|
||
background: #fafbfc;
|
||
}
|
||
|
||
.answer-evidence-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
color: #2f6bff;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.citation-action {
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
margin: 0;
|
||
border: 0;
|
||
border-left: 3px solid #d3132a;
|
||
text-align: left;
|
||
line-height: normal;
|
||
}
|
||
|
||
.citation-action::after { border: 0; }
|
||
|
||
.citation-open-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
margin-top: 8px;
|
||
padding-top: 7px;
|
||
border-top: 1px solid #e8ebef;
|
||
}
|
||
|
||
.citation-location { color: #697586; font-size: 12px; }
|
||
.citation-open { color: #c90018; font-size: 12px; font-weight: 700; }
|
||
|
||
.citation-title {
|
||
display: block;
|
||
color: #232a33;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.citation-domain {
|
||
display: inline-flex;
|
||
margin-bottom: 5px;
|
||
padding: 2px 7px;
|
||
border-radius: 999px;
|
||
background: #eef3f8;
|
||
color: #526176;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.citation-domain.project {
|
||
background: #fff0f2;
|
||
color: #c8001a;
|
||
}
|
||
|
||
.citation-meta {
|
||
display: block;
|
||
margin-top: 2px;
|
||
color: #98a1ad;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.citation-content {
|
||
display: block;
|
||
margin-top: 4px;
|
||
color: #4d5560;
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.system-row {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 2px 0;
|
||
}
|
||
|
||
.system-text {
|
||
display: flex;
|
||
max-width: 86%;
|
||
min-height: 26px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0 12px;
|
||
border-radius: 999px;
|
||
background: rgba(20, 24, 31, 0.06);
|
||
color: #6b7480;
|
||
font-size: 12px;
|
||
text-align: center;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.pending-bubble .bubble-text {
|
||
color: #8a93a0;
|
||
}
|
||
|
||
/* ===== voice-first master replies (WeChat voice message style) ===== */
|
||
.master-col {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
max-width: 82%;
|
||
}
|
||
|
||
.master-col .bubble {
|
||
max-width: 100%;
|
||
}
|
||
|
||
.voice-line {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.voice-bubble {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 96px;
|
||
padding: 11px 14px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.voice-bubble.playing {
|
||
background: #ffe9eb;
|
||
}
|
||
|
||
.voice-icon {
|
||
line-height: 1;
|
||
}
|
||
|
||
.voice-icon.playing {
|
||
animation: voice-icon-pulse 1s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes voice-icon-pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.4; }
|
||
}
|
||
|
||
.voice-duration {
|
||
color: #232a33;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.voice-dot {
|
||
flex-shrink: 0;
|
||
width: 9px;
|
||
height: 9px;
|
||
border-radius: 50%;
|
||
background: #f43b30;
|
||
}
|
||
|
||
.transcript-toggle {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
min-height: 28px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: 0;
|
||
padding: 0 12px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: rgba(20, 24, 31, 0.06);
|
||
color: #6b7480;
|
||
font-size: 12px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.transcript-collapse {
|
||
margin-top: 10px;
|
||
padding-top: 8px;
|
||
border-top: 1px dashed #eceff2;
|
||
text-align: center;
|
||
}
|
||
|
||
.transcript-collapse-text {
|
||
color: #98a1ad;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.scope-note {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
min-height: 30px;
|
||
align-items: center;
|
||
padding: 0 14px;
|
||
border-top: 1px solid #eceef1;
|
||
background: #ffffff;
|
||
color: #8a93a0;
|
||
font-size: 12px;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.master-col :deep(.memory-card) {
|
||
max-width: 100%;
|
||
}
|
||
</style>
|