feat(aihr): complete uni-app role workflows
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
<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 }}. {{ 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';
|
||||
|
||||
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 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(() => {
|
||||
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>
|
||||
Reference in New Issue
Block a user