Files
Neo-ZQYY/apps/miniprogram/miniprogram/pages/chat/chat.ts
Neo 6f8f12314f feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更:
- backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔
- admin-web: ETL 状态页、任务管理、调度配置、登录优化
- miniprogram: 看板页面、聊天集成、UI 组件、导航更新
- etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强
- tenant-admin: 项目初始化
- db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8)
- packages/shared: 枚举和工具函数更新
- tools: 数据库工具、报表生成、健康检查
- docs: PRD/架构/部署/合约文档更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 00:03:48 +08:00

513 lines
16 KiB
TypeScript
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.
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
*/
import { checkPageAccess } from '../../utils/auth-guard'
// pages/chat/chat.ts — AI 对话页
// CHANGE 2026-03-20 | RNS1.4 T8.1: 多入口参数路由,替换 mock 为真实 API
// CHANGE 2026-03-20 | RNS1.4 T8.2: 替换 simulateStreamOutput 为真实 SSE 连接
import { fetchChatMessages, fetchChatMessagesByContext } from '../../services/api'
import { formatRelativeTime, formatIMTime, shouldShowTimeDivider } from '../../utils/time'
import { API_BASE } from '../../utils/config'
/** API 返回的消息项类型 */
interface ApiMessageItem {
id: number
role: 'user' | 'assistant'
content: string
createdAt: string
referenceCard?: {
type: 'customer' | 'record'
title: string
summary: string
data: Record<string, string>
}
}
/** 将 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: ApiMessageItem[]) {
return msgs.map((m, i) => ({
id: String(m.id),
role: m.role,
content: m.content,
timestamp: m.createdAt,
timeLabel: formatRelativeTime(m.createdAt),
imTimeLabel: formatIMTime(m.createdAt),
showTimeDivider: shouldShowTimeDivider(
i === 0 ? null : msgs[i - 1].createdAt,
m.createdAt,
),
referenceCard: m.referenceCard
? { ...m.referenceCard, dataList: toDataList(m.referenceCard.data) }
: undefined,
}))
}
/** 从 Storage 获取 access_token与 request.ts 保持一致) */
function getAccessToken(): string | undefined {
const app = getApp<IAppOption>()
return app.globalData.token || wx.getStorageSync('token') || undefined
}
/**
* SSE 行缓冲解析器
* 微信 onChunkReceived 返回的 ArrayBuffer 可能跨事件边界切割,
* 需要缓冲不完整的行,只处理完整的 SSE 事件。
*/
class SSEParser {
private buffer = ''
private currentEvent = ''
private currentData = ''
constructor(
private onMessage: (token: string) => void,
private onDone: (messageId: number, createdAt: string) => void,
private onError: (message: string) => void,
) {}
/** 将 ArrayBuffer 解码为 UTF-8 字符串(兼容微信环境) */
private decodeChunk(chunk: ArrayBuffer): string {
// 微信基础库支持 TextDecoder2.19.2+
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder('utf-8').decode(chunk)
}
// 降级:手动解码 UTF-8 字节
const bytes = new Uint8Array(chunk)
let str = ''
for (let i = 0; i < bytes.length; i++) {
str += String.fromCharCode(bytes[i])
}
try {
return decodeURIComponent(escape(str))
} catch {
return str
}
}
/** 接收一个 chunk 并解析完整的 SSE 事件 */
feed(chunk: ArrayBuffer): void {
this.buffer += this.decodeChunk(chunk)
const lines = this.buffer.split('\n')
// 最后一个元素可能是不完整的行,保留在 buffer 中
this.buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.replace(/\r$/, '')
if (trimmed === '') {
// 空行 = 事件分隔符,派发当前事件
this.dispatch()
} else if (trimmed.startsWith('event:')) {
this.currentEvent = trimmed.slice(6).trim()
} else if (trimmed.startsWith('data:')) {
this.currentData = trimmed.slice(5).trim()
}
// 忽略其他行(如 id:、retry:、注释等)
}
}
private dispatch(): void {
if (!this.currentEvent || !this.currentData) {
this.currentEvent = ''
this.currentData = ''
return
}
try {
const data = JSON.parse(this.currentData)
switch (this.currentEvent) {
case 'message':
if (data.token != null) this.onMessage(data.token)
break
case 'done':
this.onDone(data.messageId, data.createdAt)
break
case 'error':
this.onError(data.message || 'AI 服务暂时不可用')
break
}
} catch {
// JSON 解析失败,忽略此事件
}
this.currentEvent = ''
this.currentData = ''
}
}
/** 展示用消息类型 */
type DisplayMessage = {
id: string
role: 'user' | 'assistant'
content: string
timestamp: string
timeLabel?: string
imTimeLabel?: string
showTimeDivider?: boolean
referenceCard?: {
type: string
title: string
summary: string
data: Record<string, string>
dataList?: Array<{ key: string; value: string }>
}
}
Page({
data: {
/** 页面状态 */
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
/** 状态栏高度 */
statusBarHeight: 0,
/** 消息列表 */
messages: [] as DisplayMessage[],
/** 输入框内容 */
inputText: '',
/** AI 正在流式回复 */
isStreaming: false,
/** 流式输出中的内容 */
streamingContent: '',
/** 滚动锚点 */
scrollToId: '',
/** 页面顶部引用卡片(从其他页面跳转时) */
referenceCard: null as { title: string; summary: string } | null,
/** 当前对话 ID后端返回用于发送消息 */
chatId: '',
/** 客户 ID兼容旧逻辑 */
customerId: '',
/** 输入栏距底部距离(软键盘弹出时动态调整)*/
inputBarBottom: 0,
/** 来源页面标识(看板入口) */
sourcePage: '',
/** 看板筛选参数 */
pageFilters: {} as Record<string, string>,
},
/** 消息计数器,用于生成唯一 ID */
_msgCounter: 0,
/** 当前 SSE 请求任务(用于中断/重试) */
_sseTask: null as WechatMiniprogram.RequestTask | null,
/** 最后一次发送的用户消息内容(用于重试) */
_lastUserContent: '',
onShow() {
// 权限守卫:检查登录状态、账号禁用、角色权限
checkPageAccess('pages/chat/chat')
},
// CHANGE 2026-03-20 | RNS1.4 T8.1: 多入口参数路由
// 优先级historyId → taskId → customerId → coachId → general
onLoad(options) {
const sysInfo = wx.getWindowInfo()
this.setData({
statusBarHeight: sysInfo.statusBarHeight || 44,
})
if (options?.historyId) {
// 从 chat-history 跳转:直接用 historyId 作为 chatId 加载历史消息
this.setData({ chatId: options.historyId })
this.loadMessages(options.historyId)
} else if (options?.taskId) {
// 从 task-detail 跳转:同一 taskId 始终复用同一对话(无时限)
this.loadMessagesByContext('task', options.taskId)
} else if (options?.customerId) {
// 从 customer-detail 跳转:≤ 3 天复用、> 3 天新建
this.setData({ customerId: options.customerId })
this.loadMessagesByContext('customer', options.customerId)
} else if (options?.coachId) {
// 从 coach-detail 跳转:≤ 3 天复用、> 3 天新建
this.loadMessagesByContext('coach', options.coachId)
} else if (options?.sourcePage) {
// 看板类入口:保存来源页面和筛选参数
const filterKeys = ['timeDimension', 'areaFilter', 'dimension', 'typeFilter', 'projectFilter']
const pageFilters: Record<string, string> = {}
for (const key of filterKeys) {
if (options[key]) pageFilters[key] = options[key]
}
this.setData({ sourcePage: options.sourcePage, pageFilters })
this.loadMessagesByContext(options.sourcePage, '')
} else {
// 无参数入口:始终新建通用对话
this.loadMessagesByContext('general', '')
}
},
/** 通过 chatId 加载历史消息historyId 入口) */
async loadMessages(chatId: string) {
this.setData({ pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
try {
const res = await fetchChatMessages(chatId)
this.setData({ chatId: String(res.chatId) })
const messages = enrichMessages(res.items as ApiMessageItem[])
this._msgCounter = messages.length
this.setData({
pageState: messages.length === 0 ? 'empty' : 'normal',
messages,
})
this.scrollToBottom()
} catch {
this.setData({ pageState: 'error' })
} finally {
wx.hideLoading()
}
},
/** 通过上下文类型加载消息task/customer/coach/general 入口) */
async loadMessagesByContext(contextType: string, contextId: string) {
this.setData({ pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
try {
const res = await fetchChatMessagesByContext(contextType, contextId)
// 缓存后端返回的 chatId后续发送消息使用
this.setData({ chatId: String(res.chatId) })
const messages = enrichMessages(res.items as ApiMessageItem[])
this._msgCounter = messages.length
// customerId 入口时显示引用卡片
const referenceCard = contextType === 'customer' && contextId
? { title: '客户详情', summary: `正在查看客户 ${contextId} 的相关信息` }
: null
const isEmpty = messages.length === 0 && !referenceCard
this.setData({
pageState: isEmpty ? 'empty' : 'normal',
messages,
referenceCard,
})
this.scrollToBottom()
} catch {
this.setData({ pageState: 'error' })
} finally {
wx.hideLoading()
}
},
/** 返回上一页 */
onBack() {
// 中断进行中的 SSE 连接
if (this._sseTask) {
this._sseTask.abort()
this._sseTask = null
}
wx.navigateBack()
},
/** 页面卸载时清理 SSE 连接 */
onUnload() {
if (this._sseTask) {
this._sseTask.abort()
this._sseTask = null
}
},
/** 重试加载 */
onRetry() {
if (this.data.chatId) {
this.loadMessages(this.data.chatId)
} else {
this.loadMessagesByContext('general', '')
}
},
/** 软键盘弹出:输入栏上移至键盘顶部 */
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: DisplayMessage = {
id: `msg-user-${this._msgCounter}`,
role: 'user',
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()
// 保存用户消息内容,用于 SSE 发送和重试
this._lastUserContent = text
// CHANGE 2026-03-20 | T8.2: 使用真实 SSE 替代 mock
setTimeout(() => {
this.triggerAIReply()
}, 300)
},
// CHANGE 2026-03-20 | T8.2: 真实 SSE 流式连接替代 mock
/** 触发 AI 流式回复(通过 SSE 连接后端 /api/xcx/chat/stream */
triggerAIReply() {
const chatId = this.data.chatId
const content = this._lastUserContent
if (!chatId || !content) return
this._msgCounter++
const aiMsgId = `msg-ai-${this._msgCounter}`
const aiNow = new Date().toISOString()
const prevMsgs = this.data.messages
const prevTs = prevMsgs.length > 0 ? prevMsgs[prevMsgs.length - 1].timestamp : null
const aiMsg: DisplayMessage = {
id: aiMsgId,
role: 'assistant',
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
let fullContent = ''
const parser = new SSEParser(
// onMessage: 逐 token 追加
(token: string) => {
fullContent += token
const key = `messages[${aiIndex}].content`
this.setData({
[key]: fullContent,
streamingContent: fullContent,
})
this.scrollToBottom()
},
// onDone: 流结束,更新消息 ID 和时间
(messageId: number, createdAt: string) => {
this.setData({
[`messages[${aiIndex}].id`]: String(messageId),
[`messages[${aiIndex}].timestamp`]: createdAt,
[`messages[${aiIndex}].timeLabel`]: formatRelativeTime(createdAt),
[`messages[${aiIndex}].imTimeLabel`]: formatIMTime(createdAt),
isStreaming: false,
streamingContent: '',
})
this._sseTask = null
},
// onError: 显示错误
(message: string) => {
// 将错误信息作为 AI 回复内容展示
const errorContent = fullContent || message
this.setData({
[`messages[${aiIndex}].content`]: errorContent,
isStreaming: false,
streamingContent: '',
})
this._sseTask = null
wx.showToast({ title: message, icon: 'none', duration: 3000 })
},
)
// 构建认证 header
const token = getAccessToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
// 使用 wx.request + enableChunked 接收 SSE
// enableChunked 和 onChunkReceived 在基础库 2.20.2+ 可用,
// 当前 typings 未包含,使用 as any 绕过类型检查
const requestTask = wx.request({
url: `${API_BASE}/api/xcx/chat/stream`,
method: 'POST',
data: {
chatId: Number(chatId),
content,
...(this.data.sourcePage ? {
sourcePage: this.data.sourcePage,
pageContext: this.data.pageFilters,
} : {}),
},
header: headers,
enableChunked: true,
responseType: 'arraybuffer',
success: () => {
// enableChunked 模式下 success 在连接关闭时触发
// 如果此时仍在 streaming 状态,说明连接正常关闭但未收到 done 事件
if (this.data.isStreaming) {
this.setData({ isStreaming: false, streamingContent: '' })
this._sseTask = null
}
},
fail: () => {
// 网络错误或连接中断
if (this.data.isStreaming) {
const errorContent = fullContent || '连接中断,请重试'
this.setData({
[`messages[${aiIndex}].content`]: errorContent,
isStreaming: false,
streamingContent: '',
})
wx.showToast({ title: '连接中断', icon: 'none', duration: 3000 })
}
this._sseTask = null
},
} as WechatMiniprogram.RequestOption)
// 监听 chunked 数据SSE 事件流)
// onChunkReceived 在 typings 中未定义,使用 as any
;(requestTask as any).onChunkReceived((res: { data: ArrayBuffer }) => {
parser.feed(res.data)
})
this._sseTask = requestTask
},
/** 滚动到底部 */
scrollToBottom() {
setTimeout(() => {
this.setData({ scrollToId: '' })
setTimeout(() => {
this.setData({ scrollToId: 'scroll-bottom' })
}, 50)
}, 50)
},
})