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:
2026-07-18 09:23:51 +08:00
co-authored by Claude Fable 5
parent a90f241def
commit 6935476e3f
3 changed files with 635 additions and 143 deletions
@@ -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条可展开,信息零删减) |
+55 -4
View File
@@ -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">
<text class="panel-title">成长证据包(待人工复核)</text> <view class="fold-head">
<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>
+579 -138
View File
@@ -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>
<view class="source-switch employee-card">
<button class="source-option" @click="goBack">企业问师傅</button>
<button class="source-option active" aria-current="page">问 AI(全网)</button>
</view>
<view class="warning-card">
<text class="warning-title">发送前请注意</text>
<text>问题会先脱敏,再发送给外部搜索与 AI 服务。答案来自互联网,不代表公司制度;涉及作业、消防、电梯或法律事项,请以公司 SOP 和官方渠道为准。</text>
</view>
<view v-if="!loggedIn" class="panel employee-card login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后才能使用独立的全网检索额度和隐私告知。</text>
</view>
<button class="primary-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel employee-card query-panel">
<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"> <text class="chat-context">互联网信息 · 独立来源</text>
<checkbox :checked="consentChecked" color="#e60012" @click="consentChecked = !consentChecked" />
<text>我已阅读并同意将脱敏后的问题发送到外部公开检索服务</text>
</label>
<button
class="primary-button submit-button"
:disabled="loading || !capabilities?.enabled || (capabilities.requiresConsent && !consentChecked) || !query.trim()"
@click="search"
>
{{ loading ? '正在检索并核对来源' : '开始全网查询' }}
</button>
<text v-if="message" class="status-message">{{ message }}</text>
</view> </view>
<view class="source-switch chat-source-switch" aria-label="问答来源">
<view v-if="result" class="panel employee-card answer-panel"> <button class="source-option" @click="goBack">企业问师傅</button>
<view class="answer-head"> <button class="source-option active" aria-current="page">问 AI(全网)</button>
<text class="panel-title">全网回答</text>
<button class="ghost-button compact-button" :disabled="speech.status === 'loading'" @click="toggleSpeech">
{{ speech.status === 'playing' ? '停止播报' : speech.status === 'loading' ? '生成语音中' : '语音播报' }}
</button>
</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>
</view>
<view v-if="result?.citations?.length" class="panel employee-card sources-panel"> <scroll-view scroll-y class="chat-thread" :scroll-top="threadScrollTop" :scroll-with-animation="true">
<text class="panel-title">公开来源</text> <view class="thread-inner">
<a <view class="bubble-row left">
v-for="(item, index) in result.citations" <view class="avatar ai-avatar">AI</view>
:key="`${item.url}-${index}`" <view class="bubble master">
class="source-card" <text class="bubble-text">我是全网 AI,检索公开网页并保留可核验引用,不与企业 SOP 混用。问题会先脱敏再发送到外部服务;答案来自互联网,不代表公司制度,涉及作业、消防、电梯或法律事项请以公司 SOP 为准。</text>
:href="item.url" <text class="bubble-subnote">不要输入业主姓名、手机号、房号、身份证、门禁或公司未公开资料。</text>
target="_blank"
rel="noopener noreferrer"
>
<text class="source-index">{{ index + 1 }}</text>
<view class="source-copy">
<text class="source-title">{{ item.title }}</text>
<text class="source-domain">{{ item.domain }}{{ item.publishedAt ? ` · ${item.publishedAt}` : '' }}</text>
<text class="source-snippet">{{ item.snippet }}</text>
</view> </view>
</a> </view>
<view v-if="capabilities && !capabilities.enabled" class="system-row">
<text class="system-text">全网 AI 尚未由管理员启用,不会静默改用企业知识库</text>
</view>
<view v-if="!loggedIn" class="bubble-row left">
<view class="avatar ai-avatar">AI</view>
<view class="bubble master">
<text class="bubble-text">登录后才能使用独立的全网检索额度和隐私告知。</text>
<view class="bubble-actions">
<button class="bubble-action primary" @click="goLogin">去登录</button>
</view>
</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">🎤 语音转文字</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 v-if="msg.result.citations?.length" class="bubble-action" @click="msg.citationsOpen = !msg.citationsOpen">
来源 {{ msg.result.citations.length }} 条 {{ msg.citationsOpen ? '▲' : '▼' }}
</button>
</view>
<view v-if="msg.citationsOpen && msg.result.citations?.length" class="bubble-citations">
<a
v-for="(item, index) in msg.result.citations"
:key="`${item.url}-${index}`"
class="citation-link"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
>
<text class="citation-title">{{ index + 1 }}. {{ item.title }}</text>
<text class="citation-meta">{{ item.domain }}{{ item.publishedAt ? ` · ${item.publishedAt}` : '' }}</text>
<text class="citation-content">{{ item.snippet }}</text>
</a>
</view>
</view>
</view>
</template>
<view :style="{ height: '12px' }" />
</view> </view>
</template> </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>