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

402 lines
11 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-page">
<text class="page-title">问师傅</text>
<text class="page-subtitle">直接问工作中的 SOP、话术和流程。答案来自知识库检索,引用在后面。</text>
<view class="panel query-panel">
<textarea
v-model="query"
class="textarea"
maxlength="200"
placeholder="例如: 业主投诉噪音,生活顾问应该怎么处理?"
/>
<view class="chip-row">
<button v-for="item in quickQuestions" :key="item" class="chip" :disabled="loading" @click="useQuickQuestion(item)">
{{ item }}
</button>
</view>
<button class="primary-button" :disabled="loading" @click="search">
{{ loading ? '检索中' : '查询' }}
</button>
<text v-if="message" class="status-message">{{ message }}</text>
</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后才能查询 SOP、生成总结卡并反馈答案。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view v-if="result" class="panel answer-panel">
<text class="panel-title">直给答案</text>
<text class="answer-text">{{ result.answer || '未生成直接答案,请查看下方引用。' }}</text>
<view v-if="result.keyPoints?.length" class="keypoints">
<text v-for="(point, index) in result.keyPoints" :key="point" class="keypoint">
{{ index + 1 }}. {{ cleanOrderedText(point) }}
</text>
</view>
<view v-if="result.cautions?.length" class="cautions">
<text v-for="item in result.cautions" :key="item">{{ item }}</text>
</view>
<view class="feedback-actions">
<button
class="secondary-button"
:class="{ active: feedbackVerdict === 'up' }"
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)"
@click="feedback('up')"
>
有用
</button>
<button
class="secondary-button"
:class="{ active: feedbackVerdict === 'down' }"
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)"
@click="feedback('down')"
>
不准
</button>
</view>
</view>
<view v-if="result?.snippets?.length" class="panel">
<text class="panel-title">引用依据</text>
<view v-for="(snippet, index) in result.snippets" :key="`${snippet.docTitle}-${index}`" class="snippet">
<text class="snippet-title">{{ snippet.docTitle }}</text>
<text class="snippet-content">{{ snippetExcerpt(snippet.content) }}</text>
<text v-if="snippet.fragmentId" class="snippet-id">片段 #{{ snippet.fragmentId }}</text>
</view>
</view>
<view v-if="result" class="panel summary-panel">
<text class="panel-title">总结卡</text>
<text class="muted">需要时再生成,避免每次查询都调用模型。</text>
<button class="ghost-button" :disabled="summaryLoading" @click="buildSummaryCard">
{{ summaryLoading ? '生成中' : '生成总结卡' }}
</button>
</view>
<view v-if="summaryCard" class="panel">
<text class="panel-title">{{ summaryCard.title }}</text>
<view class="summary-block">
<text class="summary-heading">步骤</text>
<text v-for="(step, index) in summaryCard.steps" :key="`${step.label}-${index}`" class="summary-line">
{{ index + 1 }}. {{ step.label }}: {{ step.desc }}
</text>
</view>
<view v-if="summaryCard.scripts.length" class="summary-block">
<text class="summary-heading">可说话术</text>
<text v-for="item in summaryCard.scripts" :key="item" class="summary-line">{{ item }}</text>
</view>
<view v-if="summaryCard.objections.length" class="summary-block">
<text class="summary-heading">异议回应</text>
<text v-for="item in summaryCard.objections" :key="item.concern" class="summary-line">
{{ item.concern }}: {{ item.response }}
</text>
</view>
<view v-if="summaryCard.reminders.length" class="summary-block">
<text class="summary-heading">提醒</text>
<text v-for="item in summaryCard.reminders" :key="item" class="summary-line">{{ item }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type {
AnswerFeedbackVerdict,
KnowledgeSearchResponse,
SummaryCardResponse
} from '@/types/api';
import {
generateSummaryCard,
searchKnowledge,
submitAnswerFeedback
} from '@/services/knowledge';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
const quickQuestions = ['业主投诉噪音怎么安抚', '停车费催缴怎么说', '生活顾问的一天做什么'];
const query = ref('');
const result = ref<KnowledgeSearchResponse | null>(null);
const summaryCard = ref<SummaryCardResponse | null>(null);
const loading = ref(false);
const summaryLoading = ref(false);
const feedbackSubmitting = ref(false);
const feedbackVerdict = ref<AnswerFeedbackVerdict | ''>('');
const message = ref('');
const loggedIn = ref(false);
const pendingSopQueryKey = 'aihr_sop_pending_query';
const resetResultState = () => {
result.value = null;
summaryCard.value = null;
feedbackVerdict.value = '';
feedbackSubmitting.value = false;
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = loggedIn.value) => {
refreshAuthState();
if (wasLoggedIn && !loggedIn.value) {
resetResultState();
message.value = '登录已过期,请重新登录';
return true;
}
message.value = error instanceof Error ? error.message : fallback;
return false;
};
const search = async () => {
if (loading.value) return;
const value = query.value.trim();
if (!value) {
message.value = '请先输入问题';
return;
}
refreshAuthState();
if (!loggedIn.value) {
uni.setStorageSync(pendingSopQueryKey, value);
goLogin();
return;
}
loading.value = true;
message.value = '';
resetResultState();
try {
result.value = await searchKnowledge(value);
if (!result.value.answer && !result.value.snippets?.length) {
message.value = '未检索到可引用内容';
}
} catch (error) {
handleRequestError(error, '检索失败', true);
} finally {
loading.value = false;
}
};
const useQuickQuestion = (value: string) => {
query.value = value;
void search();
};
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 feedback = async (verdict: AnswerFeedbackVerdict) => {
if (!result.value || feedbackSubmitting.value || feedbackVerdict.value) return;
const fragmentIds = Array.from(
new Set(
(result.value.snippets || [])
.map((item) => item.fragmentId)
.filter((id): id is number => typeof id === 'number' && id > 0)
)
);
if (!fragmentIds.length) {
uni.showToast({ title: '暂无可反馈依据', icon: 'none' });
return;
}
feedbackSubmitting.value = true;
try {
await submitAnswerFeedback({
queryText: result.value.queryText || query.value,
fragmentIds,
verdict
});
feedbackVerdict.value = verdict;
uni.showToast({ title: verdict === 'down' ? '已进入复核' : '已记录', icon: 'none' });
} catch (error) {
if (!handleRequestError(error, '反馈失败', true)) {
uni.showToast({
title: message.value,
icon: 'none'
});
}
} finally {
feedbackSubmitting.value = false;
}
};
const buildSummaryCard = async () => {
if (summaryLoading.value) return;
const value = (result.value?.queryText || query.value).trim();
if (!value) return;
summaryLoading.value = true;
message.value = '';
summaryCard.value = null;
try {
summaryCard.value = await generateSummaryCard(value);
} catch (error) {
handleRequestError(error, '总结卡生成失败', true);
} finally {
summaryLoading.value = false;
}
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/sop/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
onShow(() => {
resetPageScroll();
refreshAuthState();
if (!loggedIn.value) {
resetResultState();
}
const pending = uni.getStorageSync(pendingSopQueryKey);
if (!pending) {
if (loggedIn.value && query.value.trim() && message.value === '登录后继续查询该问题') {
void search();
}
return;
}
query.value = String(pending);
if (loggedIn.value) {
uni.removeStorageSync(pendingSopQueryKey);
void search();
} else {
message.value = '登录后继续查询该问题';
}
});
</script>
<style scoped>
.sop-page {
padding-bottom: 48px;
}
.query-panel,
.answer-panel,
.summary-panel {
display: flex;
flex-direction: column;
gap: 14px;
}
.login-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
}
.login-button {
min-width: 84px;
}
.answer-text {
color: #0f172a;
font-size: 18px;
font-weight: 700;
line-height: 1.65;
}
.keypoints {
display: flex;
flex-direction: column;
gap: 8px;
}
.keypoint {
padding: 10px 12px;
border-radius: 10px;
background: #f8fafc;
color: #334155;
font-size: 15px;
line-height: 1.5;
}
.cautions {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-radius: 10px;
background: #fff7ed;
color: #9a3412;
font-size: 13px;
line-height: 1.45;
}
.feedback-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.feedback-actions .active {
border-color: #16a34a;
background: #dcfce7;
color: #166534;
}
.snippet {
display: flex;
margin-top: 12px;
flex-direction: column;
gap: 6px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
}
.snippet-title {
color: #0f172a;
font-weight: 800;
}
.snippet-content {
color: #475569;
font-size: 14px;
line-height: 1.55;
}
.snippet-id {
color: #94a3b8;
font-size: 12px;
}
.summary-block {
display: flex;
margin-top: 14px;
flex-direction: column;
gap: 8px;
}
.summary-heading {
color: #1d4ed8;
font-weight: 800;
}
.summary-line {
color: #334155;
font-size: 15px;
line-height: 1.55;
}
</style>