feat(mobile): web-ai chat rebuild (batch 3) + profile page APP-ification
用户三项决定落地: 1. 问AI(全网)页会话化:重写为微信式会话(欢迎/免责=AI气泡, 脱敏与敏感信息提醒保留,未启用=系统气泡+输入条禁用, 同意告知由勾选框改为首次发送前确认弹窗(会话内记忆), 答案气泡带播报+来源N条折叠(外链可点),按住说话松开即发+ 选音频,ChatComposer 复用;无tab栏子页,输入条自带安全区) 2. 配置维持不变(active_timeout/姓名字段/固定验证码按原状) 3. 知识大库开放:财务报备/销售话术/行政制度/培训视频/图片素材 (1004/1005/1007/1008/1009/1010)绑定移动端应用并授 employee/supervisor READ;1006「验收测试」为测试库, 为免污染员工检索结果暂不开放(报告中说明)。 生产验证:员工问「财务报备流程」「销售话术要点」均返回真实答案。 新需求「H5 像 APP」首批:「我」页 APP 化——折叠不删减: - 成长摘要保留核心数字,五维评分+成长路径收进「展开能力详情」 - 成长证据包默认收起(标题行+展开按钮) - 最近练过默认3条,「展开全部N条」可切换 页面高度 3072→1976px,信息全部一次点击可达。 验证:本地5项断言+生产3项复核全过(webai气泡/禁用态/composer、 profile折叠/高度、大库检索)。发布备份 h5-before-20260718092234。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -91,4 +91,4 @@ Figma 已完成全文件结构和视觉复核,当前实测为 81 个顶层页
|
|||||||
|
|
||||||
| 功能 | 客户要求 | Figma | 当前代码 | 线上状态 | 处理决定 |
|
| 功能 | 客户要求 | Figma | 当前代码 | 线上状态 | 处理决定 |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 语音文字交互形态 | 2026-07-18 客户原话:凡语音文字交互处应做成微信聊天式(消息流+按住说话),现表单式操作太复杂 | 表单式布局(输入框+按钮组) | 问页已改会话流:ChatComposer(⊕面板/按住说话松开即发/键盘切换)+气泡消息流,引用/总结卡/反馈/播报收入答案气泡操作行 | 已发布并生产冒烟通过(2026-07-18) | 按客户要求执行,客户原话 > Figma;2026-07-18 批次二完成:练页对练进行中改为同款会话(业主台词=语音优先气泡+红点+转文字,老师傅提示气泡,风格chips,⊕面板收纳选音频/求助/结束评分,松开即发送),预习/每日三题/评分视图保持;web-ai 页待批次三 |
|
| 语音文字交互形态 | 2026-07-18 客户原话:凡语音文字交互处应做成微信聊天式(消息流+按住说话),现表单式操作太复杂 | 表单式布局(输入框+按钮组) | 问页已改会话流:ChatComposer(⊕面板/按住说话松开即发/键盘切换)+气泡消息流,引用/总结卡/反馈/播报收入答案气泡操作行 | 已发布并生产冒烟通过(2026-07-18) | 按客户要求执行,客户原话 > Figma;2026-07-18 批次二完成:练页对练进行中改为同款会话(业主台词=语音优先气泡+红点+转文字,老师傅提示气泡,风格chips,⊕面板收纳选音频/求助/结束评分,松开即发送),预习/每日三题/评分视图保持;web-ai 页批次三已完成(会话式+按住说话+同意确认弹窗+来源折叠,未启用态=系统气泡+输入条禁用);「我」页 APP 化(能力详情/成长路径/证据包默认折叠,最近练过限3条可展开,信息零删减) |
|
||||||
|
|||||||
@@ -84,7 +84,10 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="profile.dimensions?.length" class="dimension-list">
|
<button class="fold-toggle" @click="showGrowthDetail = !showGrowthDetail">
|
||||||
|
{{ showGrowthDetail ? '收起能力详情 ▲' : '展开能力详情(五维评分 · 成长路径)▼' }}
|
||||||
|
</button>
|
||||||
|
<view v-if="showGrowthDetail && profile.dimensions?.length" class="dimension-list">
|
||||||
<view v-for="item in profile.dimensions" :key="item.label" class="dimension-row">
|
<view v-for="item in profile.dimensions" :key="item.label" class="dimension-row">
|
||||||
<view>
|
<view>
|
||||||
<text class="dimension-title">{{ item.label }}</text>
|
<text class="dimension-title">{{ item.label }}</text>
|
||||||
@@ -95,7 +98,7 @@
|
|||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="profile.growthPath?.length" class="growth-section">
|
<view v-if="showGrowthDetail && profile.growthPath?.length" class="growth-section">
|
||||||
<text class="section-label">成长路径</text>
|
<text class="section-label">成长路径</text>
|
||||||
<view v-for="stage in profile.growthPath" :key="stage.level" class="growth-row">
|
<view v-for="stage in profile.growthPath" :key="stage.level" class="growth-row">
|
||||||
<view class="growth-copy">
|
<view class="growth-copy">
|
||||||
@@ -149,7 +152,13 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="loggedIn && evidence" class="panel evidence-panel">
|
<view v-if="loggedIn && evidence" class="panel evidence-panel">
|
||||||
|
<view class="fold-head">
|
||||||
<text class="panel-title">成长证据包(待人工复核)</text>
|
<text class="panel-title">成长证据包(待人工复核)</text>
|
||||||
|
<button class="fold-toggle inline" @click="showEvidence = !showEvidence">
|
||||||
|
{{ showEvidence ? '收起 ▲' : '展开 ▼' }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<template v-if="showEvidence">
|
||||||
<view class="evidence-grid">
|
<view class="evidence-grid">
|
||||||
<view class="evidence-metric">
|
<view class="evidence-metric">
|
||||||
<text>{{ evidence.currentLevel }}</text>
|
<text>{{ evidence.currentLevel }}</text>
|
||||||
@@ -179,6 +188,7 @@
|
|||||||
<view v-if="evidence.evidenceItems?.length" class="evidence-list">
|
<view v-if="evidence.evidenceItems?.length" class="evidence-list">
|
||||||
<text v-for="item in evidence.evidenceItems" :key="item" class="evidence-item">{{ item }}</text>
|
<text v-for="item in evidence.evidenceItems" :key="item" class="evidence-item">{{ item }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
</template>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="loggedIn" class="panel history-panel">
|
<view v-if="loggedIn" class="panel history-panel">
|
||||||
@@ -188,7 +198,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view v-if="history.length" class="history-list">
|
<view v-if="history.length" class="history-list">
|
||||||
<view
|
<view
|
||||||
v-for="item in history"
|
v-for="item in visibleHistory"
|
||||||
:key="item.id || item.sessionId || item.time"
|
:key="item.id || item.sessionId || item.time"
|
||||||
class="history-row history-row-clickable"
|
class="history-row history-row-clickable"
|
||||||
@click="openHistory(item.id)"
|
@click="openHistory(item.id)"
|
||||||
@@ -202,6 +212,9 @@
|
|||||||
<text>{{ item.status }}</text>
|
<text>{{ item.status }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<button v-if="history.length > 3" class="fold-toggle" @click="showAllHistory = !showAllHistory">
|
||||||
|
{{ showAllHistory ? '收起 ▲' : `展开全部 ${history.length} 条 ▼` }}
|
||||||
|
</button>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="empty">暂无训练记录</view>
|
<view v-else class="empty">暂无训练记录</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -237,6 +250,10 @@ const loading = ref(false);
|
|||||||
const message = ref('');
|
const message = ref('');
|
||||||
const profile = ref<CompetencyProfile | null>(null);
|
const profile = ref<CompetencyProfile | null>(null);
|
||||||
const history = ref<PracticeRecord[]>([]);
|
const history = ref<PracticeRecord[]>([]);
|
||||||
|
const showGrowthDetail = ref(false);
|
||||||
|
const showEvidence = ref(false);
|
||||||
|
const showAllHistory = ref(false);
|
||||||
|
const visibleHistory = computed(() => (showAllHistory.value ? history.value : history.value.slice(0, 3)));
|
||||||
const evidence = ref<PromotionEvidence | null>(null);
|
const evidence = ref<PromotionEvidence | null>(null);
|
||||||
const positionProfile = ref(getSelectedPositionProfile());
|
const positionProfile = ref(getSelectedPositionProfile());
|
||||||
const supervisorLearning = ref(false);
|
const supervisorLearning = ref(false);
|
||||||
@@ -876,4 +893,38 @@ onShow(() => {
|
|||||||
color: #8a93a0;
|
color: #8a93a0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fold-toggle {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding: 8px 0;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px dashed #eceff2;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7480;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-toggle::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-toggle.inline {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border: 1px solid #e3e6ea;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f7f8fa;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,118 +1,196 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page employee-page-shell web-ai-page">
|
<view class="page webai-chat">
|
||||||
<view class="subpage-header">
|
<view class="chat-head">
|
||||||
<button class="subpage-back-button" aria-label="返回问师傅" @click="goBack">←</button>
|
<view class="chat-head-row">
|
||||||
<view class="subpage-heading">
|
<view class="chat-head-left">
|
||||||
<text class="employee-kicker">互联网信息 · 独立来源</text>
|
<button class="chat-back" aria-label="返回问师傅" @click="goBack">←</button>
|
||||||
<text class="page-title">问 AI(全网)</text>
|
<text class="chat-title">问 AI(全网)</text>
|
||||||
<text class="page-subtitle">检索公开网页并保留可核验引用,不与企业 SOP 混用。</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
<text class="chat-context">互联网信息 · 独立来源</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="source-switch chat-source-switch" aria-label="问答来源">
|
||||||
<view class="source-switch employee-card">
|
|
||||||
<button class="source-option" @click="goBack">企业问师傅</button>
|
<button class="source-option" @click="goBack">企业问师傅</button>
|
||||||
<button class="source-option active" aria-current="page">问 AI(全网)</button>
|
<button class="source-option active" aria-current="page">问 AI(全网)</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="warning-card">
|
|
||||||
<text class="warning-title">发送前请注意</text>
|
|
||||||
<text>问题会先脱敏,再发送给外部搜索与 AI 服务。答案来自互联网,不代表公司制度;涉及作业、消防、电梯或法律事项,请以公司 SOP 和官方渠道为准。</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="!loggedIn" class="panel employee-card login-panel">
|
<scroll-view scroll-y class="chat-thread" :scroll-top="threadScrollTop" :scroll-with-animation="true">
|
||||||
<view>
|
<view class="thread-inner">
|
||||||
<text class="panel-title">需要登录</text>
|
<view class="bubble-row left">
|
||||||
<text class="muted block">登录后才能使用独立的全网检索额度和隐私告知。</text>
|
<view class="avatar ai-avatar">AI</view>
|
||||||
|
<view class="bubble master">
|
||||||
|
<text class="bubble-text">我是全网 AI,检索公开网页并保留可核验引用,不与企业 SOP 混用。问题会先脱敏再发送到外部服务;答案来自互联网,不代表公司制度,涉及作业、消防、电梯或法律事项请以公司 SOP 为准。</text>
|
||||||
|
<text class="bubble-subnote">不要输入业主姓名、手机号、房号、身份证、门禁或公司未公开资料。</text>
|
||||||
</view>
|
</view>
|
||||||
<button class="primary-button" @click="goLogin">去登录</button>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<template v-else>
|
<view v-if="capabilities && !capabilities.enabled" class="system-row">
|
||||||
<view class="panel employee-card query-panel">
|
<text class="system-text">全网 AI 尚未由管理员启用,不会静默改用企业知识库</text>
|
||||||
<text class="panel-title">你的问题</text>
|
|
||||||
<textarea
|
|
||||||
v-model="query"
|
|
||||||
class="textarea query-input"
|
|
||||||
:maxlength="capabilities?.maxQuestionLength || 500"
|
|
||||||
placeholder="例如:最近物业服务行业有哪些官方新规?"
|
|
||||||
/>
|
|
||||||
<text class="query-hint">不要输入业主姓名、手机号、房号、身份证、门禁或公司未公开资料。最多 {{ capabilities?.maxQuestionLength || 500 }} 字。</text>
|
|
||||||
<view v-if="capabilities && !capabilities.enabled" class="status-message">
|
|
||||||
全网 AI 尚未由管理员启用,不会静默改用企业知识库。
|
|
||||||
</view>
|
</view>
|
||||||
<label v-if="capabilities?.enabled && capabilities.requiresConsent" class="consent-row">
|
|
||||||
<checkbox :checked="consentChecked" color="#e60012" @click="consentChecked = !consentChecked" />
|
<view v-if="!loggedIn" class="bubble-row left">
|
||||||
<text>我已阅读并同意将脱敏后的问题发送到外部公开检索服务</text>
|
<view class="avatar ai-avatar">AI</view>
|
||||||
</label>
|
<view class="bubble master">
|
||||||
<button
|
<text class="bubble-text">登录后才能使用独立的全网检索额度和隐私告知。</text>
|
||||||
class="primary-button submit-button"
|
<view class="bubble-actions">
|
||||||
:disabled="loading || !capabilities?.enabled || (capabilities.requiresConsent && !consentChecked) || !query.trim()"
|
<button class="bubble-action primary" @click="goLogin">去登录</button>
|
||||||
@click="search"
|
</view>
|
||||||
>
|
</view>
|
||||||
{{ loading ? '正在检索并核对来源' : '开始全网查询' }}
|
</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">🎤 语音转文字</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">
|
||||||
|
<view class="avatar ai-avatar">AI</view>
|
||||||
|
<view class="bubble master pending-bubble">
|
||||||
|
<text class="bubble-text">正在检索并核对来源…</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="bubble-row left">
|
||||||
|
<view class="avatar ai-avatar">AI</view>
|
||||||
|
<view class="bubble master">
|
||||||
|
<text v-if="msg.result.status === 'NO_ANSWER'" class="bubble-noevidence">已找到公开来源,但外部服务未返回可核验答案,请直接查看来源。</text>
|
||||||
|
<text v-else-if="msg.result.noEvidence" class="bubble-noevidence">未找到可核验的公开依据,本次不生成推断答案。</text>
|
||||||
|
<text v-else class="bubble-text">{{ msg.result.answer }}</text>
|
||||||
|
<text v-if="msg.result.redacted" class="bubble-redacted">已自动隐藏问题中的个人或门禁信息</text>
|
||||||
|
<text v-if="msg.result.warning" class="bubble-warning">{{ msg.result.warning }}</text>
|
||||||
|
|
||||||
|
<view class="bubble-actions">
|
||||||
|
<button v-if="msg.result.answer" class="bubble-action" @click="toggleSpeech(msg)">
|
||||||
|
{{ speechButtonLabel(`web-${msg.id}`) }}
|
||||||
</button>
|
</button>
|
||||||
<text v-if="message" class="status-message">{{ message }}</text>
|
<button v-if="msg.result.citations?.length" class="bubble-action" @click="msg.citationsOpen = !msg.citationsOpen">
|
||||||
</view>
|
来源 {{ msg.result.citations.length }} 条 {{ msg.citationsOpen ? '▲' : '▼' }}
|
||||||
|
|
||||||
<view v-if="result" class="panel employee-card answer-panel">
|
|
||||||
<view class="answer-head">
|
|
||||||
<text class="panel-title">全网回答</text>
|
|
||||||
<button class="ghost-button compact-button" :disabled="speech.status === 'loading'" @click="toggleSpeech">
|
|
||||||
{{ speech.status === 'playing' ? '停止播报' : speech.status === 'loading' ? '生成语音中' : '语音播报' }}
|
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
<text v-if="result.status === 'NO_ANSWER'" class="no-evidence">已找到公开来源,但外部服务未返回可核验答案,请直接查看下方来源。</text>
|
|
||||||
<text v-else-if="result.noEvidence" class="no-evidence">未找到可核验的公开依据,本次不生成推断答案。</text>
|
|
||||||
<text v-else class="answer-text">{{ result.answer }}</text>
|
|
||||||
<text v-if="result.redacted" class="redacted-note">已自动隐藏问题中的个人或门禁信息</text>
|
|
||||||
<text class="answer-warning">{{ result.warning }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="result?.citations?.length" class="panel employee-card sources-panel">
|
<view v-if="msg.citationsOpen && msg.result.citations?.length" class="bubble-citations">
|
||||||
<text class="panel-title">公开来源</text>
|
|
||||||
<a
|
<a
|
||||||
v-for="(item, index) in result.citations"
|
v-for="(item, index) in msg.result.citations"
|
||||||
:key="`${item.url}-${index}`"
|
:key="`${item.url}-${index}`"
|
||||||
class="source-card"
|
class="citation-link"
|
||||||
:href="item.url"
|
:href="item.url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<text class="source-index">{{ index + 1 }}</text>
|
<text class="citation-title">{{ index + 1 }}. {{ item.title }}</text>
|
||||||
<view class="source-copy">
|
<text class="citation-meta">{{ item.domain }}{{ item.publishedAt ? ` · ${item.publishedAt}` : '' }}</text>
|
||||||
<text class="source-title">{{ item.title }}</text>
|
<text class="citation-content">{{ item.snippet }}</text>
|
||||||
<text class="source-domain">{{ item.domain }}{{ item.publishedAt ? ` · ${item.publishedAt}` : '' }}</text>
|
|
||||||
<text class="source-snippet">{{ item.snippet }}</text>
|
|
||||||
</view>
|
|
||||||
</a>
|
</a>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<view :style="{ height: '12px' }" />
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<ChatComposer
|
||||||
|
ref="composerRef"
|
||||||
|
:disabled="!loggedIn || !capabilities?.enabled"
|
||||||
|
:transcribing="voiceTranscribing"
|
||||||
|
placeholder="例如:最近物业服务行业有哪些官方新规?"
|
||||||
|
:maxlength="capabilities?.maxQuestionLength || 500"
|
||||||
|
:panel-items="[{ key: 'audio', icon: '🎵', label: '选音频转文字' }]"
|
||||||
|
@send="handleComposerSend"
|
||||||
|
@voice-blob="handleVoiceBlob"
|
||||||
|
@panel="handlePanelAction"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, ref } from 'vue';
|
import { nextTick, ref } from 'vue';
|
||||||
import { onShow } from '@dcloudio/uni-app';
|
import { onShow, onUnload } from '@dcloudio/uni-app';
|
||||||
import type { WebAiCapabilities, WebAiResponse } from '@/types/api';
|
import type { SpeechPlaybackStatus, WebAiCapabilities, WebAiResponse } from '@/types/api';
|
||||||
|
import ChatComposer from '@/components/chat/ChatComposer.vue';
|
||||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||||
import { createSpeechPlaybackController, type SpeechPlaybackSnapshot } from '@/services/speech';
|
import { chooseSpeechAudio, createSpeechPlaybackController, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||||
import { getWebAiCapabilities, queryWebAi } from '@/services/web-ai';
|
import { getWebAiCapabilities, queryWebAi } from '@/services/web-ai';
|
||||||
|
|
||||||
const path = '/pages/user/web-ai/index';
|
const path = '/pages/user/web-ai/index';
|
||||||
|
|
||||||
|
interface UserMsg {
|
||||||
|
id: number;
|
||||||
|
role: 'user';
|
||||||
|
text: string;
|
||||||
|
voice?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiMsg {
|
||||||
|
id: number;
|
||||||
|
role: 'ai';
|
||||||
|
result: WebAiResponse;
|
||||||
|
citationsOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemMsg {
|
||||||
|
id: number;
|
||||||
|
role: 'system';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingMsg {
|
||||||
|
id: number;
|
||||||
|
role: 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatMsg = UserMsg | AiMsg | SystemMsg | PendingMsg;
|
||||||
|
|
||||||
const loggedIn = ref(false);
|
const loggedIn = ref(false);
|
||||||
const query = ref('');
|
|
||||||
const message = ref('');
|
|
||||||
const loading = ref(false);
|
|
||||||
const consentChecked = ref(false);
|
|
||||||
const capabilities = ref<WebAiCapabilities | null>(null);
|
const capabilities = ref<WebAiCapabilities | null>(null);
|
||||||
const result = ref<WebAiResponse | null>(null);
|
const messages = ref<ChatMsg[]>([]);
|
||||||
const speech = ref<SpeechPlaybackSnapshot>({ key: '', status: 'idle' });
|
const threadScrollTop = ref(0);
|
||||||
const speechController = createSpeechPlaybackController(
|
const composerRef = ref<InstanceType<typeof ChatComposer> | null>(null);
|
||||||
(snapshot) => { speech.value = snapshot; },
|
const loading = ref(false);
|
||||||
(error) => { message.value = error; }
|
const voiceTranscribing = ref(false);
|
||||||
|
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
||||||
|
const consented = ref(false);
|
||||||
|
let msgSeq = 0;
|
||||||
|
let voiceTranscriptionGeneration = 0;
|
||||||
|
let abortVoiceTranscription: (() => void) | null = null;
|
||||||
|
|
||||||
|
const speechPlayback = createSpeechPlaybackController(
|
||||||
|
(state) => { speechState.value = state; },
|
||||||
|
(text) => { uni.showToast({ title: text, icon: 'none' }); }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
void nextTick(() => {
|
||||||
|
threadScrollTop.value = 100000 + msgSeq * 200;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
scrollToBottom();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushSystem = (text: string) => pushMsg<SystemMsg>({ role: 'system', text });
|
||||||
|
|
||||||
const goBack = () => uni.switchTab({ url: '/pages/user/sop/index' });
|
const goBack = () => uni.switchTab({ url: '/pages/user/sop/index' });
|
||||||
|
|
||||||
const goLogin = () => {
|
const goLogin = () => {
|
||||||
rememberLoginRedirect(path);
|
rememberLoginRedirect(path);
|
||||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||||
@@ -123,69 +201,432 @@ const loadCapabilities = async () => {
|
|||||||
try {
|
try {
|
||||||
capabilities.value = await getWebAiCapabilities();
|
capabilities.value = await getWebAiCapabilities();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = error instanceof Error ? error.message : '全网 AI 状态读取失败';
|
pushSystem(error instanceof Error ? error.message : '全网 AI 状态读取失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const search = async () => {
|
/** WeChat-style consent: confirm once per session before the first outbound query. */
|
||||||
const value = query.value.trim();
|
const ensureConsent = () =>
|
||||||
if (!value || !capabilities.value?.enabled) return;
|
new Promise<boolean>((resolve) => {
|
||||||
|
if (consented.value || !capabilities.value?.requiresConsent) {
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uni.showModal({
|
||||||
|
title: '发送前确认',
|
||||||
|
content: '问题会先脱敏,再发送给外部公开检索与 AI 服务。是否继续?',
|
||||||
|
confirmText: '同意并发送',
|
||||||
|
cancelText: '取消',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) consented.value = true;
|
||||||
|
resolve(Boolean(res.confirm));
|
||||||
|
},
|
||||||
|
fail: () => resolve(false)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendQuestion = async (rawText: string, options: { voice?: boolean } = {}) => {
|
||||||
|
if (loading.value) {
|
||||||
|
uni.showToast({ title: '上一条还在检索中,请稍候', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = rawText.trim();
|
||||||
|
if (!text) {
|
||||||
|
uni.showToast({ title: '请先输入问题', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!loggedIn.value) {
|
||||||
|
goLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!capabilities.value?.enabled) {
|
||||||
|
uni.showToast({ title: '全网 AI 尚未由管理员启用', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await ensureConsent())) return;
|
||||||
|
pushMsg<UserMsg>({ role: 'user', text, voice: options.voice });
|
||||||
|
const pending = pushMsg<PendingMsg>({ role: 'pending' });
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
message.value = '';
|
|
||||||
result.value = null;
|
|
||||||
speechController.stop();
|
|
||||||
try {
|
try {
|
||||||
result.value = await queryWebAi(value, consentChecked.value);
|
const result = await queryWebAi(text, true);
|
||||||
|
replaceMsg(pending.id, { id: pending.id, role: 'ai', result, citationsOpen: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = error instanceof Error ? error.message : '全网查询失败';
|
replaceMsg(pending.id, null);
|
||||||
|
pushSystem(error instanceof Error ? error.message : '全网查询失败,请稍后重试');
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSpeech = () => {
|
const handleComposerSend = (text: string) => {
|
||||||
if (!result.value?.answer) return;
|
void sendQuestion(text);
|
||||||
speechController.toggle('web-answer', result.value.answer);
|
};
|
||||||
|
|
||||||
|
const handleVoiceBlob = async (blob: Blob) => {
|
||||||
|
if (voiceTranscribing.value) return;
|
||||||
|
const generation = ++voiceTranscriptionGeneration;
|
||||||
|
voiceTranscribing.value = true;
|
||||||
|
try {
|
||||||
|
const asr = await transcribeSpeechBlob(blob, 'web-ai-question.webm', (abort) => {
|
||||||
|
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||||||
|
});
|
||||||
|
if (generation !== voiceTranscriptionGeneration) return;
|
||||||
|
const text = (asr.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 handlePanelAction = async (key: string) => {
|
||||||
|
if (key !== 'audio' || voiceTranscribing.value) return;
|
||||||
|
const generation = ++voiceTranscriptionGeneration;
|
||||||
|
voiceTranscribing.value = true;
|
||||||
|
try {
|
||||||
|
const file = await chooseSpeechAudio();
|
||||||
|
if (generation !== voiceTranscriptionGeneration) return;
|
||||||
|
const asr = await transcribeSpeechFile(file, (abort) => {
|
||||||
|
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||||||
|
});
|
||||||
|
if (generation !== voiceTranscriptionGeneration) return;
|
||||||
|
const text = (asr.text || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
pushSystem('语音没有识别到文字,请再试一次');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void sendQuestion(text, { voice: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (generation !== voiceTranscriptionGeneration) return;
|
||||||
|
const text = error instanceof Error ? error.message : '语音转写失败,请改用文字输入';
|
||||||
|
if (text !== '未选择音频') pushSystem(text);
|
||||||
|
} finally {
|
||||||
|
if (generation === voiceTranscriptionGeneration) {
|
||||||
|
abortVoiceTranscription = null;
|
||||||
|
voiceTranscribing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const speechButtonLabel = (key: string) => {
|
||||||
|
if (speechState.value.key !== key) return '播报';
|
||||||
|
if (speechState.value.status === 'loading') return '生成语音中';
|
||||||
|
if (speechState.value.status === 'playing') return '停止';
|
||||||
|
return '播报';
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSpeech = (msg: AiMsg) => {
|
||||||
|
if (!msg.result.answer) return;
|
||||||
|
void speechPlayback.toggle(`web-${msg.id}`, msg.result.answer);
|
||||||
};
|
};
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
loggedIn.value = isLoggedIn();
|
loggedIn.value = isLoggedIn();
|
||||||
|
if (!loggedIn.value) {
|
||||||
|
messages.value = [];
|
||||||
|
consented.value = false;
|
||||||
|
}
|
||||||
capabilities.value = null;
|
capabilities.value = null;
|
||||||
result.value = null;
|
|
||||||
consentChecked.value = false;
|
|
||||||
void loadCapabilities();
|
void loadCapabilities();
|
||||||
|
scrollToBottom();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => speechController.destroy());
|
onUnload(() => {
|
||||||
|
speechPlayback.destroy();
|
||||||
|
composerRef.value?.teardown();
|
||||||
|
voiceTranscriptionGeneration += 1;
|
||||||
|
abortVoiceTranscription?.();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.web-ai-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
|
.webai-chat {
|
||||||
.source-switch { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin-top: 18px; padding: 4px; }
|
position: fixed;
|
||||||
.source-option { min-height: 44px; background: transparent; color: var(--muted); font-weight: 800; }
|
top: var(--window-top, 0px);
|
||||||
.source-option.active { background: var(--employee-primary-soft); color: var(--brand); }
|
right: 0;
|
||||||
.warning-card { display: grid; gap: 6px; margin-top: 14px; padding: 14px 16px; border: 1px solid #f0c879; border-radius: 16px; background: #fff9e9; color: #78500a; font-size: 13px; line-height: 1.6; }
|
bottom: 0;
|
||||||
.warning-title { font-weight: 900; }
|
left: 0;
|
||||||
.login-panel { display: grid; gap: 14px; }
|
display: flex;
|
||||||
.block { display: block; margin-top: 6px; }
|
flex-direction: column;
|
||||||
.query-panel { display: grid; gap: 12px; }
|
min-height: 0;
|
||||||
.query-input { min-height: 148px; }
|
padding: 0;
|
||||||
.query-hint { color: var(--muted); font-size: 12px; line-height: 1.55; }
|
background: #f2f3f5;
|
||||||
.consent-row { display: flex; gap: 8px; align-items: flex-start; color: #435268; font-size: 13px; line-height: 1.55; }
|
overflow: hidden;
|
||||||
.submit-button { width: 100%; }
|
}
|
||||||
.status-message { display: block; padding: 10px 12px; border-radius: 8px; background: #fff0f1; color: #a9000d; font-size: 13px; line-height: 1.5; }
|
|
||||||
.answer-head { display: flex; gap: 12px; align-items: center; justify-content: space-between; }
|
.webai-chat :deep(.chat-composer-wrap) {
|
||||||
.compact-button { min-height: 38px; padding: 0 12px; font-size: 13px; }
|
padding-bottom: calc(10px + env(safe-area-inset-bottom));
|
||||||
.answer-text, .no-evidence { display: block; margin-top: 14px; color: var(--ink); font-size: 16px; line-height: 1.8; white-space: pre-wrap; }
|
}
|
||||||
.no-evidence { color: var(--warning); font-weight: 800; }
|
|
||||||
.answer-warning { display: block; margin-top: 12px; color: var(--warning); font-size: 12px; line-height: 1.5; }
|
.chat-head {
|
||||||
.redacted-note { display:block; margin-top:10px; color:var(--success); font-size:12px; font-weight:800; }
|
flex-shrink: 0;
|
||||||
.sources-panel { display: grid; gap: 12px; }
|
padding: 8px 12px 6px;
|
||||||
.source-card { display: flex; gap: 10px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; color: inherit; text-decoration: none; overflow: hidden; }
|
background: #ffffff;
|
||||||
.source-index { flex: 0 0 24px; height: 24px; border-radius: 50%; background: #eef3f9; color: #315b8f; font-weight: 900; line-height: 24px; text-align: center; }
|
border-bottom: 1px solid #ebedf0;
|
||||||
.source-copy { min-width: 0; }
|
}
|
||||||
.source-title, .source-domain, .source-snippet { display: block; overflow-wrap: anywhere; }
|
|
||||||
.source-title { font-weight: 900; line-height: 1.45; }
|
.chat-head-row {
|
||||||
.source-domain { margin-top: 4px; color: var(--knowledge); font-size: 12px; }
|
display: flex;
|
||||||
.source-snippet { margin-top: 7px; color: var(--muted); font-size: 13px; line-height: 1.55; }
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-head-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-back {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #e3e6ea;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #232a33;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-title {
|
||||||
|
color: #18202b;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-context {
|
||||||
|
color: #8a93a0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-source-switch {
|
||||||
|
display: flex;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f2f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-source-switch .source-option {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: #5a6270;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-source-switch .source-option.active {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #c8001a;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 1px 4px rgba(20, 24, 31, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-avatar {
|
||||||
|
background: #1f2733;
|
||||||
|
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-subnote {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #a04a00;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
color: #a04a00;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-noevidence {
|
||||||
|
display: block;
|
||||||
|
color: #a04a00;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-redacted {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #168243;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-warning {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #a04a00;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-action {
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #e3e6ea;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f7f8fa;
|
||||||
|
color: #38424e;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-action.primary {
|
||||||
|
border-color: #e70014;
|
||||||
|
background: #e70014;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-citations {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px dashed #eceff2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-link {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-left: 3px solid #cdd8e4;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fafbfc;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-title {
|
||||||
|
display: block;
|
||||||
|
color: #232a33;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-meta {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #315b8f;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
max-width: 86%;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(20, 24, 31, 0.06);
|
||||||
|
color: #6b7480;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-bubble .bubble-text {
|
||||||
|
color: #8a93a0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user