Files
prop-ai-hr/mobile-uni/src/pages/user/sop/index.vue
T

3557 lines
96 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>