包含多个会话的累积代码变更: - 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>
513 lines
16 KiB
TypeScript
513 lines
16 KiB
TypeScript
/* 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 {
|
||
// 微信基础库支持 TextDecoder(2.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)
|
||
},
|
||
})
|