feat: restore learning feedback and knowledge capabilities

This commit is contained in:
2026-07-18 00:50:11 +08:00
parent 876a23a123
commit 6bdc86e3fc
152 changed files with 30337 additions and 953 deletions
+191
View File
@@ -0,0 +1,191 @@
<template>
<view class="page employee-page-shell web-ai-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回问师傅" @click="goBack">←</button>
<view class="subpage-heading">
<text class="employee-kicker">互联网信息 · 独立来源</text>
<text class="page-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>
<label v-if="capabilities?.enabled && capabilities.requiresConsent" class="consent-row">
<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 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>
</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">
<text class="panel-title">公开来源</text>
<a
v-for="(item, index) in result.citations"
:key="`${item.url}-${index}`"
class="source-card"
:href="item.url"
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>
</a>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { WebAiCapabilities, WebAiResponse } from '@/types/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { createSpeechPlaybackController, type SpeechPlaybackSnapshot } from '@/services/speech';
import { getWebAiCapabilities, queryWebAi } from '@/services/web-ai';
const path = '/pages/user/web-ai/index';
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 result = ref<WebAiResponse | null>(null);
const speech = ref<SpeechPlaybackSnapshot>({ key: '', status: 'idle' });
const speechController = createSpeechPlaybackController(
(snapshot) => { speech.value = snapshot; },
(error) => { message.value = error; }
);
const goBack = () => uni.switchTab({ url: '/pages/user/sop/index' });
const goLogin = () => {
rememberLoginRedirect(path);
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const loadCapabilities = async () => {
if (!loggedIn.value) return;
try {
capabilities.value = await getWebAiCapabilities();
} catch (error) {
message.value = error instanceof Error ? error.message : '全网 AI 状态读取失败';
}
};
const search = async () => {
const value = query.value.trim();
if (!value || !capabilities.value?.enabled) return;
loading.value = true;
message.value = '';
result.value = null;
speechController.stop();
try {
result.value = await queryWebAi(value, consentChecked.value);
} catch (error) {
message.value = error instanceof Error ? error.message : '全网查询失败';
} finally {
loading.value = false;
}
};
const toggleSpeech = () => {
if (!result.value?.answer) return;
speechController.toggle('web-answer', result.value.answer);
};
onShow(() => {
loggedIn.value = isLoggedIn();
capabilities.value = null;
result.value = null;
consentChecked.value = false;
void loadCapabilities();
});
onBeforeUnmount(() => speechController.destroy());
</script>
<style scoped>
.web-ai-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
.source-switch { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin-top: 18px; padding: 4px; }
.source-option { min-height: 44px; background: transparent; color: var(--muted); font-weight: 800; }
.source-option.active { background: var(--employee-primary-soft); color: var(--brand); }
.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; }
.warning-title { font-weight: 900; }
.login-panel { display: grid; gap: 14px; }
.block { display: block; margin-top: 6px; }
.query-panel { display: grid; gap: 12px; }
.query-input { min-height: 148px; }
.query-hint { color: var(--muted); font-size: 12px; line-height: 1.55; }
.consent-row { display: flex; gap: 8px; align-items: flex-start; color: #435268; font-size: 13px; line-height: 1.55; }
.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; }
.compact-button { min-height: 38px; padding: 0 12px; font-size: 13px; }
.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; }
.redacted-note { display:block; margin-top:10px; color:var(--success); font-size:12px; font-weight:800; }
.sources-panel { display: grid; gap: 12px; }
.source-card { display: flex; gap: 10px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; color: inherit; text-decoration: none; overflow: hidden; }
.source-index { flex: 0 0 24px; height: 24px; border-radius: 50%; background: #eef3f9; color: #315b8f; font-weight: 900; line-height: 24px; text-align: center; }
.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; }
.source-domain { margin-top: 4px; color: var(--knowledge); font-size: 12px; }
.source-snippet { margin-top: 7px; color: var(--muted); font-size: 13px; line-height: 1.55; }
</style>