222 lines
6.3 KiB
TypeScript
222 lines
6.3 KiB
TypeScript
// 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)
|
||
},
|
||
})
|