Files
smartDriveEEUniApp/pages-subpackage/ai_qa/general_qa/general_qa.vue
2026-03-31 09:02:25 +08:00

881 lines
22 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="qa-page">
<!-- 顶部导航参考示例左侧菜单标题新对话右侧加号 -->
<view class="qa-header" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="qa-header-bar">
<view class="qa-header-left" @click="openMainList">
<view class="qa-header-menu-line"></view>
<view class="qa-header-menu-line qa-header-menu-line-short"></view>
</view>
<text class="qa-header-title">新对话</text>
<view class="qa-header-right" @click="startNewSession">
<text class="qa-header-plus"></text>
</view>
</view>
</view>
<!-- AiQaMain 列表面板 -->
<view v-if="showMainList" class="main-list-mask" @click="closeMainList"></view>
<view v-if="showMainList" class="main-list-drawer">
<view class="main-list-drawer-header">
<text class="main-list-drawer-title">对话列表</text>
<view class="main-list-drawer-close" @click.stop="closeMainList">
<text>关闭</text>
</view>
</view>
<scroll-view class="main-list-scroll" scroll-y="true">
<view v-if="mainListLoading" class="main-list-loading">加载中...</view>
<view v-else>
<view
v-for="g in groupedAiQaMainList"
:key="g.key"
class="main-list-group"
>
<view class="main-list-group-title">{{ g.label }}</view>
<view
v-for="item in g.items"
:key="getMainItemKey(item)"
class="main-list-item"
@click.stop="selectMainItem(item)"
>
<text class="main-list-item-text">{{ getMainItemTitle(item) }}</text>
</view>
</view>
<view v-if="!aiQaMainList || aiQaMainList.length === 0" class="main-list-empty">
暂无数据
</view>
</view>
</scroll-view>
</view>
<!-- 中间主体区域图标 + 提示文案 -->
<view class="qa-main" v-if="chatMessages.length === 0">
<view class="qa-main-icon-wrapper">
<text class="qa-main-icon">Q</text>
</view>
<text class="qa-main-text" selectable="true">今天有什么可以帮到你</text>
</view>
<!-- 对话内容 -->
<scroll-view v-else class="qa-chat" scroll-y="true">
<view class="qa-chat-inner">
<view
v-for="(m, i) in chatMessages"
:key="i"
class="qa-msg"
:class="m.role === 'user' ? 'qa-msg--user' : 'qa-msg--ai'"
>
<view class="qa-msg-actions" :class="m.role === 'ai' ? 'qa-msg-actions--left' : 'qa-msg-actions--right'">
<view class="qa-copy-btn" @click.stop="copyText(m.text)">
<text class="qa-copy-btn-icon"></text>
</view>
</view>
<text class="qa-msg-text" selectable="true">{{ m.text }}</text>
</view>
</view>
</scroll-view>
<!-- 底部输入区域 -->
<view class="qa-input-wrapper" :style="{ paddingBottom: safeBottom + 'px' }">
<view class="qa-input-row">
<view class="qa-input-box">
<textarea
class="qa-textarea"
v-model="message"
placeholder="发送消息或按住说话"
placeholder-class="qa-input-placeholder"
:auto-height="true"
:maxlength="-1"
></textarea>
</view>
<view class="qa-input-send" @click="onSend">
<uni-icons type="top" size="18" color="#fff"></uni-icons>
</view>
</view>
<view class="qa-input-actions">
<view class="qa-chip" @click="goBack">
<uni-icons type="left" size="18" color="#666"></uni-icons>
</view>
</view>
</view>
</view>
</template>
<script>
import { post, get } from '@/common/request.js'
import { getApiUrl } from '@/common/config.js'
import { store } from '@/common/store.js'
function firstNonEmptyTrimmed(...parts) {
for (const p of parts) {
if (p === undefined || p === null) continue;
const s = String(p).trim();
if (s) return s;
}
return '';
}
function getSubmitUserContext() {
try {
const login = uni.getStorageSync('backend-login-response') || {};
const ui = store.userInfo || {};
const loginAccount = firstNonEmptyTrimmed(login.userName, ui.username);
const userName = firstNonEmptyTrimmed(
login.realName,
login.name,
login.nickName,
ui.nickname,
login.email,
ui.email
);
return { loginAccount, userName };
} catch (e) {
return { loginAccount: '', userName: '' };
}
}
function getSystemInfo() {
try {
const info = uni.getSystemInfoSync();
return {
statusBarHeight: info.statusBarHeight || 20,
safeAreaInsets: info.safeAreaInsets || { bottom: 0 }
};
} catch (e) {
return {
statusBarHeight: 20,
safeAreaInsets: { bottom: 0 }
};
}
}
/**
* AiQaItem.answerText 可能是 JsonNode对象或字符串形式的 JSON。
* 这里尽量兼容后端返回的字段名,并返回“可展示”的答案内容(对象/字符串都允许,展示时再 normalizeText
*/
function extractAnswerFromAnswerText(answerText) {
if (answerText === undefined || answerText === null) return '';
// 字符串:尝试解析 JSON否则当作普通文本
if (typeof answerText === 'string') {
const s = answerText.trim();
if (!s) return '';
if (
(s.startsWith('{') && s.endsWith('}')) ||
(s.startsWith('[') && s.endsWith(']'))
) {
try {
const parsed = JSON.parse(s);
return extractAnswerFromAnswerText(parsed);
} catch (e) {
// ignore parse error
}
}
return s;
}
// 对象:优先取常见答案字段,否则原样返回
if (typeof answerText === 'object') {
return (
answerText.answer ??
answerText.content ??
answerText.response ??
answerText.result ??
answerText.message ??
answerText
);
}
return String(answerText);
}
/**
* 历史记录里有时会把“问题类型: xxx / 用户问题: xxx”拼在一起展示。
* 这里仅做展示清洗:去掉这些标签,保留最终问题内容。
*/
function stripQuestionTypePrefix(text) {
const raw = text === undefined || text === null ? '' : String(text);
let s = raw.trim();
if (!s) return '';
// 去掉“问题类型xxx”支持中英文冒号、空格/换行容忍)
s = s.replace(/^\s*问题类型\s*[:]\s*[^\r\n]*\s*/g, '');
// 去掉“用户问题xxx”中的“用户问题”标签如果存在
s = s.replace(/^\s*用户问题\s*[:]\s*/g, '');
// 有些内容可能是两行:问题类型 + 用户问题,处理完后再次 trim
return s.trim();
}
export default {
data() {
const systemInfo = getSystemInfo();
return {
statusBarHeight: systemInfo.statusBarHeight,
safeBottom: systemInfo.safeAreaInsets.bottom || 0,
message: '',
sending: false,
chatMessages: [],
sessionParentId: '',
// AiQaMain 列表面板状态
showMainList: false,
mainListLoading: false,
aiQaMainList: []
};
},
computed: {
groupedAiQaMainList() {
const groups = [
{ key: 'today', label: '今天', items: [] },
{ key: 'yesterday', label: '昨天', items: [] },
{ key: 'seven', label: '7天内', items: [] },
{ key: 'thirty', label: '30天内', items: [] },
{ key: 'older', label: '更早', items: [] }
];
const MS_DAY = 24 * 60 * 60 * 1000;
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const parseDateToMs = (v) => {
if (v === undefined || v === null) return null;
const raw = String(v).trim();
if (!raw) return null;
// 数字时间戳(秒/毫秒兜底)
if (/^\d+$/.test(raw)) {
const n = Number(raw);
if (!n) return null;
// 10 位通常是秒
const ms = raw.length === 10 ? n * 1000 : n;
if (ms > 0) return ms;
}
// yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss
const m = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/);
if (m) {
const y = Number(m[1]);
const mo = Number(m[2]) - 1;
const d = Number(m[3]);
const hh = m[4] ? Number(m[4]) : 0;
const mi = m[5] ? Number(m[5]) : 0;
const ss = m[6] ? Number(m[6]) : 0;
return new Date(y, mo, d, hh, mi, ss).getTime();
}
const dt = new Date(raw);
const t = dt.getTime();
return Number.isNaN(t) ? null : t;
};
const list = Array.isArray(this.aiQaMainList) ? this.aiQaMainList : [];
list.forEach((item) => {
const timeMs =
parseDateToMs(item.createTime) ||
parseDateToMs(item.gmtCreate) ||
parseDateToMs(item.create_time) ||
parseDateToMs(item.time) ||
null;
if (!timeMs) return;
const diffDays = Math.floor((todayStart - timeMs) / MS_DAY);
const idx = diffDays === 0 ? 0 : diffDays === 1 ? 1 : diffDays <= 6 ? 2 : diffDays <= 29 ? 3 : 4;
groups[idx].items.push(item);
});
// 过滤空组,并保持顺序
return groups.filter((g) => g.items.length > 0);
}
},
methods: {
goBack() {
uni.navigateBack();
},
openMainList() {
this.showMainList = true;
this.fetchAiQaMainList(true);
},
closeMainList() {
this.showMainList = false;
},
async fetchAiQaMainList(force = false) {
if (this.mainListLoading) return;
if (!force && Array.isArray(this.aiQaMainList) && this.aiQaMainList.length > 0) return;
this.mainListLoading = true;
try {
const login = uni.getStorageSync('backend-login-response') || {};
const salesId = login.userId ? String(login.userId).trim() : '';
// 后端要求:登录账号传到 salesPhone用于匹配 AiQaMain.salesPhone
// 这里优先取 login.userNameuserName 为空再用手机号兜底。
const salesPhone = firstNonEmptyTrimmed(login.userName, login.phone);
// salesName 暂不参与过滤(避免误筛导致列表为空)
const salesName = '';
const url = getApiUrl('/api/aiQaMain/list');
const res = await get(url, {
current: 1,
size: 50,
salesId,
salesPhone,
salesName
});
const body = res && res.data ? res.data : null;
const success = body && body.success === true;
if (!success) {
const msg = body?.message || '请求失败';
uni.showToast({ title: msg, icon: 'none' });
this.aiQaMainList = [];
return;
}
this.aiQaMainList = body.data || [];
} catch (e) {
console.error('[general_qa] fetchAiQaMainList error:', e);
uni.showToast({ title: '加载失败', icon: 'none' });
this.aiQaMainList = [];
} finally {
this.mainListLoading = false;
}
},
getMainItemKey(item) {
const key = firstNonEmptyTrimmed(
item.id,
item.mainId,
item.parentId,
item.qaMainId,
item.questionId
);
return key || JSON.stringify(item);
},
getMainItemTitle(item) {
return firstNonEmptyTrimmed(
item.questionTitle,
item.title,
item.questionContent,
item.question,
item.content,
item.summary,
item.name,
item.id
);
},
async selectMainItem(item) {
const parentId = firstNonEmptyTrimmed(
item.id, // AiQaMain.id优先
item.parentId, // 兜底:如果后端把字段直接展开了
item.mainId,
item.qaMainId,
item.sessionParentId
);
uni.hideLoading();
this.mainListLoading = false;
this.showMainList = false;
this.chatMessages = [];
this.message = '';
this.sending = false;
this.sessionParentId = parentId ? String(parentId) : '';
if (!this.sessionParentId) return;
// 拉取该会话下的历史对话明细,并恢复到聊天框
try {
uni.showLoading({ title: '加载历史...', mask: true });
const url = getApiUrl('/api/aiQaItem/listByParentId');
const res = await get(url, { parentId: this.sessionParentId });
uni.hideLoading();
const body = res && res.data ? res.data : null;
const success = body && body.success === true;
if (!success) {
const msg = body?.message || '查询失败';
uni.showToast({ title: msg, icon: 'none' });
return;
}
const list = Array.isArray(body.data) ? body.data : [];
// 后端按 createTime desc 返回,聊天展示一般按时间正序
const ordered = list.slice().reverse();
const restored = [];
ordered.forEach((qItem) => {
const questionText = firstNonEmptyTrimmed(
qItem.questSrc,
qItem.quest_src,
qItem.questionContent,
qItem.question,
qItem.title
);
if (questionText) {
const cleaned = stripQuestionTypePrefix(this.normalizeText(questionText));
if (cleaned) {
restored.push({ role: 'user', text: cleaned });
}
}
const answerText = extractAnswerFromAnswerText(
qItem.answerText ?? qItem.answer_text
);
if (answerText !== undefined && answerText !== null && String(answerText).trim()) {
restored.push({ role: 'ai', text: this.normalizeText(answerText) });
}
});
this.chatMessages = restored;
} catch (e) {
uni.hideLoading();
console.error('[general_qa] listByParentId error:', e);
uni.showToast({ title: '加载失败,请重试', icon: 'none' });
}
},
startNewSession() {
uni.hideLoading();
this.chatMessages = [];
this.sessionParentId = '';
this.message = '';
this.sending = false;
},
copyText(text) {
const content = (text || '').toString();
if (!content.trim()) return;
uni.setClipboardData({
data: content,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' });
},
fail: () => {
uni.showToast({ title: '复制失败', icon: 'none' });
}
});
},
normalizeText(value) {
if (value === undefined || value === null) return '';
if (typeof value === 'string') {
// 兼容后端把换行转义成 \\n 的情况
return value.replace(/\\n/g, '\n');
}
try {
return JSON.stringify(value, null, 2);
} catch (e) {
return String(value);
}
},
onSend() {
const text = (this.message || '').trim();
if (!text) {
uni.showToast({ title: '请输入消息', icon: 'none' });
return;
}
if (this.sending) return;
this.sending = true;
// 角色消息先入栈,减少等待感
const userMsg = { role: 'user', text };
this.chatMessages.push(userMsg);
this.message = '';
uni.showLoading({ title: '生成中...', mask: true });
// 该页面对应“通用问答”
const questionType = 'general_qa';
const url = getApiUrl('/api/aiQaItem/askAI');
const { loginAccount, userName } = getSubmitUserContext();
const payload = {
questionType,
questionContent: text,
loginAccount,
userName
};
// parentId第一次请求不传后端返回后再带上
if (this.sessionParentId) {
payload.parentId = this.sessionParentId;
}
post(url, payload)
.then((res) => {
uni.hideLoading();
const body = res && res.data ? res.data : null;
const success = body && body.success === true;
if (!success) {
const msg = body?.message || '请求失败';
this.chatMessages.push({ role: 'ai', text: msg });
return;
}
const aiData = body.data || {};
// 若后端返回 parentId则保存并用于后续请求
const returnedParentId = aiData.parentId;
if (returnedParentId !== undefined && returnedParentId !== null && String(returnedParentId).trim()) {
this.sessionParentId = String(returnedParentId).trim();
}
// 兼容不同字段名:优先取常见答案字段,否则兜底 JSON
const rawAnswer =
aiData.answer ??
aiData.content ??
aiData.response ??
aiData.result ??
aiData.message ??
aiData;
this.chatMessages.push({ role: 'ai', text: this.normalizeText(rawAnswer) });
})
.catch((err) => {
uni.hideLoading();
console.error('[general_qa] askAI error:', err);
this.chatMessages.push({ role: 'ai', text: err?.errMsg || '调用失败,请重试' });
})
.finally(() => {
this.sending = false;
});
}
}
};
</script>
<style scoped>
.qa-page {
flex: 1;
min-height: 100vh;
background-color: #F5F5F5;
display: flex;
flex-direction: column;
}
.qa-header {
background-color: #FFFFFF;
border-bottom: 1rpx solid #EDEDED;
}
.qa-header-bar {
height: 88rpx;
padding: 0 32rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.qa-header-left {
width: 60rpx;
height: 60rpx;
justify-content: center;
align-items: flex-start;
display: flex;
flex-direction: column;
}
.qa-header-menu-line {
width: 26rpx;
height: 4rpx;
border-radius: 4rpx;
background-color: #333333;
margin-bottom: 6rpx;
}
.qa-header-menu-line-short {
width: 16rpx;
}
.qa-header-title {
flex: 1;
text-align: center;
font-size: 30rpx;
font-weight: 500;
color: #333333;
}
.qa-header-right {
width: 60rpx;
height: 60rpx;
align-items: center;
justify-content: center;
display: flex;
}
.qa-header-plus {
font-size: 38rpx;
color: #333333;
}
.qa-main {
flex: 1;
align-items: center;
justify-content: center;
display: flex;
flex-direction: column;
padding-bottom: 200rpx; /* 给底部固定输入框留空间 */
}
.qa-main-icon-wrapper {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
background-color: #FFFFFF;
align-items: center;
justify-content: center;
display: flex;
margin-bottom: 32rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
.qa-main-icon {
font-size: 60rpx;
color: #2A68FF;
}
.qa-main-text {
font-size: 30rpx;
color: #333333;
}
.qa-chat {
flex: 1;
width: 100%;
padding-bottom: 200rpx; /* 给底部固定输入框留空间 */
}
.qa-chat-inner {
padding: 24rpx 24rpx 12rpx;
}
.qa-msg {
margin-bottom: 20rpx;
max-width: 88%;
position: relative;
}
.qa-msg-actions {
position: absolute;
top: 8rpx;
z-index: 2;
}
.qa-msg-actions--left {
left: 8rpx;
}
.qa-msg-actions--right {
right: 8rpx;
}
.qa-copy-btn {
width: 44rpx;
height: 44rpx;
border-radius: 22rpx;
background-color: #F4F6FA;
border: 1rpx solid #E5EAF3;
display: flex;
align-items: center;
justify-content: center;
}
.qa-copy-btn-icon {
font-size: 22rpx;
color: #8a8a8a;
line-height: 1;
}
.qa-msg--user {
margin-left: auto;
background-color: #EAF2FF;
border: 1rpx solid #D7E6FF;
}
.qa-msg--ai {
margin-right: auto;
background-color: #FFFFFF;
border: 1rpx solid #F0F0F0;
}
.qa-msg--user,
.qa-msg--ai {
padding: 18rpx 20rpx;
border-radius: 16rpx;
}
.qa-msg-text {
font-size: 26rpx;
color: #333333;
line-height: 1.6;
word-break: break-word;
user-select: text;
}
/* 复制按钮在角上绝对定位;气泡已有左右 padding再给正文加一点边距避免与 44rpx 按钮重叠 */
.qa-msg--ai .qa-msg-text {
padding-left: 40rpx;
}
.qa-msg--user .qa-msg-text {
padding-right: 40rpx;
}
.qa-input-wrapper {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
padding: 12rpx 20rpx 12rpx;
background-color: #FFFFFF;
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.04);
box-sizing: border-box;
}
/* AiQaMain 列表面板 */
.main-list-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.35);
z-index: 1999;
}
.main-list-drawer {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 560rpx;
background-color: #FFFFFF;
z-index: 2000;
border-right: 1rpx solid #F0F0F0;
display: flex;
flex-direction: column;
}
.main-list-drawer-header {
padding: 22rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1rpx solid #F0F0F0;
}
.main-list-drawer-title {
font-size: 32rpx;
font-weight: 600;
color: #333333;
}
.main-list-drawer-close {
font-size: 28rpx;
color: #666666;
}
.main-list-scroll {
flex: 1;
}
.main-list-loading,
.main-list-empty {
padding: 30rpx 24rpx;
font-size: 28rpx;
color: #888888;
}
.main-list-group {
padding: 18rpx 24rpx;
}
.main-list-group-title {
font-size: 28rpx;
color: #333333;
font-weight: 600;
margin-bottom: 10rpx;
}
.main-list-item {
background-color: #F7F7FA;
border: 1rpx solid #EFEFF4;
border-radius: 12rpx;
padding: 18rpx 16rpx;
margin-bottom: 14rpx;
}
.main-list-item-text {
font-size: 26rpx;
color: #333333;
line-height: 1.4;
word-break: break-word;
}
.qa-input-row {
flex-direction: row;
display: flex;
align-items: center;
}
.qa-input-box {
flex: 1;
min-height: 80rpx;
border-radius: 40rpx;
background-color: #F5F5F5;
padding: 0 32rpx;
display: flex;
align-items: center;
}
.qa-textarea {
width: 100%;
min-height: 88rpx; /* 默认两行2*44rpx */
max-height: 176rpx; /* 最大四行4*44rpx */
line-height: 44rpx;
font-size: 26rpx;
color: #333333;
padding: 18rpx 0;
box-sizing: border-box;
overflow-y: auto;
}
.qa-input-placeholder {
font-size: 26rpx;
color: #999999;
}
.qa-input-send {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
margin-left: 16rpx;
background-color: #2A68FF;
align-items: center;
justify-content: center;
display: flex;
}
.qa-input-actions {
margin-top: 12rpx;
flex-direction: row;
display: flex;
}
.qa-chip {
padding: 8rpx 20rpx;
border-radius: 999rpx;
background-color: #F5F5F5;
margin-right: 16rpx;
display: flex;
align-items: center;
justify-content: center;
}
</style>