chore: initialize qiming workspace repository
This commit is contained in:
59
qiming-mobile/utils/byteConverter.uts
Normal file
59
qiming-mobile/utils/byteConverter.uts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 将字节(Byte)转换为适当的单位(KB, MB, GB, TB)
|
||||
* @param bytes 字节数
|
||||
* @param decimals 小数点后位数
|
||||
* @returns 格式化后的字符串
|
||||
*/
|
||||
export function formatBytes(bytes: number, decimals = 2): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return (
|
||||
Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字节转换为千字节(KB)
|
||||
* @param bytes 字节数
|
||||
* @param decimals 小数点后位数
|
||||
* @returns 千字节数值
|
||||
*/
|
||||
export function bytesToKB(bytes: number, decimals = 2): number {
|
||||
return Number.parseFloat((bytes / 1024).toFixed(decimals));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字节转换为兆字节(MB)
|
||||
* @param bytes 字节数
|
||||
* @param decimals 小数点后位数
|
||||
* @returns 兆字节数值
|
||||
*/
|
||||
export function bytesToMB(bytes: number, decimals = 2): number {
|
||||
return Number.parseFloat((bytes / (1024 * 1024)).toFixed(decimals));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字节转换为千字节(KB)并格式化
|
||||
* @param bytes 字节数
|
||||
* @param decimals 小数点后位数
|
||||
* @returns 格式化后的千字节字符串
|
||||
*/
|
||||
export function formatBytesToKB(bytes: number, decimals = 2): string {
|
||||
return `${bytesToKB(bytes, decimals)} KB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字节转换为兆字节(MB)并格式化
|
||||
* @param bytes 字节数
|
||||
* @param decimals 小数点后位数
|
||||
* @returns 格式化后的兆字节字符串
|
||||
*/
|
||||
export function formatBytesToMB(bytes: number, decimals = 2): string {
|
||||
return `${bytesToMB(bytes, decimals)} MB`;
|
||||
}
|
||||
429
qiming-mobile/utils/chatDataAdapter.uts
Normal file
429
qiming-mobile/utils/chatDataAdapter.uts
Normal file
@@ -0,0 +1,429 @@
|
||||
import { MsgItem, markdownElItem } from '@/uni_modules/uni-ai-x/types'
|
||||
import { MessageInfo } from '@/types/interfaces/conversationInfo'
|
||||
import { AssistantRoleEnum, MessageModeEnum, MessageTypeEnum } from '@/types/enums/agent'
|
||||
import { MessageStatusEnum } from '@/types/enums/common'
|
||||
|
||||
/**
|
||||
* SSE数据块接口 - 匹配实际返回的数据结构
|
||||
*/
|
||||
export interface SSEChunk {
|
||||
requestId: string
|
||||
messageType?: string
|
||||
message_type?: string
|
||||
subType?: string
|
||||
sub_type?: string
|
||||
eventType: string
|
||||
error: string | null
|
||||
data: {
|
||||
id: string
|
||||
role: string
|
||||
type: string
|
||||
text: string
|
||||
time: string | null
|
||||
attachments: any[] | null
|
||||
think: string | null
|
||||
quotedText: string | null
|
||||
ext: any[] | null
|
||||
finished: boolean
|
||||
executeId: string | null
|
||||
messageType: string
|
||||
}
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天数据适配器
|
||||
* 用于将MessageInfo格式转换为uni-ai-x期望的MsgItem格式
|
||||
*/
|
||||
export class ChatDataAdapter {
|
||||
/**
|
||||
* 判断SSE数据块是否可以按聊天文本消息渲染
|
||||
*/
|
||||
static isRenderableSSEChunk(chunk: SSEChunk): boolean {
|
||||
const messageType = (chunk as any).messageType || (chunk as any).message_type
|
||||
if (messageType === 'acpRequestPermission') {
|
||||
return false
|
||||
}
|
||||
|
||||
const data = (chunk as any).data
|
||||
if (!data || typeof data !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
return typeof data.id === 'string' && data.id.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 将SSE数据块转换为MsgItem
|
||||
* @param chunk SSE数据块
|
||||
* @param chatId 聊天ID
|
||||
* @returns uni-ai-x的MsgItem格式
|
||||
*/
|
||||
static convertSSEChunkToMsgItem(chunk: SSEChunk, chatId: string): MsgItem {
|
||||
console.log(`Converting SSE chunk to MsgItem:`, chunk)
|
||||
const data = (chunk as any).data || {}
|
||||
|
||||
const msgItem: MsgItem = {
|
||||
_id: data.id || this.generateId(),
|
||||
from_uid: this.getFromUidFromRole(data.role || 'ASSISTANT'),
|
||||
body: data.text || '',
|
||||
create_time: this.parseTime(data.time) || Date.now(),
|
||||
state: data.finished ? 3 : 2, // 完成状态为3,未完成为2
|
||||
chat_id: chatId,
|
||||
markdownElList: [],
|
||||
rendered: false
|
||||
}
|
||||
|
||||
console.log(`Converted MsgItem:`, msgItem)
|
||||
return msgItem
|
||||
}
|
||||
|
||||
/**
|
||||
* 将MessageInfo转换为MsgItem
|
||||
* @param messageInfo 消息信息
|
||||
* @returns uni-ai-x的MsgItem格式
|
||||
*/
|
||||
static convertToMsgItem(messageInfo: MessageInfo): MsgItem {
|
||||
const msgItem: MsgItem = {
|
||||
_id: messageInfo.id?.toString() || '',
|
||||
from_uid: this.getFromUid(messageInfo.role),
|
||||
body: messageInfo.text || '',
|
||||
create_time: this.parseTime(messageInfo.time),
|
||||
state: this.getMsgState(messageInfo.status),
|
||||
chat_id: '', // 需要从外部传入
|
||||
markdownElList: (messageInfo as any).markdownElList || [],
|
||||
rendered: false,
|
||||
// 传递processingList用于自定义组件渲染
|
||||
processingList: messageInfo.processingList || []
|
||||
}
|
||||
|
||||
return msgItem
|
||||
}
|
||||
|
||||
/**
|
||||
* 将MsgItem转换为MessageInfo
|
||||
* @param msgItem uni-ai-x的MsgItem格式
|
||||
* @returns MessageInfo格式
|
||||
*/
|
||||
static convertToMessageInfo(msgItem: MsgItem): MessageInfo {
|
||||
const messageInfo: MessageInfo = {
|
||||
id: msgItem._id,
|
||||
role: this.getAssistantRole(msgItem.from_uid),
|
||||
type: this.getMessageMode(msgItem.from_uid),
|
||||
text: msgItem.body,
|
||||
think: (msgItem as any).thinkContent || '',
|
||||
time: new Date(msgItem.create_time).toISOString(),
|
||||
attachments: [],
|
||||
ext: [],
|
||||
finished: false,
|
||||
metadata: null,
|
||||
messageType: MessageTypeEnum.USER,
|
||||
status: this.getMessageStatus(msgItem.state),
|
||||
markdownElList: msgItem.markdownElList || [],
|
||||
processingList: [],
|
||||
finalResult: null,
|
||||
requestId: ''
|
||||
}
|
||||
|
||||
return messageInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量转换MessageInfo数组为MsgItem数组
|
||||
* @param messageList 消息列表
|
||||
* @param chatId 聊天ID
|
||||
* @returns MsgItem数组
|
||||
*/
|
||||
static convertMessageListToMsgItems(messageList: MessageInfo[], chatId: string = ''): MsgItem[] {
|
||||
return messageList.map(message => {
|
||||
const msgItem = this.convertToMsgItem(message)
|
||||
msgItem.chat_id = chatId
|
||||
return msgItem
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量转换MsgItem数组为MessageInfo数组
|
||||
* @param msgItems 消息项数组
|
||||
* @returns MessageInfo数组
|
||||
*/
|
||||
static convertMsgItemsToMessageList(msgItems: MsgItem[]): MessageInfo[] {
|
||||
return msgItems.map(msgItem => this.convertToMessageInfo(msgItem))
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理SSE流式数据,累积文本内容
|
||||
* @param chunks SSE数据块数组
|
||||
* @param chatId 聊天ID
|
||||
* @returns 处理后的MsgItem数组
|
||||
*/
|
||||
static processSSEChunks(chunks: SSEChunk[], chatId: string): MsgItem[] {
|
||||
const messageMap = new Map<string, MsgItem>()
|
||||
|
||||
chunks.forEach(chunk => {
|
||||
if (!this.isRenderableSSEChunk(chunk)) {
|
||||
return
|
||||
}
|
||||
|
||||
const messageId = chunk.data.id
|
||||
|
||||
if (!messageMap.has(messageId)) {
|
||||
// 创建新消息
|
||||
const msgItem = this.convertSSEChunkToMsgItem(chunk, chatId)
|
||||
messageMap.set(messageId, msgItem)
|
||||
} else {
|
||||
// 累积文本内容
|
||||
const existingMsg = messageMap.get(messageId)!
|
||||
existingMsg.body += chunk.data.text || ''
|
||||
|
||||
// 更新状态
|
||||
if (chunk.data.finished) {
|
||||
existingMsg.state = 3 // 完成状态
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 兼容性处理:将Map值转换为数组
|
||||
const result: MsgItem[] = []
|
||||
messageMap.forEach((value) => {
|
||||
result.push(value)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据角色获取from_uid
|
||||
* @param role 角色
|
||||
* @returns from_uid
|
||||
*/
|
||||
private static getFromUid(role: AssistantRoleEnum): string {
|
||||
switch (role) {
|
||||
case AssistantRoleEnum.USER:
|
||||
return 'user'
|
||||
case AssistantRoleEnum.ASSISTANT:
|
||||
return 'uni-ai'
|
||||
case AssistantRoleEnum.SYSTEM:
|
||||
return 'system'
|
||||
case AssistantRoleEnum.FUNCTION:
|
||||
return 'tool'
|
||||
default:
|
||||
return 'user'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据角色字符串获取from_uid
|
||||
* @param role 角色字符串
|
||||
* @returns from_uid
|
||||
*/
|
||||
private static getFromUidFromRole(role: string): string {
|
||||
switch (role) {
|
||||
case 'USER':
|
||||
return 'user'
|
||||
case 'ASSISTANT':
|
||||
return 'uni-ai'
|
||||
case 'SYSTEM':
|
||||
return 'system'
|
||||
case 'FUNCTION':
|
||||
return 'tool'
|
||||
default:
|
||||
return 'user'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据from_uid获取角色
|
||||
* @param fromUid from_uid
|
||||
* @returns 角色
|
||||
*/
|
||||
private static getAssistantRole(fromUid: string): AssistantRoleEnum {
|
||||
switch (fromUid) {
|
||||
case 'user':
|
||||
return AssistantRoleEnum.USER
|
||||
case 'uni-ai':
|
||||
return AssistantRoleEnum.ASSISTANT
|
||||
case 'system':
|
||||
return AssistantRoleEnum.SYSTEM
|
||||
case 'tool':
|
||||
return AssistantRoleEnum.FUNCTION
|
||||
default:
|
||||
return AssistantRoleEnum.USER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据from_uid获取消息模式
|
||||
* @param fromUid from_uid
|
||||
* @returns 消息模式
|
||||
*/
|
||||
private static getMessageMode(fromUid: string): MessageModeEnum {
|
||||
switch (fromUid) {
|
||||
case 'user':
|
||||
return MessageModeEnum.CHAT
|
||||
case 'uni-ai':
|
||||
return MessageModeEnum.ANSWER
|
||||
case 'system':
|
||||
return MessageModeEnum.GUID
|
||||
case 'tool':
|
||||
return MessageModeEnum.CHAT
|
||||
default:
|
||||
return MessageModeEnum.CHAT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据消息状态获取MsgItem状态
|
||||
* @param status 消息状态
|
||||
* @returns MsgItem状态
|
||||
*/
|
||||
private static getMsgState(status?: MessageStatusEnum | null): number {
|
||||
switch (status) {
|
||||
case MessageStatusEnum.Loading:
|
||||
return 1 // 加载中
|
||||
case MessageStatusEnum.Incomplete:
|
||||
return 2 // 未完成
|
||||
case MessageStatusEnum.Complete:
|
||||
return 3 // 完成
|
||||
case MessageStatusEnum.Error:
|
||||
return 4 // 错误
|
||||
default:
|
||||
return 3 // 默认完成
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据MsgItem状态获取消息状态
|
||||
* @param state MsgItem状态
|
||||
* @returns 消息状态
|
||||
*/
|
||||
private static getMessageStatus(state: number): MessageStatusEnum | null {
|
||||
switch (state) {
|
||||
case 1:
|
||||
return MessageStatusEnum.Loading
|
||||
case 2:
|
||||
return MessageStatusEnum.Incomplete
|
||||
case 3:
|
||||
return MessageStatusEnum.Complete
|
||||
case 4:
|
||||
return MessageStatusEnum.Error
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析时间字符串为时间戳
|
||||
* @param timeStr 时间字符串
|
||||
* @returns 时间戳
|
||||
*/
|
||||
private static parseTime(timeStr: string | null): number {
|
||||
if (!timeStr) return Date.now()
|
||||
|
||||
try {
|
||||
return new Date(timeStr).getTime()
|
||||
} catch (error) {
|
||||
return Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的用户消息
|
||||
* @param content 消息内容
|
||||
* @param chatId 聊天ID
|
||||
* @param attachments 附件
|
||||
* @returns 新的MsgItem
|
||||
*/
|
||||
static createUserMessage(content: string, chatId: string, attachments: any[] = []): MsgItem {
|
||||
return {
|
||||
_id: this.generateId(),
|
||||
from_uid: 'user',
|
||||
body: content,
|
||||
create_time: Date.now(),
|
||||
state: 3, // 完成状态
|
||||
chat_id: chatId,
|
||||
markdownElList: [],
|
||||
rendered: false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的AI消息
|
||||
* @param content 消息内容
|
||||
* @param chatId 聊天ID
|
||||
* @param markdownElements markdown元素
|
||||
* @returns 新的MsgItem
|
||||
*/
|
||||
static createAIMessage(content: string, chatId: string, markdownElements: markdownElItem[] = []): MsgItem {
|
||||
return {
|
||||
_id: this.generateId(),
|
||||
from_uid: 'uni-ai',
|
||||
body: content,
|
||||
create_time: Date.now(),
|
||||
state: 2, // 未完成状态
|
||||
chat_id: chatId,
|
||||
markdownElList: markdownElements,
|
||||
rendered: false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一ID
|
||||
* @returns 唯一ID
|
||||
*/
|
||||
private static generateId(): string {
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序中,uni.getRandomValues返回Promise,这里简化处理
|
||||
return Date.now().toString() + Math.random().toString(36).substr(2, 9)
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
// 这里需要导入uuid库
|
||||
return Date.now().toString() + Math.random().toString(36).substr(2, 9)
|
||||
// #endif
|
||||
|
||||
// 默认返回值
|
||||
return Date.now().toString() + Math.random().toString(36).substr(2, 9)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出适配器实例
|
||||
export const chatDataAdapter = new ChatDataAdapter()
|
||||
|
||||
/*
|
||||
使用示例:
|
||||
|
||||
// 1. 在聊天页面中导入适配器
|
||||
import { chatDataAdapter, SSEChunk } from '@/utils/chatDataAdapter'
|
||||
|
||||
// 2. 处理SSE流式数据
|
||||
const handleSSEChunk = (chunk: SSEChunk) => {
|
||||
// 解析SSE数据
|
||||
const sseData = JSON.parse(chunk)
|
||||
|
||||
// 累积数据块
|
||||
sseChunks.value.push(sseData)
|
||||
|
||||
// 处理并转换为MsgItem
|
||||
const msgItems = chatDataAdapter.processSSEChunks(sseChunks.value, 'chat-' + conversationId.value)
|
||||
|
||||
// 更新渲染列表
|
||||
msgItemsForRender.value = msgItems
|
||||
}
|
||||
|
||||
// 3. 在模板中使用
|
||||
<template v-for="item in msgItemsForRender" :key="item._id">
|
||||
<uni-ai-x-msg v-if="item.from_uid == 'uni-ai'" :msg="item" />
|
||||
<text v-else class="user-msg">{{item.body}}</text>
|
||||
</template>
|
||||
|
||||
// 4. 处理流式响应
|
||||
const handleStreamResponse = (chunk: string) => {
|
||||
try {
|
||||
const sseChunk: SSEChunk = JSON.parse(chunk)
|
||||
handleSSEChunk(sseChunk)
|
||||
} catch (error) {
|
||||
console.error('解析SSE数据失败:', error)
|
||||
}
|
||||
}
|
||||
*/
|
||||
145
qiming-mobile/utils/chatService.uts
Normal file
145
qiming-mobile/utils/chatService.uts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { ChatMessage, StreamChunk } from '@/types/interfaces/chat'
|
||||
import { StreamRequest } from '@/utils/streamRequest'
|
||||
import { CHAT_API, API_HEADERS } from '@/constants/api.constants'
|
||||
import { ACCESS_TOKEN } from '@/constants/home.constants'
|
||||
|
||||
// 聊天服务类
|
||||
export class ChatService {
|
||||
private currentRequest: any = null
|
||||
private isStreaming: boolean = false
|
||||
|
||||
// 发送消息并获取流式响应
|
||||
async sendMessage(
|
||||
data: any,
|
||||
onChunk: (chunk: string) => void,
|
||||
onComplete: () => void,
|
||||
onError: (error: any) => void,
|
||||
// 是否是临时会话
|
||||
isTempChat?: boolean = false,
|
||||
// 超时回调(微信小程序专用,60秒内无数据流动时触发)
|
||||
onTimeout?: () => void
|
||||
): Promise<void> {
|
||||
// if (this.isStreaming) {
|
||||
// onError(new Error('已有请求正在进行中'))
|
||||
// return
|
||||
// }
|
||||
|
||||
this.isStreaming = true
|
||||
|
||||
try {
|
||||
// 使用配置的API端点
|
||||
const apiUrl = isTempChat ? CHAT_API.TEMP_STREAM : CHAT_API.STREAM
|
||||
|
||||
const token = uni.getStorageSync(ACCESS_TOKEN)
|
||||
this.currentRequest = new StreamRequest()
|
||||
|
||||
const header = {
|
||||
...API_HEADERS,
|
||||
}
|
||||
|
||||
// #ifdef H5 || WEB
|
||||
if(process.env.NODE_ENV === 'development'){
|
||||
header['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
header['Authorization'] = `Bearer ${token}`
|
||||
// #endif
|
||||
|
||||
await this.currentRequest.request({
|
||||
url: apiUrl,
|
||||
method: 'POST',
|
||||
headers: header,
|
||||
body: {
|
||||
...data,
|
||||
// conversationId: 1442734, //TODO 调试 先写死 后面一定删除
|
||||
stream: true
|
||||
},
|
||||
onChunk: (chunk: StreamChunk) => {
|
||||
if (chunk.type === 'content') {
|
||||
onChunk(chunk.data)
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
this.isStreaming = false
|
||||
onError(error)
|
||||
},
|
||||
onComplete: () => {
|
||||
this.isStreaming = false
|
||||
onComplete()
|
||||
},
|
||||
onTimeout: () => {
|
||||
this.isStreaming = false
|
||||
if (onTimeout) {
|
||||
onTimeout()
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
this.isStreaming = false
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 中止当前请求
|
||||
abort(): void {
|
||||
if (this.currentRequest) {
|
||||
this.currentRequest.abort()
|
||||
this.currentRequest = null
|
||||
}
|
||||
this.isStreaming = false
|
||||
}
|
||||
|
||||
// 创建用户消息
|
||||
createUserMessage(content: string): ChatMessage {
|
||||
return {
|
||||
id: this.generateId(),
|
||||
role: 'user',
|
||||
content: content,
|
||||
timestamp: Date.now(),
|
||||
status: 'sent'
|
||||
}
|
||||
}
|
||||
|
||||
// 创建AI消息
|
||||
createAIMessage(content: string = ''): ChatMessage {
|
||||
return {
|
||||
id: this.generateId(),
|
||||
role: 'assistant',
|
||||
content: content,
|
||||
timestamp: Date.now(),
|
||||
status: 'processing'
|
||||
}
|
||||
}
|
||||
|
||||
// 更新AI消息内容
|
||||
updateAIMessage(message: ChatMessage, content: string): void {
|
||||
message.content = content
|
||||
}
|
||||
|
||||
// 完成AI消息
|
||||
completeAIMessage(message: ChatMessage): void {
|
||||
message.status = 'sent'
|
||||
message.rendered = true
|
||||
}
|
||||
|
||||
// 设置消息错误
|
||||
setMessageError(message: ChatMessage, error: string): void {
|
||||
message.status = 'error'
|
||||
message.error = error
|
||||
}
|
||||
|
||||
// 生成唯一ID
|
||||
private generateId(): string {
|
||||
return 'msg_' + Date.now().toString(36) + Math.random().toString(36).substr(2)
|
||||
}
|
||||
|
||||
// 检查是否正在流式传输
|
||||
get isCurrentlyStreaming(): boolean {
|
||||
return this.isStreaming
|
||||
}
|
||||
}
|
||||
|
||||
// 创建聊天服务实例
|
||||
export const chatService = new ChatService()
|
||||
544
qiming-mobile/utils/common.uts
Normal file
544
qiming-mobile/utils/common.uts
Normal file
@@ -0,0 +1,544 @@
|
||||
import { t } from "@/utils/i18n";
|
||||
|
||||
// 过滤非数字
|
||||
const getNumbersOnly = (text: string) => {
|
||||
return text?.replace(/[^0-9]/g, "");
|
||||
};
|
||||
|
||||
// 判断字符串是否全是数字
|
||||
const isNumber = (value: string) => {
|
||||
return !Number.isNaN(Number(value));
|
||||
};
|
||||
|
||||
const isWeakNumber = (value: any) => {
|
||||
if (typeof value === "number") {
|
||||
return true;
|
||||
}
|
||||
if (value && typeof value === "string") {
|
||||
return isNumber(value);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 校验手机号是否合法
|
||||
function isValidPhone(phone: string) {
|
||||
const reg = /^1[3456789]\d{9}$/;
|
||||
return reg.test(phone);
|
||||
}
|
||||
|
||||
// 校验邮箱地址是否合法
|
||||
function isValidEmail(email: string) {
|
||||
const reg = /^[\w-]+(\.[\w-]+)*@([\w-]+\.)+[a-zA-Z]{2,}$/;
|
||||
return reg.test(email);
|
||||
}
|
||||
|
||||
// 校验数据库表名是否合法
|
||||
// 1. 表名必须以字母开头,后面可以跟字母、数字或下划线。
|
||||
function validateTableName(tableName: string) {
|
||||
const reg = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
return reg.test(tableName);
|
||||
}
|
||||
|
||||
// 检测字符串是否为有效的JSON格式
|
||||
function isValidJSON(str: string): boolean {
|
||||
if (!str || typeof str !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 去除首尾空白字符
|
||||
const trimmedStr = str.trim();
|
||||
|
||||
// 检查基本结构:对象必须以 { 开头, 以} 结尾;
|
||||
if (!/^{/.test(trimmedStr) || !/}$/.test(trimmedStr)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(trimmedStr);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 检测字符串是否为有效的JSON格式,并返回解析结果
|
||||
function parseJSON<T = any>(
|
||||
str: string,
|
||||
): { isValid: boolean; data?: T; error?: string } {
|
||||
if (!str || typeof str !== "string") {
|
||||
return { isValid: false, error: t("Mobile.Common.invalidInputString") };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(str) as T;
|
||||
return { isValid: true, data };
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("Mobile.Common.invalidJsonFormat"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 校验登录密码
|
||||
function validatePassword(password: string) {
|
||||
return password?.length >= 6;
|
||||
// const reg = /^(?=.*\d)(?=.*[a-zA-Z])[\da-zA-Z~!@#$%^&*]{8,16}$/;
|
||||
// return reg.test(password);
|
||||
}
|
||||
|
||||
// 获取url地址search中的参数
|
||||
const getURLParams = () => {
|
||||
// #ifdef H5
|
||||
const searchParams = window.location.search.substring(1).split("&");
|
||||
// 为了解决元素隐式具有 "any" 类型的问题,将 params 的类型定义为 Record<string, string>
|
||||
// 这样就可以使用 string 类型的 key 来索引 params 对象
|
||||
const params: Record<string, string> = {};
|
||||
for (const param of searchParams) {
|
||||
const [key, value] = param.split("=");
|
||||
params[key] = value;
|
||||
}
|
||||
return params;
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
// 非H5平台从页面参数中获取
|
||||
const pages = getCurrentPages();
|
||||
const currentPage = pages[pages.length - 1];
|
||||
const params: Record<string, string> = {};
|
||||
if (currentPage?.options) {
|
||||
for (const key in currentPage.options) {
|
||||
params[key] = currentPage.options[key] || "";
|
||||
}
|
||||
}
|
||||
return params;
|
||||
// #endif
|
||||
};
|
||||
|
||||
// 给页面head添加base标签: <base target="_blank">
|
||||
const addBaseTarget = () => {
|
||||
// #ifdef H5
|
||||
if (!document.head.querySelector("base")) {
|
||||
const base = document.createElement("base");
|
||||
base.target = "_blank";
|
||||
document.head.append(base);
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
// 非H5平台无需处理
|
||||
// #endif
|
||||
};
|
||||
|
||||
// 判断对象是否为空
|
||||
const isEmptyObject = (obj: Record<string, any>) => {
|
||||
return obj && typeof obj === "object" && Object.keys(obj).length === 0;
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
function formatTimeAgo(targetTime: string) {
|
||||
if (!targetTime) {
|
||||
return "";
|
||||
}
|
||||
const now = new Date().getTime(); // 当前时间戳,单位为毫秒
|
||||
const target = new Date(targetTime).getTime();
|
||||
|
||||
const diff = now - target; // 时间差(毫秒)
|
||||
const diffSeconds = Math.floor(diff / 1000); // 转换为秒
|
||||
const diffMinutes = Math.floor(diffSeconds / 60); // 转换为分钟
|
||||
const diffHours = Math.floor(diffMinutes / 60); // 转换为小时
|
||||
const diffDays = Math.floor(diffHours / 24); // 转换为天
|
||||
|
||||
if (diffDays > 365 * 2) {
|
||||
return t("Mobile.Time.yearsAgo", {
|
||||
count: Math.floor(diffDays / 365),
|
||||
});
|
||||
} else if (diffDays > 365) {
|
||||
return t("Mobile.Time.lastYear");
|
||||
} else if (diffDays > 30) {
|
||||
const currentDate = new Date();
|
||||
const inputDate = new Date(targetTime);
|
||||
// 计算月份差
|
||||
let monthsDifference =
|
||||
(currentDate.getFullYear() - inputDate.getFullYear()) * 12;
|
||||
monthsDifference += currentDate.getMonth() - inputDate.getMonth();
|
||||
|
||||
// 如果当前日期的日小于输入日期的日,月份差减 1
|
||||
if (currentDate.getDate() < inputDate.getDate()) {
|
||||
monthsDifference--;
|
||||
}
|
||||
|
||||
if (monthsDifference >= 1) {
|
||||
return t("Mobile.Time.monthsAgo", {
|
||||
count: monthsDifference,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
} else if (diffDays > 6) {
|
||||
return t("Mobile.Time.daysAgo", {
|
||||
count: diffDays,
|
||||
});
|
||||
} else if (diffDays > 2) {
|
||||
let date = new Date(targetTime);
|
||||
let month = date.getMonth() + 1;
|
||||
let day = date.getDate() < 10 ? `0${date.getDate()}` : date.getDate();
|
||||
return `${month}-${day}`;
|
||||
} else if (diffDays === 2) {
|
||||
return t("Mobile.Time.dayBeforeYesterday");
|
||||
} else if (diffDays === 1) {
|
||||
return t("Mobile.Time.yesterday");
|
||||
} else if (diffHours > 1) {
|
||||
return t("Mobile.Time.hoursAgo", {
|
||||
count: diffHours,
|
||||
});
|
||||
} else if (diffMinutes > 0) {
|
||||
return t("Mobile.Time.minutesAgo", {
|
||||
count: diffMinutes,
|
||||
});
|
||||
} else {
|
||||
return t("Mobile.Time.justNow");
|
||||
}
|
||||
}
|
||||
|
||||
// html自定义转义
|
||||
function encodeHTML(str: string) {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// 检查第一个数组每个元素是否都存在于第二个数组中。
|
||||
const arraysContainSameItems = (arr1: string[], arr2: string[]) => {
|
||||
// 使用 Set 去重
|
||||
const set1 = new Set(arr1);
|
||||
const set2 = new Set(arr2);
|
||||
|
||||
// 检查 set1 中的每个元素是否都在 set2 中
|
||||
for (let item of set1) {
|
||||
if (!set2.has(item)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 向上查找元素
|
||||
const findParentElement = (element: HTMLElement, className: string) => {
|
||||
// #ifdef H5
|
||||
let currentElement = element;
|
||||
while (currentElement.parentElement) {
|
||||
if (currentElement.parentElement.classList.contains(className)) {
|
||||
return currentElement.parentElement;
|
||||
}
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
return null;
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
// 非H5平台不支持DOM操作,返回null
|
||||
return null;
|
||||
// #endif
|
||||
};
|
||||
|
||||
const findClassElement = (currentElement: HTMLElement, className: string) => {
|
||||
// #ifdef H5
|
||||
if (currentElement.classList.contains(className)) {
|
||||
return currentElement;
|
||||
}
|
||||
return findParentElement(currentElement, className);
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
// 非H5平台不支持DOM操作,返回null
|
||||
return null;
|
||||
// #endif
|
||||
};
|
||||
const noop = () => {};
|
||||
|
||||
/**
|
||||
* 生成唯一ID(同步方法,适用于所有平台)
|
||||
* 格式:时间戳(13位) + 随机数(7位) = 20位字符串
|
||||
* @returns 唯一ID字符串
|
||||
*/
|
||||
function generateUniqueId(): string {
|
||||
// 时间戳(毫秒)
|
||||
const timestamp = Date.now().toString(36);
|
||||
|
||||
// 随机数部分 - 使用多个随机源提高唯一性
|
||||
const random1 = Math.random().toString(36).substring(2, 9);
|
||||
const random2 = Math.random().toString(36).substring(2, 5);
|
||||
|
||||
// 组合成唯一ID
|
||||
return `${timestamp}${random1}${random2}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成类似 UUID 格式的ID(同步方法)
|
||||
* 格式:xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
* @returns UUID格式的字符串
|
||||
*/
|
||||
function generateUUID(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = () => Math.floor(Math.random() * 16).toString(16);
|
||||
|
||||
// 使用时间戳和随机数组合
|
||||
let uuid = "";
|
||||
for (let i = 0; i < 32; i++) {
|
||||
if (i === 8 || i === 12 || i === 16 || i === 20) {
|
||||
uuid += "-";
|
||||
}
|
||||
if (i === 12) {
|
||||
uuid += "4"; // UUID version 4
|
||||
} else if (i === 16) {
|
||||
uuid += ((Math.random() * 4) | 8).toString(16); // UUID variant
|
||||
} else {
|
||||
// 混合时间戳和随机数
|
||||
const useTime = i < 8;
|
||||
uuid += useTime
|
||||
? ((timestamp >> ((7 - i) * 4)) & 0xf).toString(16)
|
||||
: random();
|
||||
}
|
||||
}
|
||||
return uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换路径模板中的动态变量
|
||||
* @param template - 路径模板,例如 /view/detail/{id}
|
||||
* @param params - 参数对象,例如 { id: 123 }
|
||||
* @returns 替换后的路径,例如 /view/detail/123
|
||||
*/
|
||||
const fillPathParams = (
|
||||
template: string,
|
||||
params: Record<string, string | number>,
|
||||
): string => {
|
||||
return template.replace(/{(\w+)}/g, (_, key) => {
|
||||
if (params[key] === undefined) {
|
||||
throw new Error(
|
||||
t("Mobile.Common.missingPathParam", {
|
||||
key,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return String(params[key]);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查路径模板中的变量是否在 data 中存在且值有效
|
||||
* @param template 路径模板,例如 /user/{id}/{name}
|
||||
* @param data 参数对象,例如 { id: 1, name: 'Tom' }
|
||||
* @returns 是否全部存在且有效
|
||||
*/
|
||||
const checkPathParams = (
|
||||
template: string,
|
||||
data: Record<string, any>,
|
||||
): boolean => {
|
||||
const keys = [...template.matchAll(/{(\w+)}/g)].map((m) => m[1]);
|
||||
return (
|
||||
keys.length === 0 ||
|
||||
keys.every(
|
||||
(k) => data[k] !== undefined && data[k] !== null && data[k] !== "",
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 将对象转换为 URL 查询字符串
|
||||
* 用于替代 URLSearchParams(微信小程序不支持)
|
||||
* @param params 参数对象
|
||||
* @returns URL 查询字符串,例如 "key1=value1&key2=value2"
|
||||
*/
|
||||
const objectToQueryString = (
|
||||
params: Record<string, any> | null | undefined,
|
||||
): string => {
|
||||
if (!params || typeof params !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const queryParts: string[] = [];
|
||||
|
||||
for (const key in params) {
|
||||
if (params.hasOwnProperty(key)) {
|
||||
const value = params[key];
|
||||
|
||||
// 跳过 undefined 和 null 值
|
||||
if (value === undefined || value === null || value === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 对键和值进行 URL 编码
|
||||
const encodedKey = encodeURIComponent(key);
|
||||
const encodedValue = encodeURIComponent(String(value));
|
||||
|
||||
queryParts.push(`${encodedKey}=${encodedValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
return queryParts.join("&");
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 URL 中移除指定 query 参数(支持多个、支持 hash、支持不存在的参数)
|
||||
* @param url
|
||||
* @param keys
|
||||
* @returns
|
||||
*/
|
||||
const removeQueryCompat = (url: string, keys: string | string[]): string => {
|
||||
const arr = Array.isArray(keys) ? keys : [keys];
|
||||
|
||||
const [base, hash = ""] = url.split("#");
|
||||
const [path, query = ""] = base.split("?");
|
||||
|
||||
if (!query) return url; // 没 query 无需处理
|
||||
|
||||
const params = query.split("&").filter((item) => {
|
||||
const [key] = item.split("=");
|
||||
return !arr.includes(key);
|
||||
});
|
||||
|
||||
const newUrl = params.length ? `${path}?${params.join("&")}` : path;
|
||||
|
||||
return hash ? `${newUrl}#${hash}` : newUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录后跳转页面,如果redirectUrl存在,则跳转到redirectUrl,否则跳转到/pages/index/index
|
||||
* @param redirectUrl
|
||||
* @returns
|
||||
*/
|
||||
const redirectTo = (redirectUrl: string) => {
|
||||
if (!redirectUrl) {
|
||||
uni.reLaunch({ url: "/pages/index/index" });
|
||||
return;
|
||||
}
|
||||
|
||||
// #ifdef H5 || WEB
|
||||
location.replace(redirectUrl);
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 先解码URL,避免路径被错误拼接(如 %2Fpages%2Findex 会被拼接到当前路径后)
|
||||
const decodedUrl = decodeURIComponent(redirectUrl);
|
||||
uni.reLaunch({ url: decodedUrl });
|
||||
// 清空全局变量
|
||||
globalThis.appConfig.redirectUrl = null;
|
||||
// #endif
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前页面的路径
|
||||
*/
|
||||
const getCurrentPagePath = () => {
|
||||
// #ifdef H5 || WEB
|
||||
return window.location.href;
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
return getCurrentPages()[getCurrentPages().length - 1]?.route;
|
||||
// #endif
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前页面的参数
|
||||
*/
|
||||
const getCurrentPageParams = () => {
|
||||
// #ifdef H5 || WEB
|
||||
// 从 hash 中提取查询参数(适配 hash 路由模式)
|
||||
const hash = window.location.hash;
|
||||
const questionMarkIndex = hash.indexOf("?");
|
||||
|
||||
if (questionMarkIndex === -1) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const queryString = hash.substring(questionMarkIndex + 1);
|
||||
const params: Record<string, any> = {};
|
||||
|
||||
queryString.split("&").forEach((param) => {
|
||||
const [key, value] = param.split("=");
|
||||
if (key) {
|
||||
params[key] = value ? decodeURIComponent(value) : "";
|
||||
}
|
||||
});
|
||||
|
||||
return params;
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
return getCurrentPages()[getCurrentPages().length - 1]?.options;
|
||||
// #endif
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前页面的完整路径
|
||||
* @param fullPath 是否包含参数
|
||||
* @returns 当前页面的完整路径
|
||||
*/
|
||||
const getCurrentPageFullPath = (fullPath: boolean = true) => {
|
||||
const path = getCurrentPagePath();
|
||||
const params = objectToQueryString(getCurrentPageParams());
|
||||
return fullPath && params ? `${path}?${params}` : path;
|
||||
};
|
||||
|
||||
/**
|
||||
* 跳转至上一页,如果页面栈中有多个页面,正常返回,如果页面栈只有一个页面(浏览器刷新后),使用浏览器历史记录返回 如果没有历史记录,跳转到首页
|
||||
*/
|
||||
const jumpNavigateBack = () => {
|
||||
// 获取当前页面栈
|
||||
const pages = getCurrentPages();
|
||||
// 如果页面栈中有多个页面,正常返回
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
});
|
||||
} else {
|
||||
// 如果页面栈只有一个页面(浏览器刷新后),使用浏览器历史记录返回 如果没有历史记录,跳转到首页
|
||||
uni.switchTab({
|
||||
url: "/pages/index/index",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 判断 url 地址是否是 http 或者 https 开头
|
||||
const isHttpUrl = (url: string) => {
|
||||
return url.startsWith("http://") || url.startsWith("https://");
|
||||
};
|
||||
|
||||
export {
|
||||
addBaseTarget,
|
||||
arraysContainSameItems,
|
||||
encodeHTML,
|
||||
findClassElement,
|
||||
findParentElement,
|
||||
formatTimeAgo,
|
||||
generateUniqueId,
|
||||
generateUUID,
|
||||
getNumbersOnly,
|
||||
getURLParams,
|
||||
isEmptyObject,
|
||||
isNumber,
|
||||
isValidEmail,
|
||||
isValidJSON,
|
||||
isValidPhone,
|
||||
isWeakNumber,
|
||||
noop,
|
||||
parseJSON,
|
||||
validatePassword,
|
||||
validateTableName,
|
||||
fillPathParams,
|
||||
checkPathParams,
|
||||
objectToQueryString,
|
||||
removeQueryCompat,
|
||||
redirectTo,
|
||||
getCurrentPagePath,
|
||||
getCurrentPageParams,
|
||||
getCurrentPageFullPath,
|
||||
jumpNavigateBack,
|
||||
isHttpUrl,
|
||||
};
|
||||
124
qiming-mobile/utils/commonBusiness.uts
Normal file
124
qiming-mobile/utils/commonBusiness.uts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { apiPublishedAgentInfo } from "@/servers/agentDev";
|
||||
import { HideChatAreaEnum, ExpandPageAreaEnum } from "@/types/enums/agent";
|
||||
import { API_BASE_URL } from "@/constants/config";
|
||||
import { SUCCESS_CODE } from "@/constants/codes.constants";
|
||||
import { apiUserTicketCreate } from "@/servers/agentDev";
|
||||
import { getCurrentPageFullPath } from "@/utils/common";
|
||||
import { t } from "@/utils/i18n";
|
||||
|
||||
/**
|
||||
* 获取ticket
|
||||
*/
|
||||
export const getTicket = async (): Promise<string> => {
|
||||
const ticketResult = await apiUserTicketCreate();
|
||||
let _ticket: string = "";
|
||||
if (ticketResult.code === SUCCESS_CODE && ticketResult.data) {
|
||||
_ticket = ticketResult.data;
|
||||
}
|
||||
return _ticket;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断是否是根页面或id页面
|
||||
* @param str
|
||||
* @returns
|
||||
*/
|
||||
export const isRootOrId = (str: string) => {
|
||||
return str === "/" || /^\/\?id=\d+$/.test(str);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前页面地址不包含url参数 H5 || WEB
|
||||
*/
|
||||
export const getCurrentPagePath = () => {
|
||||
// #ifdef H5 || WEB
|
||||
const currentUrl = getCurrentPageFullPath()?.split("#")?.[1] || "";
|
||||
return currentUrl === "/" ? "/pages/index/index" : currentUrl;
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
return "/" + getCurrentPageFullPath();
|
||||
// #endif
|
||||
};
|
||||
|
||||
/**
|
||||
* 将ticket添加到url并跳转至webview页面
|
||||
* @param url
|
||||
* @param jump_type
|
||||
* @returns
|
||||
*/
|
||||
export const addTicketAndJumpToWebview = async (
|
||||
url: string,
|
||||
jump_type: "inner" | "outer" = "inner",
|
||||
title: string = "",
|
||||
): Promise<void> => {
|
||||
const resolvedTitle = title || t("Mobile.Common.appName");
|
||||
const andStr = url.includes("?") ? "&" : "?";
|
||||
const baseWebviewUrl = `${API_BASE_URL}${url}${andStr}title=${resolvedTitle}`;
|
||||
let webviewUrl = "";
|
||||
// #ifdef H5 || WEB
|
||||
// 当前页面地址
|
||||
const backUrl = getCurrentPageFullPath()?.split("#")?.[1] || "";
|
||||
// 保存需要返回的地址在storage中
|
||||
uni.setStorageSync("backUrl", backUrl);
|
||||
webviewUrl = encodeURIComponent(baseWebviewUrl);
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
const _ticket = await getTicket();
|
||||
webviewUrl = encodeURIComponent(
|
||||
baseWebviewUrl + `&_ticket=${_ticket}&jump_type=${jump_type}`,
|
||||
);
|
||||
// #endif
|
||||
|
||||
uni.navigateTo({
|
||||
url: `/subpackages/pages/webview/webview?url=${webviewUrl}`,
|
||||
});
|
||||
};
|
||||
|
||||
// 跳转至智能体详情页面
|
||||
export const jumpToAgentDetailPage = async (
|
||||
agentId: number,
|
||||
conversationId: number = null,
|
||||
agentType: "ChatBot" | "PageApp" | "TaskAgent" = "ChatBot",
|
||||
title: string = "",
|
||||
) => {
|
||||
const resolvedTitle = title || t("Mobile.Common.appName");
|
||||
let url = `/subpackages/pages/agent-detail/agent-detail?id=${agentId}`;
|
||||
if (conversationId) {
|
||||
url = url + "&conversationId=" + conversationId;
|
||||
}
|
||||
|
||||
// 问答型智能体、任务型智能体直接跳转至智能体详情页面
|
||||
if (agentType === "ChatBot" || agentType === "TaskAgent") {
|
||||
uni.navigateTo({ url });
|
||||
return;
|
||||
}
|
||||
|
||||
if (agentType === "PageApp") {
|
||||
try {
|
||||
uni.showLoading({ title: t("Mobile.Page.loading") });
|
||||
const { code, data, message } = await apiPublishedAgentInfo(agentId);
|
||||
if (code === SUCCESS_CODE) {
|
||||
await addTicketAndJumpToWebview(
|
||||
data.pageHomeIndex,
|
||||
"inner",
|
||||
resolvedTitle,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
uni.hideLoading();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 判断当前页面是否是tab页面
|
||||
export const isTabPage = (url: string) => {
|
||||
const tabPageList = [
|
||||
"/pages/index/index", // 首页
|
||||
"/pages/agent-union-record/agent-union-record", // 智联录
|
||||
"/pages/agent-list/agent-list", // 智能体
|
||||
"/pages/page-app/page-app", // 应用
|
||||
];
|
||||
return tabPageList.includes(url);
|
||||
};
|
||||
15
qiming-mobile/utils/console.uts
Normal file
15
qiming-mobile/utils/console.uts
Normal file
@@ -0,0 +1,15 @@
|
||||
let vConsole: any = null;
|
||||
|
||||
export function initConsole() {
|
||||
// #ifdef H5
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
// 使用动态导入以避免在正式打包时进行静态依赖扫描
|
||||
import("vconsole").then((module) => {
|
||||
const VConsole = module.default;
|
||||
vConsole = new VConsole();
|
||||
}).catch(err => {
|
||||
console.error('vConsole 加载失败', err);
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
350
qiming-mobile/utils/customActionService.uts
Normal file
350
qiming-mobile/utils/customActionService.uts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 统一的自定义操作服务
|
||||
* 管理所有自定义元素的交互事件:打开预览、远程桌面、刷新文件树、打开页面等
|
||||
*
|
||||
* 设计参考 PC端: src/models/conversationInfo.ts
|
||||
* - openPreviewView: 打开预览视图,设置viewMode为'preview',显示文件树,刷新文件列表
|
||||
* - openDesktopView: 打开远程桌面视图,设置viewMode为'desktop',启动容器,启动保活
|
||||
* - handleRefreshFileList: 刷新文件列表,调用apiGetStaticFileList
|
||||
*
|
||||
* 详细接入文档请参考: integration_guide.md
|
||||
*/
|
||||
|
||||
import { API_BASE_URL } from '@/constants/config'
|
||||
|
||||
/**
|
||||
* 事件类型枚举(对应PC端 EVENT_TYPE)
|
||||
*/
|
||||
export enum CustomEventType {
|
||||
/** 刷新文件列表 */
|
||||
REFRESH_FILE_LIST = 'custom_action_refresh_file_list',
|
||||
/** 打开预览视图 */
|
||||
OPEN_PREVIEW = 'custom_action_open_preview',
|
||||
/** 打开远程桌面 */
|
||||
OPEN_DESKTOP = 'custom_action_open_desktop',
|
||||
/** 打开扩展页面 */
|
||||
OPEN_PAGE = 'custom_action_open_page',
|
||||
/** 打开外部链接 */
|
||||
OPEN_LINK = 'custom_action_open_link',
|
||||
}
|
||||
|
||||
/**
|
||||
* 视图模式枚举(对应PC端 viewMode)
|
||||
*/
|
||||
export enum ViewModeEnum {
|
||||
/** 预览模式 */
|
||||
PREVIEW = 'preview',
|
||||
/** 远程桌面模式 */
|
||||
DESKTOP = 'desktop',
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作类型枚举
|
||||
*/
|
||||
export enum CustomActionType {
|
||||
/** 打开预览视图(文件预览模式) */
|
||||
OPEN_PREVIEW = 'OPEN_PREVIEW',
|
||||
/** 打开远程桌面视图 */
|
||||
OPEN_DESKTOP = 'OPEN_DESKTOP',
|
||||
/** 刷新文件树 */
|
||||
REFRESH_FILE_LIST = 'REFRESH_FILE_LIST',
|
||||
/** 打开扩展页面(iframe/webview) */
|
||||
OPEN_PAGE = 'OPEN_PAGE',
|
||||
/** 打开外部链接 */
|
||||
OPEN_LINK = 'OPEN_LINK',
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作参数接口
|
||||
*/
|
||||
export interface CustomActionParams {
|
||||
type: CustomActionType
|
||||
conversationId?: string
|
||||
uri?: string
|
||||
params?: Record<string, any>
|
||||
method?: string
|
||||
dataType?: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面预览选项
|
||||
*/
|
||||
export interface OpenPageOptions {
|
||||
uri: string
|
||||
params?: Record<string, any>
|
||||
method?: string
|
||||
dataType?: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开预览事件数据(对应PC端 openPreviewView 逻辑)
|
||||
*/
|
||||
export interface OpenPreviewEventData {
|
||||
conversationId: string
|
||||
viewMode: ViewModeEnum
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开远程桌面事件数据(对应PC端 openDesktopView 逻辑)
|
||||
*/
|
||||
export interface OpenDesktopEventData {
|
||||
conversationId: string
|
||||
viewMode: ViewModeEnum
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新文件列表事件数据(对应PC端 handleRefreshFileList 逻辑)
|
||||
*/
|
||||
export interface RefreshFileListEventData {
|
||||
conversationId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开页面事件数据
|
||||
*/
|
||||
export interface OpenPageEventData {
|
||||
uri: string
|
||||
method: string
|
||||
dataType: string
|
||||
requestId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的自定义操作服务类
|
||||
*
|
||||
* 使用示例:
|
||||
* - CustomActionService.openPreviewView(conversationId) // 打开预览视图
|
||||
* - CustomActionService.openDesktopView(conversationId) // 打开远程桌面
|
||||
* - CustomActionService.refreshFileList(conversationId) // 刷新文件树
|
||||
* - CustomActionService.openPage({ uri: '/page/index', params: { id: 1 } }) // 打开扩展页面
|
||||
* - CustomActionService.openLink('https://example.com') // 打开外部链接
|
||||
*
|
||||
* 事件监听示例:
|
||||
* uni.$on(CustomEventType.OPEN_PREVIEW, (data: OpenPreviewEventData) => { ... })
|
||||
* uni.$on(CustomEventType.REFRESH_FILE_LIST, (data: RefreshFileListEventData) => { ... })
|
||||
*/
|
||||
export class CustomActionService {
|
||||
/**
|
||||
* 打开预览视图
|
||||
* 对应PC端 openPreviewView 方法
|
||||
* 切换到文件预览模式并刷新文件列表
|
||||
* @param conversationId 会话ID
|
||||
*/
|
||||
static openPreviewView(conversationId: string): void {
|
||||
if (!conversationId) {
|
||||
console.warn('[CustomActionService] openPreviewView: conversationId is required')
|
||||
return
|
||||
}
|
||||
|
||||
// 移除 chat- 前缀
|
||||
const idStr = String(conversationId)
|
||||
const cid = idStr.startsWith('chat-') ? idStr.replace('chat-', '') : idStr
|
||||
|
||||
console.log('[CustomActionService] openPreviewView:', cid)
|
||||
|
||||
// 发送打开预览视图事件(对应PC端 setViewMode('preview') + setIsFileTreeVisible(true))
|
||||
const eventData: OpenPreviewEventData = {
|
||||
conversationId: cid,
|
||||
viewMode: ViewModeEnum.PREVIEW
|
||||
}
|
||||
console.log('[CustomActionService] Action: OPEN_PREVIEW, Params:', { conversationId })
|
||||
uni.$emit(CustomEventType.OPEN_PREVIEW, eventData)
|
||||
|
||||
// 触发文件列表刷新(对应PC端 eventBus.emit(EVENT_TYPE.RefreshFileList, cId))
|
||||
this.refreshFileList(conversationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开远程桌面视图
|
||||
* 对应PC端 openDesktopView 方法
|
||||
* 切换到远程桌面模式并启动VNC连接
|
||||
* @param conversationId 会话ID
|
||||
*/
|
||||
static openDesktopView(conversationId: string): void {
|
||||
if (!conversationId) {
|
||||
console.warn('[CustomActionService] openDesktopView: conversationId is required')
|
||||
return
|
||||
}
|
||||
|
||||
// 移除 chat- 前缀
|
||||
const idStr = String(conversationId)
|
||||
const cid = idStr.startsWith('chat-') ? idStr.replace('chat-', '') : idStr
|
||||
|
||||
console.log('[CustomActionService] openDesktopView:', cid)
|
||||
|
||||
// 发送打开远程桌面事件(对应PC端 setViewMode('desktop') + setIsFileTreeVisible(true) + apiEnsurePod)
|
||||
const eventData: OpenDesktopEventData = {
|
||||
conversationId: cid,
|
||||
viewMode: ViewModeEnum.DESKTOP
|
||||
}
|
||||
console.log('[CustomActionService] Action: OPEN_DESKTOP, Params:', { conversationId })
|
||||
uni.$emit(CustomEventType.OPEN_DESKTOP, eventData)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新文件树
|
||||
* 对应PC端 handleRefreshFileList 方法
|
||||
* 重新获取会话相关的文件列表
|
||||
* @param conversationId 会话ID
|
||||
*/
|
||||
static refreshFileList(conversationId: string): void {
|
||||
if (!conversationId) {
|
||||
console.warn('[CustomActionService] refreshFileList: conversationId is required')
|
||||
return
|
||||
}
|
||||
|
||||
// 移除 chat- 前缀
|
||||
const idStr = String(conversationId)
|
||||
const cid = idStr.startsWith('chat-') ? idStr.replace('chat-', '') : idStr
|
||||
|
||||
console.log('[CustomActionService] refreshFileList:', cid)
|
||||
|
||||
// 发送刷新文件列表事件(对应PC端 runGetStaticFileList(targetId))
|
||||
const eventData: RefreshFileListEventData = {
|
||||
conversationId: cid
|
||||
}
|
||||
console.log('[CustomActionService] Emitting REFRESH_FILE_LIST event:', eventData)
|
||||
// uni.$emit(CustomEventType.REFRESH_FILE_LIST, eventData)
|
||||
uni.$emit('refreshFileList', cid)
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开扩展页面
|
||||
* 在iframe或webview中打开指定页面
|
||||
* @param options 页面选项
|
||||
*/
|
||||
static openPage(options: OpenPageOptions): void {
|
||||
if (!options.uri) {
|
||||
console.warn('[CustomActionService] openPage: uri is required')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[CustomActionService] openPage:', options)
|
||||
|
||||
// 构建完整的URL
|
||||
let fullUri = options.uri
|
||||
if (options.params && Object.keys(options.params).length > 0) {
|
||||
const queryString = Object.entries(options.params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||||
.join('&')
|
||||
fullUri = `${fullUri}${fullUri.includes('?') ? '&' : '?'}${queryString}`
|
||||
}
|
||||
|
||||
// 发送打开页面事件
|
||||
const eventData: OpenPageEventData = {
|
||||
uri: fullUri,
|
||||
method: options.method || 'browser_open_page',
|
||||
dataType: options.dataType || 'html',
|
||||
requestId: options.requestId || ''
|
||||
}
|
||||
console.log('[CustomActionService] Action: OPEN_PAGE, Params:', { uri: fullUri, method: eventData.method })
|
||||
uni.$emit(CustomEventType.OPEN_PAGE, eventData)
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开外部链接
|
||||
* 在新窗口或跳转页面打开链接
|
||||
* @param uri 链接地址
|
||||
* @param params 查询参数
|
||||
*/
|
||||
static openLink(uri: string, params?: Record<string, any>): void {
|
||||
if (!uri) {
|
||||
console.warn('[CustomActionService] openLink: uri is required')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[CustomActionService] openLink:', uri, params)
|
||||
|
||||
// 构建完整的URL
|
||||
let fullUri = uri
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
const queryString = Object.entries(params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||||
.join('&')
|
||||
fullUri = `${fullUri}${fullUri.includes('?') ? '&' : '?'}${queryString}`
|
||||
}
|
||||
|
||||
// #ifdef H5 || WEB
|
||||
window.open(fullUri, '_blank')
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 小程序环境下使用webview打开
|
||||
uni.navigateTo({
|
||||
url: `/subpackages/pages/common/webview/webview?url=${encodeURIComponent(fullUri)}`
|
||||
})
|
||||
// #endif
|
||||
|
||||
// 同时发送事件,允许页面自定义处理
|
||||
console.log('[CustomActionService] Action: OPEN_LINK, Params:', { uri: fullUri })
|
||||
uni.$emit(CustomEventType.OPEN_LINK, { uri: fullUri })
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的动作分发器
|
||||
* 根据动作类型调用相应的处理方法
|
||||
* @param action 动作参数
|
||||
*/
|
||||
static dispatch(action: CustomActionParams): void {
|
||||
if (!action || !action.type) {
|
||||
console.warn('[CustomActionService] dispatch: action type is required')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[CustomActionService] dispatch:', action.type, action)
|
||||
|
||||
switch (action.type) {
|
||||
case CustomActionType.OPEN_PREVIEW:
|
||||
if (action.conversationId) {
|
||||
this.openPreviewView(action.conversationId)
|
||||
}
|
||||
break
|
||||
|
||||
case CustomActionType.OPEN_DESKTOP:
|
||||
if (action.conversationId) {
|
||||
this.openDesktopView(action.conversationId)
|
||||
}
|
||||
break
|
||||
|
||||
case CustomActionType.REFRESH_FILE_LIST:
|
||||
if (action.conversationId) {
|
||||
this.refreshFileList(action.conversationId)
|
||||
}
|
||||
break
|
||||
|
||||
case CustomActionType.OPEN_PAGE:
|
||||
if (action.uri) {
|
||||
this.openPage({
|
||||
uri: action.uri,
|
||||
params: action.params,
|
||||
method: action.method,
|
||||
dataType: action.dataType,
|
||||
requestId: action.requestId
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case CustomActionType.OPEN_LINK:
|
||||
if (action.uri) {
|
||||
this.openLink(action.uri, action.params)
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn('[CustomActionService] dispatch: unknown action type:', action.type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出事件类型,方便外部监听
|
||||
export { CustomEventType as EVENT_TYPE }
|
||||
|
||||
// 导出默认实例方法,方便使用
|
||||
export const openPreviewView = CustomActionService.openPreviewView.bind(CustomActionService)
|
||||
export const openDesktopView = CustomActionService.openDesktopView.bind(CustomActionService)
|
||||
export const refreshFileList = CustomActionService.refreshFileList.bind(CustomActionService)
|
||||
export const openPage = CustomActionService.openPage.bind(CustomActionService)
|
||||
export const openLink = CustomActionService.openLink.bind(CustomActionService)
|
||||
export const dispatchAction = CustomActionService.dispatch.bind(CustomActionService)
|
||||
65
qiming-mobile/utils/eventBus.uts
Normal file
65
qiming-mobile/utils/eventBus.uts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 事件总线封装
|
||||
*
|
||||
* 对外提供与 PC 端一致的 API:on / off / emit / once
|
||||
* 内部使用 uni-app x 原生事件系统:uni.$on / uni.$off / uni.$emit / uni.$once
|
||||
*
|
||||
* 参考文档:https://doc.dcloud.net.cn/uni-app-x/api/event-bus.html
|
||||
*/
|
||||
|
||||
type EventCallback = (...args: any[]) => void;
|
||||
|
||||
/**
|
||||
* 事件总线类
|
||||
* 封装 uni-app 原生事件 API,提供统一接口
|
||||
*/
|
||||
class EventBus {
|
||||
/**
|
||||
* 订阅事件
|
||||
* @param eventName 事件名称
|
||||
* @param callback 回调函数
|
||||
*/
|
||||
on(eventName: string, callback: EventCallback): void {
|
||||
uni.$on(eventName, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅事件
|
||||
* @param eventName 事件名称
|
||||
* @param callback 回调函数
|
||||
*/
|
||||
off(eventName: string, callback: EventCallback): void {
|
||||
uni.$off(eventName, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布事件
|
||||
* @param eventName 事件名称
|
||||
* @param args 参数
|
||||
*/
|
||||
emit(eventName: string, ...args: any[]): void {
|
||||
uni.$emit(eventName, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性订阅事件(触发一次后自动取消)
|
||||
* @param eventName 事件名称
|
||||
* @param callback 回调函数
|
||||
*/
|
||||
once(eventName: string, callback: EventCallback): void {
|
||||
uni.$once(eventName, callback);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局事件总线实例
|
||||
const eventBus = new EventBus();
|
||||
|
||||
export default eventBus;
|
||||
|
||||
// 定义常用的事件名称常量(与 PC 端保持一致)
|
||||
export const EVENT_NAMES = {
|
||||
// 发送聊天消息事件
|
||||
SEND_CHAT_MESSAGE: 'send_chat_message',
|
||||
// 清空聊天输入框事件
|
||||
CLEAR_CHAT_INPUT: 'clear_chat_input',
|
||||
} as const;
|
||||
102
qiming-mobile/utils/functionUtils.uts
Normal file
102
qiming-mobile/utils/functionUtils.uts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 节流函数工具
|
||||
* 用于限制函数的执行频率,在指定时间间隔内只执行一次
|
||||
*/
|
||||
|
||||
/**
|
||||
* 创建一个节流函数
|
||||
* @param func 需要节流的函数
|
||||
* @param delay 节流时间间隔(毫秒)
|
||||
* @returns 节流后的函数
|
||||
*
|
||||
* @example
|
||||
* const handleClick = throttle(() => {
|
||||
* console.log('点击事件');
|
||||
* }, 1000);
|
||||
*
|
||||
* // 即使快速点击多次,1秒内只会执行一次
|
||||
* button.addEventListener('click', handleClick);
|
||||
*/
|
||||
export function throttle<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let lastTime = 0;
|
||||
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
const now = Date.now();
|
||||
|
||||
// 如果距离上次执行时间超过了延迟时间,则执行函数
|
||||
if (now - lastTime >= delay) {
|
||||
lastTime = now;
|
||||
func.apply(this, args);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个防抖函数
|
||||
* @param func 需要防抖的函数
|
||||
* @param delay 防抖时间间隔(毫秒)
|
||||
* @returns 防抖后的函数
|
||||
*
|
||||
* @example
|
||||
* const handleInput = debounce((value: string) => {
|
||||
* console.log('搜索:', value);
|
||||
* }, 500);
|
||||
*
|
||||
* // 用户停止输入 500ms 后才会执行
|
||||
* input.addEventListener('input', (e) => handleInput(e.target.value));
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timer: number = 0;
|
||||
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
// 清除之前的定时器
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
// 设置新的定时器
|
||||
timer = setTimeout(() => {
|
||||
func.apply(this, args);
|
||||
timer = 0;
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个节流函数(立即执行版本)
|
||||
* 第一次调用时立即执行,之后在时间间隔内的调用会被忽略
|
||||
* @param func 需要节流的函数
|
||||
* @param delay 节流时间间隔(毫秒)
|
||||
* @returns 节流后的函数
|
||||
*/
|
||||
export function throttleImmediate<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let lastTime = 0;
|
||||
let isFirstCall = true;
|
||||
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
const now = Date.now();
|
||||
|
||||
// 第一次调用立即执行
|
||||
if (isFirstCall) {
|
||||
isFirstCall = false;
|
||||
lastTime = now;
|
||||
func.apply(this, args);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果距离上次执行时间超过了延迟时间,则执行函数
|
||||
if (now - lastTime >= delay) {
|
||||
lastTime = now;
|
||||
func.apply(this, args);
|
||||
}
|
||||
};
|
||||
}
|
||||
173
qiming-mobile/utils/gptMathConverter.uts
Normal file
173
qiming-mobile/utils/gptMathConverter.uts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* GPT 风格数学公式转换工具
|
||||
* 支持将 GPT 生成的数学公式标记转换为标准 LaTeX 格式
|
||||
*
|
||||
* GPT 通常生成以下格式:
|
||||
* - 行内公式: (\( ... \))
|
||||
* - 块级公式: (\[ ... \])
|
||||
*
|
||||
* 转换为标准 LaTeX 格式:
|
||||
* - 行内公式: \( ... \)
|
||||
* - 块级公式: \[ ... \]
|
||||
*/
|
||||
|
||||
/**
|
||||
* 转换 GPT 风格的数学公式标记
|
||||
* @param text 包含 GPT 风格数学公式的文本
|
||||
* @returns 转换后的文本
|
||||
*/
|
||||
export function convertGptStyleMath(text: string): string {
|
||||
if (!text || text.trim() === '') {
|
||||
return text
|
||||
}
|
||||
|
||||
let result = text
|
||||
|
||||
// 转换 GPT 风格的行内公式: (\( ... \)) → \( ... \)
|
||||
result = result.replace(/\(\\\(/g, '\\(')
|
||||
result = result.replace(/\\\)\)/g, '\\)')
|
||||
|
||||
// 转换 GPT 风格的块级公式: (\[ ... \]) → \[ ... \]
|
||||
result = result.replace(/\(\\\[/g, '\\[')
|
||||
result = result.replace(/\\\]\)/g, '\\]')
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测文本中是否包含 GPT 风格的数学公式
|
||||
* @param text 要检测的文本
|
||||
* @returns 是否包含 GPT 风格数学公式
|
||||
*/
|
||||
export function hasGptStyleMath(text: string): boolean {
|
||||
if (!text || text.trim() === '') {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否包含 GPT 风格的数学公式标记
|
||||
return /\(\\\(|\\\)\)|\(\\\[|\\\]\)/.test(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文本中所有 GPT 风格数学公式的位置信息
|
||||
* @param text 要分析的文本
|
||||
* @returns 数学公式位置信息数组
|
||||
*/
|
||||
export interface MathFormulaInfo {
|
||||
type: 'inline' | 'block'
|
||||
start: number
|
||||
end: number
|
||||
content: string
|
||||
original: string
|
||||
}
|
||||
|
||||
export function findGptStyleMath(text: string): MathFormulaInfo[] {
|
||||
if (!text || text.trim() === '') {
|
||||
return []
|
||||
}
|
||||
|
||||
const results: MathFormulaInfo[] = []
|
||||
|
||||
// 查找 GPT 风格的行内公式: (\( ... \))
|
||||
const inlineRegex = /\(\\\(((?:\\.|[^\)])+?)\\\)\)/g
|
||||
let inlineMatch
|
||||
while ((inlineMatch = inlineRegex.exec(text)) !== null) {
|
||||
results.push({
|
||||
type: 'inline',
|
||||
start: inlineMatch.index,
|
||||
end: inlineMatch.index + inlineMatch[0].length,
|
||||
content: inlineMatch[1],
|
||||
original: inlineMatch[0]
|
||||
})
|
||||
}
|
||||
|
||||
// 查找 GPT 风格的块级公式: (\[ ... \])
|
||||
const blockRegex = /\(\\\[((?:\\.|[^\]])+?)\\\]\)/g
|
||||
let blockMatch
|
||||
while ((blockMatch = blockRegex.exec(text)) !== null) {
|
||||
results.push({
|
||||
type: 'block',
|
||||
start: blockMatch.index,
|
||||
end: blockMatch.index + blockMatch[0].length,
|
||||
content: blockMatch[1],
|
||||
original: blockMatch[0]
|
||||
})
|
||||
}
|
||||
|
||||
// 按位置排序
|
||||
return results.sort((a, b) => a.start - b.start)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 GPT 风格的数学公式转换为 mp-html 支持的格式
|
||||
* @param text 原始文本
|
||||
* @param displayMode 是否为块级显示
|
||||
* @returns 转换后的 HTML 内容
|
||||
*/
|
||||
export function convertToMpHtmlFormat(text: string, displayMode: boolean = false): string {
|
||||
if (!text || text.trim() === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 先转换 GPT 风格标记
|
||||
let convertedText = convertGptStyleMath(text)
|
||||
|
||||
// 将 LaTeX 风格的数学公式转换为 KaTeX 能识别的格式
|
||||
// LaTeX 风格: \(...\) → $...$ (行内)
|
||||
convertedText = convertedText.replace(/\\\(/g, '$')
|
||||
convertedText = convertedText.replace(/\\\)/g, '$')
|
||||
|
||||
// LaTeX 风格: \[...\] → $$...$$ (块级)
|
||||
convertedText = convertedText.replace(/\\\[/g, '$$')
|
||||
convertedText = convertedText.replace(/\\\]/g, '$$')
|
||||
|
||||
// 根据显示模式包装
|
||||
if (displayMode) {
|
||||
// 块级显示:如果已经是 $$...$$ 格式,直接返回;否则包装
|
||||
if (convertedText.startsWith('$$') && convertedText.endsWith('$$')) {
|
||||
return convertedText
|
||||
} else {
|
||||
return `$$${convertedText}$$`
|
||||
}
|
||||
} else {
|
||||
// 内联显示:如果已经是 $...$ 格式,直接返回;否则包装
|
||||
if (convertedText.startsWith('$') && convertedText.endsWith('$')) {
|
||||
return convertedText
|
||||
} else {
|
||||
return `$${convertedText}$`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量转换文本中的数学公式
|
||||
* @param text 包含多个数学公式的文本
|
||||
* @returns 转换后的文本
|
||||
*/
|
||||
export function batchConvertMathFormulas(text: string): string {
|
||||
if (!text || text.trim() === '') {
|
||||
return text
|
||||
}
|
||||
|
||||
// 查找所有数学公式
|
||||
const mathFormulas = findGptStyleMath(text)
|
||||
|
||||
// 从后往前替换,避免位置偏移
|
||||
for (let i = mathFormulas.length - 1; i >= 0; i--) {
|
||||
const formula = mathFormulas[i]
|
||||
const before = text.substring(0, formula.start)
|
||||
const after = text.substring(formula.end)
|
||||
|
||||
// 根据类型转换
|
||||
let replacement: string
|
||||
if (formula.type === 'inline') {
|
||||
replacement = `\\(${formula.content}\\)`
|
||||
} else {
|
||||
replacement = `\\[${formula.content}\\]`
|
||||
}
|
||||
|
||||
text = before + replacement + after
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
266
qiming-mobile/utils/historyMessageAdapter.uts
Normal file
266
qiming-mobile/utils/historyMessageAdapter.uts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 历史消息适配器
|
||||
* 将PC端历史会话消息中的自定义HTML标签转换为移动端支持的Markdown容器语法
|
||||
*
|
||||
* PC端格式: <div><markdown-custom-process executeId="xxx" type="xxx" status="xxx" name="xxx"></markdown-custom-process></div>
|
||||
* 移动端格式: :::container executeId="xxx" type="xxx" status="xxx" name="xxx"\n:::\n
|
||||
*/
|
||||
|
||||
import type { MessageInfo, ProcessingInfo, ExecuteResultInfo } from '@/types/interfaces/conversationInfo'
|
||||
import { ProcessingEnum } from '@/types/enums/common'
|
||||
import { AssistantRoleEnum, MessageTypeEnum, AgentComponentTypeEnum } from '@/types/enums/agent'
|
||||
|
||||
/**
|
||||
* 组件执行列表项接口(来自API返回)
|
||||
*/
|
||||
export interface ComponentExecutedItem {
|
||||
name: string
|
||||
result: {
|
||||
executeId: string
|
||||
id: number
|
||||
name: string
|
||||
type: string // "Plan", "ToolCall", "Skill", "Workflow", "Page", etc.
|
||||
startTime: number
|
||||
endTime?: number
|
||||
success?: boolean
|
||||
data?: any
|
||||
input?: any
|
||||
}
|
||||
status: string // "FINISHED" | "FAILED" | "EXECUTING"
|
||||
targetId: number
|
||||
type: string // "Plan", "ToolCall", "Skill", "Workflow", "Page", etc.
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析的自定义标签信息
|
||||
*/
|
||||
interface ParsedCustomTag {
|
||||
executeId: string
|
||||
type: string
|
||||
status: string
|
||||
name: string
|
||||
fullMatch: string // 完整的匹配字符串,用于替换
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析PC端的markdown-custom-process HTML标签
|
||||
* @param text 包含HTML标签的文本
|
||||
* @returns 解析出的标签信息数组
|
||||
*/
|
||||
export function parseMarkdownCustomProcess(text: string): ParsedCustomTag[] {
|
||||
if (!text) return []
|
||||
|
||||
const results: ParsedCustomTag[] = []
|
||||
|
||||
// 匹配 <div><markdown-custom-process ...></markdown-custom-process></div> 格式
|
||||
// 以及 <markdown-custom-process ...></markdown-custom-process> 格式(无div包裹)
|
||||
const regex = /<div>[\s\n]*<markdown-custom-process([^>]*)><\/markdown-custom-process>[\s\n]*<\/div>|<markdown-custom-process([^>]*)><\/markdown-custom-process>/g
|
||||
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const attrString = match[1] || match[2] || ''
|
||||
const fullMatch = match[0]
|
||||
|
||||
// 解析属性
|
||||
const executeId = extractAttribute(attrString, 'executeId')
|
||||
const type = extractAttribute(attrString, 'type')
|
||||
const status = extractAttribute(attrString, 'status')
|
||||
const name = extractAttribute(attrString, 'name')
|
||||
|
||||
if (executeId) {
|
||||
results.push({
|
||||
executeId,
|
||||
type: type || '',
|
||||
status: status || '',
|
||||
name: name || '',
|
||||
fullMatch
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 从属性字符串中提取指定属性的值
|
||||
* @param attrString 属性字符串,如 ' executeId="xxx" type="yyy"'
|
||||
* @param attrName 属性名
|
||||
* @returns 属性值
|
||||
*/
|
||||
function extractAttribute(attrString: string, attrName: string): string {
|
||||
const regex = new RegExp(`${attrName}="([^"]*)"`)
|
||||
const match = attrString.match(regex)
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 将PC端HTML标签转换为移动端Markdown容器语法
|
||||
* @param tag 解析的标签信息
|
||||
* @returns Markdown容器语法字符串
|
||||
*/
|
||||
export function convertToMobileContainerSyntax(tag: ParsedCustomTag): string {
|
||||
const attrs = [
|
||||
`executeId="${tag.executeId}"`,
|
||||
tag.type ? `type="${tag.type}"` : '',
|
||||
tag.status ? `status="${tag.status}"` : '',
|
||||
tag.name ? `name="${tag.name}"` : ''
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
return `\n\n:::container ${attrs}\n:::\n\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析状态字符串为ProcessingEnum
|
||||
* @param status 状态字符串
|
||||
* @returns ProcessingEnum值
|
||||
*/
|
||||
function parseStatus(status: string): ProcessingEnum {
|
||||
switch (status) {
|
||||
case 'FINISHED':
|
||||
return ProcessingEnum.FINISHED
|
||||
case 'FAILED':
|
||||
return ProcessingEnum.FAILED
|
||||
case 'EXECUTING':
|
||||
default:
|
||||
return ProcessingEnum.EXECUTING
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析组件类型字符串为AgentComponentTypeEnum
|
||||
* @param type 类型字符串
|
||||
* @returns AgentComponentTypeEnum值
|
||||
*/
|
||||
function parseComponentType(type: string): AgentComponentTypeEnum {
|
||||
switch (type) {
|
||||
case 'Plan':
|
||||
return AgentComponentTypeEnum.Plan
|
||||
case 'ToolCall':
|
||||
return AgentComponentTypeEnum.ToolCall
|
||||
case 'Skill':
|
||||
return AgentComponentTypeEnum.Skill
|
||||
case 'Workflow':
|
||||
return AgentComponentTypeEnum.Workflow
|
||||
case 'Knowledge':
|
||||
return AgentComponentTypeEnum.Knowledge
|
||||
case 'Page':
|
||||
return AgentComponentTypeEnum.Page
|
||||
case 'Plugin':
|
||||
return AgentComponentTypeEnum.Plugin
|
||||
case 'Event':
|
||||
return AgentComponentTypeEnum.Event
|
||||
case 'Agent':
|
||||
return AgentComponentTypeEnum.Agent
|
||||
case 'Mcp':
|
||||
case 'MCP':
|
||||
return AgentComponentTypeEnum.MCP
|
||||
default:
|
||||
// 默认返回 ToolCall,因为这是最常见的类型
|
||||
return AgentComponentTypeEnum.ToolCall
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将componentExecutedList转换为processingList
|
||||
* @param componentExecutedList 组件执行列表
|
||||
* @returns ProcessingInfo数组
|
||||
*/
|
||||
export function convertComponentExecutedListToProcessingList(
|
||||
componentExecutedList: ComponentExecutedItem[] | null | undefined
|
||||
): ProcessingInfo[] {
|
||||
if (!componentExecutedList || !Array.isArray(componentExecutedList)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return componentExecutedList.map((item): ProcessingInfo => {
|
||||
const result = item.result || {}
|
||||
const status = parseStatus(item.status)
|
||||
const componentType = parseComponentType(item.type)
|
||||
|
||||
// 构建ExecuteResultInfo
|
||||
const executeResult: ExecuteResultInfo = {
|
||||
kind: result?.kind || '',
|
||||
id: result.id || -1,
|
||||
icon: '',
|
||||
name: result.name || item.name || '',
|
||||
type: componentType,
|
||||
success: result.success ?? (item.status === 'FINISHED'),
|
||||
error: '',
|
||||
data: result.data || null,
|
||||
startTime: result.startTime || 0,
|
||||
endTime: result.endTime || 0,
|
||||
input: result.input || null
|
||||
}
|
||||
|
||||
return {
|
||||
executeId: result.executeId || '',
|
||||
name: item.name || result.name || '',
|
||||
type: componentType,
|
||||
status: status,
|
||||
targetId: item.targetId || result.id || -1,
|
||||
result: executeResult,
|
||||
cardBindConfig: null as any
|
||||
} as ProcessingInfo
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 适配单条历史消息
|
||||
* 将PC端格式的自定义标签转换为移动端支持的格式
|
||||
* @param message 原始消息
|
||||
* @returns 适配后的消息
|
||||
*/
|
||||
export function adaptHistoryMessage(message: MessageInfo): MessageInfo {
|
||||
if (!message) return message
|
||||
|
||||
// 只处理ASSISTANT消息,非ASSISTANT消息确保text有值后直接返回
|
||||
if (message.role !== AssistantRoleEnum.ASSISTANT && message.messageType !== MessageTypeEnum.ASSISTANT) {
|
||||
// 确保 text 字段有值
|
||||
if (message.text === undefined || message.text === null) {
|
||||
return { ...message, text: '' }
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
let adaptedText = message.text || ''
|
||||
let processingList = message.processingList || []
|
||||
|
||||
// 1. 从componentExecutedList转换processingList
|
||||
const componentExecutedList = (message as any).componentExecutedList as ComponentExecutedItem[] | null
|
||||
if (componentExecutedList && componentExecutedList.length > 0) {
|
||||
const convertedProcessingList = convertComponentExecutedListToProcessingList(componentExecutedList)
|
||||
// 合并已有的processingList(如果有的话)
|
||||
processingList = [...processingList, ...convertedProcessingList]
|
||||
}
|
||||
|
||||
// 2. 解析并转换PC端HTML标签为移动端Markdown容器语法
|
||||
const parsedTags = parseMarkdownCustomProcess(adaptedText)
|
||||
|
||||
for (const tag of parsedTags) {
|
||||
const containerSyntax = convertToMobileContainerSyntax(tag)
|
||||
// 替换HTML标签为Markdown容器语法
|
||||
adaptedText = adaptedText.replace(tag.fullMatch, containerSyntax)
|
||||
}
|
||||
|
||||
// 3. 清理多余的空行(超过2个连续换行符的情况)
|
||||
adaptedText = adaptedText.replace(/\n{3,}/g, '\n\n')
|
||||
|
||||
return {
|
||||
...message,
|
||||
text: adaptedText || '', // 确保 text 始终有值
|
||||
processingList
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量适配历史消息列表
|
||||
* @param messageList 原始消息列表
|
||||
* @returns 适配后的消息列表
|
||||
*/
|
||||
export function adaptHistoryMessageList(messageList: MessageInfo[]): MessageInfo[] {
|
||||
if (!messageList || !Array.isArray(messageList)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return messageList.map(message => adaptHistoryMessage(message))
|
||||
}
|
||||
665
qiming-mobile/utils/htmlToMarkdown.uts
Normal file
665
qiming-mobile/utils/htmlToMarkdown.uts
Normal file
@@ -0,0 +1,665 @@
|
||||
/**
|
||||
* HTML 转 Markdown 转换器(增强版)
|
||||
* 适用于微信小程序环境
|
||||
* 支持处理大文本和复杂 HTML 结构
|
||||
*/
|
||||
|
||||
/**
|
||||
* HTML 节点类型
|
||||
*/
|
||||
interface HtmlNode {
|
||||
type: 'element' | 'text';
|
||||
tag?: string;
|
||||
content?: string;
|
||||
children?: HtmlNode[];
|
||||
attributes?: Map<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 HTML 字符串转换为 Markdown 字符串(增强版)
|
||||
* @param html HTML 字符串
|
||||
* @param options 转换选项
|
||||
* @returns Markdown 字符串
|
||||
*/
|
||||
export function htmlToMarkdown(html: string, options?: {
|
||||
preserveNewlines?: boolean;
|
||||
codeBlockLanguage?: string;
|
||||
maxNestingDepth?: number;
|
||||
}): string {
|
||||
if (!html) return '';
|
||||
|
||||
const opts = {
|
||||
preserveNewlines: options?.preserveNewlines ?? false,
|
||||
codeBlockLanguage: options?.codeBlockLanguage ?? '',
|
||||
maxNestingDepth: options?.maxNestingDepth ?? 20
|
||||
};
|
||||
|
||||
// 预处理:移除注释和脚本
|
||||
let processed = preprocess(html);
|
||||
|
||||
// 使用流式处理来处理大文本
|
||||
let markdown = processHtmlStream(processed, opts);
|
||||
|
||||
// 后处理:清理和格式化
|
||||
markdown = postprocess(markdown);
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预处理 HTML
|
||||
*/
|
||||
function preprocess(html: string): string {
|
||||
let result = html;
|
||||
|
||||
// 移除注释
|
||||
result = result.replace(/<!--[\s\S]*?-->/g, '');
|
||||
|
||||
// 移除 script 标签
|
||||
result = result.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||
|
||||
// 移除 style 标签
|
||||
result = result.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
|
||||
|
||||
// 标准化空白字符(保留必要的空格)
|
||||
result = result.replace(/\r\n/g, '\n');
|
||||
result = result.replace(/\r/g, '\n');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式处理 HTML(避免一次性处理导致的内存问题)
|
||||
*/
|
||||
function processHtmlStream(html: string, options: any): string {
|
||||
let markdown = html;
|
||||
|
||||
// 第一轮:处理代码块(优先处理,避免代码内容被转换)
|
||||
markdown = processCodeBlocks(markdown, options);
|
||||
|
||||
// 第二轮:处理表格
|
||||
markdown = processTables(markdown);
|
||||
|
||||
// 第三轮:处理列表(嵌套支持)
|
||||
markdown = processLists(markdown, 0);
|
||||
|
||||
// 第四轮:处理引用块(嵌套支持)
|
||||
markdown = processBlockquotes(markdown, 0);
|
||||
|
||||
// 第五轮:处理标题
|
||||
markdown = processHeadings(markdown);
|
||||
|
||||
// 第六轮:处理图片和链接
|
||||
markdown = processImages(markdown);
|
||||
markdown = processLinks(markdown);
|
||||
|
||||
// 第七轮:处理文本格式(加粗、斜体、删除线等)
|
||||
markdown = processTextFormatting(markdown);
|
||||
|
||||
// 第八轮:处理段落和换行
|
||||
markdown = processParagraphs(markdown);
|
||||
|
||||
// 第九轮:处理水平线
|
||||
markdown = markdown.replace(/<hr[^>]*\/?>/gi, '\n---\n');
|
||||
|
||||
// 第十轮:移除剩余的 HTML 标签
|
||||
markdown = removeRemainingTags(markdown);
|
||||
|
||||
// 解码 HTML 实体
|
||||
markdown = decodeHtmlEntities(markdown);
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理代码块
|
||||
*/
|
||||
function processCodeBlocks(html: string, options: any): string {
|
||||
let result = html;
|
||||
|
||||
// 处理 <pre><code class="language-xxx">
|
||||
result = result.replace(/<pre[^>]*>\s*<code[^>]*class=["']language-(\w+)["'][^>]*>([\s\S]*?)<\/code>\s*<\/pre>/gi,
|
||||
(match, lang, code) => {
|
||||
const decodedCode = decodeHtmlEntities(code).trim();
|
||||
return `\n\`\`\`${lang}\n${decodedCode}\n\`\`\`\n`;
|
||||
}
|
||||
);
|
||||
|
||||
// 处理 <pre><code>
|
||||
result = result.replace(/<pre[^>]*>\s*<code[^>]*>([\s\S]*?)<\/code>\s*<\/pre>/gi,
|
||||
(match, code) => {
|
||||
const decodedCode = decodeHtmlEntities(code).trim();
|
||||
const lang = options.codeBlockLanguage || '';
|
||||
return `\n\`\`\`${lang}\n${decodedCode}\n\`\`\`\n`;
|
||||
}
|
||||
);
|
||||
|
||||
// 处理 <pre>
|
||||
result = result.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi,
|
||||
(match, code) => {
|
||||
const decodedCode = decodeHtmlEntities(code).trim();
|
||||
const lang = options.codeBlockLanguage || '';
|
||||
return `\n\`\`\`${lang}\n${decodedCode}\n\`\`\`\n`;
|
||||
}
|
||||
);
|
||||
|
||||
// 处理行内代码 <code>(排除已处理的)
|
||||
result = result.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi,
|
||||
(match, code) => {
|
||||
// 检查是否在 ``` 代码块标记附近,避免重复处理
|
||||
return '`' + decodeHtmlEntities(code) + '`';
|
||||
}
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理表格
|
||||
*/
|
||||
function processTables(html: string): string {
|
||||
return html.replace(/<table[^>]*>([\s\S]*?)<\/table>/gi, (match, content) => {
|
||||
let result = '\n';
|
||||
|
||||
// 提取表头
|
||||
const theadMatch = content.match(/<thead[^>]*>([\s\S]*?)<\/thead>/i);
|
||||
let hasHeader = false;
|
||||
|
||||
if (theadMatch) {
|
||||
const headerRows = theadMatch[1].match(/<tr[^>]*>([\s\S]*?)<\/tr>/gi);
|
||||
if (headerRows && headerRows.length > 0) {
|
||||
const firstRow = headerRows[0];
|
||||
const headerCells = firstRow.match(/<th[^>]*>([\s\S]*?)<\/th>/gi);
|
||||
if (headerCells) {
|
||||
const headers = headerCells.map((th: string) => {
|
||||
return cleanHtmlTags(th.replace(/<\/?th[^>]*>/gi, '')).trim();
|
||||
});
|
||||
result += '| ' + headers.join(' | ') + ' |\n';
|
||||
result += '| ' + headers.map(() => '---').join(' | ') + ' |\n';
|
||||
hasHeader = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提取表体
|
||||
const tbodyMatch = content.match(/<tbody[^>]*>([\s\S]*?)<\/tbody>/i);
|
||||
const bodyContent = tbodyMatch ? tbodyMatch[1] : content;
|
||||
const rows = bodyContent.match(/<tr[^>]*>([\s\S]*?)<\/tr>/gi);
|
||||
|
||||
if (rows) {
|
||||
rows.forEach((row: string, index: number) => {
|
||||
const cells = row.match(/<td[^>]*>([\s\S]*?)<\/td>/gi);
|
||||
if (cells) {
|
||||
const cellTexts = cells.map((td: string) => {
|
||||
return cleanHtmlTags(td.replace(/<\/?td[^>]*>/gi, '')).trim();
|
||||
});
|
||||
|
||||
// 如果没有表头,第一行作为表头
|
||||
if (!hasHeader && index === 0) {
|
||||
result += '| ' + cellTexts.join(' | ') + ' |\n';
|
||||
result += '| ' + cellTexts.map(() => '---').join(' | ') + ' |\n';
|
||||
hasHeader = true;
|
||||
} else {
|
||||
result += '| ' + cellTexts.join(' | ') + ' |\n';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result + '\n';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理列表(支持嵌套)
|
||||
*/
|
||||
function processLists(html: string, depth: number): string {
|
||||
if (depth > 10) return html; // 防止无限递归
|
||||
|
||||
const indent = ' '.repeat(depth);
|
||||
let result = html;
|
||||
|
||||
// 处理无序列表
|
||||
result = result.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (match, content) => {
|
||||
let listResult = '\n';
|
||||
const items = extractListItems(content);
|
||||
|
||||
items.forEach((item: string) => {
|
||||
// 递归处理嵌套列表
|
||||
const processedItem = processLists(item, depth + 1);
|
||||
const cleanedItem = cleanHtmlTags(processedItem).trim();
|
||||
if (cleanedItem) {
|
||||
listResult += indent + '- ' + cleanedItem + '\n';
|
||||
}
|
||||
});
|
||||
|
||||
return listResult;
|
||||
});
|
||||
|
||||
// 处理有序列表
|
||||
result = result.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (match, content) => {
|
||||
let listResult = '\n';
|
||||
const items = extractListItems(content);
|
||||
|
||||
items.forEach((item: string, index: number) => {
|
||||
// 递归处理嵌套列表
|
||||
const processedItem = processLists(item, depth + 1);
|
||||
const cleanedItem = cleanHtmlTags(processedItem).trim();
|
||||
if (cleanedItem) {
|
||||
listResult += indent + `${index + 1}. ` + cleanedItem + '\n';
|
||||
}
|
||||
});
|
||||
|
||||
return listResult;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取列表项
|
||||
*/
|
||||
function extractListItems(content: string): string[] {
|
||||
const items: string[] = [];
|
||||
const regex = /<li[^>]*>([\s\S]*?)<\/li>/gi;
|
||||
let match;
|
||||
|
||||
// 使用 exec 方法逐个匹配,避免一次性处理大量数据
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
items.push(match[1]);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理引用块(支持嵌套)
|
||||
*/
|
||||
function processBlockquotes(html: string, depth: number): string {
|
||||
if (depth > 10) return html;
|
||||
|
||||
return html.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (match, content) => {
|
||||
// 递归处理嵌套引用
|
||||
const processedContent = processBlockquotes(content, depth + 1);
|
||||
const lines = processedContent.trim().split('\n');
|
||||
const quotedLines = lines.map((line: string) => {
|
||||
const trimmedLine = line.trim();
|
||||
// 如果已经是引用格式,增加引用层级
|
||||
if (trimmedLine.startsWith('>')) {
|
||||
return '> ' + trimmedLine;
|
||||
}
|
||||
return '> ' + trimmedLine;
|
||||
});
|
||||
return '\n' + quotedLines.join('\n') + '\n';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理标题
|
||||
*/
|
||||
function processHeadings(html: string): string {
|
||||
let result = html;
|
||||
|
||||
result = result.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, '\n# $1\n');
|
||||
result = result.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, '\n## $1\n');
|
||||
result = result.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, '\n### $1\n');
|
||||
result = result.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, '\n#### $1\n');
|
||||
result = result.replace(/<h5[^>]*>([\s\S]*?)<\/h5>/gi, '\n##### $1\n');
|
||||
result = result.replace(/<h6[^>]*>([\s\S]*?)<\/h6>/gi, '\n###### $1\n');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理图片
|
||||
*/
|
||||
function processImages(html: string): string {
|
||||
let result = html;
|
||||
|
||||
// <img> with alt and src
|
||||
result = result.replace(/<img[^>]*\salt=["']([^"']*)["'][^>]*\ssrc=["']([^"']*)["'][^>]*\/?>/gi, '');
|
||||
result = result.replace(/<img[^>]*\ssrc=["']([^"']*)["'][^>]*\salt=["']([^"']*)["'][^>]*\/?>/gi, '');
|
||||
|
||||
// <img> with only src
|
||||
result = result.replace(/<img[^>]*\ssrc=["']([^"']*)["'][^>]*\/?>/gi, '');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理链接
|
||||
*/
|
||||
function processLinks(html: string): string {
|
||||
return html.replace(/<a[^>]*\shref=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)');
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文本格式
|
||||
*/
|
||||
function processTextFormatting(html: string): string {
|
||||
let result = html;
|
||||
|
||||
// 处理加粗和斜体组合 <strong><em> 或 <b><i>
|
||||
result = result.replace(/<strong[^>]*>\s*<em[^>]*>([\s\S]*?)<\/em>\s*<\/strong>/gi, '***$1***');
|
||||
result = result.replace(/<em[^>]*>\s*<strong[^>]*>([\s\S]*?)<\/strong>\s*<\/em>/gi, '***$1***');
|
||||
result = result.replace(/<b[^>]*>\s*<i[^>]*>([\s\S]*?)<\/i>\s*<\/b>/gi, '***$1***');
|
||||
result = result.replace(/<i[^>]*>\s*<b[^>]*>([\s\S]*?)<\/b>\s*<\/i>/gi, '***$1***');
|
||||
|
||||
// 处理加粗 <strong> 和 <b>
|
||||
result = result.replace(/<strong[^>]*>([\s\S]*?)<\/strong>/gi, '**$1**');
|
||||
result = result.replace(/<b[^>]*>([\s\S]*?)<\/b>/gi, '**$1**');
|
||||
|
||||
// 处理斜体 <em> 和 <i>
|
||||
result = result.replace(/<em[^>]*>([\s\S]*?)<\/em>/gi, '*$1*');
|
||||
result = result.replace(/<i[^>]*>([\s\S]*?)<\/i>/gi, '*$1*');
|
||||
|
||||
// 处理删除线 <del>、<s> 和 <strike>
|
||||
result = result.replace(/<del[^>]*>([\s\S]*?)<\/del>/gi, '~~$1~~');
|
||||
result = result.replace(/<s[^>]*>([\s\S]*?)<\/s>/gi, '~~$1~~');
|
||||
result = result.replace(/<strike[^>]*>([\s\S]*?)<\/strike>/gi, '~~$1~~');
|
||||
|
||||
// 处理下划线 <u>(Markdown 不直接支持,用 HTML)
|
||||
result = result.replace(/<u[^>]*>([\s\S]*?)<\/u>/gi, '<u>$1</u>');
|
||||
|
||||
// 处理上标 <sup> 和下标 <sub>
|
||||
result = result.replace(/<sup[^>]*>([\s\S]*?)<\/sup>/gi, '^$1^');
|
||||
result = result.replace(/<sub[^>]*>([\s\S]*?)<\/sub>/gi, '~$1~');
|
||||
|
||||
// 处理标记/高亮 <mark>
|
||||
result = result.replace(/<mark[^>]*>([\s\S]*?)<\/mark>/gi, '==$1==');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理段落和换行
|
||||
*/
|
||||
function processParagraphs(html: string): string {
|
||||
let result = html;
|
||||
|
||||
// 处理 div(作为段落分隔符)
|
||||
result = result.replace(/<div[^>]*>([\s\S]*?)<\/div>/gi, '\n$1\n');
|
||||
|
||||
// 处理段落
|
||||
result = result.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, '\n$1\n');
|
||||
|
||||
// 处理换行
|
||||
result = result.replace(/<br\s*\/?>/gi, '\n');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除剩余的 HTML 标签
|
||||
*/
|
||||
function removeRemainingTags(html: string): string {
|
||||
// 移除常见的容器标签但保留内容
|
||||
let result = html;
|
||||
|
||||
const containerTags = ['article', 'section', 'nav', 'aside', 'header', 'footer', 'main', 'figure', 'figcaption', 'span', 'time', 'address'];
|
||||
containerTags.forEach((tag: string) => {
|
||||
const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'gi');
|
||||
result = result.replace(regex, '$1');
|
||||
});
|
||||
|
||||
// 移除所有剩余标签
|
||||
result = result.replace(/<[^>]+>/g, '');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 HTML 标签(用于表格等特殊场景)
|
||||
*/
|
||||
function cleanHtmlTags(text: string): string {
|
||||
let result = text;
|
||||
|
||||
// 先处理格式标签
|
||||
result = processTextFormatting(result);
|
||||
|
||||
// 然后移除剩余标签
|
||||
result = result.replace(/<[^>]+>/g, '');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后处理:清理和格式化
|
||||
*/
|
||||
function postprocess(markdown: string): string {
|
||||
let result = markdown;
|
||||
|
||||
// 第一步:清理行首尾空白(先处理,避免空白行干扰)
|
||||
const lines = result.split('\n');
|
||||
result = lines.map((line: string) => {
|
||||
// 保留列表和引用的缩进
|
||||
if (line.match(/^\s*[-*+]\s/) || line.match(/^\s*\d+\.\s/) || line.match(/^\s*>/)) {
|
||||
return line.trimEnd();
|
||||
}
|
||||
return line.trim();
|
||||
}).join('\n');
|
||||
|
||||
// 第二步:清理连续的空行(最多保留一个空行)
|
||||
result = result.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
// 第三步:特殊处理:在代码块、表格、水平线前后确保有空行(但不超过一个)
|
||||
// 代码块前后
|
||||
result = result.replace(/([^\n])\n```/g, '$1\n\n```');
|
||||
result = result.replace(/```\n([^\n])/g, '```\n\n$1');
|
||||
|
||||
// 表格前后(以 | 开头的行)
|
||||
result = result.replace(/([^\n])\n\|/g, '$1\n\n|');
|
||||
result = result.replace(/\|\s*\n([^\n|])/g, '|\n\n$1');
|
||||
|
||||
// 水平线前后
|
||||
result = result.replace(/([^\n])\n---\n/g, '$1\n\n---\n');
|
||||
result = result.replace(/\n---\n([^\n])/g, '\n---\n\n$1');
|
||||
|
||||
// 标题前后(以 # 开头的行)
|
||||
result = result.replace(/([^\n])\n(#{1,6}\s)/g, '$1\n\n$2');
|
||||
result = result.replace(/(#{1,6}\s[^\n]*)\n([^\n#])/g, '$1\n\n$2');
|
||||
|
||||
// 引用块前后(以 > 开头的行)
|
||||
result = result.replace(/([^\n>])\n>/g, '$1\n\n>');
|
||||
result = result.replace(/>\s*([^\n>])\n([^\n>])/g, '> $1\n\n$2');
|
||||
|
||||
// 列表前后
|
||||
result = result.replace(/([^\n])\n([-*+]|\d+\.)\s/g, '$1\n\n$2 ');
|
||||
result = result.replace(/([-*+]|\d+\.)\s[^\n]*\n([^\n\s-*+\d])/g, (match, p1, p2) => {
|
||||
// 确保列表后有空行,但不在列表项之间添加
|
||||
return match.replace(/\n([^\n\s-*+\d])/, '\n\n$1');
|
||||
});
|
||||
|
||||
// 第四步:再次清理可能产生的多余空行
|
||||
result = result.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
// 第五步:清理首尾空白
|
||||
result = result.trim();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 HTML 实体(增强版)
|
||||
* @param text 包含 HTML 实体的文本
|
||||
* @returns 解码后的文本
|
||||
*/
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
// 常见 HTML 实体映射表(扩展版)
|
||||
const entities: Map<string, string> = new Map([
|
||||
// 基础实体
|
||||
[' ', ' '],
|
||||
['<', '<'],
|
||||
['>', '>'],
|
||||
['&', '&'],
|
||||
['"', '"'],
|
||||
[''', "'"],
|
||||
[''', "'"],
|
||||
|
||||
// 货币符号
|
||||
['¢', '¢'],
|
||||
['£', '£'],
|
||||
['¥', '¥'],
|
||||
['€', '€'],
|
||||
['¤', '¤'],
|
||||
|
||||
// 版权和商标
|
||||
['©', '©'],
|
||||
['®', '®'],
|
||||
['™', '™'],
|
||||
|
||||
// 数学符号
|
||||
['×', '×'],
|
||||
['÷', '÷'],
|
||||
['±', '±'],
|
||||
['−', '−'],
|
||||
['½', '½'],
|
||||
['¼', '¼'],
|
||||
['¾', '¾'],
|
||||
['°', '°'],
|
||||
['‰', '‰'],
|
||||
['∞', '∞'],
|
||||
['√', '√'],
|
||||
['∑', '∑'],
|
||||
['∏', '∏'],
|
||||
['∫', '∫'],
|
||||
['≠', '≠'],
|
||||
['≡', '≡'],
|
||||
['≤', '≤'],
|
||||
['≥', '≥'],
|
||||
['≈', '≈'],
|
||||
|
||||
// 标点符号
|
||||
['—', '—'],
|
||||
['–', '–'],
|
||||
['“', '"'],
|
||||
['”', '"'],
|
||||
['‘', "'"],
|
||||
['’', "'"],
|
||||
['‚', '‚'],
|
||||
['„', '„'],
|
||||
['…', '…'],
|
||||
['•', '•'],
|
||||
['·', '·'],
|
||||
['§', '§'],
|
||||
['¶', '¶'],
|
||||
['†', '†'],
|
||||
['‡', '‡'],
|
||||
|
||||
// 箭头
|
||||
['←', '←'],
|
||||
['↑', '↑'],
|
||||
['→', '→'],
|
||||
['↓', '↓'],
|
||||
['↔', '↔'],
|
||||
['↵', '↵'],
|
||||
['⇐', '⇐'],
|
||||
['⇑', '⇑'],
|
||||
['⇒', '⇒'],
|
||||
['⇓', '⇓'],
|
||||
['⇔', '⇔'],
|
||||
|
||||
// 希腊字母(小写)
|
||||
['α', 'α'],
|
||||
['β', 'β'],
|
||||
['γ', 'γ'],
|
||||
['δ', 'δ'],
|
||||
['ε', 'ε'],
|
||||
['ζ', 'ζ'],
|
||||
['η', 'η'],
|
||||
['θ', 'θ'],
|
||||
['ι', 'ι'],
|
||||
['κ', 'κ'],
|
||||
['λ', 'λ'],
|
||||
['μ', 'μ'],
|
||||
['ν', 'ν'],
|
||||
['ξ', 'ξ'],
|
||||
['ο', 'ο'],
|
||||
['π', 'π'],
|
||||
['ρ', 'ρ'],
|
||||
['σ', 'σ'],
|
||||
['τ', 'τ'],
|
||||
['υ', 'υ'],
|
||||
['φ', 'φ'],
|
||||
['χ', 'χ'],
|
||||
['ψ', 'ψ'],
|
||||
['ω', 'ω'],
|
||||
|
||||
// 希腊字母(大写)
|
||||
['Α', 'Α'],
|
||||
['Β', 'Β'],
|
||||
['Γ', 'Γ'],
|
||||
['Δ', 'Δ'],
|
||||
['Ε', 'Ε'],
|
||||
['Ζ', 'Ζ'],
|
||||
['Η', 'Η'],
|
||||
['Θ', 'Θ'],
|
||||
['Ι', 'Ι'],
|
||||
['Κ', 'Κ'],
|
||||
['Λ', 'Λ'],
|
||||
['Μ', 'Μ'],
|
||||
['Ν', 'Ν'],
|
||||
['Ξ', 'Ξ'],
|
||||
['Ο', 'Ο'],
|
||||
['Π', 'Π'],
|
||||
['Ρ', 'Ρ'],
|
||||
['Σ', 'Σ'],
|
||||
['Τ', 'Τ'],
|
||||
['Υ', 'Υ'],
|
||||
['Φ', 'Φ'],
|
||||
['Χ', 'Χ'],
|
||||
['Ψ', 'Ψ'],
|
||||
['Ω', 'Ω'],
|
||||
|
||||
// 其他符号
|
||||
['♥', '♥'],
|
||||
['♦', '♦'],
|
||||
['♣', '♣'],
|
||||
['♠', '♠'],
|
||||
]);
|
||||
|
||||
let result = text;
|
||||
|
||||
// 先处理 & 特殊情况(避免重复解码)
|
||||
// 临时标记已解码的 &
|
||||
result = result.replace(/&/g, '\u0001AMP\u0001');
|
||||
|
||||
// 替换其他命名实体
|
||||
entities.forEach((value, key) => {
|
||||
if (key !== '&') {
|
||||
result = result.replace(new RegExp(key, 'g'), value);
|
||||
}
|
||||
});
|
||||
|
||||
// 替换数字实体 &#xxx;(十进制)
|
||||
result = result.replace(/&#(\d+);/g, (match, dec) => {
|
||||
const code = parseInt(dec);
|
||||
if (code >= 0 && code <= 0x10FFFF) {
|
||||
return String.fromCharCode(code);
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
// 替换数字实体 &#xHHH;(十六进制)
|
||||
result = result.replace(/&#x([0-9a-f]+);/gi, (match, hex) => {
|
||||
const code = parseInt(hex, 16);
|
||||
if (code >= 0 && code <= 0x10FFFF) {
|
||||
return String.fromCharCode(code);
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
// 恢复 & 的解码
|
||||
result = result.replace(/\u0001AMP\u0001/g, '&');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认导出
|
||||
*/
|
||||
export default {
|
||||
htmlToMarkdown,
|
||||
decodeHtmlEntities
|
||||
};
|
||||
|
||||
51
qiming-mobile/utils/i18n-third-party.uts
Normal file
51
qiming-mobile/utils/i18n-third-party.uts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { reactive } from "vue";
|
||||
import { DEFAULT_LANG } from "@/constants/i18n.constants";
|
||||
import { getLocalFallback, toBundleKey } from "@/constants/i18n.local.constants";
|
||||
|
||||
export interface UniLoadMoreContentText {
|
||||
contentdown: string;
|
||||
contentrefresh: string;
|
||||
contentnomore: string;
|
||||
}
|
||||
|
||||
interface ThirdPartyLocaleState {
|
||||
currentLang: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 第三方库语言状态适配层。
|
||||
* 这里不维护独立词典,只缓存当前项目语言,确保所有第三方入口都跟随主 i18n 状态。
|
||||
*/
|
||||
export const thirdPartyLocaleState = reactive<ThirdPartyLocaleState>({
|
||||
currentLang: DEFAULT_LANG,
|
||||
});
|
||||
|
||||
const normalizeThirdPartyLang = (lang: string): string => {
|
||||
return toBundleKey(lang);
|
||||
};
|
||||
|
||||
/**
|
||||
* 同步第三方库当前语言。
|
||||
* 目前先作为统一收口点,后续新增 dayjs / markdown / UI 库时只需要继续往这里扩展。
|
||||
*/
|
||||
export const syncThirdPartyLocales = (lang: string) => {
|
||||
thirdPartyLocaleState.currentLang = normalizeThirdPartyLang(lang);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取 `uni-load-more` 组件所需的文案对象。
|
||||
* 通过显式传入 `content-text`,避免组件内部继续使用自己的默认语言逻辑。
|
||||
*/
|
||||
export const getUniLoadMoreContentText = (
|
||||
overrides: Partial<UniLoadMoreContentText> = {},
|
||||
): UniLoadMoreContentText => {
|
||||
const lang = normalizeThirdPartyLang(thirdPartyLocaleState.currentLang);
|
||||
return {
|
||||
contentdown:
|
||||
overrides.contentdown || getLocalFallback(lang, "Mobile.Common.loadMore"),
|
||||
contentrefresh:
|
||||
overrides.contentrefresh || getLocalFallback(lang, "Mobile.Page.loadingMore"),
|
||||
contentnomore:
|
||||
overrides.contentnomore || getLocalFallback(lang, "Mobile.Common.noMoreData"),
|
||||
};
|
||||
};
|
||||
431
qiming-mobile/utils/i18n.uts
Normal file
431
qiming-mobile/utils/i18n.uts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { computed, reactive } from "vue";
|
||||
import { SUCCESS_CODE } from "@/constants/codes.constants";
|
||||
import {
|
||||
DEFAULT_LANG,
|
||||
I18N_LANG_LIST_STORAGE_KEY,
|
||||
I18N_LANG_MAP_STORAGE_KEY,
|
||||
I18N_LANG_STORAGE_KEY,
|
||||
} from "@/constants/i18n.constants";
|
||||
import {
|
||||
getLocalFallback,
|
||||
resolveLiteralKey,
|
||||
toBundleKey,
|
||||
} from "@/constants/i18n.local.constants";
|
||||
import { apiI18nLangList, apiI18nQuery, apiI18nSetLang } from "@/servers/i18n";
|
||||
import type { I18nLangDto, I18nState } from "@/types/interfaces/i18n";
|
||||
import { syncThirdPartyLocales } from "@/utils/i18n-third-party";
|
||||
|
||||
const parseJSON = <T = any,>(value: string, fallback: T): T => {
|
||||
if (!value) return fallback;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const isH5Platform = (): boolean => {
|
||||
// #ifdef H5 || WEB
|
||||
return true;
|
||||
// #endif
|
||||
return false;
|
||||
};
|
||||
|
||||
const isMpWeixinPlatform = (): boolean => {
|
||||
// #ifdef MP-WEIXIN
|
||||
return true;
|
||||
// #endif
|
||||
return false;
|
||||
};
|
||||
|
||||
const getBrowserLang = (): string => {
|
||||
// #ifdef H5 || WEB
|
||||
const navLang = navigator?.language || DEFAULT_LANG;
|
||||
return navLang;
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
const sysLang = uni.getSystemInfoSync().language;
|
||||
if (sysLang) return sysLang;
|
||||
} catch (e) {}
|
||||
// #endif
|
||||
return DEFAULT_LANG;
|
||||
};
|
||||
|
||||
export const normalizeLang = (lang: string): string => {
|
||||
const source = (lang || "").trim();
|
||||
if (!source) return DEFAULT_LANG;
|
||||
|
||||
const bundleKey = toBundleKey(source);
|
||||
|
||||
// 将 bundle key 转换为 PascalCase 显示格式
|
||||
if (bundleKey === "zh-cn") return "zh-CN";
|
||||
if (bundleKey === "zh-tw") return "zh-TW";
|
||||
if (bundleKey === "zh-hk") return "zh-HK";
|
||||
if (bundleKey === "en-us") return "en-US";
|
||||
|
||||
// 未知语言保持原始归一化形式
|
||||
return source.replaceAll("_", "-");
|
||||
};
|
||||
|
||||
const getStoredLang = (): string => {
|
||||
const cachedLang = uni.getStorageSync(I18N_LANG_STORAGE_KEY);
|
||||
if (cachedLang) return normalizeLang(cachedLang);
|
||||
return "";
|
||||
};
|
||||
|
||||
const setStoredLang = (lang: string) => {
|
||||
uni.setStorageSync(I18N_LANG_STORAGE_KEY, normalizeLang(lang));
|
||||
};
|
||||
|
||||
const setStoredLangMap = (langMap: Record<string, string>) => {
|
||||
uni.setStorageSync(I18N_LANG_MAP_STORAGE_KEY, JSON.stringify(langMap || {}));
|
||||
};
|
||||
|
||||
const setStoredLangList = (langList: I18nLangDto[]) => {
|
||||
uni.setStorageSync(
|
||||
I18N_LANG_LIST_STORAGE_KEY,
|
||||
JSON.stringify(langList || []),
|
||||
);
|
||||
};
|
||||
|
||||
const getCachedLangMap = (): Record<string, string> => {
|
||||
const text = uni.getStorageSync(I18N_LANG_MAP_STORAGE_KEY) || "";
|
||||
return parseJSON<Record<string, string>>(text, {});
|
||||
};
|
||||
|
||||
const getCachedLangList = (): I18nLangDto[] => {
|
||||
const text = uni.getStorageSync(I18N_LANG_LIST_STORAGE_KEY) || "";
|
||||
return parseJSON<I18nLangDto[]>(text, []);
|
||||
};
|
||||
|
||||
const sortLangList = (list: I18nLangDto[]): I18nLangDto[] => {
|
||||
const filtered = (list || []).filter((item) => item?.status === 1);
|
||||
return filtered.sort((a, b) => {
|
||||
if (a.sort !== b.sort) return a.sort - b.sort;
|
||||
return a.id - b.id;
|
||||
});
|
||||
};
|
||||
|
||||
const getDefaultLangFromList = (list: I18nLangDto[]): string => {
|
||||
const defaultItem = list.find((item) => item?.isDefault === 1);
|
||||
return normalizeLang(defaultItem?.lang || DEFAULT_LANG);
|
||||
};
|
||||
|
||||
export const i18nState = reactive<I18nState>({
|
||||
currentLang: DEFAULT_LANG,
|
||||
langMap: {},
|
||||
langList: [],
|
||||
loaded: false,
|
||||
loading: false,
|
||||
});
|
||||
let loadI18nTask: Promise<void> | null = null;
|
||||
let loadLangListTask: Promise<void> | null = null;
|
||||
|
||||
const TAB_BAR_I18N_ITEMS: Array<{ index: number; key: string }> = [
|
||||
{ index: 0, key: "Mobile.Nav.home" },
|
||||
{ index: 1, key: "Mobile.Nav.unionRecord" },
|
||||
{ index: 2, key: "Mobile.Nav.agent" },
|
||||
{ index: 3, key: "Mobile.Nav.app" },
|
||||
];
|
||||
|
||||
const fillTemplate = (
|
||||
source: string,
|
||||
params: Record<string, string | number> = {},
|
||||
): string => {
|
||||
let output = source || "";
|
||||
Object.keys(params || {}).forEach((key) => {
|
||||
const val = String(params[key] ?? "");
|
||||
output = output.replace(new RegExp(`\\{${key}\\}`, "g"), val);
|
||||
});
|
||||
return output;
|
||||
};
|
||||
|
||||
const pickLangForInit = (): string => {
|
||||
const cacheLang = getStoredLang();
|
||||
const browserLang = normalizeLang(getBrowserLang());
|
||||
return normalizeLang(cacheLang || browserLang || DEFAULT_LANG);
|
||||
};
|
||||
|
||||
const fetchLangList = async (): Promise<I18nLangDto[]> => {
|
||||
// 允许在 H5 和微信小程序环境调用接口
|
||||
if (isH5Platform() || isMpWeixinPlatform()) {
|
||||
try {
|
||||
const res = await apiI18nLangList();
|
||||
if (res?.code === SUCCESS_CODE && Array.isArray(res?.data)) {
|
||||
const sortedList = sortLangList(res.data);
|
||||
setStoredLangList(sortedList);
|
||||
return sortedList;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("fetchLangList failed:", error);
|
||||
}
|
||||
}
|
||||
return sortLangList(getCachedLangList());
|
||||
};
|
||||
|
||||
const fetchLangMap = async (lang: string = "", active: boolean = false): Promise<Record<string, string>> => {
|
||||
// 允许在 H5 和微信小程序环境调用接口
|
||||
if (!isH5Platform() && !isMpWeixinPlatform()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// 仅在主动切换 (active=true) 且有有效 lang 时才透传给后端,
|
||||
// 否则传空字符串,后端将返回默认翻译而不会修改用户配置。
|
||||
const targetLang = (active && lang) ? normalizeLang(lang) : "";
|
||||
try {
|
||||
// lang 为空时由后端按用户已保存语言/命中逻辑返回对应词典
|
||||
const res = await apiI18nQuery(targetLang);
|
||||
// 只有成功且有数据才返回数据,其余(失败、异常、data=null)统统返回空对象以触发兜底逻辑
|
||||
if (res?.code === SUCCESS_CODE && res?.data) {
|
||||
return res.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("fetchLangMap failed:", error);
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
export const bootstrapI18nCache = () => {
|
||||
const lang = getStoredLang();
|
||||
i18nState.currentLang = normalizeLang(lang || pickLangForInit());
|
||||
i18nState.langMap = getCachedLangMap();
|
||||
i18nState.langList = sortLangList(getCachedLangList());
|
||||
i18nState.loaded = Object.keys(i18nState.langMap || {}).length > 0;
|
||||
syncThirdPartyLocales(i18nState.currentLang);
|
||||
applyTabBarI18n();
|
||||
};
|
||||
|
||||
const resolveMessage = (
|
||||
lang: string,
|
||||
keyOrText: string,
|
||||
fallback: string = "",
|
||||
): string => {
|
||||
const key = resolveLiteralKey(keyOrText);
|
||||
|
||||
// 1. 优先尝试从服务端下发的语言包匹配
|
||||
const langMap = i18nState.langMap;
|
||||
if (langMap != null) {
|
||||
const fromServer = langMap[key];
|
||||
// 如果 key 存在(即便 value 是空字符串)
|
||||
if (fromServer !== undefined) {
|
||||
// 如果 value 为空字符串,则返回 key 本身;否则返回翻译值(保持同 PC 端一致)
|
||||
return fromServer === "" ? key : fromServer;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 映射未命中或原生端通过本地包兜底
|
||||
const currentLangKey = toBundleKey(i18nState.currentLang || DEFAULT_LANG);
|
||||
// 如果当前是中文类语言,优先尝试本地中文包兜底(实现原生端默认中文需求)
|
||||
if (currentLangKey.startsWith("zh")) {
|
||||
const zhFallbackValue = getLocalFallback(currentLangKey, key);
|
||||
if (zhFallbackValue) return zhFallbackValue;
|
||||
}
|
||||
|
||||
// 3. 全局英语兜底(仅当非中文且远程失败,或中文本地包仍缺失时)
|
||||
const enFallbackValue = getLocalFallback("en-us", key);
|
||||
if (enFallbackValue) return enFallbackValue;
|
||||
|
||||
// 3. 最后兜底返回
|
||||
return fallback || keyOrText;
|
||||
};
|
||||
|
||||
export const t = (
|
||||
key: string,
|
||||
params: Record<string, string | number> = {},
|
||||
fallback: string = "",
|
||||
): string => {
|
||||
const lang = normalizeLang(i18nState.currentLang || DEFAULT_LANG);
|
||||
const value = resolveMessage(lang, key, fallback);
|
||||
return fillTemplate(value, params);
|
||||
};
|
||||
|
||||
export const translateText = (
|
||||
text: string,
|
||||
params: Record<string, string | number> = {},
|
||||
): string => {
|
||||
if (!text) return "";
|
||||
return t(text, params, text);
|
||||
};
|
||||
|
||||
export const applyTabBarI18n = () => {
|
||||
// #ifndef MP-WEIXIN || H5 || WEB
|
||||
return;
|
||||
// #endif
|
||||
|
||||
TAB_BAR_I18N_ITEMS.forEach((item) => {
|
||||
try {
|
||||
uni.setTabBarItem({
|
||||
index: item.index,
|
||||
text: t(item.key),
|
||||
fail: (err) => {
|
||||
// Ignore tabBar sync failures on non-tab pages.
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore tabBar sync failures on non-tab pages.
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const loadI18n = async (active: boolean = false) => {
|
||||
if (loadI18nTask) {
|
||||
await loadI18nTask;
|
||||
return;
|
||||
}
|
||||
|
||||
loadI18nTask = (async () => {
|
||||
i18nState.loading = true;
|
||||
try {
|
||||
const initLang = pickLangForInit();
|
||||
let targetLang = initLang;
|
||||
|
||||
// 如果翻译包为空(接口失败或返回 null),统一强制回退到本地英语态作为安全兜底
|
||||
i18nState.currentLang = normalizeLang(targetLang);
|
||||
const langMap = await fetchLangMap(targetLang, active);
|
||||
|
||||
// 如果翻译包为空(接口失败或返回 null),且非中文环境,统一回退到本地英语态作为安全兜底
|
||||
if (Object.keys(langMap).length === 0 && !targetLang.toLowerCase().startsWith("zh")) {
|
||||
i18nState.currentLang = normalizeLang("en-us");
|
||||
i18nState.langMap = {};
|
||||
} else {
|
||||
i18nState.currentLang = normalizeLang(targetLang);
|
||||
i18nState.langMap = langMap;
|
||||
}
|
||||
|
||||
i18nState.loaded = true;
|
||||
setStoredLang(i18nState.currentLang);
|
||||
setStoredLangMap(i18nState.langMap);
|
||||
syncThirdPartyLocales(i18nState.currentLang);
|
||||
applyTabBarI18n();
|
||||
} finally {
|
||||
i18nState.loading = false;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await loadI18nTask;
|
||||
} finally {
|
||||
loadI18nTask = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 确保语言列表已加载(按需加载)
|
||||
*/
|
||||
export const ensureLangList = async () => {
|
||||
if (loadLangListTask) {
|
||||
await loadLangListTask;
|
||||
return;
|
||||
}
|
||||
|
||||
loadLangListTask = (async () => {
|
||||
try {
|
||||
const list = await fetchLangList();
|
||||
i18nState.langList = list;
|
||||
} catch (error) {
|
||||
console.error("ensureLangList failed:", error);
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await loadLangListTask;
|
||||
} finally {
|
||||
loadLangListTask = null;
|
||||
}
|
||||
};
|
||||
|
||||
export const setLanguage = async (lang: string): Promise<boolean> => {
|
||||
const targetLang = normalizeLang(lang);
|
||||
if (!targetLang) return false;
|
||||
|
||||
const previousLang = i18nState.currentLang;
|
||||
const previousLangMap = i18nState.langMap;
|
||||
|
||||
try {
|
||||
const queryResult = await apiI18nQuery(targetLang);
|
||||
|
||||
// 接口请求失败或业务码错误,执行统一英语兜底
|
||||
if (!queryResult || queryResult.code !== SUCCESS_CODE || !queryResult.data) {
|
||||
i18nState.currentLang = normalizeLang("en-us");
|
||||
i18nState.langMap = {};
|
||||
} else {
|
||||
i18nState.currentLang = targetLang;
|
||||
i18nState.langMap = queryResult.data;
|
||||
}
|
||||
|
||||
setStoredLang(i18nState.currentLang);
|
||||
setStoredLangMap(i18nState.langMap);
|
||||
syncThirdPartyLocales(i18nState.currentLang);
|
||||
applyTabBarI18n();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("setLanguage failed, perform English fallback:", error);
|
||||
// 接口异常,执行统一英语兜底
|
||||
i18nState.currentLang = normalizeLang("en-us");
|
||||
i18nState.langMap = {};
|
||||
setStoredLang(i18nState.currentLang);
|
||||
setStoredLangMap(i18nState.langMap);
|
||||
syncThirdPartyLocales(i18nState.currentLang);
|
||||
applyTabBarI18n();
|
||||
return true; // 即使异常也视为“处理成功(切换到了兜底态)”以避免业务阻塞
|
||||
}
|
||||
};
|
||||
|
||||
export const syncLanguageFromUser = async (
|
||||
lang: string = "",
|
||||
): Promise<void> => {
|
||||
const userLang = normalizeLang(lang);
|
||||
if (!userLang) return;
|
||||
|
||||
if (userLang === normalizeLang(i18nState.currentLang)) {
|
||||
setStoredLang(userLang);
|
||||
if (Object.keys(i18nState.langMap || {}).length === 0) {
|
||||
const latestLangMap = await fetchLangMap(userLang, false);
|
||||
// 如果翻译包为空且本地已为空,强制开启英语兜底状态
|
||||
if (Object.keys(latestLangMap).length === 0) {
|
||||
i18nState.currentLang = normalizeLang("en-us");
|
||||
i18nState.langMap = {};
|
||||
} else {
|
||||
i18nState.langMap = latestLangMap;
|
||||
}
|
||||
setStoredLangMap(i18nState.langMap);
|
||||
setStoredLang(i18nState.currentLang);
|
||||
syncThirdPartyLocales(i18nState.currentLang);
|
||||
applyTabBarI18n();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
i18nState.currentLang = userLang;
|
||||
setStoredLang(userLang);
|
||||
|
||||
const latestLangMap = await fetchLangMap(userLang, false);
|
||||
// 如果同步中翻译包为空(接口异常等),统一回退到英语
|
||||
if (Object.keys(latestLangMap).length === 0) {
|
||||
i18nState.currentLang = normalizeLang("en-us");
|
||||
i18nState.langMap = {};
|
||||
} else {
|
||||
i18nState.langMap = latestLangMap;
|
||||
}
|
||||
|
||||
setStoredLangMap(i18nState.langMap);
|
||||
setStoredLang(i18nState.currentLang);
|
||||
|
||||
syncThirdPartyLocales(i18nState.currentLang);
|
||||
applyTabBarI18n();
|
||||
};
|
||||
|
||||
export const useI18n = () => {
|
||||
return {
|
||||
i18nState,
|
||||
t,
|
||||
translateText,
|
||||
currentLang: computed(() => i18nState.currentLang),
|
||||
langList: computed(() => sortLangList(i18nState.langList || [])),
|
||||
loadI18n,
|
||||
ensureLangList,
|
||||
setLanguage,
|
||||
syncLanguageFromUser,
|
||||
};
|
||||
};
|
||||
198
qiming-mobile/utils/markdown.uts
Normal file
198
qiming-mobile/utils/markdown.uts
Normal file
@@ -0,0 +1,198 @@
|
||||
|
||||
const defaultDelimiters = [
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
];
|
||||
|
||||
// 转义括号规则 - 通用数学公式解析器
|
||||
function escapedBracketRule(delimiters: any) {
|
||||
return (text: string, startPos: number = 0) => {
|
||||
const max = text.length;
|
||||
const start = startPos;
|
||||
|
||||
for (const { left, right, display } of delimiters) {
|
||||
// 检查是否以左标记开始
|
||||
if (!text.slice(start).startsWith(left)) continue;
|
||||
|
||||
// 跳过左标记的长度
|
||||
let pos = start + left.length;
|
||||
|
||||
// 寻找匹配的右标记
|
||||
while (pos < max) {
|
||||
if (text.slice(pos).startsWith(right)) {
|
||||
break;
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
|
||||
// 没找到匹配的右标记,跳过,进入下个匹配
|
||||
if (pos >= max) continue;
|
||||
|
||||
// 提取数学公式内容
|
||||
const content = text.slice(start + left.length, pos);
|
||||
const endPos = pos + right.length;
|
||||
|
||||
return {
|
||||
formula: content,
|
||||
display,
|
||||
start,
|
||||
end: endPos,
|
||||
left,
|
||||
right,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
formula: '',
|
||||
display: false,
|
||||
start: 0,
|
||||
end: 0,
|
||||
left: '',
|
||||
right: '',
|
||||
success: false,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 根据规则将连续的过程标签(ToolCall, Skill等)合并
|
||||
* 规则:
|
||||
* 1. 连续 2 个及以上的过程标签合并
|
||||
* 2. 中间包含“执行计划”的不合并(作为分隔符)
|
||||
* 3. 标签间只包含空白字符时不中断合并
|
||||
* 4. 支持 :::container 语法和 PC 端的 HTML 标签语法
|
||||
* @param text - 待处理的 Markdown 文本
|
||||
* @returns 处理后的文本
|
||||
*/
|
||||
export function groupMarkdownContainers(text : string) : string {
|
||||
if (!text) return '';
|
||||
|
||||
// 1. 匹配 HTML 格式的过程标签 (markdown-custom-process)
|
||||
// 2. 匹配 :::container 格式的标签
|
||||
const blockRegex = /(?:[\r\n\s]*)(?:(?:<(?:div|p)\b[^>]*>)?\s*(<markdown-custom-process\b[^>]*?>(?:<\/markdown-custom-process>|\/>)?)\s*(?:<\/(?:div|p)>)?|(:::container\b[^\n]*?\n:::))/g;
|
||||
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
let currentGroup : string[] = [];
|
||||
let match : RegExpExecArray | null;
|
||||
|
||||
const flushGroup = () => {
|
||||
if (currentGroup.length > 0) {
|
||||
if (currentGroup.length >= 2) {
|
||||
// 多个时,为了支持层级,必须使用规范化后的 HTML 标签
|
||||
const groupContent = `\n\n<markdown-custom-process-group>\n${normalizedGroup.join('\n')}\n</markdown-custom-process-group>\n\n`;
|
||||
result += groupContent;
|
||||
} else {
|
||||
// 只有一个工具时,保持原样输出,最稳妥
|
||||
result += `\n\n${currentGroup[0]}\n\n`;
|
||||
}
|
||||
currentGroup = [];
|
||||
normalizedGroup = [];
|
||||
}
|
||||
};
|
||||
|
||||
let count = 0;
|
||||
let normalizedGroup : string[] = [];
|
||||
|
||||
while ((match = blockRegex.exec(text)) !== null) {
|
||||
count++;
|
||||
const textBefore = text.slice(lastIndex, match.index);
|
||||
|
||||
if (textBefore.trim() !== '') {
|
||||
flushGroup();
|
||||
result += textBefore;
|
||||
} else {
|
||||
if (currentGroup.length === 0) {
|
||||
result += textBefore;
|
||||
}
|
||||
}
|
||||
|
||||
let tagMatch = match[1] || match[2] || '';
|
||||
|
||||
// 为了分组显示,我们依然需要一份规范化后的 HTML 标签
|
||||
let normalizedTag = '';
|
||||
if (tagMatch.startsWith(':::container')) {
|
||||
const executeId = extractAttribute(tagMatch, 'executeId');
|
||||
const type = extractAttribute(tagMatch, 'type');
|
||||
const status = extractAttribute(tagMatch, 'status');
|
||||
const name = extractAttribute(tagMatch, 'name');
|
||||
const safeName = name.replace(/"/g, '"');
|
||||
normalizedTag = `<markdown-custom-process executeid="${executeId}" type="${type || ''}" status="${status || ''}" name="${safeName}"></markdown-custom-process>`;
|
||||
} else {
|
||||
normalizedTag = tagMatch;
|
||||
if (!normalizedTag.includes('</markdown-custom-process>') && !normalizedTag.endsWith('/>')) {
|
||||
normalizedTag = normalizedTag.replace('>', '></markdown-custom-process>');
|
||||
}
|
||||
}
|
||||
|
||||
const isPlan = /type=["']Plan["']/.test(normalizedTag);
|
||||
if (isPlan) {
|
||||
flushGroup();
|
||||
// Plan 类型保持原样输出
|
||||
result += `\n\n${tagMatch}\n\n`;
|
||||
} else {
|
||||
currentGroup.push(tagMatch);
|
||||
normalizedGroup.push(normalizedTag);
|
||||
}
|
||||
|
||||
lastIndex = blockRegex.lastIndex;
|
||||
}
|
||||
|
||||
flushGroup();
|
||||
result += text.slice(lastIndex);
|
||||
|
||||
const finalResult = result.replace(/\n{3,}/g, '\n\n').trim();
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 从属性字符串中提取指定属性的值
|
||||
*/
|
||||
function extractAttribute(attrString : string, attrName : string) : string {
|
||||
const regex = new RegExp(`${attrName}=["']([^"']*)["']`);
|
||||
const match = attrString.match(regex);
|
||||
if (match != null) {
|
||||
const val = match[1];
|
||||
// 还原 HTML 实体,避免在后续处理中出现双重转义
|
||||
return val.replace(/"/g, '"');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 新的数学公式替换函数 - 直接替换为 $$ 分隔符
|
||||
export function replaceMathBracket(text : string) : string {
|
||||
// return text; //临时关闭
|
||||
|
||||
// 创建只包含非美元符号分隔符的选项
|
||||
const nonDollarDelimiters = defaultDelimiters.filter(
|
||||
(delimiter) =>
|
||||
!delimiter.left.includes('$') && !delimiter.right.includes('$'),
|
||||
);
|
||||
|
||||
const rule = escapedBracketRule(nonDollarDelimiters);
|
||||
let result = '';
|
||||
let pos = 0;
|
||||
|
||||
while (pos < text.length) {
|
||||
const match = rule(text, pos);
|
||||
if (match.success) {
|
||||
// 添加匹配前的文本
|
||||
result += text.slice(pos, match.start);
|
||||
// 替换为 $$ 分隔符
|
||||
const delimiter = match.display ? '$$' : '$';
|
||||
result += `${delimiter}${match.formula}${delimiter}`;
|
||||
pos = match.end;
|
||||
} else {
|
||||
// 没有匹配,添加当前字符
|
||||
result += text[pos];
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
79
qiming-mobile/utils/markdownParser.uts
Normal file
79
qiming-mobile/utils/markdownParser.uts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { MarkdownElement } from '@/types/interfaces/chat'
|
||||
|
||||
// Markdown解析器
|
||||
export class MarkdownParser {
|
||||
// 解析markdown文本
|
||||
parse(text: string): MarkdownElement[] {
|
||||
const elements: MarkdownElement[] = []
|
||||
const lines = text.split('\n')
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
|
||||
if (line.startsWith('#')) {
|
||||
// 解析标题
|
||||
const level = line.match(/^#+/)?.[0].length || 1
|
||||
const content = line.replace(/^#+\s*/, '')
|
||||
elements.push({
|
||||
type: 'heading',
|
||||
content: content,
|
||||
level: level
|
||||
})
|
||||
} else if (line.startsWith('```')) {
|
||||
// 解析代码块
|
||||
const language = line.replace(/^```/, '').trim()
|
||||
let codeContent = ''
|
||||
i++
|
||||
while (i < lines.length && !lines[i].startsWith('```')) {
|
||||
codeContent += lines[i] + '\n'
|
||||
i++
|
||||
}
|
||||
elements.push({
|
||||
type: 'code',
|
||||
content: codeContent.trim(),
|
||||
language: language
|
||||
})
|
||||
} else if (line.startsWith('- ')) {
|
||||
// 解析列表
|
||||
const content = line.replace(/^-\s*/, '')
|
||||
elements.push({
|
||||
type: 'list',
|
||||
content: content
|
||||
})
|
||||
} else if (line.startsWith('> ')) {
|
||||
// 解析引用
|
||||
const content = line.replace(/^>\s*/, '')
|
||||
elements.push({
|
||||
type: 'blockquote',
|
||||
content: content
|
||||
})
|
||||
} else if (line === '---') {
|
||||
// 解析分割线
|
||||
elements.push({
|
||||
type: 'hr',
|
||||
content: ''
|
||||
})
|
||||
} else if (line.includes('[') && line.includes('](')) {
|
||||
// 解析链接
|
||||
const match = line.match(/\[([^\]]+)\]\(([^)]+)\)/)
|
||||
if (match && match[1] && match[2]) {
|
||||
elements.push({
|
||||
type: 'link',
|
||||
content: match[1],
|
||||
href: match[2]
|
||||
})
|
||||
}
|
||||
} else if (line) {
|
||||
// 普通文本
|
||||
elements.push({
|
||||
type: 'text',
|
||||
content: line
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return elements
|
||||
}
|
||||
}
|
||||
|
||||
export const markdownParser = new MarkdownParser()
|
||||
465
qiming-mobile/utils/mockApiService.uts
Normal file
465
qiming-mobile/utils/mockApiService.uts
Normal file
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* 模拟后端API服务
|
||||
* 用于语音转文字功能的模拟接口实现
|
||||
*/
|
||||
|
||||
// API响应接口
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// 语音转文字请求参数
|
||||
export interface VoiceToTextRequest {
|
||||
audio: any // 音频文件
|
||||
format: string // 音频格式
|
||||
duration: number // 音频时长(秒)
|
||||
language?: string // 语言类型
|
||||
userId?: string // 用户ID
|
||||
}
|
||||
|
||||
// 语音转文字响应数据
|
||||
export interface VoiceToTextResponse {
|
||||
text: string // 转换后的文本
|
||||
confidence: number // 识别置信度 (0-1)
|
||||
duration: number // 音频时长
|
||||
segments?: Array<{ // 分段结果
|
||||
text: string
|
||||
start: number
|
||||
end: number
|
||||
confidence: number
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟API服务类
|
||||
*/
|
||||
export class MockApiService {
|
||||
private static instance: MockApiService
|
||||
private requestDelay: number = 1500 // 模拟网络延迟
|
||||
private successRate: number = 0.95 // 成功率
|
||||
|
||||
// 预设的模拟文本库
|
||||
private mockTexts = {
|
||||
short: [
|
||||
"你好",
|
||||
"谢谢",
|
||||
"再见",
|
||||
"是的",
|
||||
"不是",
|
||||
"好的",
|
||||
"没问题",
|
||||
"可以"
|
||||
],
|
||||
medium: [
|
||||
"这是一段测试语音",
|
||||
"语音识别功能正常",
|
||||
"请检查录音质量",
|
||||
"系统运行正常",
|
||||
"欢迎使用语音功能",
|
||||
"录音测试成功",
|
||||
"语音转文字完成"
|
||||
],
|
||||
long: [
|
||||
"这是一段较长的语音转文字测试内容,用于验证系统的识别能力",
|
||||
"人工智能语音识别技术在近年来取得了重大突破,准确率不断提升",
|
||||
"语音交互将成为未来人机交互的重要方式,应用前景非常广阔",
|
||||
"通过深度学习和神经网络技术,语音识别系统能够更好地理解人类语言",
|
||||
"语音转文字功能在智能客服、会议记录、语音助手等场景中发挥重要作用"
|
||||
]
|
||||
}
|
||||
|
||||
// 错误类型库
|
||||
private errorTypes = [
|
||||
{
|
||||
code: 'NETWORK_ERROR',
|
||||
message: '网络连接异常,请检查网络设置'
|
||||
},
|
||||
{
|
||||
code: 'AUDIO_QUALITY_LOW',
|
||||
message: '音频质量较低,识别结果可能不准确'
|
||||
},
|
||||
{
|
||||
code: 'SERVER_BUSY',
|
||||
message: '服务器繁忙,请稍后重试'
|
||||
},
|
||||
{
|
||||
code: 'FORMAT_NOT_SUPPORTED',
|
||||
message: '音频格式不支持,请使用标准格式'
|
||||
},
|
||||
{
|
||||
code: 'DURATION_TOO_SHORT',
|
||||
message: '音频时长过短,无法识别'
|
||||
},
|
||||
{
|
||||
code: 'LANGUAGE_NOT_SUPPORTED',
|
||||
message: '不支持的语言类型'
|
||||
}
|
||||
]
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
static getInstance(): MockApiService {
|
||||
if (!MockApiService.instance) {
|
||||
MockApiService.instance = new MockApiService()
|
||||
}
|
||||
return MockApiService.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音转文字接口
|
||||
*/
|
||||
async voiceToText(request: VoiceToTextRequest): Promise<ApiResponse<VoiceToTextResponse>> {
|
||||
// 参数验证
|
||||
const validationError = this.validateRequest(request)
|
||||
if (validationError) {
|
||||
return this.createErrorResponse(validationError.code, validationError.message)
|
||||
}
|
||||
|
||||
// 模拟网络延迟
|
||||
await this.delay(this.requestDelay + Math.random() * 1000)
|
||||
|
||||
// 模拟请求失败
|
||||
if (Math.random() > this.successRate) {
|
||||
const randomError = this.errorTypes[Math.floor(Math.random() * this.errorTypes.length)]
|
||||
return this.createErrorResponse(randomError.code, randomError.message)
|
||||
}
|
||||
|
||||
// 生成模拟结果
|
||||
const result = this.generateMockResult(request)
|
||||
|
||||
return this.createSuccessResponse(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证请求参数
|
||||
*/
|
||||
private validateRequest(request: VoiceToTextRequest): { code: string, message: string } | null {
|
||||
if (!request.audio) {
|
||||
return { code: 'MISSING_AUDIO', message: '音频文件不能为空' }
|
||||
}
|
||||
|
||||
if (request.duration < 0.5) {
|
||||
return { code: 'DURATION_TOO_SHORT', message: '音频时长过短,至少需要0.5秒' }
|
||||
}
|
||||
|
||||
if (request.duration > 600) {
|
||||
return { code: 'DURATION_TOO_LONG', message: '音频时长过长,最多支持10分钟' }
|
||||
}
|
||||
|
||||
const supportedFormats = ['mp3', 'wav', 'aac', 'PCM', 'webm']
|
||||
if (!supportedFormats.includes(request.format?.toLowerCase())) {
|
||||
return { code: 'FORMAT_NOT_SUPPORTED', message: '不支持的音频格式' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成模拟识别结果
|
||||
*/
|
||||
private generateMockResult(request: VoiceToTextRequest): VoiceToTextResponse {
|
||||
const duration = request.duration
|
||||
let selectedText: string
|
||||
|
||||
// 根据音频时长选择合适的文本
|
||||
if (duration < 2) {
|
||||
selectedText = this.mockTexts.short[Math.floor(Math.random() * this.mockTexts.short.length)]
|
||||
} else if (duration < 8) {
|
||||
selectedText = this.mockTexts.medium[Math.floor(Math.random() * this.mockTexts.medium.length)]
|
||||
} else {
|
||||
selectedText = this.mockTexts.long[Math.floor(Math.random() * this.mockTexts.long.length)]
|
||||
}
|
||||
|
||||
// 根据语言类型调整文本
|
||||
if (request.language === 'en-US') {
|
||||
selectedText = this.translateToEnglish(selectedText)
|
||||
}
|
||||
|
||||
// 生成置信度 (时长越长,置信度可能越低)
|
||||
let baseConfidence = 0.95
|
||||
if (duration > 30) {
|
||||
baseConfidence = 0.85
|
||||
} else if (duration > 60) {
|
||||
baseConfidence = 0.80
|
||||
}
|
||||
|
||||
const confidence = baseConfidence - Math.random() * 0.15
|
||||
|
||||
return {
|
||||
text: selectedText,
|
||||
confidence: Math.max(0.60, confidence),
|
||||
duration: duration,
|
||||
segments: this.generateSegments(selectedText, duration)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的中英文翻译(模拟)
|
||||
*/
|
||||
private translateToEnglish(chineseText: string): string {
|
||||
const translations: Record<string, string> = {
|
||||
'你好': 'Hello',
|
||||
'谢谢': 'Thank you',
|
||||
'再见': 'Goodbye',
|
||||
'是的': 'Yes',
|
||||
'不是': 'No',
|
||||
'好的': 'OK',
|
||||
'没问题': 'No problem',
|
||||
'可以': 'Sure',
|
||||
'这是一段测试语音': 'This is a test voice',
|
||||
'语音识别功能正常': 'Voice recognition is working',
|
||||
'请检查录音质量': 'Please check recording quality',
|
||||
'系统运行正常': 'System is running normally',
|
||||
'欢迎使用语音功能': 'Welcome to use voice function',
|
||||
'录音测试成功': 'Recording test successful',
|
||||
'语音转文字完成': 'Speech to text completed'
|
||||
}
|
||||
|
||||
return translations[chineseText] || 'This is a voice recognition test'
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成分段结果
|
||||
*/
|
||||
private generateSegments(text: string, duration: number): Array<{
|
||||
text: string
|
||||
start: number
|
||||
end: number
|
||||
confidence: number
|
||||
}> {
|
||||
const segments = []
|
||||
const words = text.split('')
|
||||
const avgTimePerChar = duration / words.length
|
||||
|
||||
let currentTime = 0
|
||||
let currentSegment = ''
|
||||
const segmentLength = Math.max(1, Math.floor(words.length / Math.max(1, Math.floor(duration / 3))))
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
currentSegment += words[i]
|
||||
|
||||
if (currentSegment.length >= segmentLength || i === words.length - 1) {
|
||||
const segmentDuration = currentSegment.length * avgTimePerChar
|
||||
|
||||
segments.push({
|
||||
text: currentSegment,
|
||||
start: currentTime,
|
||||
end: currentTime + segmentDuration,
|
||||
confidence: 0.70 + Math.random() * 0.30
|
||||
})
|
||||
|
||||
currentTime += segmentDuration
|
||||
currentSegment = ''
|
||||
}
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建成功响应
|
||||
*/
|
||||
private createSuccessResponse<T>(data: T): ApiResponse<T> {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建错误响应
|
||||
*/
|
||||
private createErrorResponse(code: string, message: string): ApiResponse {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message
|
||||
},
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟函数
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置请求延迟
|
||||
*/
|
||||
setRequestDelay(delay: number): void {
|
||||
this.requestDelay = Math.max(0, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置成功率
|
||||
*/
|
||||
setSuccessRate(rate: number): void {
|
||||
this.successRate = Math.max(0, Math.min(1, rate))
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加自定义文本
|
||||
*/
|
||||
addMockText(category: 'short' | 'medium' | 'long', text: string): void {
|
||||
this.mockTexts[category].push(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加自定义错误类型
|
||||
*/
|
||||
addErrorType(code: string, message: string): void {
|
||||
this.errorTypes.push({ code, message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API客户端类
|
||||
* 用于统一管理API调用
|
||||
*/
|
||||
export class ApiClient {
|
||||
private baseUrl: string = ''
|
||||
private timeout: number = 30000
|
||||
private mockService: MockApiService
|
||||
|
||||
constructor(baseUrl: string = '', useMock: boolean = true) {
|
||||
this.baseUrl = baseUrl
|
||||
this.mockService = MockApiService.getInstance()
|
||||
|
||||
// 如果没有提供baseUrl或者明确使用模拟服务,则使用模拟服务
|
||||
if (!baseUrl || useMock) {
|
||||
console.log('使用模拟API服务')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音转文字接口调用
|
||||
*/
|
||||
async voiceToText(audioFile: any, options: {
|
||||
format: string
|
||||
duration: number
|
||||
language?: string
|
||||
userId?: string
|
||||
}): Promise<ApiResponse<VoiceToTextResponse>> {
|
||||
|
||||
// 如果没有配置真实API地址,使用模拟服务
|
||||
if (!this.baseUrl) {
|
||||
return this.mockService.voiceToText({
|
||||
audio: audioFile,
|
||||
format: options.format,
|
||||
duration: options.duration,
|
||||
language: options.language || 'zh-CN',
|
||||
userId: options.userId
|
||||
})
|
||||
}
|
||||
|
||||
// 真实API调用实现
|
||||
return this.realApiCall(audioFile, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 真实API调用
|
||||
*/
|
||||
private async realApiCall(audioFile: any, options: any): Promise<ApiResponse<VoiceToTextResponse>> {
|
||||
try {
|
||||
const response = await uni.request({
|
||||
url: `${this.baseUrl}/api/voice-to-text`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
audio: audioFile,
|
||||
format: options.format,
|
||||
duration: options.duration,
|
||||
language: options.language || 'zh-CN',
|
||||
userId: options.userId
|
||||
},
|
||||
timeout: this.timeout,
|
||||
header: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.statusCode === 200) {
|
||||
return response.data as ApiResponse<VoiceToTextResponse>
|
||||
} else {
|
||||
throw new Error(`API调用失败,状态码: ${response.statusCode}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'API_ERROR',
|
||||
message: error.message || 'API调用失败'
|
||||
},
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置超时时间
|
||||
*/
|
||||
setTimeout(timeout: number): void {
|
||||
this.timeout = timeout
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模拟服务实例
|
||||
*/
|
||||
getMockService(): MockApiService {
|
||||
return this.mockService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建API客户端实例
|
||||
*/
|
||||
export function createApiClient(baseUrl?: string, useMock: boolean = true): ApiClient {
|
||||
return new ApiClient(baseUrl, useMock)
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认API客户端实例
|
||||
*/
|
||||
export const defaultApiClient = createApiClient()
|
||||
|
||||
/**
|
||||
* 便捷的语音转文字函数
|
||||
*/
|
||||
export async function convertVoiceToText(
|
||||
audioFile: any,
|
||||
options: {
|
||||
format: string
|
||||
duration: number
|
||||
language?: string
|
||||
userId?: string
|
||||
}
|
||||
): Promise<string> {
|
||||
try {
|
||||
const response = await defaultApiClient.voiceToText(audioFile, options)
|
||||
|
||||
if (response.success && response.data) {
|
||||
return response.data.text
|
||||
} else {
|
||||
throw new Error(response.error?.message || '语音转换失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('语音转文字失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 导出模拟服务实例
|
||||
export const mockApiService = MockApiService.getInstance()
|
||||
111
qiming-mobile/utils/parseUrlInfo.uts
Normal file
111
qiming-mobile/utils/parseUrlInfo.uts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* URL 解析工具(兼容 H5 & 微信小程序)
|
||||
*/
|
||||
export interface ParseUrlResult {
|
||||
path: string;
|
||||
query: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 url 中解析 path 和指定 query 参数
|
||||
* @param input url 字符串(完整 url / 相对路径 / query)
|
||||
* @param pickKeys 需要提取的 query key(不传则全部返回)
|
||||
*/
|
||||
export function parseUrlInfo(
|
||||
input: string,
|
||||
pickKeys?: string[],
|
||||
): ParseUrlResult {
|
||||
if (!input) {
|
||||
return { path: "", query: {} };
|
||||
}
|
||||
|
||||
// 统一 decode(避免多次 decode)
|
||||
let decoded = input;
|
||||
try {
|
||||
decoded = decodeURIComponent(input);
|
||||
} catch {
|
||||
decoded = input;
|
||||
}
|
||||
|
||||
let path = "";
|
||||
let queryStr = "";
|
||||
|
||||
// 1️⃣ 完整 URL(https://xxx.com/xx?a=1)
|
||||
if (/^https?:\/\//i.test(decoded)) {
|
||||
const [p, q = ""] = decoded.split("?");
|
||||
path = p.replace(/^https?:\/\/[^/]+/, "");
|
||||
queryStr = q;
|
||||
}
|
||||
// 2️⃣ 相对路径(/xx/yy?a=1)
|
||||
else if (decoded.includes("?")) {
|
||||
const [p, q] = decoded.split("?");
|
||||
path = p;
|
||||
queryStr = q;
|
||||
}
|
||||
// 3️⃣ 仅 path
|
||||
else if (decoded.startsWith("/")) {
|
||||
path = decoded;
|
||||
}
|
||||
// 4️⃣ 仅 query(a=1&b=2)
|
||||
else {
|
||||
queryStr = decoded;
|
||||
}
|
||||
|
||||
// 解析 query
|
||||
const query: Record<string, string> = {};
|
||||
if (queryStr) {
|
||||
queryStr.split("&").forEach((item) => {
|
||||
if (!item) return;
|
||||
const [k, v = ""] = item.split("=");
|
||||
if (!k) return;
|
||||
|
||||
// 二次保护 decode
|
||||
try {
|
||||
query[k] = decodeURIComponent(v);
|
||||
} catch {
|
||||
query[k] = v;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 只取指定 key
|
||||
if (pickKeys?.length) {
|
||||
const picked: Record<string, string> = {};
|
||||
pickKeys.forEach((k) => {
|
||||
if (query[k] !== undefined) {
|
||||
picked[k] = query[k];
|
||||
}
|
||||
});
|
||||
return { path, query: picked };
|
||||
}
|
||||
|
||||
return { path, query };
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 从 URL 或字符串中获取文件名
|
||||
* @param input URL 或 文件名
|
||||
* @returns 文件名
|
||||
*/
|
||||
export function getFileName(input: string): string {
|
||||
if (!input) return '';
|
||||
|
||||
// 去掉 query 参数
|
||||
const withoutQuery = input.split('?')[0];
|
||||
|
||||
// 取最后一个 /
|
||||
const lastSlashIndex = withoutQuery.lastIndexOf('/');
|
||||
const fileName =
|
||||
lastSlashIndex !== -1
|
||||
? withoutQuery.slice(lastSlashIndex + 1)
|
||||
: withoutQuery;
|
||||
|
||||
// 解码(防止中文被 encode)
|
||||
try {
|
||||
return decodeURIComponent(fileName);
|
||||
} catch {
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
262
qiming-mobile/utils/permissionHelper.uts
Normal file
262
qiming-mobile/utils/permissionHelper.uts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 权限处理工具类
|
||||
* 用于处理H5端的各种权限请求和状态检测
|
||||
*/
|
||||
import { t } from "@/utils/i18n";
|
||||
|
||||
// 权限状态类型
|
||||
export type PermissionState = 'granted' | 'denied' | 'prompt' | 'unknown'
|
||||
|
||||
// 权限类型
|
||||
export type PermissionType = 'microphone' | 'camera' | 'geolocation' | 'notifications'
|
||||
|
||||
/**
|
||||
* 权限管理器类
|
||||
*/
|
||||
export class PermissionManager {
|
||||
private static instance: PermissionManager | null = null
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
static getInstance(): PermissionManager {
|
||||
if (!PermissionManager.instance) {
|
||||
PermissionManager.instance = new PermissionManager()
|
||||
}
|
||||
return PermissionManager.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查权限状态
|
||||
* @param permissionType 权限类型
|
||||
* @returns 权限状态
|
||||
*/
|
||||
async checkPermission(permissionType: PermissionType): Promise<PermissionState> {
|
||||
// #ifdef H5
|
||||
try {
|
||||
if (navigator.permissions && navigator.permissions.query) {
|
||||
const permission = await navigator.permissions.query({ name: permissionType as PermissionName })
|
||||
return permission.state
|
||||
} else {
|
||||
// 如果不支持 permissions API,尝试获取权限来检测状态
|
||||
return await this.checkPermissionByRequest(permissionType)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`检查${permissionType}权限失败:`, error)
|
||||
return 'unknown'
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
return 'granted' // 非H5平台默认有权限
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过请求权限来检测权限状态
|
||||
* @param permissionType 权限类型
|
||||
* @returns 权限状态
|
||||
*/
|
||||
private async checkPermissionByRequest(permissionType: PermissionType): Promise<PermissionState> {
|
||||
// #ifdef H5
|
||||
try {
|
||||
switch (permissionType) {
|
||||
case 'microphone': // 麦克风
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
return 'granted' // 允许
|
||||
case 'camera': // 摄像头
|
||||
const videoStream = await navigator.mediaDevices.getUserMedia({ video: true })
|
||||
videoStream.getTracks().forEach(track => track.stop())
|
||||
return 'granted' // 允许
|
||||
default: // 其他
|
||||
return 'unknown' // 未知
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'NotAllowedError') {
|
||||
return 'denied' // 拒绝
|
||||
} else {
|
||||
return 'prompt' // 提示
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
return 'granted'
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求权限
|
||||
* @param permissionType 权限类型
|
||||
* @returns 是否成功获取权限
|
||||
*/
|
||||
async requestPermission(permissionType: PermissionType): Promise<boolean> {
|
||||
// #ifdef H5
|
||||
try {
|
||||
switch (permissionType) {
|
||||
case 'microphone': // 麦克风
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
return true
|
||||
case 'camera': // 摄像头
|
||||
const videoStream = await navigator.mediaDevices.getUserMedia({ video: true })
|
||||
videoStream.getTracks().forEach(track => track.stop())
|
||||
return true
|
||||
default: // 其他
|
||||
return false // 失败
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`请求${permissionType}权限失败:`, error)
|
||||
return false
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
return true
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听权限状态变化
|
||||
* @param permissionType 权限类型
|
||||
* @param callback 回调函数
|
||||
*/
|
||||
async watchPermission(permissionType: PermissionType, callback: (state: PermissionState) => void): Promise<void> {
|
||||
// #ifdef H5
|
||||
try {
|
||||
if (navigator.permissions && navigator.permissions.query) {
|
||||
const permission = await navigator.permissions.query({ name: permissionType as PermissionName })
|
||||
permission.onchange = () => {
|
||||
callback(permission.state)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`监听${permissionType}权限变化失败:`, error)
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示权限引导弹窗
|
||||
* @param permissionType 权限类型
|
||||
*/
|
||||
showPermissionGuide(permissionType: PermissionType): void {
|
||||
const guides = {
|
||||
microphone: {
|
||||
title: t("Mobile.Permission.microphoneTitle"),
|
||||
description: t("Mobile.Permission.microphoneDescription"),
|
||||
steps: [
|
||||
t("Mobile.Permission.stepClickLock"),
|
||||
t("Mobile.Permission.stepAllowMicrophone"),
|
||||
t("Mobile.Permission.stepRefreshRetryRecord")
|
||||
]
|
||||
},
|
||||
camera: {
|
||||
title: t("Mobile.Permission.cameraTitle"),
|
||||
description: t("Mobile.Permission.cameraDescription"),
|
||||
steps: [
|
||||
t("Mobile.Permission.stepClickLock"),
|
||||
t("Mobile.Permission.stepAllowCamera"),
|
||||
t("Mobile.Permission.stepRefreshRetryPhoto")
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const guide = guides[permissionType]
|
||||
if (guide) {
|
||||
uni.showModal({
|
||||
title: guide.title,
|
||||
content: t("Mobile.Permission.guideContent", {
|
||||
description: guide.description,
|
||||
steps: guide.steps.map((step, index) => `${index + 1}. ${step}`).join('\n'),
|
||||
}),
|
||||
showCancel: true,
|
||||
cancelText: t("Mobile.Common.cancel"),
|
||||
confirmText: t("Mobile.Permission.gotIt"),
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
console.log('用户确认了权限引导')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否支持权限API
|
||||
* @returns 是否支持
|
||||
*/
|
||||
isPermissionAPISupported(): boolean {
|
||||
// #ifdef H5
|
||||
return !!(navigator.permissions && navigator.permissions.query)
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
return false
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 麦克风权限专用工具函数
|
||||
*/
|
||||
export class MicrophonePermissionHelper {
|
||||
private static manager = PermissionManager.getInstance()
|
||||
|
||||
/**
|
||||
* 检查麦克风权限状态
|
||||
*/
|
||||
static async check(): Promise<PermissionState> {
|
||||
return await this.manager.checkPermission('microphone')
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求麦克风权限
|
||||
*/
|
||||
static async request(): Promise<boolean> {
|
||||
return await this.manager.requestPermission('microphone')
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听麦克风权限变化
|
||||
*/
|
||||
static async watch(callback: (state: PermissionState) => void): Promise<void> {
|
||||
return await this.manager.watchPermission('microphone', callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示麦克风权限引导
|
||||
*/
|
||||
static showGuide(): void {
|
||||
this.manager.showPermissionGuide('microphone')
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保麦克风权限可用
|
||||
* @param showGuide 是否显示权限引导
|
||||
* @returns 是否成功获取权限
|
||||
*/
|
||||
static async ensure(showGuide: boolean = true): Promise<boolean> {
|
||||
// Always attempt to request permission first
|
||||
const success = await this.request()
|
||||
if (success) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If request fails, check the current state
|
||||
const state = await this.check()
|
||||
|
||||
// Show guide if denied and showGuide is true
|
||||
if (showGuide && state === 'denied') {
|
||||
this.showGuide()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 导出默认实例
|
||||
export default PermissionManager.getInstance()
|
||||
10410
qiming-mobile/utils/pinyin.uts
Normal file
10410
qiming-mobile/utils/pinyin.uts
Normal file
File diff suppressed because it is too large
Load Diff
269
qiming-mobile/utils/sseDataProcessor.uts
Normal file
269
qiming-mobile/utils/sseDataProcessor.uts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { ChatDataAdapter, SSEChunk } from './chatDataAdapter'
|
||||
import { MsgItem } from '@/uni_modules/uni-ai-x/types'
|
||||
|
||||
/**
|
||||
* SSE数据处理器
|
||||
* 专门用于处理Server-Sent Events流式数据
|
||||
*/
|
||||
export class SSEDataProcessor {
|
||||
|
||||
// 存储累积的SSE数据块
|
||||
private sseChunks: SSEChunk[] = []
|
||||
|
||||
// 当前会话ID
|
||||
private chatId: string = ''
|
||||
|
||||
// 消息映射表,用于累积文本内容
|
||||
private messageMap = new Map<string, MsgItem>()
|
||||
|
||||
constructor(chatId: string) {
|
||||
this.chatId = chatId
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个SSE数据块
|
||||
* @param chunk SSE数据块
|
||||
* @returns 处理后的MsgItem数组
|
||||
*/
|
||||
processChunk(chunk: SSEChunk): MsgItem[] {
|
||||
// 添加到累积列表
|
||||
this.sseChunks.push(chunk)
|
||||
|
||||
if (!ChatDataAdapter.isRenderableSSEChunk(chunk)) {
|
||||
return this.getAllMessages()
|
||||
}
|
||||
|
||||
// 处理数据块
|
||||
this.processMessage(chunk)
|
||||
|
||||
// 返回当前所有消息
|
||||
return this.getAllMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理消息累积
|
||||
* @param chunk SSE数据块
|
||||
*/
|
||||
private processMessage(chunk: SSEChunk): void {
|
||||
if (!ChatDataAdapter.isRenderableSSEChunk(chunk)) {
|
||||
return
|
||||
}
|
||||
|
||||
const messageId = chunk.data.id
|
||||
const text = chunk.data.text || ''
|
||||
const isFinished = chunk.data.finished
|
||||
|
||||
// console.log(`Processing message ${messageId}: text="${text}", finished=${isFinished}`)
|
||||
|
||||
if (!this.messageMap.has(messageId)) {
|
||||
// 创建新消息
|
||||
// console.log(`Creating new message for ID: ${messageId}`)
|
||||
const msgItem = ChatDataAdapter.convertSSEChunkToMsgItem(chunk, this.chatId)
|
||||
this.messageMap.set(messageId, msgItem)
|
||||
// console.log(`New message created:`, msgItem)
|
||||
} else {
|
||||
// 累积文本内容,使用更智能的去重逻辑
|
||||
// console.log(`Updating existing message for ID: ${messageId}`)
|
||||
const existingMsg = this.messageMap.get(messageId)!
|
||||
// console.log(`Previous body: "${existingMsg.body}"`)
|
||||
|
||||
// 智能去重:检查新文本是否是现有内容的子串或重复
|
||||
if (this.shouldAppendText(existingMsg.body, text)) {
|
||||
existingMsg.body += text
|
||||
// console.log(`New body: "${existingMsg.body}"`)
|
||||
} else {
|
||||
// console.log(`Text skipped (duplicate or redundant): "${text}"`)
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
if (isFinished) {
|
||||
existingMsg.state = 3 // 完成状态
|
||||
console.log(`Message ${messageId} marked as finished`)
|
||||
}
|
||||
}
|
||||
|
||||
// console.log(`Current message map size: ${this.messageMap.size}`)
|
||||
// this.messageMap.forEach((msg, id) => {
|
||||
// console.log(`Message ${id}: body="${msg.body}", state=${msg.state}`)
|
||||
// })
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能判断是否应该追加文本
|
||||
* @param existingBody 现有文本内容
|
||||
* @param newText 新文本内容
|
||||
* @returns 是否应该追加
|
||||
*/
|
||||
private shouldAppendText(existingBody: string, newText: string): boolean {
|
||||
// 如果新文本为空,不追加
|
||||
if (!newText || newText.trim().length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果现有内容已经包含新文本,不追加
|
||||
if (existingBody.includes(newText)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果新文本是现有内容的子串,不追加
|
||||
if (newText.length < existingBody.length && existingBody.includes(newText)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否是重复的标点符号或空格
|
||||
const lastChar = existingBody.charAt(existingBody.length - 1)
|
||||
const firstChar = newText.charAt(0)
|
||||
|
||||
// 避免重复的标点符号
|
||||
if (lastChar === firstChar && '.,!?;:'.includes(lastChar)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 避免重复的空格
|
||||
if (lastChar === ' ' && firstChar === ' ') {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有消息
|
||||
* @returns MsgItem数组
|
||||
*/
|
||||
getAllMessages(): MsgItem[] {
|
||||
const result: MsgItem[] = []
|
||||
// console.log(`Getting all messages from map (size: ${this.messageMap.size})`)
|
||||
|
||||
this.messageMap.forEach((value, key) => {
|
||||
// console.log(`Adding message ${key}:`, value)
|
||||
result.push(value)
|
||||
})
|
||||
|
||||
// console.log(`Returning ${result.length} messages:`, result)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定ID的消息
|
||||
* @param messageId 消息ID
|
||||
* @returns MsgItem或null
|
||||
*/
|
||||
getMessage(messageId: string): MsgItem | null {
|
||||
return this.messageMap.get(messageId) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有数据
|
||||
*/
|
||||
clear(): void {
|
||||
this.sseChunks = []
|
||||
this.messageMap.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取累积的SSE数据块
|
||||
* @returns SSE数据块数组
|
||||
*/
|
||||
getSSEChunks(): SSEChunk[] {
|
||||
return [...this.sseChunks]
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有未完成的消息
|
||||
* @returns 是否有未完成的消息
|
||||
*/
|
||||
hasIncompleteMessages(): boolean {
|
||||
let hasIncomplete = false
|
||||
this.messageMap.forEach((msg) => {
|
||||
if (msg.state === 2) { // 未完成状态
|
||||
hasIncomplete = true
|
||||
}
|
||||
})
|
||||
return hasIncomplete
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未完成的消息数量
|
||||
* @returns 未完成消息数量
|
||||
*/
|
||||
getIncompleteMessageCount(): number {
|
||||
let count = 0
|
||||
this.messageMap.forEach((msg) => {
|
||||
if (msg.state === 2) { // 未完成状态
|
||||
count++
|
||||
}
|
||||
})
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析SSE数据字符串
|
||||
* @param sseString SSE原始字符串
|
||||
* @returns SSEChunk数组
|
||||
*/
|
||||
export function parseSSEString(sseString: string): SSEChunk[] {
|
||||
const chunks: SSEChunk[] = []
|
||||
|
||||
// 按行分割
|
||||
const lines = sseString.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim()
|
||||
|
||||
// 跳过空行和注释行
|
||||
if (!trimmedLine || trimmedLine.startsWith(':')) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否是data行
|
||||
if (trimmedLine.startsWith('data:')) {
|
||||
const dataContent = trimmedLine.substring(5).trim()
|
||||
|
||||
// 跳过[DONE]标记
|
||||
if (dataContent === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析JSON数据
|
||||
const chunk: SSEChunk = JSON.parse(dataContent)
|
||||
chunks.push(chunk)
|
||||
} catch (error) {
|
||||
console.error('解析SSE数据失败:', error, '原始数据:', dataContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理完整的SSE响应
|
||||
* @param sseString 完整的SSE响应字符串
|
||||
* @param chatId 聊天ID
|
||||
* @returns 处理后的MsgItem数组
|
||||
*/
|
||||
export function processCompleteSSEResponse(sseString: string, chatId: string): MsgItem[] {
|
||||
const chunks = parseSSEString(sseString)
|
||||
const processor = new SSEDataProcessor(chatId)
|
||||
|
||||
chunks.forEach(chunk => {
|
||||
processor.processChunk(chunk)
|
||||
})
|
||||
|
||||
return processor.getAllMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建SSE数据处理器实例
|
||||
* @param chatId 聊天ID
|
||||
* @returns SSEDataProcessor实例
|
||||
*/
|
||||
export function createSSEProcessor(chatId: string): SSEDataProcessor {
|
||||
return new SSEDataProcessor(chatId)
|
||||
}
|
||||
|
||||
// 重新导出SSEChunk类型
|
||||
export { SSEChunk } from './chatDataAdapter'
|
||||
645
qiming-mobile/utils/streamRequest.uts
Normal file
645
qiming-mobile/utils/streamRequest.uts
Normal file
@@ -0,0 +1,645 @@
|
||||
import { StreamRequestConfig, StreamChunk } from "@/types/interfaces/chat";
|
||||
import { createSSEConnection } from "@/servers/useRequest.uts";
|
||||
import { utf8ArrayToString } from "@/utils/utf8.uts";
|
||||
import { t } from "@/utils/i18n";
|
||||
|
||||
// 全局错误处理 - 捕获未处理的 AbortError
|
||||
// #ifdef H5
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
if (event.reason && event.reason.name === "AbortError") {
|
||||
console.log("捕获到未处理的 AbortError,已静默处理");
|
||||
event.preventDefault(); // 阻止默认的错误处理
|
||||
}
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
|
||||
// 流式请求工具类
|
||||
export class StreamRequest {
|
||||
private requestTask: RequestTask | null = null;
|
||||
private isAborted: boolean = false;
|
||||
private abortController: AbortController | null = null;
|
||||
private buffer: string = ""; // 用于缓存不完整的 SSE 消息(以 \n\n 为完整标记)
|
||||
private pendingBytes: Uint8Array = new Uint8Array(0); // 用于缓存不完整的 UTF-8 字节序列
|
||||
|
||||
// 微信小程序数据流超时监控
|
||||
private lastChunkReceivedTime: number = 0; // 最后一次接收数据的时间戳
|
||||
private timeoutCheckTimer: any = null; // 超时检查定时器(使用 any 类型以兼容不同平台)
|
||||
private readonly TIMEOUT_DURATION: number = 60000; // 超时时长 60 秒
|
||||
private timeoutCallback: (() => void) | null = null; // 超时回调
|
||||
|
||||
// 发起流式请求
|
||||
async request(config: StreamRequestConfig): Promise<void> {
|
||||
this.isAborted = false;
|
||||
this.buffer = ""; // 重置缓冲区
|
||||
this.pendingBytes = new Uint8Array(0); // 重置字节缓冲区
|
||||
|
||||
// #ifdef H5
|
||||
// 只在 H5 环境中创建 AbortController
|
||||
this.abortController = new AbortController();
|
||||
// #endif
|
||||
|
||||
// 清除可能存在的旧定时器(重要:避免定时器泄漏)
|
||||
console.log("[超时监控] 新请求开始,清除旧定时器");
|
||||
this.clearTimeoutMonitor();
|
||||
// 保存超时回调
|
||||
this.timeoutCallback = config.onTimeout || null;
|
||||
|
||||
try {
|
||||
// #ifdef H5 || WEB
|
||||
await this.requestH5(config);
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
await this.requestWeapp(config);
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
await this.requestApp(config);
|
||||
// #endif
|
||||
} catch (error) {
|
||||
// 如果是用户主动中止,不触发错误回调
|
||||
// #ifdef H5
|
||||
if (error.name === "AbortError" || this.isAborted) {
|
||||
console.log("请求已被中止");
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN || APP-PLUS
|
||||
if (this.isAborted) {
|
||||
console.log("请求已被中止");
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
|
||||
config.onError?.(error);
|
||||
}
|
||||
}
|
||||
|
||||
// H5环境下的流式请求
|
||||
private async requestH5(config: StreamRequestConfig): Promise<void> {
|
||||
try {
|
||||
// 关键修复:在创建 requestTask 后立即启动超时监控
|
||||
// 因为 onChunkReceived 可能比 success 回调更早触发
|
||||
console.log("[请求] requestTask 已创建,立即启动超时监控");
|
||||
this.startTimeoutMonitor();
|
||||
console.log("[请求] 超时监控启动完成");
|
||||
|
||||
await createSSEConnection({
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
headers: config.headers as any,
|
||||
body: config.body,
|
||||
abortController: this.abortController,
|
||||
onMessage: (data: any) => {
|
||||
if (this.isAborted) return;
|
||||
// 更新最后接收数据的时间
|
||||
this.updateLastChunkTime();
|
||||
|
||||
try {
|
||||
// 检查是否完成
|
||||
if (data && data.completed) {
|
||||
console.log("[SSE] 收到完成标记 completed");
|
||||
|
||||
// 清除超时监控
|
||||
console.log(
|
||||
"[SSE] completed 标记触发,准备清除定时器,当前 timer ID:",
|
||||
this.timeoutCheckTimer,
|
||||
);
|
||||
this.clearTimeoutMonitor();
|
||||
console.log("[SSE] completed 标记处理完成,定时器已清除");
|
||||
|
||||
config.onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理数据块
|
||||
const chunk: StreamChunk = {
|
||||
type: "content",
|
||||
data: data ? JSON.stringify(data) : "",
|
||||
};
|
||||
config.onChunk?.(chunk);
|
||||
} catch (e) {
|
||||
console.error("Parse chunk error:", e);
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
// 清除超时监控
|
||||
this.clearTimeoutMonitor();
|
||||
// 如果是中止错误,不触发错误回调
|
||||
if (error.name === "AbortError" || this.isAborted) {
|
||||
console.log("SSE连接已被中止");
|
||||
return;
|
||||
}
|
||||
config.onError?.(error);
|
||||
},
|
||||
onOpen: (response: any) => {
|
||||
console.log("SSE连接已建立:", response);
|
||||
},
|
||||
onClose: () => {
|
||||
console.log("SSE连接已关闭");
|
||||
// 调用超时回调-关闭loading状态以及恢复停止按钮
|
||||
config.onTimeout?.();
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// 捕获所有可能的错误,包括 AbortError
|
||||
if (
|
||||
error.message === "Connection aborted by user" ||
|
||||
error.name === "AbortError" ||
|
||||
this.isAborted
|
||||
) {
|
||||
console.log("SSE连接已被中止");
|
||||
return;
|
||||
}
|
||||
// 只有真正的错误才触发错误回调
|
||||
config.onError?.(error);
|
||||
throw error; // 重新抛出非中止错误
|
||||
}
|
||||
}
|
||||
|
||||
// 处理完整的 SSE 消息
|
||||
private processSSEMessage(
|
||||
content: string,
|
||||
config: StreamRequestConfig,
|
||||
resolve: (value: void | PromiseLike<void>) => void,
|
||||
): void {
|
||||
// 检查是否完成
|
||||
if (content === "[DONE]") {
|
||||
console.log("[SSE] 收到完成标记 [DONE]");
|
||||
|
||||
// 清除超时监控
|
||||
console.log(
|
||||
"[SSE] [DONE] 标记触发,准备清除定时器,当前 timer ID:",
|
||||
this.timeoutCheckTimer,
|
||||
);
|
||||
this.clearTimeoutMonitor();
|
||||
console.log("[SSE] [DONE] 标记处理完成,定时器已清除");
|
||||
|
||||
config.onComplete?.();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否看起来像 JSON (以 { 或 [ 开头)
|
||||
const looksLikeJSON = content.startsWith("{") || content.startsWith("[");
|
||||
|
||||
if (looksLikeJSON) {
|
||||
// 尝试解析 JSON 数据
|
||||
try {
|
||||
const data = JSON.parse(content);
|
||||
if (data && data.completed) {
|
||||
console.log("[SSE] 收到完成标记 completed");
|
||||
|
||||
// 清除超时监控
|
||||
console.log(
|
||||
"[SSE] completed 标记触发,准备清除定时器,当前 timer ID:",
|
||||
this.timeoutCheckTimer,
|
||||
);
|
||||
this.clearTimeoutMonitor();
|
||||
console.log("[SSE] completed 标记处理完成,定时器已清除");
|
||||
|
||||
config.onComplete?.();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理数据块
|
||||
const chunk: StreamChunk = {
|
||||
type: "content",
|
||||
data: content,
|
||||
};
|
||||
config.onChunk?.(chunk);
|
||||
} catch (e) {
|
||||
console.log("[SSE] JSON 解析失败,内容:", content);
|
||||
}
|
||||
} else {
|
||||
// 不是 JSON 格式,直接作为内容处理
|
||||
const chunk: StreamChunk = {
|
||||
type: "content",
|
||||
data: content,
|
||||
};
|
||||
config.onChunk?.(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
// 启动超时监控
|
||||
private startTimeoutMonitor(): void {
|
||||
console.log(
|
||||
"[超时监控] 准备启动超时监控,当前 timer ID:",
|
||||
this.timeoutCheckTimer,
|
||||
);
|
||||
|
||||
// 记录初始时间
|
||||
this.lastChunkReceivedTime = Date.now();
|
||||
|
||||
// 先清除可能存在的旧定时器
|
||||
if (
|
||||
this.timeoutCheckTimer !== null &&
|
||||
this.timeoutCheckTimer !== undefined
|
||||
) {
|
||||
console.log(
|
||||
"[超时监控] 发现旧定时器,先清除,ID:",
|
||||
this.timeoutCheckTimer,
|
||||
);
|
||||
clearInterval(this.timeoutCheckTimer);
|
||||
this.timeoutCheckTimer = null;
|
||||
}
|
||||
|
||||
// 每 10 秒检查一次是否超时
|
||||
const timerId = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastChunk = now - this.lastChunkReceivedTime;
|
||||
|
||||
console.log(
|
||||
`[超时监控] 检查中... 距离上次接收数据: ${timeSinceLastChunk}ms,定时器 ID: ${this.timeoutCheckTimer}`,
|
||||
);
|
||||
|
||||
if (timeSinceLastChunk >= this.TIMEOUT_DURATION) {
|
||||
console.log(
|
||||
`[超时监控] 数据流超时: ${timeSinceLastChunk}ms 未收到数据`,
|
||||
);
|
||||
this.handleTimeout();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
this.timeoutCheckTimer = timerId;
|
||||
console.log("[超时监控] 定时器已创建,新 ID:", this.timeoutCheckTimer);
|
||||
}
|
||||
|
||||
// 清除超时监控
|
||||
private clearTimeoutMonitor(): void {
|
||||
console.log("[超时监控] 尝试清除,当前 timer ID:", this.timeoutCheckTimer);
|
||||
|
||||
if (
|
||||
this.timeoutCheckTimer !== null &&
|
||||
this.timeoutCheckTimer !== undefined
|
||||
) {
|
||||
console.log("[超时监控] 清除超时监控,timer ID:", this.timeoutCheckTimer);
|
||||
clearInterval(this.timeoutCheckTimer);
|
||||
this.timeoutCheckTimer = null;
|
||||
} else {
|
||||
console.log("[超时监控] 定时器 ID 为空,无需清除");
|
||||
}
|
||||
}
|
||||
|
||||
// 更新最后接收数据的时间
|
||||
private updateLastChunkTime(): void {
|
||||
this.lastChunkReceivedTime = Date.now();
|
||||
}
|
||||
|
||||
// 处理超时
|
||||
private handleTimeout(): void {
|
||||
console.log("[超时监控] 触发超时处理");
|
||||
|
||||
// 清除定时器
|
||||
this.clearTimeoutMonitor();
|
||||
|
||||
// 标记为中止状态
|
||||
this.isAborted = true;
|
||||
|
||||
// 中止请求
|
||||
if (this.requestTask) {
|
||||
this.requestTask.abort();
|
||||
this.requestTask = null;
|
||||
}
|
||||
|
||||
// 显示轻提示
|
||||
uni.showToast({
|
||||
title: t("Mobile.Stream.timeoutDisconnected"),
|
||||
icon: "none",
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// 调用超时回调
|
||||
if (this.timeoutCallback) {
|
||||
this.timeoutCallback();
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 UTF-8 字节流,处理分片造成的字符截断问题
|
||||
private decodeUTF8(chunk: ArrayBuffer): string {
|
||||
const newBytes = new Uint8Array(chunk);
|
||||
let combined = newBytes;
|
||||
|
||||
if (this.pendingBytes.length > 0) {
|
||||
combined = new Uint8Array(this.pendingBytes.length + newBytes.length);
|
||||
combined.set(this.pendingBytes);
|
||||
combined.set(newBytes, this.pendingBytes.length);
|
||||
this.pendingBytes = new Uint8Array(0);
|
||||
}
|
||||
|
||||
if (combined.length === 0) return "";
|
||||
|
||||
// 寻找最后一个安全的字节边界
|
||||
let safeEnd = combined.length;
|
||||
let i = combined.length - 1;
|
||||
let backtrack = 0;
|
||||
|
||||
// 向前回溯,检查末尾是否被截断
|
||||
// UTF-8 最大字符长度为 4 字节
|
||||
while (backtrack < 4 && i >= 0) {
|
||||
const b = combined[i];
|
||||
|
||||
// ASCII (0xxxxxxx) - 字符结束,且是单字节,安全
|
||||
if ((b & 0x80) === 0) {
|
||||
// 如果我们是回溯过程中遇到的 ASCII,说明之前回溯过的字节(在 ASCII 之后)是孤立的或不完整的?
|
||||
// 比如 [ASCII, Cont, Cont],我们在 Cont 回溯。
|
||||
// 遇到 ASCII,说明后面的 Cont 没有 Start byte,是无效的。
|
||||
// 但如果是 [ASCII],backtrack=0,safeEnd=length。
|
||||
// 这里的逻辑:遇到 ASCII,说明前面的序列都结束了。
|
||||
// 那么从 i+1 到 length 的字节就是我们回溯过的“剩余字节”。
|
||||
safeEnd = combined.length - backtrack;
|
||||
break;
|
||||
}
|
||||
|
||||
// Start byte (11xxxxxx)
|
||||
if ((b & 0xc0) === 0xc0) {
|
||||
let need = 2;
|
||||
if ((b & 0xe0) === 0xe0) need = 3;
|
||||
else if ((b & 0xf0) === 0xf0) need = 4;
|
||||
|
||||
const have = combined.length - i;
|
||||
if (have < need) {
|
||||
// 不完整,需要保留从 i 开始的所有字节
|
||||
safeEnd = i;
|
||||
} else {
|
||||
// 完整
|
||||
safeEnd = combined.length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
i--;
|
||||
backtrack++;
|
||||
}
|
||||
|
||||
// 如果 safeEnd 小于总长度,保存剩余字节
|
||||
if (safeEnd < combined.length) {
|
||||
this.pendingBytes = combined.slice(safeEnd);
|
||||
return utf8ArrayToString(combined.slice(0, safeEnd));
|
||||
}
|
||||
|
||||
return utf8ArrayToString(combined);
|
||||
}
|
||||
|
||||
// 微信小程序环境下的流式请求
|
||||
private async requestWeapp(config: StreamRequestConfig): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.requestTask = uni.request({
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
header: config.headers as any,
|
||||
data: config.body,
|
||||
enableChunked: true, // 启用分块传输
|
||||
responseType: "arraybuffer", // 响应类型
|
||||
timeout: 1000 * 60 * 60 * 12, // 设置超时时间为 12小时
|
||||
success: (res) => {
|
||||
console.log("[请求] success 回调触发,statusCode:", res.statusCode);
|
||||
if (res.statusCode === 200) {
|
||||
// 流式数据通过 onChunkReceived 接收,success 仅表示请求建立成功
|
||||
// 实际的流式数据处理在 onChunkReceived 回调中
|
||||
config.onOpen?.(res);
|
||||
|
||||
resolve(); // 请求成功建立连接后即可 resolve
|
||||
} else {
|
||||
console.log("[请求] 请求失败,statusCode:", res.statusCode);
|
||||
// 清除超时监控
|
||||
this.clearTimeoutMonitor();
|
||||
reject(
|
||||
new Error(
|
||||
t("Mobile.Stream.httpErrorWithCode", {
|
||||
code: res.statusCode,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
// 清除超时监控
|
||||
this.clearTimeoutMonitor();
|
||||
|
||||
if (!this.isAborted) {
|
||||
// 忽略超时错误,因为对于长连接流式请求,超时是预期内的行为
|
||||
// 只要 connection 还在接收数据,就不应该视为真正的错误
|
||||
if (
|
||||
err.errMsg &&
|
||||
(err.errMsg.includes("time out") ||
|
||||
err.errMsg.includes("timeout"))
|
||||
) {
|
||||
console.log("请求超时警告(流式请求正常现象):", err);
|
||||
// 不 reject,也不触发 onError,保持连接继续接收数据
|
||||
return;
|
||||
}
|
||||
|
||||
config.onError?.(err);
|
||||
reject(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 关键修复:在创建 requestTask 后立即启动超时监控
|
||||
// 因为 onChunkReceived 可能比 success 回调更早触发
|
||||
console.log("[请求] requestTask 已创建,立即启动超时监控");
|
||||
this.startTimeoutMonitor();
|
||||
console.log("[请求] 超时监控启动完成");
|
||||
|
||||
// 监听分块数据接收
|
||||
this.requestTask?.onChunkReceived?.((res) => {
|
||||
// console.log('[数据接收] onChunkReceived 触发')
|
||||
if (this.isAborted) return;
|
||||
|
||||
// 更新最后接收数据的时间
|
||||
this.updateLastChunkTime();
|
||||
|
||||
try {
|
||||
// 使用 decodeUTF8 处理分块数据,解决字符截断问题
|
||||
const str = this.decodeUTF8(res.data as ArrayBuffer);
|
||||
|
||||
// console.log('[SSE] 接收到分块数据, 长度:', str.length)
|
||||
|
||||
// 将新数据添加到缓冲区
|
||||
this.buffer += str;
|
||||
|
||||
// SSE 协议:完整的消息以 \n\n 结尾
|
||||
// 按 \n\n 分割出完整的消息块
|
||||
const messages = this.buffer.split("\n\n");
|
||||
|
||||
// 保存最后一个可能不完整的消息块
|
||||
this.buffer = messages.pop() || "";
|
||||
|
||||
// 处理所有完整的消息块
|
||||
for (const message of messages) {
|
||||
if (!message.trim()) continue;
|
||||
|
||||
// 按行分割消息块,处理多行的情况
|
||||
const lines = message.split("\n");
|
||||
let dataContent = "";
|
||||
|
||||
// 提取所有 data: 开头的行
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data:")) {
|
||||
// 移除 "data:" 前缀,保留后面的内容
|
||||
const content = line.slice(5).trim();
|
||||
|
||||
// 拼接多个 data: 行的内容(用换行符连接)
|
||||
if (dataContent.length > 0) {
|
||||
dataContent += "\n" + content;
|
||||
} else {
|
||||
dataContent = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果提取到了数据内容,进行处理
|
||||
if (dataContent.length > 0) {
|
||||
// 统一由 processSSEMessage 处理所有消息,包括 [DONE] 标记
|
||||
this.processSSEMessage(dataContent, config, resolve);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("处理分块数据失败:", error);
|
||||
if (!this.isAborted) {
|
||||
config.onError?.(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// App环境下的流式请求
|
||||
private async requestApp(config: StreamRequestConfig): Promise<void> {
|
||||
// #ifdef APP-PLUS
|
||||
return new Promise((resolve, reject) => {
|
||||
this.requestTask = uni.request({
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
header: config.headers as any,
|
||||
data: config.body,
|
||||
success: (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
// 处理流式响应
|
||||
this.handleStreamResponse(res.data as string, config);
|
||||
resolve();
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
t("Mobile.Stream.httpErrorWithCode", {
|
||||
code: res.statusCode,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
if (!this.isAborted) {
|
||||
reject(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
// #endif
|
||||
}
|
||||
|
||||
// 处理流式响应数据
|
||||
private handleStreamResponse(
|
||||
data: string,
|
||||
config: StreamRequestConfig,
|
||||
): void {
|
||||
console.log("[SSE-APP] 处理流式响应数据, 长度:", data.length);
|
||||
const lines = data.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === "") continue;
|
||||
|
||||
if (line.startsWith("data:")) {
|
||||
const content = line.slice(5).trim();
|
||||
|
||||
// 跳过空内容
|
||||
if (!content) {
|
||||
console.log("[SSE-APP] 跳过空内容行");
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log("[SSE-APP] 处理数据行:", content);
|
||||
|
||||
// 检查是否完成
|
||||
if (content === "[DONE]") {
|
||||
console.log("[SSE-APP] 收到完成标记 [DONE]");
|
||||
config.onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否看起来像 JSON (以 { 或 [ 开头)
|
||||
const looksLikeJSON =
|
||||
content.startsWith("{") || content.startsWith("[");
|
||||
|
||||
if (looksLikeJSON) {
|
||||
// 尝试解析 JSON 数据
|
||||
try {
|
||||
const parseData = JSON.parse(content);
|
||||
if (parseData && parseData.completed) {
|
||||
console.log("[SSE-APP] 收到完成标记 completed");
|
||||
config.onComplete?.();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// JSON 解析失败,继续处理为普通数据
|
||||
console.log("[SSE-APP] JSON 解析失败,作为普通内容处理");
|
||||
}
|
||||
} else {
|
||||
console.log("[SSE-APP] 非 JSON 格式,直接处理");
|
||||
}
|
||||
|
||||
// 处理数据块
|
||||
try {
|
||||
const chunk: StreamChunk = {
|
||||
type: "content",
|
||||
data: content,
|
||||
};
|
||||
config.onChunk?.(chunk);
|
||||
} catch (e) {
|
||||
console.error("[SSE-APP] 处理数据块失败:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 中止请求
|
||||
abort(): void {
|
||||
this.isAborted = true;
|
||||
this.buffer = ""; // 清空缓冲区
|
||||
|
||||
// 清除超时监控(所有平台都需要)
|
||||
this.clearTimeoutMonitor();
|
||||
|
||||
// #ifdef H5
|
||||
// 中止 H5 的 SSE 连接
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 中止小程序的请求
|
||||
if (this.requestTask) {
|
||||
this.requestTask.abort();
|
||||
this.requestTask = null;
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
// 中止 App 的请求
|
||||
if (this.requestTask) {
|
||||
this.requestTask.abort();
|
||||
this.requestTask = null;
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
|
||||
// 创建流式请求实例
|
||||
export const streamRequest = new StreamRequest();
|
||||
329
qiming-mobile/utils/system.uts
Normal file
329
qiming-mobile/utils/system.uts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { ALLOW_EXTERNAL_LINK_DOMAIN } from "@/constants/config";
|
||||
import { TENANT_CONFIG_INFO } from "@/constants/home.constants";
|
||||
import { SUCCESS_CODE } from "@/constants/codes.constants";
|
||||
import { apiTenantConfig } from "@/servers/account";
|
||||
import { apiGetStaticFileList } from "@/servers/agentDev.uts";
|
||||
import { t } from "@/utils/i18n";
|
||||
|
||||
const SYSTEM_INFO = uni.getSystemInfoSync();
|
||||
|
||||
export const getStatusBarHeight = () => SYSTEM_INFO.statusBarHeight || 15;
|
||||
|
||||
export const formatDate = (
|
||||
input: string | number | Date,
|
||||
format:
|
||||
| "YYYY-MM-DD"
|
||||
| "MM-DD"
|
||||
| "HH:mm"
|
||||
| "YYYY-MM-DD HH:mm:ss"
|
||||
| "YYYY-MM-DD HH:mm" = "MM-DD",
|
||||
): string => {
|
||||
const date = new Date(input);
|
||||
|
||||
const YYYY = date.getFullYear();
|
||||
const MM = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const DD = String(date.getDate()).padStart(2, "0");
|
||||
const HH = String(date.getHours()).padStart(2, "0");
|
||||
const mm = String(date.getMinutes()).padStart(2, "0");
|
||||
|
||||
switch (format) {
|
||||
case "YYYY-MM-DD":
|
||||
return `${YYYY}-${MM}-${DD}`;
|
||||
case "YYYY-MM-DD HH:mm:ss":
|
||||
const ss = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${YYYY}-${MM}-${DD} ${HH}:${mm}:${ss}`;
|
||||
case "YYYY-MM-DD HH:mm":
|
||||
return `${YYYY}-${MM}-${DD} ${HH}:${mm}`;
|
||||
case "HH:mm":
|
||||
return `${HH}:${mm}`;
|
||||
default:
|
||||
return `${MM}-${DD}`;
|
||||
}
|
||||
};
|
||||
|
||||
// 判断是否是允许外部链接跳转的域名
|
||||
export const isAllowExternalLinkDomain = (url: string) => {
|
||||
return ALLOW_EXTERNAL_LINK_DOMAIN.some((domain) => url.includes(domain));
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据链接地址判断不允许调整的页面需要复制链接到剪切板
|
||||
* @param url 链接地址
|
||||
* @returns void
|
||||
*/
|
||||
export const handleExternalLink = (url: string) => {
|
||||
// 如果链接地址为空,则提示链接地址配置错误
|
||||
if (!url) {
|
||||
uni.showToast({
|
||||
title: t("Mobile.Link.invalidConfig"),
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// #ifdef H5 || WEB
|
||||
// H5 环境直接打开外部链接
|
||||
try {
|
||||
// 判断是否为有效的 URL
|
||||
const isValidUrl =
|
||||
url.startsWith("http://") ||
|
||||
url.startsWith("https://") ||
|
||||
url.startsWith("//");
|
||||
|
||||
if (isValidUrl) {
|
||||
// 尝试在新标签页中打开链接
|
||||
const newWindow = window.open(url, "_blank");
|
||||
|
||||
// 检查是否被浏览器拦截(某些浏览器会阻止弹窗)
|
||||
if (
|
||||
!newWindow ||
|
||||
newWindow.closed ||
|
||||
typeof newWindow.closed === "undefined"
|
||||
) {
|
||||
// 如果被拦截,提示用户并复制链接到剪贴板
|
||||
console.warn("链接被浏览器拦截,使用备用方案");
|
||||
|
||||
// 尝试复制到剪贴板
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
uni.showToast({
|
||||
title: t("Mobile.Link.copyAndOpen"),
|
||||
icon: "none",
|
||||
duration: 3000,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// 如果复制失败,直接在当前页面打开
|
||||
window.location.href = url;
|
||||
});
|
||||
} else {
|
||||
// 如果不支持剪贴板 API,直接在当前页面打开
|
||||
window.location.href = url;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果不是完整的 URL,使用 navigateTo 跳转到 webview 页面
|
||||
uni.navigateTo({
|
||||
url: `/subpackages/pages/webview/webview?url=${encodeURIComponent(url)}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("打开链接失败:", error);
|
||||
uni.showToast({
|
||||
title: t("Mobile.Link.openFailed"),
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 如果允许外部链接跳转,则打开外部链接
|
||||
if (isAllowExternalLinkDomain(url)) {
|
||||
uni.navigateTo({
|
||||
url: `/subpackages/pages/webview/webview?url=${encodeURIComponent(url)}`,
|
||||
});
|
||||
} else {
|
||||
uni.setClipboardData({
|
||||
data: url,
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: t("Mobile.Link.copied"),
|
||||
icon: "none",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
};
|
||||
|
||||
// 获取用户配置并设置页面标题和缓存租户信息
|
||||
export const fetchTenantConfig = async () => {
|
||||
const { code, data } = await apiTenantConfig();
|
||||
const siteName = data?.siteName;
|
||||
if (code === SUCCESS_CODE && siteName) {
|
||||
// 设置当前页面导航栏标题
|
||||
uni.setNavigationBarTitle({ title: siteName });
|
||||
// 缓存租户信息
|
||||
uni.setStorageSync(TENANT_CONFIG_INFO, JSON.stringify(data));
|
||||
}
|
||||
};
|
||||
|
||||
// 设置当前页面导航栏标题
|
||||
export const setCurrentPageNavigationBarTitle = async () => {
|
||||
// 获取本地用户配置
|
||||
const tenantConfigInfoString = await uni.getStorageSync(TENANT_CONFIG_INFO);
|
||||
if (tenantConfigInfoString) {
|
||||
try {
|
||||
// JSON.parse 返回 unknown,这里显式约束为仅关心 siteName 的结构
|
||||
const tenantConfig = JSON.parse(tenantConfigInfoString) as {
|
||||
siteName?: string;
|
||||
};
|
||||
const { siteName } = tenantConfig;
|
||||
if (siteName) {
|
||||
// 设置当前页面导航栏标题
|
||||
uni.setNavigationBarTitle({ title: siteName });
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取本地用户配置失败:", error);
|
||||
}
|
||||
}
|
||||
fetchTenantConfig(); // 获取用户配置并设置页面标题和缓存租户信息
|
||||
};
|
||||
|
||||
// 根据会话id和文件路径 跳转至预览页面
|
||||
export const jumpToFilePreviewPage = (
|
||||
conversationId: string,
|
||||
fileProxyUrl: string,
|
||||
) => {
|
||||
if (!conversationId || !fileProxyUrl) {
|
||||
return;
|
||||
}
|
||||
const path = "/subpackages/pages/file-preview-page/file-preview-page";
|
||||
const url = `${path}?cId=${conversationId}&fileProxyUrl=${encodeURIComponent(fileProxyUrl)}`;
|
||||
uni.navigateTo({ url });
|
||||
};
|
||||
|
||||
// 根据会话id查询文件列表并根据文件路径获取到文件代理url
|
||||
export const getFileProxyUrlByConversationIdAndFilePath = async (
|
||||
conversationId: string,
|
||||
filePath: string,
|
||||
) => {
|
||||
if (!conversationId || !filePath) {
|
||||
return null;
|
||||
}
|
||||
// 接口要求 number 类型,先做显式转换并校验,避免把非法字符串传入服务层
|
||||
const normalizedConversationId = Number(conversationId);
|
||||
if (Number.isNaN(normalizedConversationId)) {
|
||||
return null;
|
||||
}
|
||||
const result = await apiGetStaticFileList(normalizedConversationId);
|
||||
if (result.code === SUCCESS_CODE) {
|
||||
const { files } = result.data || {};
|
||||
const file = files.find((item) => item.name === filePath);
|
||||
return file?.fileProxyUrl;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 任务结果数据
|
||||
*/
|
||||
interface TaskResultData {
|
||||
hasTaskResult: boolean;
|
||||
description?: string;
|
||||
file?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取任务结果数据
|
||||
* @param text 文本
|
||||
* @returns 任务结果数据
|
||||
*/
|
||||
export const extractTaskResult = (text: string): TaskResultData => {
|
||||
const result: TaskResultData = {
|
||||
hasTaskResult: false,
|
||||
};
|
||||
|
||||
if (!text) return result;
|
||||
|
||||
// 1️⃣ 匹配 <task-result>...</task-result>
|
||||
const taskResultMatch = text.match(/<task-result>([\s\S]*?)<\/task-result>/);
|
||||
|
||||
if (!taskResultMatch) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.hasTaskResult = true;
|
||||
const taskResultContent = taskResultMatch[1];
|
||||
|
||||
// 2️⃣ 提取 description
|
||||
const descriptionMatch = taskResultContent.match(
|
||||
/<description>([\s\S]*?)<\/description>/,
|
||||
);
|
||||
if (descriptionMatch) {
|
||||
result.description = descriptionMatch[1].trim();
|
||||
}
|
||||
|
||||
// 3️⃣ 提取 file
|
||||
const fileMatch = taskResultContent.match(/<file>([\s\S]*?)<\/file>/);
|
||||
if (fileMatch) {
|
||||
result.file = fileMatch[1].trim();
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 提取字符串中最后一个 <task-result> 内的 <file> 内容
|
||||
* @param text 原始字符串
|
||||
* @returns file 内容或 null
|
||||
*/
|
||||
export const extractLastTaskResultFile = (text: string): string | null => {
|
||||
if (!text) return null;
|
||||
|
||||
// 匹配所有 <task-result>...</task-result>
|
||||
const taskResultMatches = text.match(/<task-result>[\s\S]*?<\/task-result>/g);
|
||||
|
||||
if (!taskResultMatches || taskResultMatches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 取最后一个 <task-result>
|
||||
const lastTaskResult = taskResultMatches[taskResultMatches.length - 1];
|
||||
|
||||
// 在其中提取 <file> 内容
|
||||
const fileMatch = lastTaskResult.match(/<file>([\s\S]*?)<\/file>/);
|
||||
|
||||
return fileMatch?.[1]?.trim() ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 健壮的千分位数字格式化工具函数(支持整数、浮点数以及负数,带有强健的边界容错降级机制)
|
||||
* @param value 输入数值或字符
|
||||
* @returns 格式化后的千分位字符串
|
||||
*/
|
||||
export const formatNumber = (value: any, precision: number = -1): string => {
|
||||
if (value == null) {
|
||||
return "0";
|
||||
}
|
||||
let str = String(value).trim();
|
||||
if (str === "" || str === "null" || str === "undefined") {
|
||||
return "0";
|
||||
}
|
||||
|
||||
const num = Number(str);
|
||||
if (Number.isNaN(num) || !Number.isFinite(num)) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
if (precision >= 0) {
|
||||
str = num.toFixed(precision);
|
||||
} else {
|
||||
str = num.toString();
|
||||
}
|
||||
|
||||
const isNegative = str.startsWith("-");
|
||||
if (isNegative) {
|
||||
str = str.substring(1);
|
||||
}
|
||||
|
||||
const parts = str.split(".");
|
||||
const integerPart = parts[0];
|
||||
const decimalPart = parts.length > 1 ? "." + parts[1] : "";
|
||||
|
||||
let result = "";
|
||||
let counter = 0;
|
||||
for (let i = integerPart.length - 1; i >= 0; i--) {
|
||||
result = integerPart.charAt(i) + result;
|
||||
counter++;
|
||||
if (counter === 3 && i !== 0) {
|
||||
result = "," + result;
|
||||
counter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return (isNegative ? "-" : "") + result + decimalPart;
|
||||
};
|
||||
30
qiming-mobile/utils/utf8.uts
Normal file
30
qiming-mobile/utils/utf8.uts
Normal file
@@ -0,0 +1,30 @@
|
||||
// UTF-8 解码工具:将 Uint8Array 转换为字符串,兼容微信小程序无 TextDecoder 场景
|
||||
export function utf8ArrayToString(bytes: Uint8Array): string {
|
||||
let out = ''
|
||||
let i = 0
|
||||
const len = bytes.length
|
||||
while (i < len) {
|
||||
const c = bytes[i++]
|
||||
if (c < 0x80) {
|
||||
out += String.fromCharCode(c)
|
||||
} else if (c < 0xE0) {
|
||||
const c2 = bytes[i++]
|
||||
out += String.fromCharCode(((c & 0x1F) << 6) | (c2 & 0x3F))
|
||||
} else if (c < 0xF0) {
|
||||
const c2 = bytes[i++]
|
||||
const c3 = bytes[i++]
|
||||
out += String.fromCharCode(((c & 0x0F) << 12) | ((c2 & 0x3F) << 6) | (c3 & 0x3F))
|
||||
} else {
|
||||
const c2 = bytes[i++]
|
||||
const c3 = bytes[i++]
|
||||
const c4 = bytes[i++]
|
||||
let codepoint = ((c & 0x07) << 18) | ((c2 & 0x3F) << 12) | ((c3 & 0x3F) << 6) | (c4 & 0x3F)
|
||||
codepoint -= 0x10000
|
||||
out += String.fromCharCode(0xD800 + (codepoint >> 10))
|
||||
out += String.fromCharCode(0xDC00 + (codepoint & 0x3FF))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user