1460 lines
40 KiB
Vue
1460 lines
40 KiB
Vue
<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
|
||
v-if="m.role === 'user'"
|
||
class="qa-msg-text"
|
||
selectable="true"
|
||
>{{ m.text }}</text>
|
||
<!-- AI 消息支持 Markdown 渲染,保留纯文本兜底 -->
|
||
<view
|
||
v-else
|
||
class="qa-msg-text qa-msg-text-markdown"
|
||
>
|
||
<rich-text
|
||
:nodes="renderMarkdownNodes(m.text)"
|
||
></rich-text>
|
||
</view>
|
||
</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 qa-chip-stream" :class="useStreamOutput ? 'qa-chip-stream--active' : ''" @click="toggleStreamOutput">
|
||
<text class="qa-chip-stream-icon">∿</text>
|
||
<text class="qa-chip-stream-text">流式</text>
|
||
</view>
|
||
<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,
|
||
useStreamOutput: true,
|
||
chatMessages: [],
|
||
sessionParentId: '',
|
||
streamRequestTask: null,
|
||
streamAiMsgIndex: -1,
|
||
streamBuffer: '',
|
||
streamTextDecoder: null,
|
||
streamPendingBytes: [],
|
||
|
||
// 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.userName;userName 为空再用手机号兜底。
|
||
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() {
|
||
this.abortStreamRequest();
|
||
uni.hideLoading();
|
||
this.chatMessages = [];
|
||
this.sessionParentId = '';
|
||
this.message = '';
|
||
this.sending = false;
|
||
},
|
||
toggleStreamOutput() {
|
||
this.useStreamOutput = !this.useStreamOutput;
|
||
uni.showToast({
|
||
title: this.useStreamOutput ? '已开启流式输出' : '已关闭流式输出',
|
||
icon: 'none'
|
||
});
|
||
},
|
||
getRequestHeaders() {
|
||
const header = {
|
||
'Content-Type': 'application/json'
|
||
};
|
||
const token = uni.getStorageSync('backend-token') || '';
|
||
if (token) {
|
||
header.Authorization = `Bearer ${token}`;
|
||
}
|
||
const tenantId = uni.getStorageSync('backend-tenant-id') || '';
|
||
if (tenantId) {
|
||
header['X-Tenant-Id'] = tenantId;
|
||
}
|
||
const roleName = uni.getStorageSync('backend-role-name') || '';
|
||
if (roleName) {
|
||
header['X-Role-Name'] = roleName;
|
||
}
|
||
const scenario = uni.getStorageSync('backend-scenario') || '';
|
||
if (scenario) {
|
||
header['X-Scenario'] = scenario;
|
||
}
|
||
return header;
|
||
},
|
||
abortStreamRequest() {
|
||
if (this.streamRequestTask && typeof this.streamRequestTask.abort === 'function') {
|
||
try {
|
||
this.streamRequestTask.abort();
|
||
} catch (e) {
|
||
// ignore abort error
|
||
}
|
||
}
|
||
this.streamRequestTask = null;
|
||
this.streamAiMsgIndex = -1;
|
||
this.streamBuffer = '';
|
||
this.streamTextDecoder = null;
|
||
this.streamPendingBytes = [];
|
||
},
|
||
decodeUtf8Bytes(bytes) {
|
||
if (!bytes || !bytes.length) return '';
|
||
|
||
// 优先使用 TextDecoder,支持分片流式解码(自动处理半包字符)
|
||
if (typeof TextDecoder !== 'undefined') {
|
||
if (!this.streamTextDecoder) {
|
||
this.streamTextDecoder = new TextDecoder('utf-8');
|
||
}
|
||
return this.streamTextDecoder.decode(bytes, { stream: true });
|
||
}
|
||
|
||
// 兜底:无 TextDecoder 时尝试按 UTF-8 手动解码
|
||
const pending = Array.isArray(this.streamPendingBytes) ? this.streamPendingBytes : [];
|
||
const merged = pending.concat(Array.from(bytes));
|
||
this.streamPendingBytes = [];
|
||
|
||
let i = 0;
|
||
let out = '';
|
||
while (i < merged.length) {
|
||
const b1 = merged[i];
|
||
if ((b1 & 0x80) === 0) {
|
||
out += String.fromCharCode(b1);
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if ((b1 & 0xe0) === 0xc0) {
|
||
if (i + 1 >= merged.length) break;
|
||
const b2 = merged[i + 1];
|
||
out += String.fromCharCode(((b1 & 0x1f) << 6) | (b2 & 0x3f));
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if ((b1 & 0xf0) === 0xe0) {
|
||
if (i + 2 >= merged.length) break;
|
||
const b2 = merged[i + 1];
|
||
const b3 = merged[i + 2];
|
||
out += String.fromCharCode(
|
||
((b1 & 0x0f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f)
|
||
);
|
||
i += 3;
|
||
continue;
|
||
}
|
||
if ((b1 & 0xf8) === 0xf0) {
|
||
if (i + 3 >= merged.length) break;
|
||
const b2 = merged[i + 1];
|
||
const b3 = merged[i + 2];
|
||
const b4 = merged[i + 3];
|
||
const codePoint =
|
||
((b1 & 0x07) << 18) |
|
||
((b2 & 0x3f) << 12) |
|
||
((b3 & 0x3f) << 6) |
|
||
(b4 & 0x3f);
|
||
out += String.fromCodePoint(codePoint);
|
||
i += 4;
|
||
continue;
|
||
}
|
||
// 非法字节,跳过
|
||
i += 1;
|
||
}
|
||
|
||
if (i < merged.length) {
|
||
this.streamPendingBytes = merged.slice(i);
|
||
}
|
||
return out;
|
||
},
|
||
decodeChunkData(raw) {
|
||
if (raw === undefined || raw === null) return '';
|
||
if (typeof raw === 'string') return raw;
|
||
try {
|
||
const u8 = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
|
||
return this.decodeUtf8Bytes(u8);
|
||
} catch (e) {
|
||
return String(raw);
|
||
}
|
||
},
|
||
appendStreamTextChunk(text) {
|
||
if (text === undefined || text === null) return;
|
||
const chunk = String(text);
|
||
if (!chunk) return;
|
||
const idx = this.streamAiMsgIndex;
|
||
if (idx < 0 || !this.chatMessages[idx] || this.chatMessages[idx].role !== 'ai') return;
|
||
this.chatMessages[idx].text += chunk;
|
||
},
|
||
handleStreamDataLine(line) {
|
||
const raw = String(line || '').trim();
|
||
if (!raw) return;
|
||
if (raw === '[DONE]') return;
|
||
|
||
let textToAppend = raw;
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === 'object') {
|
||
const parentId = firstNonEmptyTrimmed(parsed.parentId, parsed.parent_id);
|
||
if (parentId) {
|
||
this.sessionParentId = String(parentId);
|
||
}
|
||
textToAppend =
|
||
parsed.delta ??
|
||
parsed.content ??
|
||
parsed.answer ??
|
||
parsed.text ??
|
||
parsed.data ??
|
||
parsed.result ??
|
||
parsed.message ??
|
||
parsed.output ??
|
||
parsed.token ??
|
||
'';
|
||
// 若对象结构未知,至少回退到原始行,避免“有返回但前端无内容”
|
||
if ((textToAppend === undefined || textToAppend === null || String(textToAppend) === '') && raw) {
|
||
textToAppend = raw;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// 非 JSON 分片按纯文本处理
|
||
}
|
||
|
||
if (textToAppend) {
|
||
this.appendStreamTextChunk(this.normalizeText(textToAppend));
|
||
}
|
||
},
|
||
flushStreamBuffer(force = false) {
|
||
if (!this.streamBuffer) return;
|
||
let data = this.streamBuffer;
|
||
if (!force) {
|
||
const lastLf = data.lastIndexOf('\n');
|
||
if (lastLf < 0) return;
|
||
this.streamBuffer = data.slice(lastLf + 1);
|
||
data = data.slice(0, lastLf);
|
||
} else {
|
||
this.streamBuffer = '';
|
||
}
|
||
if (!data) return;
|
||
|
||
const lines = data.split(/\r?\n/);
|
||
lines.forEach((line) => {
|
||
const s = String(line || '').trim();
|
||
if (!s) return;
|
||
if (s.startsWith('data:')) {
|
||
this.handleStreamDataLine(s.slice(5));
|
||
return;
|
||
}
|
||
if (s.startsWith('event:') || s.startsWith('id:') || s.startsWith(':')) {
|
||
return;
|
||
}
|
||
this.handleStreamDataLine(s);
|
||
});
|
||
},
|
||
sendByNormal(payload) {
|
||
const url = getApiUrl('/api/aiQaItem/askAI');
|
||
// 非流式回答耗时更长,放宽超时避免频繁超时失败
|
||
post(url, payload, { timeout: 120000 })
|
||
.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 || {};
|
||
const returnedParentId = aiData.parentId;
|
||
if (returnedParentId !== undefined && returnedParentId !== null && String(returnedParentId).trim()) {
|
||
this.sessionParentId = String(returnedParentId).trim();
|
||
}
|
||
|
||
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;
|
||
});
|
||
},
|
||
async sendByStream(payload) {
|
||
const url = getApiUrl('/api/aiQaItem/askAIStream');
|
||
this.chatMessages.push({ role: 'ai', text: '' });
|
||
this.streamAiMsgIndex = this.chatMessages.length - 1;
|
||
this.streamBuffer = '';
|
||
this.streamTextDecoder = null;
|
||
this.streamPendingBytes = [];
|
||
|
||
// H5 环境:使用 fetch + ReadableStream 实现流式
|
||
// #ifdef H5
|
||
try {
|
||
const headerObj = this.getRequestHeaders();
|
||
const headers = new Headers();
|
||
Object.keys(headerObj || {}).forEach((k) => {
|
||
if (headerObj[k] !== undefined && headerObj[k] !== null) {
|
||
headers.append(k, headerObj[k]);
|
||
}
|
||
});
|
||
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
if (!res.ok) {
|
||
throw new Error(`HTTP ${res.status}`);
|
||
}
|
||
|
||
const reader = res.body && res.body.getReader ? res.body.getReader() : null;
|
||
if (!reader) {
|
||
throw new Error('当前浏览器不支持流式读取');
|
||
}
|
||
|
||
const pump = async () => {
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) {
|
||
this.flushStreamBuffer(true);
|
||
break;
|
||
}
|
||
const chunk = this.decodeUtf8Bytes(value);
|
||
this.streamBuffer += chunk;
|
||
this.flushStreamBuffer(false);
|
||
}
|
||
};
|
||
|
||
await pump();
|
||
|
||
const idx = this.streamAiMsgIndex;
|
||
if (idx >= 0 && this.chatMessages[idx] && !String(this.chatMessages[idx].text || '').trim()) {
|
||
this.chatMessages[idx].text = '未获取到流式内容';
|
||
}
|
||
} catch (err) {
|
||
console.error('[general_qa] askAIStream H5 error:', err);
|
||
const idx = this.streamAiMsgIndex;
|
||
if (idx >= 0 && this.chatMessages[idx]) {
|
||
const raw = String(this.chatMessages[idx].text || '').trim();
|
||
this.chatMessages[idx].text = raw || (err?.message || '调用失败,请重试');
|
||
} else {
|
||
this.chatMessages.push({ role: 'ai', text: err?.message || '调用失败,请重试' });
|
||
}
|
||
} finally {
|
||
uni.hideLoading();
|
||
this.sending = false;
|
||
this.streamRequestTask = null;
|
||
this.streamAiMsgIndex = -1;
|
||
this.streamBuffer = '';
|
||
this.streamTextDecoder = null;
|
||
this.streamPendingBytes = [];
|
||
}
|
||
return;
|
||
// #endif
|
||
|
||
// 非 H5:仍然使用 uni.request / wx.request + onChunkReceived
|
||
const requestOptions = {
|
||
url,
|
||
method: 'POST',
|
||
data: payload,
|
||
header: this.getRequestHeaders(),
|
||
enableChunked: true,
|
||
responseType: 'text',
|
||
success: () => {
|
||
this.flushStreamBuffer(true);
|
||
const idx = this.streamAiMsgIndex;
|
||
if (idx >= 0 && this.chatMessages[idx] && !String(this.chatMessages[idx].text || '').trim()) {
|
||
this.chatMessages[idx].text = '未获取到流式内容';
|
||
}
|
||
},
|
||
fail: (err) => {
|
||
console.error('[general_qa] askAIStream error:', err);
|
||
const idx = this.streamAiMsgIndex;
|
||
if (idx >= 0 && this.chatMessages[idx]) {
|
||
const raw = String(this.chatMessages[idx].text || '').trim();
|
||
this.chatMessages[idx].text = raw || (err?.errMsg || '调用失败,请重试');
|
||
} else {
|
||
this.chatMessages.push({ role: 'ai', text: err?.errMsg || '调用失败,请重试' });
|
||
}
|
||
},
|
||
complete: () => {
|
||
uni.hideLoading();
|
||
this.sending = false;
|
||
this.streamRequestTask = null;
|
||
this.streamAiMsgIndex = -1;
|
||
this.streamBuffer = '';
|
||
this.streamTextDecoder = null;
|
||
this.streamPendingBytes = [];
|
||
}
|
||
};
|
||
|
||
let task = null;
|
||
// 微信小程序端优先使用原生 wx.request,确保 onChunkReceived 可用
|
||
// #ifdef MP-WEIXIN
|
||
if (typeof wx !== 'undefined' && wx && typeof wx.request === 'function') {
|
||
task = wx.request(requestOptions);
|
||
} else {
|
||
task = uni.request(requestOptions);
|
||
}
|
||
// #endif
|
||
// #ifndef MP-WEIXIN
|
||
task = uni.request(requestOptions);
|
||
// #endif
|
||
|
||
this.streamRequestTask = task;
|
||
if (task && typeof task.onChunkReceived === 'function') {
|
||
task.onChunkReceived((res) => {
|
||
try {
|
||
const chunk = this.decodeChunkData(res.data);
|
||
this.streamBuffer += chunk;
|
||
this.flushStreamBuffer(false);
|
||
} catch (e) {
|
||
console.error('[general_qa] stream chunk parse error:', e);
|
||
}
|
||
});
|
||
} else {
|
||
const idx = this.streamAiMsgIndex;
|
||
uni.hideLoading();
|
||
this.sending = false;
|
||
this.streamRequestTask = null;
|
||
this.streamAiMsgIndex = -1;
|
||
this.streamBuffer = '';
|
||
if (idx >= 0 && this.chatMessages[idx]) {
|
||
this.chatMessages[idx].text = '当前运行环境不支持流式输出,请关闭流式后重试';
|
||
} else {
|
||
this.chatMessages.push({ role: 'ai', text: '当前运行环境不支持流式输出,请关闭流式后重试' });
|
||
}
|
||
}
|
||
},
|
||
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);
|
||
}
|
||
},
|
||
/**
|
||
* 非常轻量的 Markdown 转换,仅支持:
|
||
* - 换行 => <br/>
|
||
* - **加粗** => <strong>
|
||
* - *斜体* => <em>
|
||
* - 无序列表行 "- " => <ul><li>
|
||
* - 代码块 ``` => <pre><code>
|
||
* 复杂 Markdown 语法暂不完全覆盖,但足够保证结构清晰。
|
||
*/
|
||
parseInlineMarkdownToNodes(line) {
|
||
const text = String(line || '');
|
||
const nodes = [];
|
||
const tokenRe = /(\*\*[^*]+\*\*|`[^`\n]+`|\*[^*\n]+\*)/g;
|
||
let lastIndex = 0;
|
||
let match = null;
|
||
|
||
while ((match = tokenRe.exec(text)) !== null) {
|
||
const idx = match.index;
|
||
if (idx > lastIndex) {
|
||
nodes.push({ type: 'text', text: text.slice(lastIndex, idx) });
|
||
}
|
||
const token = match[0];
|
||
if (token.startsWith('**') && token.endsWith('**')) {
|
||
nodes.push({
|
||
name: 'strong',
|
||
attrs: { style: 'font-weight:600;' },
|
||
children: [{ type: 'text', text: token.slice(2, -2) }]
|
||
});
|
||
} else if (token.startsWith('`') && token.endsWith('`')) {
|
||
nodes.push({
|
||
name: 'span',
|
||
attrs: {
|
||
style: 'font-family:monospace;background:#f2f2f4;padding:1px 4px;border-radius:3px;'
|
||
},
|
||
children: [{ type: 'text', text: token.slice(1, -1) }]
|
||
});
|
||
} else if (token.startsWith('*') && token.endsWith('*')) {
|
||
nodes.push({
|
||
name: 'em',
|
||
attrs: { style: 'font-style:italic;' },
|
||
children: [{ type: 'text', text: token.slice(1, -1) }]
|
||
});
|
||
} else {
|
||
nodes.push({ type: 'text', text: token });
|
||
}
|
||
lastIndex = idx + token.length;
|
||
}
|
||
|
||
if (lastIndex < text.length) {
|
||
nodes.push({ type: 'text', text: text.slice(lastIndex) });
|
||
}
|
||
if (nodes.length === 0) {
|
||
nodes.push({ type: 'text', text: '' });
|
||
}
|
||
return nodes;
|
||
},
|
||
renderMarkdownNodes(text) {
|
||
try {
|
||
const raw = String(text || '');
|
||
const lines = raw.replace(/\r\n/g, '\n').split('\n');
|
||
const nodes = [];
|
||
let i = 0;
|
||
let inCode = false;
|
||
let codeLines = [];
|
||
|
||
while (i < lines.length) {
|
||
const line = lines[i];
|
||
|
||
// 代码块围栏
|
||
if (line.trim().startsWith('```')) {
|
||
if (!inCode) {
|
||
inCode = true;
|
||
codeLines = [];
|
||
} else {
|
||
inCode = false;
|
||
nodes.push({
|
||
name: 'div',
|
||
attrs: {
|
||
style: 'background:#f7f7f9;border-radius:6px;padding:8px 10px;font-family:monospace;white-space:pre-wrap;word-break:break-word;'
|
||
},
|
||
children: [{ type: 'text', text: codeLines.join('\n') }]
|
||
});
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (inCode) {
|
||
codeLines.push(line);
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
// 无序列表
|
||
if (/^\s*-\s+/.test(line)) {
|
||
const ulChildren = [];
|
||
while (i < lines.length && /^\s*-\s+/.test(lines[i])) {
|
||
const content = lines[i].replace(/^\s*-\s+/, '');
|
||
ulChildren.push({
|
||
name: 'li',
|
||
attrs: { style: 'margin:4px 0;' },
|
||
children: this.parseInlineMarkdownToNodes(content)
|
||
});
|
||
i += 1;
|
||
}
|
||
nodes.push({
|
||
name: 'ul',
|
||
attrs: { style: 'padding-left:18px;margin:6px 0;' },
|
||
children: ulChildren
|
||
});
|
||
continue;
|
||
}
|
||
|
||
// 空行
|
||
if (!line.trim()) {
|
||
nodes.push({ name: 'div', children: [{ type: 'text', text: ' ' }] });
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
// 普通段落
|
||
nodes.push({
|
||
name: 'div',
|
||
attrs: { style: 'line-height:1.6;margin:2px 0;word-break:break-word;' },
|
||
children: this.parseInlineMarkdownToNodes(line)
|
||
});
|
||
i += 1;
|
||
}
|
||
|
||
// 兜底:如果代码块未闭合,也照样展示
|
||
if (inCode && codeLines.length > 0) {
|
||
nodes.push({
|
||
name: 'div',
|
||
attrs: {
|
||
style: 'background:#f7f7f9;border-radius:6px;padding:8px 10px;font-family:monospace;white-space:pre-wrap;word-break:break-word;'
|
||
},
|
||
children: [{ type: 'text', text: codeLines.join('\n') }]
|
||
});
|
||
}
|
||
|
||
return nodes;
|
||
} catch (e) {
|
||
return [{ type: 'text', text: String(text || '') }];
|
||
}
|
||
},
|
||
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 { loginAccount, userName } = getSubmitUserContext();
|
||
const payload = {
|
||
questionType,
|
||
questionContent: text,
|
||
loginAccount,
|
||
userName
|
||
};
|
||
// parentId:第一次请求不传;后端返回后再带上
|
||
if (this.sessionParentId) {
|
||
payload.parentId = this.sessionParentId;
|
||
}
|
||
|
||
if (this.useStreamOutput) {
|
||
this.sendByStream(payload);
|
||
} else {
|
||
this.sendByNormal(payload);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
</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;
|
||
}
|
||
|
||
.qa-msg-text-markdown {
|
||
padding-top: 6rpx;
|
||
}
|
||
|
||
.md-root {
|
||
font-size: 26rpx;
|
||
line-height: 1.6;
|
||
color: #333333;
|
||
}
|
||
|
||
.md-root strong {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.md-root em {
|
||
font-style: italic;
|
||
}
|
||
|
||
.md-root ul {
|
||
padding-left: 32rpx;
|
||
margin: 8rpx 0;
|
||
}
|
||
|
||
.md-root li {
|
||
margin: 4rpx 0;
|
||
}
|
||
|
||
.md-code-block {
|
||
background-color: #f7f7f9;
|
||
border-radius: 8rpx;
|
||
padding: 10rpx 14rpx;
|
||
font-family: monospace;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.md-inline-code {
|
||
font-family: monospace;
|
||
background-color: #f2f2f4;
|
||
padding: 2rpx 6rpx;
|
||
border-radius: 4rpx;
|
||
}
|
||
|
||
/* 复制按钮在角上绝对定位;气泡已有左右 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;
|
||
}
|
||
|
||
.qa-chip-stream {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
background-color: #F1F4FA;
|
||
}
|
||
|
||
.qa-chip-stream--active {
|
||
background-color: #E7F0FF;
|
||
}
|
||
|
||
.qa-chip-stream-icon {
|
||
font-size: 24rpx;
|
||
color: #2A68FF;
|
||
line-height: 1;
|
||
margin-right: 8rpx;
|
||
}
|
||
|
||
.qa-chip-stream-text {
|
||
font-size: 24rpx;
|
||
color: #2A68FF;
|
||
line-height: 1;
|
||
}
|
||
|
||
</style>
|