feat: 百科rag
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
// pages/wiki/chat/history/index.js
|
||||
import request from '../../../../utils/request';
|
||||
|
||||
Page({
|
||||
data: {
|
||||
list: [],
|
||||
total: 0,
|
||||
current: 1,
|
||||
pageSize: 15,
|
||||
loading: false,
|
||||
hasMore: true,
|
||||
showClearDialog: false,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.fetchHistory(true);
|
||||
},
|
||||
|
||||
fetchHistory(reset = false) {
|
||||
if (this.data.loading) return;
|
||||
if (!reset && !this.data.hasMore) return;
|
||||
|
||||
const current = reset ? 1 : this.data.current;
|
||||
this.setData({ loading: true });
|
||||
|
||||
request.get('/plant/chat/history', { current, pageSize: this.data.pageSize })
|
||||
.then(res => {
|
||||
const items = (res.list || []).map(item => ({
|
||||
...item,
|
||||
answerPreview: (item.answer || '').substring(0, 80) + ((item.answer || '').length > 80 ? '...' : ''),
|
||||
}));
|
||||
const total = res.total || 0;
|
||||
|
||||
if (reset) {
|
||||
this.setData({
|
||||
list: items,
|
||||
total,
|
||||
current: 2,
|
||||
hasMore: items.length < total,
|
||||
loading: false,
|
||||
});
|
||||
} else {
|
||||
const old = this.data.list;
|
||||
const update = {};
|
||||
items.forEach((item, i) => {
|
||||
update[`list[${old.length + i}]`] = item;
|
||||
});
|
||||
update.current = current + 1;
|
||||
update.hasMore = (old.length + items.length) < total;
|
||||
update.loading = false;
|
||||
update.total = total;
|
||||
this.setData(update);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.setData({ loading: false });
|
||||
});
|
||||
},
|
||||
|
||||
loadMore() {
|
||||
this.fetchHistory(false);
|
||||
},
|
||||
|
||||
onTapItem(e) {
|
||||
const item = e.currentTarget.dataset.item;
|
||||
// Navigate to chat page with prefilled Q&A
|
||||
wx.navigateTo({
|
||||
url: '/pages/wiki/chat/index?fromHistory=1',
|
||||
success(res) {
|
||||
res.eventChannel.emit('historyData', {
|
||||
question: item.question,
|
||||
answer: item.answer,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
onDeleteItem(e) {
|
||||
const id = e.currentTarget.dataset.id;
|
||||
wx.showModal({
|
||||
title: '删除记录',
|
||||
content: '确定删除这条问答记录吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
request.post('/plant/chat/history/delete', { id }).then(() => {
|
||||
wx.showToast({ title: '已删除', icon: 'success' });
|
||||
this.fetchHistory(true);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
onClearAll() {
|
||||
this.setData({ showClearDialog: true });
|
||||
},
|
||||
|
||||
closeClearDialog() {
|
||||
this.setData({ showClearDialog: false });
|
||||
},
|
||||
|
||||
doClearAll() {
|
||||
this.setData({ showClearDialog: false });
|
||||
request.post('/plant/chat/history/clear').then(() => {
|
||||
wx.showToast({ title: '已清空', icon: 'success' });
|
||||
this.setData({ list: [], total: 0, hasMore: false });
|
||||
});
|
||||
},
|
||||
|
||||
goToChat() {
|
||||
wx.navigateBack();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"navigationBarTitleText": "问答历史",
|
||||
"navigationBarBackgroundColor": "#558B2F",
|
||||
"navigationBarTextStyle": "white",
|
||||
"usingComponents": {
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading",
|
||||
"t-empty": "tdesign-miniprogram/empty/empty",
|
||||
"t-dialog": "tdesign-miniprogram/dialog/dialog",
|
||||
"t-swipe-cell": "tdesign-miniprogram/swipe-cell/swipe-cell"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<!--pages/wiki/chat/history/index.wxml-->
|
||||
<view class="history-page">
|
||||
<scroll-view
|
||||
class="history-scroll"
|
||||
scroll-y
|
||||
bindscrolltolower="loadMore"
|
||||
enhanced
|
||||
show-scrollbar="{{false}}"
|
||||
>
|
||||
<!-- Header Actions -->
|
||||
<view class="header-bar" wx:if="{{list.length > 0}}">
|
||||
<text class="header-count">共 {{total}} 条记录</text>
|
||||
<view class="clear-btn" bindtap="onClearAll">
|
||||
<t-icon name="delete" size="32rpx" color="#EF4444" />
|
||||
<text>清空全部</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- History List -->
|
||||
<view wx:for="{{list}}" wx:key="id" class="history-card" bindtap="onTapItem" data-item="{{item}}">
|
||||
<view class="card-header">
|
||||
<text class="card-time">{{item.createdAtStr}}</text>
|
||||
<view class="card-del" catchtap="onDeleteItem" data-id="{{item.id}}">
|
||||
<t-icon name="close" size="28rpx" color="#9CA3AF" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-question">
|
||||
<text class="q-label">Q</text>
|
||||
<text class="q-text">{{item.question}}</text>
|
||||
</view>
|
||||
<view class="card-answer">
|
||||
<text class="a-label">A</text>
|
||||
<text class="a-text">{{item.answerPreview}}</text>
|
||||
<view class="card-arrow">
|
||||
<t-icon name="chevron-right" size="28rpx" color="#CCC" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- States -->
|
||||
<view class="footer">
|
||||
<t-loading wx:if="{{loading}}" theme="circular" size="40rpx" text="加载中..." />
|
||||
<text wx:elif="{{!hasMore && list.length > 0}}" class="no-more">没有更多了</text>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{!loading && list.length === 0}}" class="empty-wrap">
|
||||
<view class="empty-icon">📝</view>
|
||||
<text class="empty-text">暂无问答记录</text>
|
||||
<text class="empty-sub">去和AI助手聊聊吧</text>
|
||||
<view class="empty-cta" bindtap="goToChat">
|
||||
<text>开始提问</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view style="height: 60rpx;"></view>
|
||||
</scroll-view>
|
||||
|
||||
<t-dialog
|
||||
visible="{{showClearDialog}}"
|
||||
title="清空全部历史"
|
||||
content="确定要清空所有问答记录吗?此操作不可恢复。"
|
||||
confirm-btn="确定清空"
|
||||
cancel-btn="取消"
|
||||
bind:confirm="doClearAll"
|
||||
bind:cancel="closeClearDialog"
|
||||
/>
|
||||
</view>
|
||||
@@ -0,0 +1,195 @@
|
||||
/** pages/wiki/chat/history/index.wxss **/
|
||||
.history-page {
|
||||
height: 100vh;
|
||||
background: linear-gradient(180deg, #EEF3E5 0%, #F4F6F0 100%);
|
||||
}
|
||||
|
||||
.history-scroll {
|
||||
height: 100%;
|
||||
padding: 24rpx 28rpx;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
|
||||
.header-count {
|
||||
font-size: 26rpx;
|
||||
color: #78909C;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
font-size: 26rpx;
|
||||
color: #EF4444;
|
||||
font-weight: 600;
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.clear-btn:active {
|
||||
background: rgba(239,68,68,0.08);
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.history-card {
|
||||
background: rgba(255,255,255,0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 24rpx;
|
||||
padding: 24rpx 24rpx 20rpx;
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(85,139,47,0.05);
|
||||
border: 1rpx solid rgba(85,139,47,0.04);
|
||||
transition: all 0.15s;
|
||||
animation: cardIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes cardIn {
|
||||
from { opacity: 0; transform: translateY(12rpx); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.history-card:active {
|
||||
transform: scale(0.98);
|
||||
background: #FAFDF7;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.card-time {
|
||||
font-size: 22rpx;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
.card-del {
|
||||
padding: 8rpx;
|
||||
margin: -8rpx;
|
||||
}
|
||||
|
||||
.card-question {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.q-label {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 10rpx;
|
||||
background: linear-gradient(135deg, #558B2F, #7CB342);
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
line-height: 40rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.q-text {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #1F2937;
|
||||
line-height: 1.5;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-answer {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.a-label {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 10rpx;
|
||||
background: #E8F5E9;
|
||||
color: #2E7D32;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
line-height: 40rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.a-text {
|
||||
font-size: 26rpx;
|
||||
color: #6B7280;
|
||||
line-height: 1.6;
|
||||
flex: 1;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
padding-left: 8rpx;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
padding: 32rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
font-size: 24rpx;
|
||||
color: #CCC;
|
||||
}
|
||||
|
||||
/* Empty */
|
||||
.empty-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 96rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #9CA3AF;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 26rpx;
|
||||
color: #CCC;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.empty-cta {
|
||||
padding: 16rpx 48rpx;
|
||||
border-radius: 40rpx;
|
||||
background: linear-gradient(135deg, #558B2F, #7CB342);
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 6rpx 20rpx rgba(85,139,47,0.3);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.empty-cta:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// pages/wiki/chat/index.js
|
||||
|
||||
Page({
|
||||
data: {
|
||||
messages: [],
|
||||
inputValue: '',
|
||||
isTyping: false,
|
||||
scrollAnchor: '',
|
||||
_counter: 0,
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
if (options && options.fromHistory === '1') {
|
||||
const channel = this.getOpenerEventChannel();
|
||||
channel.on('historyData', (data) => {
|
||||
const msgs = [
|
||||
{ id: 'h1', role: 'user', content: data.question },
|
||||
{ id: 'h2', role: 'ai', content: this._cleanMd(data.answer) },
|
||||
];
|
||||
this.setData({ messages: msgs, _counter: 2 }, () => this.scrollToBottom());
|
||||
});
|
||||
} else if (options && options.prefillQuestion) {
|
||||
const q = decodeURIComponent(options.prefillQuestion);
|
||||
this.setData({ inputValue: q }, () => this.onSend());
|
||||
}
|
||||
},
|
||||
|
||||
goToHistory() {
|
||||
wx.navigateTo({ url: '/pages/wiki/chat/history/index' });
|
||||
},
|
||||
|
||||
onQuickAsk(e) {
|
||||
const query = e.currentTarget.dataset.q;
|
||||
this.setData({ inputValue: query }, () => this.onSend());
|
||||
},
|
||||
|
||||
onInput(e) {
|
||||
this.setData({ inputValue: e.detail.value });
|
||||
},
|
||||
|
||||
onSend() {
|
||||
const query = this.data.inputValue.trim();
|
||||
if (!query || this.data.isTyping) return;
|
||||
|
||||
const uid = 'u' + (++this.data._counter);
|
||||
const aid = 'a' + (++this.data._counter);
|
||||
const len = this.data.messages.length;
|
||||
|
||||
// Push user msg + empty AI msg at once
|
||||
this.setData({
|
||||
[`messages[${len}]`]: { id: uid, role: 'user', content: query },
|
||||
[`messages[${len + 1}]`]: { id: aid, role: 'ai', content: '' },
|
||||
inputValue: '',
|
||||
isTyping: true,
|
||||
}, () => {
|
||||
this.scrollToBottom();
|
||||
this._streamRequest(query, aid);
|
||||
});
|
||||
},
|
||||
|
||||
_streamRequest(query, aiMsgId) {
|
||||
const token = wx.getStorageSync('token');
|
||||
const baseUrl = 'http://192.168.0.184:8889';
|
||||
const url = `${baseUrl}/plant/chat/stream?query=${encodeURIComponent(query)}`;
|
||||
let fullText = '';
|
||||
|
||||
const task = wx.request({
|
||||
url,
|
||||
method: 'GET',
|
||||
enableChunked: true,
|
||||
header: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'text/event-stream',
|
||||
},
|
||||
success: () => {
|
||||
this.setData({ isTyping: false });
|
||||
this.scrollToBottom();
|
||||
},
|
||||
fail: () => {
|
||||
this._updateAiMsg(aiMsgId, '网络连接失败,请稍后重试');
|
||||
this.setData({ isTyping: false });
|
||||
},
|
||||
});
|
||||
|
||||
task.onChunkReceived((res) => {
|
||||
const text = this._decode(res.data);
|
||||
const lines = text.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const chunk = line.substring(6);
|
||||
|
||||
if (chunk === '[DONE]') {
|
||||
this.setData({ isTyping: false });
|
||||
return;
|
||||
}
|
||||
if (chunk.startsWith('[ERROR]')) {
|
||||
fullText += '\n⚠️ ' + (chunk.substring(7) || '服务异常');
|
||||
this._updateAiMsg(aiMsgId, fullText);
|
||||
this.setData({ isTyping: false });
|
||||
return;
|
||||
}
|
||||
|
||||
fullText += chunk;
|
||||
this._updateAiMsg(aiMsgId, fullText);
|
||||
}
|
||||
this.scrollToBottom();
|
||||
});
|
||||
},
|
||||
|
||||
_updateAiMsg(id, content) {
|
||||
const idx = this.data.messages.findIndex(m => m.id === id);
|
||||
if (idx !== -1) {
|
||||
this.setData({ [`messages[${idx}].content`]: this._cleanMd(content) });
|
||||
}
|
||||
},
|
||||
|
||||
// Strip residual markdown symbols for clean display
|
||||
_cleanMd(text) {
|
||||
return text
|
||||
.replace(/^#{1,6}\s*/gm, '') // ### headers
|
||||
.replace(/\*\*(.+?)\*\*/g, '【$1】') // **bold** → 【bold】
|
||||
.replace(/\*(.+?)\*/g, '$1') // *italic*
|
||||
.replace(/^[\-\*]\s+/gm, '· ') // - list → · list
|
||||
.replace(/^\d+\.\s+/gm, (m) => m) // keep numbered lists
|
||||
.replace(/`([^`]+)`/g, '$1') // `code`
|
||||
.replace(/^---+$/gm, '————') // --- → ————
|
||||
.replace(/\n{3,}/g, '\n\n'); // collapse blank lines
|
||||
},
|
||||
|
||||
_decode(buffer) {
|
||||
try {
|
||||
return new TextDecoder('utf-8').decode(new Uint8Array(buffer));
|
||||
} catch (e) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let s = '';
|
||||
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||||
try { return decodeURIComponent(escape(s)); } catch (_) { return s; }
|
||||
}
|
||||
},
|
||||
|
||||
scrollToBottom() {
|
||||
this.setData({ scrollAnchor: 'scroll-bottom' });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"navigationBarTitleText": "植物AI助手",
|
||||
"navigationBarBackgroundColor": "#558B2F",
|
||||
"navigationBarTextStyle": "white",
|
||||
"usingComponents": {
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<!--pages/wiki/chat/index.wxml-->
|
||||
<view class="chat-page">
|
||||
<!-- Messages Area -->
|
||||
<scroll-view
|
||||
class="chat-messages"
|
||||
scroll-y
|
||||
scroll-into-view="{{scrollAnchor}}"
|
||||
enhanced
|
||||
show-scrollbar="{{false}}"
|
||||
scroll-with-animation
|
||||
>
|
||||
<!-- Welcome -->
|
||||
<view class="welcome" wx:if="{{messages.length === 0}}">
|
||||
<view class="welcome-glow"></view>
|
||||
<view class="welcome-icon anim-float">🌿</view>
|
||||
<view class="welcome-title">植物AI百科</view>
|
||||
<view class="welcome-sub">基于知识库的智能问答助手</view>
|
||||
<view class="history-entry" bindtap="goToHistory">
|
||||
<t-icon name="time" size="32rpx" color="#558B2F" />
|
||||
<text>查看问答历史</text>
|
||||
</view>
|
||||
<view class="quick-grid">
|
||||
<view class="quick-card" bindtap="onQuickAsk" data-q="龟背竹怎么养护?">
|
||||
<text class="qc-emoji">🌱</text>
|
||||
<text class="qc-text">龟背竹怎么养护?</text>
|
||||
</view>
|
||||
<view class="quick-card" bindtap="onQuickAsk" data-q="哪些植物适合室内?">
|
||||
<text class="qc-emoji">🏠</text>
|
||||
<text class="qc-text">哪些植物适合室内?</text>
|
||||
</view>
|
||||
<view class="quick-card" bindtap="onQuickAsk" data-q="多肉浇水注意什么?">
|
||||
<text class="qc-emoji">💧</text>
|
||||
<text class="qc-text">多肉浇水注意什么?</text>
|
||||
</view>
|
||||
<view class="quick-card" bindtap="onQuickAsk" data-q="植物叶子发黄怎么办?">
|
||||
<text class="qc-emoji">🍂</text>
|
||||
<text class="qc-text">叶子发黄怎么办?</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Chat Bubbles -->
|
||||
<block wx:for="{{messages}}" wx:key="id">
|
||||
<view id="msg-{{item.id}}" class="msg-row {{item.role}}">
|
||||
<!-- AI avatar -->
|
||||
<view wx:if="{{item.role === 'ai'}}" class="ai-avatar-wrap">
|
||||
<text class="ai-avatar-emoji">🌱</text>
|
||||
</view>
|
||||
|
||||
<view class="msg-bubble {{item.role}}">
|
||||
<!-- AI: typing state -->
|
||||
<view wx:if="{{item.role === 'ai' && !item.content}}" class="typing-wrap">
|
||||
<view class="typing-dots">
|
||||
<view class="dot"></view>
|
||||
<view class="dot"></view>
|
||||
<view class="dot"></view>
|
||||
</view>
|
||||
<text class="typing-label">思考中...</text>
|
||||
</view>
|
||||
<text wx:elif="{{item.role === 'ai'}}" class="ai-text" user-select>{{item.content}}</text>
|
||||
<!-- User text -->
|
||||
<text wx:else class="msg-text" user-select>{{item.content}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<view style="height: 32rpx;" id="scroll-bottom"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- Input -->
|
||||
<view class="input-area">
|
||||
<view class="input-row">
|
||||
<input
|
||||
class="chat-input"
|
||||
placeholder="{{isTyping ? 'AI正在回答中...' : '问我任何植物相关的问题...'}}"
|
||||
value="{{inputValue}}"
|
||||
bindinput="onInput"
|
||||
bindconfirm="onSend"
|
||||
confirm-type="send"
|
||||
disabled="{{isTyping}}"
|
||||
adjust-position="{{true}}"
|
||||
cursor-spacing="20"
|
||||
/>
|
||||
<view class="send-btn {{inputValue && !isTyping ? 'active' : ''}}" bindtap="onSend">
|
||||
<t-icon name="arrow-up" size="36rpx" color="#fff" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="safe-bottom"></view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,265 @@
|
||||
/** pages/wiki/chat/index.wxss **/
|
||||
.chat-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, #EEF3E5 0%, #F4F6F0 35%, #F4F6F0 100%);
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 20rpx 24rpx;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* ── Welcome ── */
|
||||
.welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40rpx 20rpx 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.welcome-glow {
|
||||
position: absolute;
|
||||
top: -60rpx;
|
||||
width: 480rpx;
|
||||
height: 480rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(85,139,47,0.15) 0%, rgba(85,139,47,0.04) 50%, transparent 75%);
|
||||
pointer-events: none;
|
||||
animation: glowPulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes glowPulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.8; }
|
||||
50% { transform: scale(1.08); opacity: 1; }
|
||||
}
|
||||
|
||||
.welcome-icon { font-size: 88rpx; margin-bottom: 12rpx; position: relative; }
|
||||
|
||||
.anim-float { animation: floatUp 3s ease-in-out infinite; }
|
||||
|
||||
@keyframes floatUp {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-12rpx); }
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 42rpx;
|
||||
font-weight: 800;
|
||||
color: #2E7D32;
|
||||
margin-bottom: 8rpx;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.welcome-sub {
|
||||
font-size: 24rpx;
|
||||
color: #90A4AE;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.history-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 32rpx;
|
||||
background: rgba(255,255,255,0.85);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2rpx 12rpx rgba(85,139,47,0.08);
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: #558B2F;
|
||||
margin-bottom: 32rpx;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.history-entry:active {
|
||||
transform: scale(0.96);
|
||||
background: #F0F7EB;
|
||||
}
|
||||
|
||||
/* Quick Ask Grid */
|
||||
.quick-grid {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.quick-card {
|
||||
background: rgba(255,255,255,0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 24rpx;
|
||||
padding: 24rpx 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
box-shadow: 0 2rpx 16rpx rgba(85,139,47,0.06);
|
||||
border: 1rpx solid rgba(85,139,47,0.06);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.quick-card:active {
|
||||
transform: scale(0.96);
|
||||
background: #F0F7EB;
|
||||
border-color: rgba(85,139,47,0.15);
|
||||
}
|
||||
|
||||
.qc-emoji { font-size: 40rpx; }
|
||||
|
||||
.qc-text {
|
||||
font-size: 25rpx;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ── Message Row ── */
|
||||
.msg-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24rpx;
|
||||
gap: 12rpx;
|
||||
animation: fadeSlideIn 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeSlideIn {
|
||||
from { opacity: 0; transform: translateY(16rpx); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.msg-row.user { flex-direction: row-reverse; }
|
||||
|
||||
.ai-avatar-wrap {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #E8F5E9, #C8E6C9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4rpx 12rpx rgba(85,139,47,0.12);
|
||||
}
|
||||
|
||||
.ai-avatar-emoji { font-size: 32rpx; }
|
||||
|
||||
/* ── Bubbles ── */
|
||||
.msg-bubble {
|
||||
max-width: 78%;
|
||||
padding: 22rpx 26rpx;
|
||||
border-radius: 24rpx;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.msg-bubble.ai {
|
||||
max-width: 88%;
|
||||
background: rgba(255,255,255,0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
border-top-left-radius: 6rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.msg-bubble.user {
|
||||
background: linear-gradient(135deg, #558B2F, #7CB342);
|
||||
border-top-right-radius: 6rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(85,139,47,0.2);
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
font-size: 29rpx;
|
||||
line-height: 1.7;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ai-text {
|
||||
font-size: 29rpx;
|
||||
line-height: 1.85;
|
||||
color: #1F2937;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ── Typing ── */
|
||||
.typing-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.typing-dots {
|
||||
display: flex;
|
||||
gap: 8rpx;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: #A5D6A7;
|
||||
animation: bounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.dot:nth-child(2) { animation-delay: 0.16s; }
|
||||
.dot:nth-child(3) { animation-delay: 0.32s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0.5); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.typing-label {
|
||||
font-size: 22rpx;
|
||||
color: #90A4AE;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Input ── */
|
||||
.input-area {
|
||||
background: rgba(255,255,255,0.95);
|
||||
backdrop-filter: blur(16px);
|
||||
border-top: 1rpx solid rgba(0,0,0,0.03);
|
||||
padding: 14rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
height: 78rpx;
|
||||
background: #F0F4E8;
|
||||
border-radius: 40rpx;
|
||||
padding: 0 28rpx;
|
||||
font-size: 28rpx;
|
||||
color: #1F2937;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 74rpx;
|
||||
height: 74rpx;
|
||||
border-radius: 50%;
|
||||
background: #D1D5DB;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
|
||||
.send-btn.active {
|
||||
background: linear-gradient(135deg, #558B2F, #7CB342);
|
||||
box-shadow: 0 4rpx 20rpx rgba(85,139,47,0.35);
|
||||
}
|
||||
|
||||
.send-btn:active { transform: scale(0.85); }
|
||||
|
||||
.safe-bottom {
|
||||
height: calc(14rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
Reference in New Issue
Block a user