feat: batch update - gift card breakdown spec, backend APIs, miniprogram pages, ETL finance recharge, docs & migrations
This commit is contained in:
10
apps/DEMO-miniprogram/miniprogram/pages/chat/chat.json
Normal file
10
apps/DEMO-miniprogram/miniprogram/pages/chat/chat.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"navigationBarTitleText": "AI 助手",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"usingComponents": {
|
||||
"t-loading": "tdesign-miniprogram/loading/loading",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"dev-fab": "/components/dev-fab/dev-fab"
|
||||
}
|
||||
}
|
||||
221
apps/DEMO-miniprogram/miniprogram/pages/chat/chat.ts
Normal file
221
apps/DEMO-miniprogram/miniprogram/pages/chat/chat.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
// pages/chat/chat.ts — AI 对话页
|
||||
import { mockChatMessages } from '../../utils/mock-data'
|
||||
import type { ChatMessage } from '../../utils/mock-data'
|
||||
import { simulateStreamOutput } from '../../utils/chat'
|
||||
import { formatRelativeTime, formatIMTime, shouldShowTimeDivider } from '../../utils/time'
|
||||
|
||||
/** 将 referenceCard.data (Record<string,string>) 转为数组供 WXML wx:for 渲染 */
|
||||
function toDataList(data?: Record<string, string>): Array<{ key: string; value: string }> {
|
||||
if (!data) return []
|
||||
return Object.keys(data).map((k) => ({ key: k, value: data[k] }))
|
||||
}
|
||||
|
||||
/** 为消息列表补充展示字段(时间分割线、IM时间、引用卡片)*/
|
||||
function enrichMessages(msgs: ChatMessage[]) {
|
||||
return msgs.map((m, i) => ({
|
||||
...m,
|
||||
timeLabel: formatRelativeTime(m.timestamp),
|
||||
imTimeLabel: formatIMTime(m.timestamp),
|
||||
showTimeDivider: shouldShowTimeDivider(
|
||||
i === 0 ? null : msgs[i - 1].timestamp,
|
||||
m.timestamp,
|
||||
),
|
||||
referenceCard: m.referenceCard
|
||||
? { ...m.referenceCard, dataList: toDataList(m.referenceCard.data) }
|
||||
: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Mock AI 回复模板 */
|
||||
const mockAIReplies = [
|
||||
'根据数据分析,这位客户近期消费频次有所下降,建议安排一次主动回访,了解具体原因。',
|
||||
'好的,我已经记录了你的需求。下次回访时我会提醒你。',
|
||||
'这位客户偏好中式台球,最近对斯诺克也产生了兴趣,可以推荐相关课程。',
|
||||
]
|
||||
|
||||
Page({
|
||||
data: {
|
||||
/** 页面状态 */
|
||||
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
|
||||
/** 状态栏高度 */
|
||||
statusBarHeight: 0,
|
||||
/** 消息列表 */
|
||||
messages: [] as Array<ChatMessage & {
|
||||
timeLabel?: string
|
||||
imTimeLabel?: string
|
||||
showTimeDivider?: boolean
|
||||
referenceCard?: ChatMessage['referenceCard'] & { dataList?: Array<{ key: string; value: string }> }
|
||||
}>,
|
||||
/** 输入框内容 */
|
||||
inputText: '',
|
||||
/** AI 正在流式回复 */
|
||||
isStreaming: false,
|
||||
/** 流式输出中的内容 */
|
||||
streamingContent: '',
|
||||
/** 滚动锚点 */
|
||||
scrollToId: '',
|
||||
/** 页面顶部引用卡片(从其他页面跳转时) */
|
||||
referenceCard: null as { title: string; summary: string } | null,
|
||||
/** 客户 ID */
|
||||
customerId: '',
|
||||
/** 输入栏距底部距离(软键盘弹出时动态调整)*/
|
||||
inputBarBottom: 0,
|
||||
},
|
||||
|
||||
/** 消息计数器,用于生成唯一 ID */
|
||||
_msgCounter: 0,
|
||||
|
||||
onLoad(options) {
|
||||
const sysInfo = wx.getWindowInfo()
|
||||
const customerId = options?.customerId || ''
|
||||
this.setData({
|
||||
customerId,
|
||||
statusBarHeight: sysInfo.statusBarHeight || 44,
|
||||
})
|
||||
this.loadMessages(customerId)
|
||||
},
|
||||
|
||||
/** 加载消息(Mock) */
|
||||
loadMessages(customerId: string) {
|
||||
this.setData({ pageState: 'loading' })
|
||||
|
||||
try {
|
||||
setTimeout(() => {
|
||||
// TODO: 替换为真实 API 调用
|
||||
const messages = enrichMessages(mockChatMessages)
|
||||
this._msgCounter = messages.length
|
||||
|
||||
// 如果携带 customerId,显示引用卡片
|
||||
const referenceCard = customerId
|
||||
? { title: '客户详情', summary: `正在查看客户 ${customerId} 的相关信息` }
|
||||
: null
|
||||
|
||||
const isEmpty = messages.length === 0 && !referenceCard
|
||||
|
||||
this.setData({
|
||||
pageState: isEmpty ? 'empty' : 'normal',
|
||||
messages,
|
||||
referenceCard,
|
||||
})
|
||||
|
||||
this.scrollToBottom()
|
||||
}, 500)
|
||||
} catch {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
},
|
||||
|
||||
/** 返回上一页 */
|
||||
onBack() {
|
||||
wx.navigateBack()
|
||||
},
|
||||
|
||||
/** 重试加载 */
|
||||
onRetry() {
|
||||
this.loadMessages(this.data.customerId)
|
||||
},
|
||||
|
||||
/** 软键盘弹出:输入栏上移至键盘顶部 */
|
||||
onInputFocus(e: WechatMiniprogram.InputFocus) {
|
||||
const keyboardHeight = e.detail.height || 0
|
||||
this.setData({ inputBarBottom: keyboardHeight })
|
||||
setTimeout(() => this.scrollToBottom(), 120)
|
||||
},
|
||||
|
||||
/** 软键盘收起:输入栏归位 */
|
||||
onInputBlur() {
|
||||
this.setData({ inputBarBottom: 0 })
|
||||
},
|
||||
|
||||
/** 输入框内容变化 */
|
||||
onInputChange(e: WechatMiniprogram.Input) {
|
||||
this.setData({ inputText: e.detail.value })
|
||||
},
|
||||
|
||||
/** 发送消息 */
|
||||
onSendMessage() {
|
||||
const text = this.data.inputText.trim()
|
||||
if (!text || this.data.isStreaming) return
|
||||
|
||||
this._msgCounter++
|
||||
const now = new Date().toISOString()
|
||||
const prevMsgs = this.data.messages
|
||||
const prevTs = prevMsgs.length > 0 ? prevMsgs[prevMsgs.length - 1].timestamp : null
|
||||
const userMsg = {
|
||||
id: `msg-user-${this._msgCounter}`,
|
||||
role: 'user' as const,
|
||||
content: text,
|
||||
timestamp: now,
|
||||
timeLabel: '刚刚',
|
||||
imTimeLabel: formatIMTime(now),
|
||||
showTimeDivider: shouldShowTimeDivider(prevTs, now),
|
||||
}
|
||||
|
||||
const messages = [...this.data.messages, userMsg]
|
||||
this.setData({
|
||||
messages,
|
||||
inputText: '',
|
||||
pageState: 'normal',
|
||||
})
|
||||
|
||||
this.scrollToBottom()
|
||||
|
||||
setTimeout(() => {
|
||||
this.triggerAIReply()
|
||||
}, 300)
|
||||
},
|
||||
|
||||
/** 触发 AI 流式回复 */
|
||||
triggerAIReply() {
|
||||
this._msgCounter++
|
||||
const aiMsgId = `msg-ai-${this._msgCounter}`
|
||||
const replyText = mockAIReplies[this._msgCounter % mockAIReplies.length]
|
||||
|
||||
const aiNow = new Date().toISOString()
|
||||
const prevMsgs = this.data.messages
|
||||
const prevTs = prevMsgs.length > 0 ? prevMsgs[prevMsgs.length - 1].timestamp : null
|
||||
const aiMsg = {
|
||||
id: aiMsgId,
|
||||
role: 'assistant' as const,
|
||||
content: '',
|
||||
timestamp: aiNow,
|
||||
timeLabel: '刚刚',
|
||||
imTimeLabel: formatIMTime(aiNow),
|
||||
showTimeDivider: shouldShowTimeDivider(prevTs, aiNow),
|
||||
}
|
||||
|
||||
const messages = [...this.data.messages, aiMsg]
|
||||
this.setData({
|
||||
messages,
|
||||
isStreaming: true,
|
||||
streamingContent: '',
|
||||
})
|
||||
|
||||
this.scrollToBottom()
|
||||
|
||||
const aiIndex = messages.length - 1
|
||||
simulateStreamOutput(replyText, (partial: string) => {
|
||||
const key = `messages[${aiIndex}].content`
|
||||
this.setData({
|
||||
[key]: partial,
|
||||
streamingContent: partial,
|
||||
})
|
||||
this.scrollToBottom()
|
||||
}).then(() => {
|
||||
this.setData({
|
||||
isStreaming: false,
|
||||
streamingContent: '',
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
/** 滚动到底部 */
|
||||
scrollToBottom() {
|
||||
setTimeout(() => {
|
||||
this.setData({ scrollToId: '' })
|
||||
setTimeout(() => {
|
||||
this.setData({ scrollToId: 'scroll-bottom' })
|
||||
}, 50)
|
||||
}, 50)
|
||||
},
|
||||
})
|
||||
165
apps/DEMO-miniprogram/miniprogram/pages/chat/chat.wxml
Normal file
165
apps/DEMO-miniprogram/miniprogram/pages/chat/chat.wxml
Normal file
@@ -0,0 +1,165 @@
|
||||
<!-- pages/chat/chat.wxml — AI 对话页 -->
|
||||
<wxs src="../../utils/time.wxs" module="timefmt" />
|
||||
|
||||
<!-- 加载态 -->
|
||||
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
|
||||
<view class="g-toast-loading-inner">
|
||||
<t-loading theme="circular" size="40rpx" />
|
||||
<text class="g-toast-loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 错误态 -->
|
||||
<view class="page-error" wx:elif="{{pageState === 'error'}}">
|
||||
<view class="error-content">
|
||||
<text class="error-text">加载失败,请重试</text>
|
||||
<view class="retry-btn" hover-class="retry-btn--hover" bindtap="onRetry">
|
||||
<text class="retry-btn-text">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 正常态 -->
|
||||
<view class="chat-page" wx:elif="{{pageState === 'normal' || pageState === 'empty'}}">
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view
|
||||
class="message-list"
|
||||
scroll-y
|
||||
scroll-into-view="{{scrollToId}}"
|
||||
scroll-with-animation
|
||||
enhanced
|
||||
show-scrollbar="{{false}}"
|
||||
style="bottom: {{inputBarBottom}}px;"
|
||||
>
|
||||
|
||||
<!-- 引用内容卡片(从其他页面跳转时显示)-->
|
||||
<view class="reference-card" wx:if="{{referenceCard}}">
|
||||
<view class="reference-label-row">
|
||||
<text class="reference-tag">引用内容</text>
|
||||
<text class="reference-source">{{referenceCard.title}}</text>
|
||||
</view>
|
||||
<text class="reference-summary">{{referenceCard.summary}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 空对话提示 -->
|
||||
<view class="empty-hint" wx:if="{{pageState === 'empty' && messages.length === 0}}">
|
||||
<view class="empty-ai-avatar">
|
||||
<image src="/assets/icons/ai-robot.svg" class="empty-ai-img" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="empty-text">你好,我是 AI 助手</text>
|
||||
<text class="empty-sub">有什么可以帮你的?</text>
|
||||
</view>
|
||||
|
||||
<!-- 消息气泡列表 -->
|
||||
<block wx:for="{{messages}}" wx:key="id">
|
||||
|
||||
<!--
|
||||
IM 时间分割线
|
||||
· 首条消息始终显示
|
||||
· 相邻消息间隔 ≥ 5 分钟时显示
|
||||
· 格式:今天 HH:mm / 今年 MM-DD HH:mm / 跨年 YYYY-MM-DD HH:mm
|
||||
-->
|
||||
<view class="time-divider" wx:if="{{item.showTimeDivider}}">
|
||||
<view class="time-divider-inner">
|
||||
<text class="time-divider-text">{{timefmt.imTime(item.timestamp)}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 用户消息:右对齐蓝色 -->
|
||||
<view class="message-row message-user" wx:if="{{item.role === 'user'}}" id="msg-{{item.id}}">
|
||||
<view class="user-bubble-col">
|
||||
<view class="bubble bubble-user">
|
||||
<text class="bubble-text">{{item.content}}</text>
|
||||
</view>
|
||||
<!-- 用户侧引用卡片(用户发给 AI 的上下文卡片)-->
|
||||
<view class="inline-ref-card inline-ref-card--user" wx:if="{{item.referenceCard}}">
|
||||
<view class="inline-ref-header">
|
||||
<text class="inline-ref-type">{{item.referenceCard.type === 'customer' ? '👤 客户' : '📋 记录'}}</text>
|
||||
<text class="inline-ref-title">{{item.referenceCard.title}}</text>
|
||||
</view>
|
||||
<text class="inline-ref-summary">{{item.referenceCard.summary}}</text>
|
||||
<view class="inline-ref-data">
|
||||
<view class="ref-data-item" wx:for="{{item.referenceCard.dataList}}" wx:for-item="entry" wx:key="key">
|
||||
<text class="ref-data-key">{{entry.key}}</text>
|
||||
<text class="ref-data-value">{{entry.value}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- AI 消息:左对齐白色 -->
|
||||
<view class="message-row message-assistant" wx:else id="msg-{{item.id}}">
|
||||
<view class="ai-avatar">
|
||||
<image src="/assets/icons/ai-robot.svg" class="ai-avatar-img" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="bubble-col">
|
||||
<view class="bubble bubble-assistant">
|
||||
<text class="bubble-text">{{item.content}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</block>
|
||||
|
||||
<!-- AI 正在输入指示器 -->
|
||||
<view class="message-row message-assistant" wx:if="{{isStreaming && !streamingContent}}" id="msg-typing">
|
||||
<view class="ai-avatar">
|
||||
<image src="/assets/icons/ai-robot.svg" class="ai-avatar-img" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="bubble bubble-assistant typing-bubble">
|
||||
<view class="typing-dots">
|
||||
<view class="dot dot-1"></view>
|
||||
<view class="dot dot-2"></view>
|
||||
<view class="dot dot-3"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部占位 -->
|
||||
<view class="scroll-bottom-spacer" id="scroll-bottom"></view>
|
||||
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部输入区域 -->
|
||||
<view class="input-bar" style="bottom: {{inputBarBottom}}px;">
|
||||
<view class="input-wrapper">
|
||||
<input
|
||||
class="chat-input"
|
||||
value="{{inputText}}"
|
||||
placeholder="输入消息..."
|
||||
placeholder-class="input-placeholder"
|
||||
confirm-type="send"
|
||||
bindinput="onInputChange"
|
||||
bindconfirm="onSendMessage"
|
||||
bindfocus="onInputFocus"
|
||||
bindblur="onInputBlur"
|
||||
adjust-position="{{false}}"
|
||||
disabled="{{isStreaming}}"
|
||||
cursor-spacing="16"
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
class="send-btn {{inputText.length > 0 && !isStreaming ? 'send-btn-active' : 'send-btn-disabled'}}"
|
||||
hover-class="send-btn--hover"
|
||||
bindtap="onSendMessage"
|
||||
>
|
||||
<image
|
||||
wx:if="{{inputText.length > 0 && !isStreaming}}"
|
||||
src="/assets/icons/send-arrow-white.svg"
|
||||
class="send-icon"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<image
|
||||
wx:else
|
||||
src="/assets/icons/send-arrow-gray.svg"
|
||||
class="send-icon"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
|
||||
<dev-fab />
|
||||
372
apps/DEMO-miniprogram/miniprogram/pages/chat/chat.wxss
Normal file
372
apps/DEMO-miniprogram/miniprogram/pages/chat/chat.wxss
Normal file
@@ -0,0 +1,372 @@
|
||||
/* pages/chat/chat.wxss — AI 对话页样式 */
|
||||
|
||||
page {
|
||||
background-color: var(--color-gray-1, #f3f3f3);
|
||||
}
|
||||
|
||||
/* ========== 错误态 ========== */
|
||||
.page-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: var(--color-gray-1, #f3f3f3);
|
||||
}
|
||||
.error-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24rpx;
|
||||
}
|
||||
.error-text {
|
||||
font-size: 28rpx;
|
||||
color: var(--color-gray-8, #777777);
|
||||
}
|
||||
.retry-btn {
|
||||
padding: 16rpx 48rpx;
|
||||
background: var(--color-primary, #0052d9);
|
||||
border-radius: 22rpx;
|
||||
}
|
||||
.retry-btn--hover { opacity: 0.8; }
|
||||
.retry-btn-text {
|
||||
font-size: 28rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ========== 页面容器 ========== */
|
||||
.chat-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: var(--color-gray-1, #f3f3f3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ========== 消息列表 ========== */
|
||||
.message-list {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 112rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scroll-bottom-spacer {
|
||||
height: 160rpx;
|
||||
}
|
||||
|
||||
/* ========== 引用内容卡片 ========== */
|
||||
.reference-card {
|
||||
background: #ecf2fe;
|
||||
border-radius: 20rpx;
|
||||
border-left: 6rpx solid #0052d9;
|
||||
padding: 22rpx 26rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.reference-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
.reference-quote-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.reference-tag {
|
||||
font-size: 20rpx;
|
||||
line-height: 29rpx;
|
||||
font-weight: 600;
|
||||
color: #0052d9;
|
||||
background: rgba(0, 82, 217, 0.10);
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
.reference-source {
|
||||
font-size: 20rpx;
|
||||
line-height: 29rpx;
|
||||
color: #5e5e5e;
|
||||
}
|
||||
.reference-summary {
|
||||
font-size: 24rpx;
|
||||
line-height: 36rpx;
|
||||
color: #393939;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ========== 空对话提示 ========== */
|
||||
.empty-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 160rpx;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.empty-ai-avatar {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8rpx;
|
||||
box-shadow: 0 12rpx 32rpx rgba(102, 126, 234, 0.30);
|
||||
}
|
||||
.empty-ai-img {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
}
|
||||
.empty-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--color-gray-13, #242424);
|
||||
}
|
||||
.empty-sub {
|
||||
font-size: 26rpx;
|
||||
color: var(--color-gray-6, #a6a6a6);
|
||||
}
|
||||
|
||||
/* ========== IM 时间分割线 ========== */
|
||||
.time-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 20rpx 0 8rpx;
|
||||
padding: 0 28rpx;
|
||||
}
|
||||
|
||||
.time-divider-inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6rpx 20rpx;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-radius: 20rpx;
|
||||
margin: 12rpx;
|
||||
}
|
||||
|
||||
.time-divider-text {
|
||||
font-size: 20rpx;
|
||||
color: var(--color-gray-7, #8b8b8b);
|
||||
line-height: 28rpx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ========== IM 消息行 ========== */
|
||||
.message-row {
|
||||
display: flex;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.message-user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.message-assistant {
|
||||
justify-content: flex-start;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
/* ========== AI 头像 ========== */
|
||||
.ai-avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 6rpx 16rpx rgba(102, 126, 234, 0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ai-avatar-img {
|
||||
width: 46rpx;
|
||||
height: 46rpx;
|
||||
}
|
||||
|
||||
/* ========== 气泡 ========== */
|
||||
.bubble-wrapper {
|
||||
max-width: 80%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.bubble {
|
||||
padding: 20rpx 28rpx;
|
||||
word-break: break-all;
|
||||
}
|
||||
.bubble-text {
|
||||
font-size: 28rpx;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.bubble-user {
|
||||
max-width: 80%;
|
||||
background-color: var(--color-primary, #0052d9);
|
||||
border-radius: 32rpx 8rpx 32rpx 32rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 82, 217, 0.22);
|
||||
}
|
||||
.bubble-user .bubble-text { color: #ffffff; }
|
||||
.bubble-assistant {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8rpx 32rpx 32rpx 32rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.bubble-assistant .bubble-text { color: var(--color-gray-13, #242424); }
|
||||
|
||||
/* ========== AI 内联引用卡片 ========== */
|
||||
.inline-ref-card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
border-left: 6rpx solid var(--color-primary, #0052d9);
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
/* 用户侧引用卡片:右对齐,挂在用户气泡下方 */
|
||||
.user-bubble-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12rpx;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.inline-ref-card--user {
|
||||
border-left: none;
|
||||
border-right: 6rpx solid var(--color-primary, #0052d9);
|
||||
background-color: #ecf2fe;
|
||||
max-width: 100%;
|
||||
}
|
||||
.inline-ref-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
.inline-ref-type {
|
||||
font-size: 22rpx;
|
||||
color: var(--color-primary, #0052d9);
|
||||
font-weight: 600;
|
||||
}
|
||||
.inline-ref-title {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-gray-13, #242424);
|
||||
font-weight: 500;
|
||||
}
|
||||
.inline-ref-summary {
|
||||
font-size: 22rpx;
|
||||
color: var(--color-gray-8, #777777);
|
||||
margin-bottom: 12rpx;
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.inline-ref-data {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx 24rpx;
|
||||
}
|
||||
.ref-data-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.ref-data-key {
|
||||
font-size: 20rpx;
|
||||
color: var(--color-gray-7, #8b8b8b);
|
||||
}
|
||||
.ref-data-value {
|
||||
font-size: 20rpx;
|
||||
color: var(--color-gray-13, #242424);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ========== 打字指示器 ========== */
|
||||
.typing-bubble { padding: 20rpx 32rpx; }
|
||||
.typing-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-gray-6, #a6a6a6);
|
||||
animation: typingBounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
.dot-1 { animation-delay: 0s; }
|
||||
.dot-2 { animation-delay: 0.2s; }
|
||||
.dot-3 { animation-delay: 0.4s; }
|
||||
@keyframes typingBounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ========== 底部输入区域 ========== */
|
||||
.input-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 24rpx 32rpx;
|
||||
background-color: #ffffff;
|
||||
border-top: 2rpx solid var(--color-gray-2, #eeeeee);
|
||||
box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.04);
|
||||
z-index: 100;
|
||||
transition: bottom 0.25s ease;
|
||||
}
|
||||
.input-wrapper {
|
||||
flex: 1;
|
||||
background-color: var(--color-gray-1, #f3f3f3);
|
||||
border-radius: 48rpx;
|
||||
padding: 18rpx 28rpx;
|
||||
min-height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.chat-input {
|
||||
width: 100%;
|
||||
font-size: 28rpx;
|
||||
color: var(--color-gray-13, #242424);
|
||||
line-height: 1.4;
|
||||
background: transparent;
|
||||
}
|
||||
.input-placeholder {
|
||||
color: var(--color-gray-6, #a6a6a6);
|
||||
font-size: 28rpx;
|
||||
}
|
||||
.send-btn {
|
||||
width: 88rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 36rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.send-btn-active {
|
||||
background: var(--color-primary, #0052d9);
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 82, 217, 0.28);
|
||||
}
|
||||
.send-btn-disabled {
|
||||
background-color: var(--color-gray-3, #e0e0e0);
|
||||
}
|
||||
.send-btn--hover {
|
||||
opacity: 0.75;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.send-icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
}
|
||||
Reference in New Issue
Block a user