Initial commit of akmon project
This commit is contained in:
94
utils/MSG_SERVICE_UPGRADE_SUMMARY.md
Normal file
94
utils/MSG_SERVICE_UPGRADE_SUMMARY.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# MsgDataServiceReal 类型转换升级总结
|
||||
|
||||
## 修改概述
|
||||
|
||||
已成功将 `msgDataServiceReal.uts` 中的所有 Supabase 查询从 `.execute()` 升级为 `.executeAs<T>()` 方式,实现了完整的类型安全。
|
||||
|
||||
## 主要修改内容
|
||||
|
||||
### 1. 查询方法升级
|
||||
|
||||
| 方法 | 原始方式 | 新方式 | 返回类型 |
|
||||
|------|----------|--------|----------|
|
||||
| `getMessageTypes()` | `.execute()` | `.executeAs<Array<MessageType>>()` | `Array<MessageType>` |
|
||||
| `getMessages()` | `.execute()` | `.executeAs<Array<Message>>()` | `Array<Message>` |
|
||||
| `getMessageById()` | `.execute()` | `.executeAs<Message>()` | `Message` |
|
||||
| `sendMessage()` | `.execute()` | `.executeAs<Message>()` | `Message` |
|
||||
| `markAsRead()` | `.execute()` | `.executeAs<Array<MessageRecipient>>()` | `boolean` |
|
||||
| `deleteMessage()` | `.execute()` | `.executeAs<Array<Message>>()` | `boolean` |
|
||||
| `batchOperation()` | `.execute()` | `.executeAs<Array<MessageRecipient>>()` | `boolean` |
|
||||
| `getMessageStats()` | `.execute()` | `.executeAs<Array<MessageRecipient>>()` | `MessageStats` |
|
||||
| `searchUsers()` | `.execute()` | `.executeAs<Array<UserOption>>()` | `Array<UserOption>` |
|
||||
| `getGroups()` | `.execute()` | `.executeAs<Array<MessageGroup>>()` | `Array<MessageGroup>` |
|
||||
| `getGroupMembers()` | `.execute()` | `.executeAs<Array<GroupMember>>()` | `Array<GroupMember>` |
|
||||
|
||||
### 2. 代码简化
|
||||
|
||||
#### 移除的旧代码:
|
||||
- `handleResponse<T>()` 私有方法
|
||||
- `transformResponse<T, U>()` 转换函数
|
||||
- `convertToMessageTypes()` 转换函数
|
||||
- `convertToMessages()` 转换函数
|
||||
- 手动的 `UTSJSONObject` 数据转换逻辑
|
||||
|
||||
#### 保留的工具函数:
|
||||
- `createErrorResponse<T>()` - 用于创建标准错误响应
|
||||
|
||||
### 3. 错误处理改进
|
||||
|
||||
所有错误处理都统一使用 `new UniError()` 构造函数,确保类型一致性。
|
||||
|
||||
### 4. 类型定义补充
|
||||
|
||||
添加了 `GroupMember` 类型定义,确保所有使用的类型都有明确定义。
|
||||
|
||||
## 优势
|
||||
|
||||
### 🚀 性能提升
|
||||
- 在 Android 平台使用 `UTSJSONObject.parse<T>()` 进行真正的类型转换
|
||||
- 减少了手动类型转换的开销
|
||||
- 简化了代码逻辑
|
||||
|
||||
### 🛡️ 类型安全
|
||||
- 编译时类型检查
|
||||
- 智能代码提示
|
||||
- 运行时类型转换(Android 平台)
|
||||
|
||||
### 🧹 代码简化
|
||||
- 移除了大量的手动转换代码
|
||||
- 统一了响应处理逻辑
|
||||
- 提高了代码可维护性
|
||||
|
||||
## 平台兼容性
|
||||
|
||||
| 平台 | 支持方式 | 说明 |
|
||||
|------|----------|------|
|
||||
| Android | `UTSJSONObject.parse<T>()` | 真正的类型转换,最佳性能 |
|
||||
| iOS | `as T` | 类型断言,编译时检查 |
|
||||
| Web | `as T` | 类型断言,编译时检查 |
|
||||
| HarmonyOS | `UTSJSONObject.parse<T>()` | 真正的类型转换 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
```typescript
|
||||
// 之前的方式
|
||||
const response = await supa.from('ak_messages').select('*').execute();
|
||||
const messages = convertToMessages(response.data); // 需要手动转换
|
||||
|
||||
// 现在的方式
|
||||
const response = await supa.from('ak_messages')
|
||||
.select('*')
|
||||
.executeAs<Array<Message>>(); // 自动类型转换
|
||||
const messages = response.data; // 直接使用,有完整类型提示
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **向后兼容**:原有的 `.execute()` 方法仍然可用,不影响其他代码
|
||||
2. **错误处理**:所有错误都会正确地 fallback 到原始数据
|
||||
3. **空值处理**:保持了原有的空值检查逻辑
|
||||
4. **测试建议**:建议在各个平台上进行充分测试
|
||||
|
||||
## 总结
|
||||
|
||||
这次升级成功地将 `MsgDataServiceReal` 类迁移到了新的类型化查询方式,在保持功能完整性的同时,大幅提升了类型安全性和开发体验。代码变得更加简洁、易维护,同时在 Android 和 HarmonyOS 平台上获得了真正的类型转换支持。
|
||||
293
utils/audioUploadService.uts
Normal file
293
utils/audioUploadService.uts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { AkReqResponse } from '@/uni_modules/ak-req/index.uts'
|
||||
import { SUPA_URL } from '@/ak/config.uts'
|
||||
import supa, { supaReady } from '@/components/supadb/aksupainstance.uts'
|
||||
import { AkSupaStorageUploadBuilder } from '@/components/supadb/aksupa.uts'
|
||||
|
||||
export type UploadAudioResult = {
|
||||
success : boolean
|
||||
url : string | null
|
||||
error ?: string | null
|
||||
durationMs ?: number | null
|
||||
mime ?: string | null
|
||||
sizeBytes ?: number | null
|
||||
bucket ?: string | null
|
||||
objectPath ?: string | null
|
||||
}
|
||||
|
||||
export type UploadAudioOptions = {
|
||||
filename ?: string
|
||||
mime ?: string
|
||||
bucket ?: string
|
||||
folder ?: string
|
||||
userId ?: string
|
||||
upsert ?: boolean
|
||||
cacheControlSeconds ?: number
|
||||
objectPath ?: string
|
||||
metadata ?: UTSJSONObject | null
|
||||
}
|
||||
|
||||
export class AudioUploadService {
|
||||
private static readonly DEFAULT_BUCKET = 'chat-audio'
|
||||
private static readonly DEFAULT_FOLDER = 'voices/app'
|
||||
|
||||
private static async ensureSession() : Promise<void> {
|
||||
try {
|
||||
const ready = await supaReady
|
||||
if (!ready) {
|
||||
console.warn('Supabase session not established for audio upload')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Supabase session initialization error', err)
|
||||
}
|
||||
}
|
||||
|
||||
private static normalizeFilename(filename : string, mime : string) : string {
|
||||
let base = filename
|
||||
if (base == null || base.trim().length === 0) {
|
||||
base = 'audio_' + Date.now()
|
||||
}
|
||||
base = base.trim()
|
||||
let cleaned = ''
|
||||
for (let i : Int = 0; i < base.length; i++) {
|
||||
const ch = base.charAt(i)
|
||||
const code = base.charCodeAt(i)
|
||||
if (code == null) continue
|
||||
const isDigit = code >= 48 && code <= 57
|
||||
const isUpper = code >= 65 && code <= 90
|
||||
const isLower = code >= 97 && code <= 122
|
||||
const isAllowedSymbol = (ch === '-' || ch === '_' || ch === '.' )
|
||||
if (isDigit || isUpper || isLower || isAllowedSymbol) {
|
||||
cleaned = cleaned + ch
|
||||
} else if (ch === ' ') {
|
||||
cleaned = cleaned + '_'
|
||||
}
|
||||
}
|
||||
if (cleaned.length === 0) {
|
||||
cleaned = 'audio_' + Date.now()
|
||||
}
|
||||
if (cleaned.indexOf('.') < 0) {
|
||||
cleaned = cleaned + AudioUploadService.extensionForMime(mime)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
private static extensionForMime(mime : string) : string {
|
||||
if (mime === 'audio/mpeg' || mime === 'audio/mp3') return '.mp3'
|
||||
if (mime === 'audio/wav') return '.wav'
|
||||
if (mime === 'audio/ogg') return '.ogg'
|
||||
if (mime === 'audio/aac') return '.aac'
|
||||
return '.mp3'
|
||||
}
|
||||
|
||||
private static sanitizeSegment(value : string) : string {
|
||||
let result = ''
|
||||
for (let i : Int = 0; i < value.length; i++) {
|
||||
const ch = value.charAt(i)
|
||||
const code = value.charCodeAt(i)
|
||||
if (code == null) continue
|
||||
const isDigit = code >= 48 && code <= 57
|
||||
const isUpper = code >= 65 && code <= 90
|
||||
const isLower = code >= 97 && code <= 122
|
||||
const isAllowedSymbol = (ch === '-' || ch === '_' || ch === '.')
|
||||
if (isDigit || isUpper || isLower || isAllowedSymbol) {
|
||||
result = result + ch
|
||||
}
|
||||
}
|
||||
if (result.length === 0) return 'segment'
|
||||
return result
|
||||
}
|
||||
|
||||
private static splitSegments(input : string) : Array<string> {
|
||||
const segments = new Array<string>()
|
||||
if (input == null) return segments
|
||||
const parts = input.split('/')
|
||||
for (let i : Int = 0; i < parts.length; i++) {
|
||||
const part = parts[i]
|
||||
if (part == null) continue
|
||||
const trimmed = part.trim()
|
||||
if (trimmed.length === 0) continue
|
||||
segments.push(AudioUploadService.sanitizeSegment(trimmed))
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
private static formatTwoDigits(value : number) : string {
|
||||
if (value < 10) {
|
||||
return '0' + value
|
||||
}
|
||||
return value.toString()
|
||||
}
|
||||
|
||||
private static buildDateSegments(date : Date) : Array<string> {
|
||||
const segments = new Array<string>()
|
||||
segments.push(date.getFullYear().toString())
|
||||
segments.push(AudioUploadService.formatTwoDigits(date.getMonth() + 1))
|
||||
segments.push(AudioUploadService.formatTwoDigits(date.getDate()))
|
||||
return segments
|
||||
}
|
||||
|
||||
private static normalizeObjectPath(path : string, filename : string) : string {
|
||||
const trimmed = path.trim()
|
||||
if (trimmed.length === 0) {
|
||||
return filename
|
||||
}
|
||||
const useFolder = trimmed.endsWith('/') ? trimmed : trimmed + '/'
|
||||
const folderSegments = AudioUploadService.splitSegments(useFolder)
|
||||
folderSegments.push(filename)
|
||||
return folderSegments.join('/')
|
||||
}
|
||||
|
||||
private static buildObjectPath(filename : string, opts : UploadAudioOptions | null) : string {
|
||||
if (opts != null && opts.objectPath != null && opts.objectPath.trim().length > 0) {
|
||||
return AudioUploadService.normalizeObjectPath(opts.objectPath!!, filename)
|
||||
}
|
||||
const folderValue = opts != null && opts.folder != null ? opts.folder!! : AudioUploadService.DEFAULT_FOLDER
|
||||
const segments = AudioUploadService.splitSegments(folderValue)
|
||||
if (opts != null && opts.userId != null && opts.userId.trim().length > 0) {
|
||||
segments.push('users')
|
||||
segments.push(AudioUploadService.sanitizeSegment(opts.userId!!))
|
||||
}
|
||||
const dateSegments = AudioUploadService.buildDateSegments(new Date())
|
||||
for (let i : Int = 0; i < dateSegments.length; i++) {
|
||||
segments.push(dateSegments[i])
|
||||
}
|
||||
segments.push(filename)
|
||||
return segments.join('/')
|
||||
}
|
||||
|
||||
private static buildPublicUrl(bucket : string, objectPath : string) : string {
|
||||
let base = SUPA_URL
|
||||
if (base.endsWith('/')) {
|
||||
base = base.substring(0, base.length - 1)
|
||||
}
|
||||
let normalizedPath = objectPath
|
||||
while (normalizedPath.startsWith('/')) {
|
||||
normalizedPath = normalizedPath.substring(1)
|
||||
}
|
||||
return base + '/storage/v1/object/public/' + bucket + '/' + normalizedPath
|
||||
}
|
||||
|
||||
private static mergeJson(target : UTSJSONObject, source : UTSJSONObject) {
|
||||
const keys = UTSJSONObject.keys(source)
|
||||
for (let i : Int = 0; i < keys.length; i++) {
|
||||
const key = keys[i]
|
||||
target[key] = source.get(key)
|
||||
}
|
||||
}
|
||||
|
||||
private static async fetchFileSize(filePath : string) : Promise<number | null> {
|
||||
return await new Promise<number | null>((resolve) => {
|
||||
let settled = false
|
||||
const finish = (value : number | null) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
resolve(value)
|
||||
}
|
||||
try {
|
||||
const fs = uni.getFileSystemManager()
|
||||
fs.getFileInfo({
|
||||
filePath,
|
||||
success: (res ) => {
|
||||
if (res != null && typeof res.size === 'number') {
|
||||
finish(res.size as number)
|
||||
return
|
||||
}
|
||||
finish(null)
|
||||
},
|
||||
fail: (_err) => {
|
||||
fs.stat({
|
||||
path: filePath,
|
||||
recursive: false,
|
||||
success: (statRes ) => {
|
||||
let size : number | null = null
|
||||
const stats = statRes != null ? statRes.stats as Array<any> : null
|
||||
if (stats != null) {
|
||||
for (let i : Int = 0; i < stats.length; i++) {
|
||||
const entry = stats[i]
|
||||
if (entry == null) {
|
||||
continue
|
||||
}
|
||||
const candidate = (entry as UTSJSONObject).get('size') as number | null
|
||||
if (candidate != null) {
|
||||
size = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
finish(size)
|
||||
},
|
||||
fail: () => finish(null)
|
||||
} as StatOptions)
|
||||
}
|
||||
} as GetFileInfoOptions)
|
||||
} catch (_fatal) {
|
||||
finish(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static async uploadAudio(filePath : string, opts ?: UploadAudioOptions) : Promise<UploadAudioResult> {
|
||||
if (filePath == null || filePath.length === 0) {
|
||||
return { success: false, url: null, error: 'invalid file path' }
|
||||
}
|
||||
await AudioUploadService.ensureSession()
|
||||
const mime = (opts != null && opts.mime != null && opts.mime.trim().length > 0) ? opts.mime!! : 'audio/mpeg'
|
||||
const filenameInput = opts != null && opts.filename != null ? opts.filename!! : ''
|
||||
const filename = AudioUploadService.normalizeFilename(filenameInput, mime)
|
||||
const bucket = (opts != null && opts.bucket != null && opts.bucket.trim().length > 0) ? opts.bucket!! : AudioUploadService.DEFAULT_BUCKET
|
||||
const objectPath = AudioUploadService.buildObjectPath(filename, opts ?? null)
|
||||
const uploadOptions = {} as UTSJSONObject
|
||||
const cacheSeconds = (opts != null && typeof opts.cacheControlSeconds === 'number') ? opts.cacheControlSeconds!! : 3600
|
||||
uploadOptions['cacheControl'] = cacheSeconds.toString()
|
||||
uploadOptions['contentType'] = mime
|
||||
if (opts != null && opts.upsert === true) {
|
||||
uploadOptions['x-upsert'] = 'true'
|
||||
}
|
||||
if (opts != null && opts.metadata != null) {
|
||||
AudioUploadService.mergeJson(uploadOptions, opts.metadata!!)
|
||||
}
|
||||
let response : AkReqResponse<any>
|
||||
try {
|
||||
const builder = new AkSupaStorageUploadBuilder(supa, bucket)
|
||||
response = await builder
|
||||
.path(objectPath)
|
||||
.file(filePath)
|
||||
.options(uploadOptions)
|
||||
.upload()
|
||||
} catch (err) {
|
||||
const message = (err instanceof Error) ? err.message : 'upload failed'
|
||||
return { success: false, url: null, error: message }
|
||||
}
|
||||
if (!(response.status >= 200 && response.status < 300)) {
|
||||
return { success: false, url: null, error: 'upload failed', mime }
|
||||
}
|
||||
let finalKey = objectPath
|
||||
const data = response.data
|
||||
if (data instanceof UTSJSONObject) {
|
||||
const key = data.getString('Key')
|
||||
if (key != null && key.length > 0) {
|
||||
finalKey = key
|
||||
}
|
||||
} else if (data != null && typeof data === 'object') {
|
||||
const jsonData = data as UTSJSONObject
|
||||
const keyValue = jsonData.getString('Key')
|
||||
if (keyValue != null && keyValue.length > 0) {
|
||||
finalKey = keyValue
|
||||
}
|
||||
}
|
||||
const publicUrl = AudioUploadService.buildPublicUrl(bucket, finalKey)
|
||||
const sizeBytes = await AudioUploadService.fetchFileSize(filePath)
|
||||
return {
|
||||
success: true,
|
||||
url: publicUrl,
|
||||
mime,
|
||||
sizeBytes,
|
||||
bucket,
|
||||
objectPath: finalKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AudioUploadService
|
||||
5
utils/bleConfig.uts
Normal file
5
utils/bleConfig.uts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Simple BLE configuration constants
|
||||
// Change BLE_PREFIX to adjust the default device name prefix filter used by the Health BLE page.
|
||||
export const BLE_PREFIX = 'CF'
|
||||
|
||||
// If you prefer to disable prefix filtering by default, set BLE_PREFIX = ''
|
||||
827
utils/chatDataService.uts
Normal file
827
utils/chatDataService.uts
Normal file
@@ -0,0 +1,827 @@
|
||||
import supa, { supaReady } from '@/components/supadb/aksupainstance.uts'
|
||||
import AkSupaRealtime from '@/components/supadb/aksuparealtime.uts'
|
||||
import { WS_URL, SUPA_KEY } from '@/ak/config.uts'
|
||||
import type { AkReqResponse } from '@/uni_modules/ak-req/index.uts'
|
||||
import MediaCacheService from '@/utils/mediaCacheService.uts'
|
||||
|
||||
export type AudioErrorEvent = {
|
||||
errCode ?: number
|
||||
errMsg ?: string
|
||||
}
|
||||
|
||||
export type ChatAudioContext = {
|
||||
src : string
|
||||
autoplay : boolean
|
||||
play ?: () => void
|
||||
pause ?: () => void
|
||||
stop ?: () => void
|
||||
destroy ?: () => void
|
||||
onError ?: (callback : (res : AudioErrorEvent) => void) => void
|
||||
}
|
||||
|
||||
// Helper describing the subset of inner audio context api we use
|
||||
|
||||
export type ViewMessage = {
|
||||
id : string
|
||||
conversation_id : string
|
||||
sender_id : string
|
||||
content : string
|
||||
content_type : string
|
||||
reply_to : string | null
|
||||
metadata : UTSJSONObject | null
|
||||
created_at : string
|
||||
updated_at : string
|
||||
ingress_type ?: string | null
|
||||
durationSeconds ?: number;
|
||||
locationTitle ?: string;
|
||||
videoSrc : string;
|
||||
timeText : string;
|
||||
avatar : string;
|
||||
senderName ?: string | null;
|
||||
isMe : boolean;
|
||||
bubbleExtraClass : string;
|
||||
rowExtraClass : string;
|
||||
anchorId : string;
|
||||
bubbleInlineStyle : string;
|
||||
ingressType ?: string | null;
|
||||
ingressLabel : string;
|
||||
};
|
||||
|
||||
export type MemberProfile = {
|
||||
id : string,
|
||||
name : string,
|
||||
avatar ?: string | null,
|
||||
initials : string
|
||||
}
|
||||
export type ChatConversationView = {
|
||||
id : string
|
||||
title : string | null
|
||||
is_group : boolean
|
||||
owner_id : string | null
|
||||
last_message_at : string | null
|
||||
metadata : UTSJSONObject | null
|
||||
created_at : string
|
||||
updated_at : string
|
||||
memberNames ?: string;
|
||||
members ?: Array<MemberProfile>;
|
||||
avatarMembers ?: Array<MemberProfile>;
|
||||
unreadCount ?: number;
|
||||
}
|
||||
|
||||
export type ChatConversation = {
|
||||
id : string
|
||||
title : string | null
|
||||
is_group : boolean
|
||||
owner_id : string | null
|
||||
last_message_at : string | null
|
||||
metadata : UTSJSONObject | null
|
||||
created_at : string
|
||||
updated_at : string
|
||||
}
|
||||
export type SendDeviceTextDownlinkParams = {
|
||||
conversationId : string
|
||||
createdBy : string
|
||||
text : string
|
||||
targetUserId ?: string | null
|
||||
qos ?: number | null
|
||||
retain ?: boolean | null
|
||||
}
|
||||
export type ChatMessage = {
|
||||
id : string
|
||||
conversation_id : string
|
||||
sender_id : string
|
||||
content : string
|
||||
content_type : string
|
||||
reply_to : string | null
|
||||
metadata : UTSJSONObject | null
|
||||
created_at : string
|
||||
updated_at : string
|
||||
ingress_type ?: string | null
|
||||
sender_profile ?: UserOption | null
|
||||
}
|
||||
|
||||
export type ChatNotification = {
|
||||
id : string
|
||||
user_id : string
|
||||
conversation_id : string | null
|
||||
message_id : string | null
|
||||
type : string
|
||||
is_read : boolean
|
||||
created_at : string
|
||||
}
|
||||
|
||||
export type ChatParticipant = {
|
||||
id : string
|
||||
conversation_id : string
|
||||
user_id : string
|
||||
role : string
|
||||
joined_at : string
|
||||
last_read_at : string | null
|
||||
is_muted : boolean
|
||||
settings : UTSJSONObject | null
|
||||
created_at : string
|
||||
updated_at : string
|
||||
}
|
||||
export type SendAudioMessageParams = {
|
||||
conversationId : string
|
||||
senderId : string
|
||||
s3Url : string
|
||||
durationMs ?: number | null
|
||||
mime ?: string | null
|
||||
sizeBytes ?: number | null
|
||||
}
|
||||
|
||||
export type ChatParticipantWithProfile = {
|
||||
id : string
|
||||
conversation_id : string
|
||||
|
||||
user_id : string
|
||||
role : string
|
||||
joined_at : string
|
||||
last_read_at : string | null
|
||||
is_muted : boolean
|
||||
settings : UTSJSONObject | null
|
||||
created_at : string
|
||||
updated_at : string
|
||||
user ?: UserOption | null
|
||||
}
|
||||
|
||||
export type SubscriptionDisposer = {
|
||||
dispose : () => void
|
||||
}
|
||||
|
||||
export type UserOption = {
|
||||
id : string
|
||||
username ?: string | null
|
||||
nickname ?: string | null
|
||||
real_name ?: string | null
|
||||
email ?: string | null
|
||||
avatar_url ?: string | null
|
||||
phone ?: string | null
|
||||
}
|
||||
|
||||
export type CreateConversationPayload = {
|
||||
title ?: string | null
|
||||
isGroup ?: boolean
|
||||
memberIds ?: Array<string>
|
||||
}
|
||||
|
||||
async function ensureSupaSession() {
|
||||
try {
|
||||
const ok = await supaReady
|
||||
if (!ok) {
|
||||
console.warn('Supabase session not established; subsequent requests may fail')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Supabase session initialization error', err)
|
||||
}
|
||||
}
|
||||
|
||||
function extractRealtimeRecord(payload : any) : UTSJSONObject | null {
|
||||
try {
|
||||
if (payload == null) return null
|
||||
const wrapper = payload as UTSJSONObject
|
||||
const directNew = wrapper.get('new') as UTSJSONObject | null
|
||||
if (directNew != null) return directNew
|
||||
const directRecord = wrapper.get('record') as UTSJSONObject | null
|
||||
if (directRecord != null) return directRecord
|
||||
const dataSection = wrapper.get('data') as UTSJSONObject | null
|
||||
if (dataSection != null) {
|
||||
const dataRecord = dataSection.get('record') as UTSJSONObject | null
|
||||
if (dataRecord != null) return dataRecord
|
||||
const dataNew = dataSection.get('new') as UTSJSONObject | null
|
||||
if (dataNew != null) return dataNew
|
||||
}
|
||||
} catch (_) { }
|
||||
return null
|
||||
}
|
||||
|
||||
function toChatMessage(row : UTSJSONObject, senderProfile : UserOption | null) : ChatMessage | null {
|
||||
try {
|
||||
const id = row.getString('id') as string | null
|
||||
const conversationId = row.getString('conversation_id') as string | null
|
||||
const senderId = row.getString('sender_id') as string | null
|
||||
const content = row.getString('content') as string | null
|
||||
const contentType = row.getString('content_type') as string | null
|
||||
const createdAt = row.getString('created_at') as string | null
|
||||
const updatedAt = row.getString('updated_at') as string | null
|
||||
if (id == null || conversationId == null || senderId == null || content == null || contentType == null || createdAt == null || updatedAt == null) {
|
||||
return null
|
||||
}
|
||||
const replyToRaw = row.get('reply_to')
|
||||
const replyTo = typeof replyToRaw == 'string' ? replyToRaw : null
|
||||
const metadata = row.get('metadata') as UTSJSONObject | null
|
||||
const ingressRaw = row.get('ingress_type')
|
||||
const ingressType = typeof ingressRaw == 'string' ? ingressRaw : null
|
||||
return {
|
||||
id: id,
|
||||
conversation_id: conversationId,
|
||||
sender_id: senderId,
|
||||
content: content,
|
||||
content_type: contentType,
|
||||
reply_to: replyTo,
|
||||
metadata: metadata ?? null,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
ingress_type: ingressType,
|
||||
sender_profile: senderProfile ?? null
|
||||
}
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function toChatNotification(row : UTSJSONObject) : ChatNotification | null {
|
||||
try {
|
||||
const id = row.getString('id') as string | null
|
||||
const userId = row.getString('user_id') as string | null
|
||||
const typeValue = row.getString('type') as string | null
|
||||
const createdAt = row.getString('created_at') as string | null
|
||||
if (id == null || userId == null || typeValue == null || createdAt == null) {
|
||||
return null
|
||||
}
|
||||
const conversationIdRaw = row.get('conversation_id')
|
||||
const conversationId = typeof conversationIdRaw == 'string' ? conversationIdRaw : null
|
||||
const messageIdRaw = row.get('message_id')
|
||||
const messageId = typeof messageIdRaw == 'string' ? messageIdRaw : null
|
||||
const isReadRaw = row.get('is_read')
|
||||
const isRead = typeof isReadRaw == 'boolean' ? isReadRaw : false
|
||||
return {
|
||||
id: id,
|
||||
user_id: userId,
|
||||
conversation_id: conversationId,
|
||||
message_id: messageId,
|
||||
type: typeValue,
|
||||
is_read: isRead,
|
||||
created_at: createdAt
|
||||
}
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export class ChatDataService {
|
||||
|
||||
private static rt : AkSupaRealtime | null = null
|
||||
private static initialized = false
|
||||
private static realtimeReady : Promise<void> | null = null
|
||||
private static lastRealtimeErrorToastAt = 0
|
||||
private static senderProfileCache = new Map<string, UserOption>()
|
||||
|
||||
private static notifyRealtimeError(err : any) {
|
||||
try {
|
||||
console.error('Realtime connection error', err)
|
||||
const now = Date.now()
|
||||
if (now - this.lastRealtimeErrorToastAt > 5000) {
|
||||
this.lastRealtimeErrorToastAt = now
|
||||
try {
|
||||
uni.showToast({ title: '实时通道连接失败', icon: 'none' })
|
||||
} catch (_) { }
|
||||
}
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
private static getCachedSenderProfile(userId : string | null) : UserOption | null {
|
||||
if (typeof userId !== 'string' || userId == '') return null
|
||||
return this.senderProfileCache.get(userId) ?? null
|
||||
}
|
||||
|
||||
private static async ensureSenderProfiles(userIds : Array<string>) : Promise<void> {
|
||||
const ids = Array.from(new Set(userIds.filter((id) => typeof id == 'string' && id !== '')))
|
||||
if (ids.length == 0) return
|
||||
const missing : Array<string> = []
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const id = ids[i] as string
|
||||
if (!this.senderProfileCache.has(id)) {
|
||||
missing.push(id)
|
||||
}
|
||||
}
|
||||
if (missing.length == 0) return
|
||||
await ensureSupaSession()
|
||||
try {
|
||||
const res = await supa
|
||||
.from('ak_users')
|
||||
.select('id, username, nickname, real_name, email, avatar_url, phone', {})
|
||||
.in('id', missing as any[])
|
||||
.executeAs<UserOption>()
|
||||
if (!(res.status >= 200 && res.status < 300) || res.data == null) return
|
||||
const arr = Array.isArray(res.data) ? (res.data as any[]) : [res.data]
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item = arr[i] as UTSJSONObject | null
|
||||
if (item == null) continue
|
||||
const id = item['id'] as string | null
|
||||
if (typeof id !== 'string' || id == '') continue
|
||||
const profile : UserOption = {
|
||||
id: id as string,
|
||||
username: item['username'] as string | null ?? null,
|
||||
nickname: item['nickname'] as string | null ?? null,
|
||||
real_name: item['real_name'] as string | null ?? null,
|
||||
email: item['email'] as string | null ?? null,
|
||||
avatar_url: item['avatar_url'] as string | null ?? null,
|
||||
phone: item['phone'] as string | null ?? null
|
||||
}
|
||||
this.senderProfileCache.set(id as string, profile)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('ensureSenderProfiles error', err)
|
||||
}
|
||||
}
|
||||
|
||||
private static async ensureRealtime() {
|
||||
if (this.rt != null && this.rt.isOpen == true) {
|
||||
this.initialized = true
|
||||
return
|
||||
}
|
||||
if (this.realtimeReady != null) {
|
||||
await this.realtimeReady
|
||||
return
|
||||
}
|
||||
await ensureSupaSession()
|
||||
let token : string | null = null
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
if (session != null) {
|
||||
token = session.session?.access_token
|
||||
}
|
||||
} catch (_) { }
|
||||
let resolveReady : (() => void) | null = null
|
||||
let rejectReady : ((reason : any) => void) | null = null
|
||||
const readyPromise = new Promise<void>((resolve, reject) => {
|
||||
resolveReady = () => { resolve() }
|
||||
rejectReady = reject
|
||||
})
|
||||
this.realtimeReady = readyPromise
|
||||
this.rt = new AkSupaRealtime({
|
||||
url: WS_URL,
|
||||
channel: 'realtime:*',
|
||||
apikey: SUPA_KEY,
|
||||
token: token ?? null,
|
||||
onMessage: (_data : any) => { },
|
||||
onOpen: (_res : any) => {
|
||||
this.initialized = true
|
||||
const resolver = resolveReady
|
||||
resolveReady = null
|
||||
rejectReady = null
|
||||
this.realtimeReady = null
|
||||
resolver?.()
|
||||
},
|
||||
onError: (err : any) => {
|
||||
if (resolveReady != null) {
|
||||
const rejector = rejectReady
|
||||
resolveReady = null
|
||||
rejectReady = null
|
||||
this.realtimeReady = null
|
||||
rejector?.(err)
|
||||
}
|
||||
},
|
||||
onClose: (_res : any) => {
|
||||
this.initialized = false
|
||||
this.rt = null
|
||||
if (resolveReady != null) {
|
||||
const rejector = rejectReady
|
||||
resolveReady = null
|
||||
rejectReady = null
|
||||
this.realtimeReady = null
|
||||
rejector?.(new Error('Realtime connection closed before ready'))
|
||||
}
|
||||
}
|
||||
})
|
||||
this.rt.connect()
|
||||
const timeoutMs = 8000
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
if (resolveReady != null) {
|
||||
const rejector = rejectReady
|
||||
resolveReady = null
|
||||
rejectReady = null
|
||||
this.realtimeReady = null
|
||||
rejector?.(new Error('Realtime connection timeout'))
|
||||
}
|
||||
}, timeoutMs)
|
||||
try {
|
||||
await readyPromise
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle)
|
||||
}
|
||||
}
|
||||
|
||||
static async subscribeMessages(conversationId : string, onInsert : (msg : ChatMessage) => void) : Promise<SubscriptionDisposer> {
|
||||
try {
|
||||
await this.ensureRealtime()
|
||||
} catch (err) {
|
||||
this.notifyRealtimeError(err)
|
||||
this.closeRealtime()
|
||||
return { dispose: () => { } }
|
||||
}
|
||||
console.log('subscribeMessages',conversationId)
|
||||
const guard = { active: true }
|
||||
this.rt?.subscribePostgresChanges({
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'chat_messages',
|
||||
onChange: (payload) => {
|
||||
console.log('realtime payload',payload)
|
||||
try {
|
||||
if (guard.active != true || payload == null) return
|
||||
const newRow = extractRealtimeRecord(payload)
|
||||
if (newRow == null) { return }
|
||||
const convId = newRow.getString('conversation_id') as string | null
|
||||
if (convId != null && convId == conversationId) {
|
||||
const senderId = newRow.getString('sender_id') as string | null
|
||||
const emitMessage = (profile : UserOption | null) => {
|
||||
if (guard.active != true) return
|
||||
const message = toChatMessage(newRow, profile)
|
||||
if (message != null) onInsert(message)
|
||||
}
|
||||
if (senderId != null && senderId !== '') {
|
||||
const cachedProfile = this.getCachedSenderProfile(senderId)
|
||||
if (cachedProfile == null) {
|
||||
this.ensureSenderProfiles([senderId as string]).then(() => {
|
||||
if (guard.active != true) return
|
||||
const updatedProfile = this.getCachedSenderProfile(senderId)
|
||||
emitMessage(updatedProfile ?? null)
|
||||
})
|
||||
return
|
||||
}
|
||||
emitMessage(cachedProfile)
|
||||
} else {
|
||||
emitMessage(null)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('realtime payload handling error', err)
|
||||
}
|
||||
}
|
||||
})
|
||||
return { dispose: () => { guard.active = false } }
|
||||
}
|
||||
|
||||
static async subscribeNotifications(userId : string, onInsert : (n : ChatNotification) => void) : Promise<SubscriptionDisposer> {
|
||||
try {
|
||||
await this.ensureRealtime()
|
||||
} catch (err) {
|
||||
this.notifyRealtimeError(err)
|
||||
this.closeRealtime()
|
||||
return { dispose: () => { } }
|
||||
}
|
||||
const guard = { active: true }
|
||||
this.rt?.subscribePostgresChanges({
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'chat_notifications',
|
||||
onChange: (payload : any) => {
|
||||
try {
|
||||
const rec = extractRealtimeRecord(payload)
|
||||
if (guard.active != true || rec == null) return
|
||||
const userIdStr = rec.getString('user_id') as string | null
|
||||
if (userIdStr != null && userIdStr == userId) {
|
||||
const notification = toChatNotification(rec)
|
||||
if (notification != null) onInsert(notification)
|
||||
}
|
||||
} catch (_) { }
|
||||
}
|
||||
})
|
||||
return { dispose: () => { guard.active = false } }
|
||||
}
|
||||
|
||||
static closeRealtime() {
|
||||
try {
|
||||
this.rt?.close({})
|
||||
} catch (_) { }
|
||||
this.rt = null
|
||||
this.initialized = false
|
||||
this.realtimeReady = null
|
||||
}
|
||||
|
||||
static async listMyConversations(userId : string) : Promise<AkReqResponse<Array<ChatConversation>>> {
|
||||
await ensureSupaSession()
|
||||
const res = await supa
|
||||
.from('chat_conversations')
|
||||
.select('*', {})
|
||||
.order('last_message_at', { ascending: false })
|
||||
.executeAs<ChatConversation>()
|
||||
return res as AkReqResponse<Array<ChatConversation>>
|
||||
}
|
||||
|
||||
static async getConversation(conversationId : string) : Promise<AkReqResponse<ChatConversation>> {
|
||||
await ensureSupaSession()
|
||||
const res = await supa
|
||||
.from('chat_conversations')
|
||||
.select('*', {})
|
||||
.eq('id', conversationId)
|
||||
.single()
|
||||
.executeAs<ChatConversation>()
|
||||
return res as AkReqResponse<ChatConversation>
|
||||
}
|
||||
|
||||
static async listMessages(conversationId : string, limit = 100) : Promise<AkReqResponse<Array<ChatMessage>>> {
|
||||
await ensureSupaSession()
|
||||
console.log('listMessages')
|
||||
const res = await supa
|
||||
.from('chat_messages')
|
||||
.select('*', {})
|
||||
.eq('conversation_id', conversationId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(limit)
|
||||
.executeAs<ChatMessage>()
|
||||
try {
|
||||
const raw = res.data
|
||||
const list : Array<UTSJSONObject> = []
|
||||
if (Array.isArray(raw)) {
|
||||
for (const item of raw as any[]) {
|
||||
if (item != null) {
|
||||
list.push(item as UTSJSONObject)
|
||||
}
|
||||
}
|
||||
} else if (raw != null) {
|
||||
list.push(raw as UTSJSONObject)
|
||||
}
|
||||
|
||||
const senderIds : Array<string> = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const item = list[i]
|
||||
let senderId : string | null = null
|
||||
if (item != null) {
|
||||
senderId = item.getString('sender_id') as string | null
|
||||
if (senderId == null) {
|
||||
senderId = item['sender_id'] as string | null
|
||||
}
|
||||
}
|
||||
if (typeof senderId == 'string' && senderId !== '') {
|
||||
senderIds.push(senderId!!)
|
||||
}
|
||||
}
|
||||
if (senderIds.length > 0) {
|
||||
await this.ensureSenderProfiles(senderIds)
|
||||
for (let j = 0; j < list.length; j++) {
|
||||
const item = list[j]
|
||||
if (item != null) {
|
||||
let senderId : string | null = item.getString('sender_id') as string | null
|
||||
if (senderId == null) {
|
||||
senderId = item['sender_id'] as string | null
|
||||
}
|
||||
if (typeof senderId == 'string' && senderId !== '') {
|
||||
const profile = this.getCachedSenderProfile(senderId)
|
||||
if (profile != null) {
|
||||
item['sender_profile'] = profile
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('listMessages profile hydrate error', err)
|
||||
}
|
||||
console.log('listMessages ',res,conversationId)
|
||||
return res as AkReqResponse<Array<ChatMessage>>
|
||||
|
||||
}
|
||||
|
||||
static async sendMessage(conversationId : string, senderId : string, content : string) : Promise<AkReqResponse<ChatMessage>> {
|
||||
await ensureSupaSession()
|
||||
const payload = {
|
||||
conversation_id: conversationId,
|
||||
sender_id: senderId,
|
||||
content,
|
||||
content_type: 'text',
|
||||
ingress_type: 'manual',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
} as UTSJSONObject
|
||||
const res = await supa
|
||||
.from('chat_messages')
|
||||
.insert(payload)
|
||||
.single()
|
||||
.executeAs<ChatMessage>()
|
||||
|
||||
return res as AkReqResponse<ChatMessage>
|
||||
|
||||
}
|
||||
|
||||
static async listNotifications(userId : string, limit = 50) : Promise<AkReqResponse<Array<ChatNotification>>> {
|
||||
await ensureSupaSession()
|
||||
const res = await supa
|
||||
.from('chat_notifications')
|
||||
.select('*', {})
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit)
|
||||
.executeAs<ChatNotification>()
|
||||
return res as AkReqResponse<Array<ChatNotification>>
|
||||
}
|
||||
|
||||
static async markConversationNotificationsRead(userId : string, conversationId : string) : Promise<AkReqResponse<Array<ChatNotification>>> {
|
||||
await ensureSupaSession()
|
||||
const res = await supa
|
||||
.from('chat_notifications')
|
||||
.update({ is_read: true })
|
||||
.eq('user_id', userId)
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('type', 'message')
|
||||
.eq('is_read', false)
|
||||
.select('id', {})
|
||||
.executeAs<ChatNotification>()
|
||||
return res as AkReqResponse<Array<ChatNotification>>
|
||||
}
|
||||
|
||||
static async markNotificationRead(notificationId : string) : Promise<AkReqResponse<Array<ChatNotification>>> {
|
||||
await ensureSupaSession()
|
||||
const res = await supa
|
||||
.from('chat_notifications')
|
||||
.update({ is_read: true })
|
||||
.eq('id', notificationId)
|
||||
.eq('is_read', false)
|
||||
.select('id', {})
|
||||
.executeAs<ChatNotification>()
|
||||
return res as AkReqResponse<Array<ChatNotification>>
|
||||
}
|
||||
|
||||
static async searchUsers(keyword : string, limit = 20) : Promise<AkReqResponse<Array<UserOption>>> {
|
||||
await ensureSupaSession()
|
||||
let query = supa
|
||||
.from('ak_users')
|
||||
.select('id, username, nickname, email', {})
|
||||
.limit(limit)
|
||||
const k = keyword.trim()
|
||||
if (k !== '') {
|
||||
query = query.or(`username.ilike.%${k}%,nickname.ilike.%${k}%,email.ilike.%${k}%`)
|
||||
}
|
||||
const res = await query.executeAs<UserOption>()
|
||||
return res as AkReqResponse<Array<UserOption>>
|
||||
}
|
||||
|
||||
// Create a conversation and add participants. The creator becomes owner.
|
||||
static async createConversation(ownerId : string, payload : CreateConversationPayload) : Promise<AkReqResponse<ChatConversation>> {
|
||||
await ensureSupaSession()
|
||||
const title = payload.title ?? null
|
||||
const isGroup = payload.isGroup ?? ((payload.memberIds?.length ?? 0) > 1)
|
||||
const memberIds = (payload.memberIds ?? []).filter((id) => id !== ownerId)
|
||||
// 1) create conversation
|
||||
const convRes = await supa
|
||||
.from('chat_conversations')
|
||||
.insert({ title, is_group: isGroup, owner_id: ownerId } as UTSJSONObject)
|
||||
.single()
|
||||
.executeAs<ChatConversation>()
|
||||
if (convRes.status != null && (convRes.status < 200 || convRes.status >= 300 || convRes.data == null)) {
|
||||
return convRes as AkReqResponse<ChatConversation>
|
||||
}
|
||||
const conv = convRes.data as ChatConversation
|
||||
// 2) insert owner participant first
|
||||
const ownerRow = { conversation_id: conv.id, user_id: ownerId, role: 'owner' }
|
||||
const ownerIns = await supa
|
||||
.from('chat_participants')
|
||||
.insert(ownerRow)
|
||||
.single()
|
||||
.executeAs<ChatParticipant>()
|
||||
if (!(ownerIns.status >= 200 && ownerIns.status < 300)) {
|
||||
// return conversation as created but warn via status
|
||||
return convRes as AkReqResponse<ChatConversation>
|
||||
}
|
||||
// 3) insert other members (if any) in bulk
|
||||
if (memberIds.length > 0) {
|
||||
const rows = memberIds.map((uid) => ({ conversation_id: conv.id, user_id: uid, role: 'member' }))
|
||||
await supa.from('chat_participants').insert(rows as any[] as UTSJSONObject[]).executeAs<ChatParticipant>()
|
||||
}
|
||||
return convRes as AkReqResponse<ChatConversation>
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Insert a downlink row to chat_mqtt_downlinks; gateway will publish to MQTT
|
||||
static async sendDeviceTextDownlink(params : SendDeviceTextDownlinkParams) : Promise<AkReqResponse<any>> {
|
||||
await ensureSupaSession()
|
||||
const row = {
|
||||
conversation_id: params.conversationId,
|
||||
target_user_id: params.targetUserId ?? null,
|
||||
topic: null, // let gateway derive topic by target_user_id
|
||||
payload: params.text,
|
||||
payload_encoding: 'utf8',
|
||||
qos: (params.qos ?? 1),
|
||||
retain: (params.retain ?? false),
|
||||
status: 'pending',
|
||||
scheduled_at: new Date().toISOString(),
|
||||
created_by: params.createdBy
|
||||
} as UTSJSONObject;
|
||||
const res = await supa
|
||||
.from('chat_mqtt_downlinks')
|
||||
.insert(row)
|
||||
.single()
|
||||
.executeAs<any>()
|
||||
return res
|
||||
}
|
||||
|
||||
// For 1:1 conversation, derive the peer id (the other participant)
|
||||
static async getPeerId(conversationId : string, myId : string) : Promise<string | null> {
|
||||
await ensureSupaSession()
|
||||
const res = await supa
|
||||
.from('chat_participants')
|
||||
.select('*', {})
|
||||
.eq('conversation_id', conversationId)
|
||||
.order('joined_at', { ascending: true })
|
||||
.limit(10)
|
||||
.executeAs<ChatParticipant>()
|
||||
if (!(res.status >= 200 && res.status < 300) || res.data == null) return null
|
||||
const arr = res.data as any[]
|
||||
if (arr.length == 2) {
|
||||
const a = arr[0] as UTSJSONObject;
|
||||
const b = arr[1] as UTSJSONObject
|
||||
const aId = a['user_id'] as string;
|
||||
const bId = b['user_id'] as string
|
||||
return aId == myId ? bId : (bId == myId ? aId : null)
|
||||
}
|
||||
// not strict 1:1
|
||||
return null
|
||||
}
|
||||
// Invite additional members to an existing conversation (owner/admin only)
|
||||
static async inviteMembers(conversationId : string, memberIds : Array<string>) : Promise<AkReqResponse<Array<ChatParticipant>>> {
|
||||
await ensureSupaSession()
|
||||
const ids = Array.from(new Set(memberIds))
|
||||
if (ids.length == 0) {
|
||||
// Simulate a 200 empty insert response
|
||||
return { status: 200, data: [] as ChatParticipant[], error: null, headers: {} } as AkReqResponse<Array<ChatParticipant>>
|
||||
}
|
||||
const rows = ids.map((uid) => ({ conversation_id: conversationId, user_id: uid, role: 'member' }))
|
||||
const res = await supa
|
||||
.from('chat_participants')
|
||||
.insert(rows as any[] as UTSJSONObject[])
|
||||
.executeAs<ChatParticipant>()
|
||||
return res as AkReqResponse<Array<ChatParticipant>>
|
||||
}
|
||||
|
||||
static async listParticipants(conversationId : string) : Promise<AkReqResponse<Array<ChatParticipant>>> {
|
||||
await ensureSupaSession()
|
||||
const res = await supa
|
||||
.from('chat_participants')
|
||||
.select('*', {})
|
||||
.eq('conversation_id', conversationId)
|
||||
.order('joined_at', { ascending: true })
|
||||
.executeAs<ChatParticipant>()
|
||||
return res as AkReqResponse<Array<ChatParticipant>>
|
||||
}
|
||||
|
||||
static async listParticipantsWithProfile(conversationId : string) : Promise<AkReqResponse<Array<ChatParticipantWithProfile>>> {
|
||||
await ensureSupaSession()
|
||||
const res = await supa
|
||||
.from('chat_participants')
|
||||
.select('id, conversation_id, user_id, role, joined_at, last_read_at, is_muted, settings, created_at, updated_at, user:ak_users(id, nickname, username, email, real_name, avatar_url,phone)', {})
|
||||
.eq('conversation_id', conversationId)
|
||||
.order('joined_at', { ascending: true })
|
||||
.executeAs<ChatParticipantWithProfile>()
|
||||
return res as AkReqResponse<Array<ChatParticipantWithProfile>>
|
||||
}
|
||||
|
||||
|
||||
static async sendAudioMessage(params : SendAudioMessageParams) : Promise<AkReqResponse<ChatMessage>> {
|
||||
await ensureSupaSession()
|
||||
const metadata = {
|
||||
duration_ms: params.durationMs ?? null,
|
||||
mime: params.mime ?? 'audio/mpeg',
|
||||
size: params.sizeBytes ?? null
|
||||
} as UTSJSONObject
|
||||
const payload = {
|
||||
conversation_id: params.conversationId,
|
||||
sender_id: params.senderId,
|
||||
content: params.s3Url,
|
||||
content_type: 'audio',
|
||||
metadata,
|
||||
ingress_type: 'manual',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
const res = await supa
|
||||
.from('chat_messages')
|
||||
.insert(payload)
|
||||
.single()
|
||||
.executeAs<ChatMessage>()
|
||||
|
||||
return res as AkReqResponse<ChatMessage>
|
||||
|
||||
}
|
||||
|
||||
// Simple audio playback helper using inner audio context (uni-app API)
|
||||
static async playAudio(url : string) : Promise<InnerAudioContext | null> {
|
||||
try {
|
||||
const cached = await MediaCacheService.getCachedPath(url)
|
||||
console.log(cached,url)
|
||||
const audio = uni.createInnerAudioContext()
|
||||
audio.src = url
|
||||
audio.autoplay = true
|
||||
audio.onError((e:ICreateInnerAudioContextFail) => {
|
||||
console.log('audio error', e.errCode, e.errMsg)
|
||||
})
|
||||
return audio
|
||||
} catch (e) {
|
||||
console.log('audio not supported', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Video caching helper: return a playable local path for <video> component
|
||||
static async resolveVideoSource(url : string) : Promise<string> {
|
||||
try {
|
||||
const cached = await MediaCacheService.getCachedPath(url)
|
||||
return cached
|
||||
} catch (_) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
32
utils/coord.uts
Normal file
32
utils/coord.uts
Normal file
@@ -0,0 +1,32 @@
|
||||
export function wgs84ToGcj02(lat: number, lng: number): number[] {
|
||||
if (outOfChina(lat, lng)) return [lat, lng]
|
||||
const dLat = transformLat(lng - 105.0, lat - 35.0)
|
||||
const dLng = transformLng(lng - 105.0, lat - 35.0)
|
||||
const radLat = lat / 180.0 * Math.PI
|
||||
let magic = Math.sin(radLat)
|
||||
magic = 1 - 0.00669342162296594323 * magic * magic
|
||||
const sqrtMagic = Math.sqrt(magic)
|
||||
const dLat2 = (dLat * 180.0) / ((6335552.717000426 * magic) / (sqrtMagic) * Math.PI)
|
||||
const dLng2 = (dLng * 180.0) / ((6378245.0 / sqrtMagic * Math.cos(radLat)) * Math.PI)
|
||||
return [lat + dLat2, lng + dLng2]
|
||||
}
|
||||
|
||||
function outOfChina(lat: number, lng: number): boolean {
|
||||
return (lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271)
|
||||
}
|
||||
|
||||
function transformLat(x: number, y: number): number {
|
||||
let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x))
|
||||
ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0
|
||||
ret += (20.0 * Math.sin(y * Math.PI) + 40.0 * Math.sin(y / 3.0 * Math.PI)) * 2.0 / 3.0
|
||||
ret += (160.0 * Math.sin(y / 12.0 * Math.PI) + 320 * Math.sin(y * Math.PI / 30.0)) * 2.0 / 3.0
|
||||
return ret
|
||||
}
|
||||
|
||||
function transformLng(x: number, y: number): number {
|
||||
let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x))
|
||||
ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0
|
||||
ret += (20.0 * Math.sin(x * Math.PI) + 40.0 * Math.sin(x / 3.0 * Math.PI)) * 2.0 / 3.0
|
||||
ret += (150.0 * Math.sin(x / 12.0 * Math.PI) + 300.0 * Math.sin(x / 30.0 * Math.PI)) * 2.0 / 3.0
|
||||
return ret
|
||||
}
|
||||
409
utils/deviceRealtimeService.uts
Normal file
409
utils/deviceRealtimeService.uts
Normal file
@@ -0,0 +1,409 @@
|
||||
import supa, { supaReady } from '@/components/supadb/aksupainstance.uts'
|
||||
import AkSupaRealtime from '@/components/supadb/aksuparealtime.uts'
|
||||
import { WS_URL, SUPA_KEY } from '@/ak/config.uts'
|
||||
|
||||
export type DeviceBatchItem = {
|
||||
ap: string
|
||||
ch: number
|
||||
id: number
|
||||
cmd: number
|
||||
rssi: number
|
||||
time: number
|
||||
status: number
|
||||
quality: number
|
||||
recvtime: number
|
||||
heartrate: number
|
||||
rrinterval: number
|
||||
studentName: string
|
||||
activitylevel: number
|
||||
}
|
||||
|
||||
export type DeviceRealtimeHandlers = {
|
||||
onData?: (item: DeviceBatchItem) => void
|
||||
onError?: (err: any) => void
|
||||
}
|
||||
|
||||
export type DeviceRealtimeSubscription = {
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export type DeviceRealtimeSubscriptionParams = {
|
||||
roomId: string
|
||||
deviceId: number
|
||||
}
|
||||
|
||||
type SubscriptionGuard = {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
type ActiveSubscription = {
|
||||
params: DeviceRealtimeSubscriptionParams
|
||||
handlers: DeviceRealtimeHandlers
|
||||
guard: SubscriptionGuard
|
||||
}
|
||||
|
||||
async function ensureSupaSession(): Promise<void> {
|
||||
try {
|
||||
await supaReady
|
||||
} catch (err) {
|
||||
console.error('Supabase session initialization error', err)
|
||||
}
|
||||
}
|
||||
|
||||
function extractRecord(payload: any): UTSJSONObject | null {
|
||||
try {
|
||||
if (payload == null) return null
|
||||
const wrapper = payload as UTSJSONObject
|
||||
const directNew = wrapper.get('new') as UTSJSONObject | null
|
||||
if (directNew != null) return directNew
|
||||
const record = wrapper.get('record') as UTSJSONObject | null
|
||||
if (record != null) return record
|
||||
const dataSection = wrapper.get('data') as UTSJSONObject | null
|
||||
if (dataSection != null) {
|
||||
const dataRecord = dataSection.get('record') as UTSJSONObject | null
|
||||
if (dataRecord != null) return dataRecord
|
||||
const dataNew = dataSection.get('new') as UTSJSONObject | null
|
||||
if (dataNew != null) return dataNew
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('extractRecord error', err)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export class DeviceRealtimeService {
|
||||
private static rt: AkSupaRealtime | null = null
|
||||
private static initializing: Promise<void> | null = null
|
||||
private static initialized: boolean = false
|
||||
private static subscriptions: Array<ActiveSubscription> = []
|
||||
private static reconnectAttempts: number = 0
|
||||
private static reconnectTimer: number | null = null
|
||||
private static reconnecting: boolean = false
|
||||
|
||||
private static async waitForRealtimeOpen(timeoutMs: number = 5000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (true) {
|
||||
if (this.rt != null && this.rt.isOpen == true) {
|
||||
return
|
||||
}
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error('Realtime socket not ready')
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 50)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private static clearReconnectTimer(): void {
|
||||
const timer = this.reconnectTimer
|
||||
if (timer != null) {
|
||||
clearTimeout(timer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private static handleSocketClose(origin: string | null): void {
|
||||
this.initialized = false
|
||||
this.rt = null
|
||||
if (this.subscriptions.length == 0) {
|
||||
this.clearReconnectTimer()
|
||||
this.reconnectAttempts = 0
|
||||
return
|
||||
}
|
||||
if (this.reconnecting == true) {
|
||||
return
|
||||
}
|
||||
console.warn('[DeviceRealtimeService] realtime closed, scheduling reconnect', origin ?? 'unknown')
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
|
||||
private static async scheduleReconnect(): Promise<void> {
|
||||
if (this.reconnectTimer != null) return
|
||||
if (this.subscriptions.every((sub) => sub.guard.active !== true)) {
|
||||
this.subscriptions = []
|
||||
this.reconnectAttempts = 0
|
||||
return
|
||||
}
|
||||
const attempt = this.reconnectAttempts + 1
|
||||
this.reconnectAttempts = attempt
|
||||
const baseDelay = Math.min(Math.pow(2, attempt - 1) * 1000, 10000)
|
||||
const jitter = Math.floor(Math.random() * 200)
|
||||
const delay = baseDelay + jitter
|
||||
console.log('[DeviceRealtimeService] reconnect scheduled in', delay, 'ms (attempt', attempt, ')')
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
const runner = async () => {
|
||||
this.reconnectTimer = null
|
||||
if (this.subscriptions.every((sub) => sub.guard.active !== true)) {
|
||||
this.subscriptions = []
|
||||
this.reconnectAttempts = 0
|
||||
return
|
||||
}
|
||||
this.reconnecting = true
|
||||
try {
|
||||
await this.ensureRealtime()
|
||||
await this.waitForRealtimeOpen(3000)
|
||||
this.reconnectAttempts = 0
|
||||
await this.resubscribeAll()
|
||||
} catch (err) {
|
||||
console.error('reconnect attempt failed', err)
|
||||
this.scheduleReconnect()
|
||||
} finally {
|
||||
this.reconnecting = false
|
||||
}
|
||||
}
|
||||
runner().catch((err) => {
|
||||
console.error('reconnect timer runner error', err)
|
||||
})
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private static async resubscribeAll(): Promise<void> {
|
||||
let hadFailure = false
|
||||
for (let i = 0; i < this.subscriptions.length; i++) {
|
||||
const record = this.subscriptions[i]
|
||||
if (record.guard.active !== true) continue
|
||||
try {
|
||||
await this.performSubscription(record)
|
||||
} catch (err) {
|
||||
console.error('resubscribe error', err)
|
||||
record.handlers?.onError?.(err)
|
||||
hadFailure = true
|
||||
}
|
||||
}
|
||||
if (hadFailure) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private static removeSubscription(record: ActiveSubscription): void {
|
||||
const index = this.subscriptions.indexOf(record)
|
||||
if (index >= 0) {
|
||||
this.subscriptions.splice(index, 1)
|
||||
}
|
||||
if (this.subscriptions.length == 0) {
|
||||
this.clearReconnectTimer()
|
||||
this.reconnectAttempts = 0
|
||||
}
|
||||
}
|
||||
|
||||
private static async performSubscription(record: ActiveSubscription): Promise<void> {
|
||||
if (record.guard.active !== true) return
|
||||
const params = record.params
|
||||
const handlers = record.handlers
|
||||
|
||||
const isSubscribeAll = params.roomId == '*'
|
||||
const filter = isSubscribeAll ? undefined : `room_id=eq.${params.roomId}`
|
||||
|
||||
const maxAttempts = 2
|
||||
let attempt = 0
|
||||
while (attempt < maxAttempts) {
|
||||
attempt += 1
|
||||
if (record.guard.active !== true) return
|
||||
try {
|
||||
if (this.rt == null || this.rt.isOpen !== true) {
|
||||
await this.ensureRealtime()
|
||||
await this.waitForRealtimeOpen(2000)
|
||||
}
|
||||
const realtime = this.rt
|
||||
if (realtime == null) {
|
||||
throw new Error('Realtime client not initialized')
|
||||
}
|
||||
console.log('[DeviceRealtimeService] realtime ready, preparing subscription', params)
|
||||
|
||||
realtime.subscribePostgresChanges({
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'realtime_device_states',
|
||||
filter: filter,
|
||||
onChange: (payload: any) => {
|
||||
if (record.guard.active !== true) return
|
||||
const row = extractRecord(payload)
|
||||
if (row == null) return
|
||||
|
||||
const batchData = row.get('batch_data')
|
||||
if (batchData != null && Array.isArray(batchData)) {
|
||||
const list = batchData as UTSArray<UTSJSONObject>
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const item = list[i]
|
||||
const id = item.getNumber('id')
|
||||
if (isSubscribeAll || id == params.deviceId) {
|
||||
const deviceItem: DeviceBatchItem = {
|
||||
ap: item.getString('ap') ?? '',
|
||||
ch: item.getNumber('ch') ?? 0,
|
||||
id: id,
|
||||
cmd: item.getNumber('cmd') ?? 0,
|
||||
rssi: item.getNumber('rssi') ?? 0,
|
||||
time: item.getNumber('time') ?? 0,
|
||||
status: item.getNumber('status') ?? 0,
|
||||
quality: item.getNumber('quality') ?? 0,
|
||||
recvtime: item.getNumber('recvtime') ?? 0,
|
||||
heartrate: item.getNumber('heartrate') ?? 0,
|
||||
rrinterval: item.getNumber('rrinterval') ?? 0,
|
||||
studentName: item.getString('studentName') ?? '',
|
||||
activitylevel: item.getNumber('activitylevel') ?? 0
|
||||
}
|
||||
handlers?.onData?.(deviceItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log('[DeviceRealtimeService] subscription ready', params.roomId)
|
||||
return
|
||||
} catch (subscribeErr) {
|
||||
console.error('performSubscription error', subscribeErr, 'attempt', attempt)
|
||||
if (attempt >= maxAttempts) {
|
||||
throw subscribeErr
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 200)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async ensureRealtime(): Promise<void> {
|
||||
if (this.rt != null && this.rt.isOpen == true) {
|
||||
this.initialized = true
|
||||
return
|
||||
}
|
||||
if (this.initializing != null) {
|
||||
await this.initializing
|
||||
return
|
||||
}
|
||||
await ensureSupaSession()
|
||||
let token: string | null = null
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
if (session != null) {
|
||||
token = session.session?.access_token ?? null
|
||||
}
|
||||
} catch (_) { }
|
||||
|
||||
let resolveReady: ((value: void) => void) | null = null
|
||||
let rejectReady: ((reason: any) => void) | null = null
|
||||
const readyPromise = new Promise<void>((resolve, reject) => {
|
||||
resolveReady = resolve as (value: void) => void
|
||||
rejectReady = reject
|
||||
})
|
||||
|
||||
this.initializing = readyPromise
|
||||
const realtime = new AkSupaRealtime({
|
||||
url: WS_URL,
|
||||
channel: 'realtime:device_states',
|
||||
apikey: SUPA_KEY,
|
||||
token: token,
|
||||
onMessage: (_data: any) => { },
|
||||
onOpen: (_res: any) => {
|
||||
this.initialized = true
|
||||
this.reconnectAttempts = 0
|
||||
this.clearReconnectTimer()
|
||||
this.reconnecting = false
|
||||
const resolver = resolveReady
|
||||
resolveReady = null
|
||||
rejectReady = null
|
||||
this.initializing = null
|
||||
resolver?.()
|
||||
},
|
||||
onError: (err: any) => {
|
||||
if (resolveReady != null) {
|
||||
const rejector = rejectReady
|
||||
resolveReady = null
|
||||
rejectReady = null
|
||||
this.initializing = null
|
||||
rejector?.(err)
|
||||
}
|
||||
},
|
||||
onClose: (_res: any) => {
|
||||
if (resolveReady != null) {
|
||||
const rejector = rejectReady
|
||||
resolveReady = null
|
||||
rejectReady = null
|
||||
this.initializing = null
|
||||
rejector?.(new Error('Realtime connection closed before ready'))
|
||||
} else {
|
||||
this.handleSocketClose('close')
|
||||
}
|
||||
}
|
||||
})
|
||||
this.rt = realtime
|
||||
this.rt?.connect()
|
||||
try {
|
||||
await readyPromise
|
||||
} finally {
|
||||
this.initializing = null
|
||||
}
|
||||
}
|
||||
public static async subscribeAllDevices(handlers: DeviceRealtimeHandlers): Promise<DeviceRealtimeSubscription> {
|
||||
await this.ensureRealtime()
|
||||
const guard: SubscriptionGuard = { active: true }
|
||||
const record: ActiveSubscription = {
|
||||
params: { roomId: '*', deviceId: 0 },
|
||||
handlers: handlers,
|
||||
guard: guard
|
||||
}
|
||||
this.subscriptions.push(record)
|
||||
|
||||
this.performSubscription(record).catch((err) => {
|
||||
console.error('subscribeAllDevices initial error', err)
|
||||
handlers.onError?.(err)
|
||||
})
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
guard.active = false
|
||||
this.removeSubscription(record)
|
||||
}
|
||||
}
|
||||
}
|
||||
static async subscribeDevice(params: DeviceRealtimeSubscriptionParams, handlers: DeviceRealtimeHandlers): Promise<DeviceRealtimeSubscription> {
|
||||
if (params.roomId == '') {
|
||||
throw new Error('roomId is required')
|
||||
}
|
||||
const guard: SubscriptionGuard = { active: true }
|
||||
const record: ActiveSubscription = {
|
||||
params: params,
|
||||
handlers: handlers,
|
||||
guard: guard
|
||||
}
|
||||
this.subscriptions.push(record)
|
||||
try {
|
||||
await this.performSubscription(record)
|
||||
} catch (err) {
|
||||
console.error('subscribeDevice failed', err)
|
||||
guard.active = false
|
||||
this.removeSubscription(record)
|
||||
handlers?.onError?.(err)
|
||||
return { dispose: () => { } }
|
||||
}
|
||||
console.log('[DeviceRealtimeService] subscription active', params.roomId)
|
||||
return {
|
||||
dispose: () => {
|
||||
if (guard.active !== true) return
|
||||
guard.active = false
|
||||
this.removeSubscription(record)
|
||||
console.log('[DeviceRealtimeService] subscription disposed', params.roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static closeRealtime(): void {
|
||||
try {
|
||||
if (this.rt != null) {
|
||||
this.rt.close({ code: 1000, reason: 'manual close' })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('closeRealtime error', err)
|
||||
} finally {
|
||||
this.rt = null
|
||||
this.initialized = false
|
||||
this.initializing = null
|
||||
this.reconnecting = false
|
||||
this.clearReconnectTimer()
|
||||
this.reconnectAttempts = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DeviceRealtimeService
|
||||
427
utils/gatewayRealtimeService.uts
Normal file
427
utils/gatewayRealtimeService.uts
Normal file
@@ -0,0 +1,427 @@
|
||||
import supa, { supaReady } from '@/components/supadb/aksupainstance.uts'
|
||||
import AkSupaRealtime from '@/components/supadb/aksuparealtime.uts'
|
||||
import { WS_URL, SUPA_KEY } from '@/ak/config.uts'
|
||||
|
||||
export type GatewayNode = {
|
||||
id: string
|
||||
name: string
|
||||
mqtt_client_id: string
|
||||
version: string | null
|
||||
region: string | null
|
||||
tags: string | null
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type GatewayRealtimeHandlers = {
|
||||
onInitial: (nodes: GatewayNode[]) => void
|
||||
onInsert: (node: GatewayNode) => void
|
||||
onUpdate: (node: GatewayNode) => void
|
||||
onDelete: (id: string) => void
|
||||
onError: (err: any) => void
|
||||
}
|
||||
|
||||
export type GatewayRealtimeSubscription = {
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
type SubscriptionGuard = {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
type ActiveSubscription = {
|
||||
handlers: GatewayRealtimeHandlers
|
||||
guard: SubscriptionGuard
|
||||
}
|
||||
|
||||
async function ensureSupaSession(): Promise<void> {
|
||||
try {
|
||||
await supaReady
|
||||
} catch (err) {
|
||||
console.error('Supabase session initialization error', err)
|
||||
}
|
||||
}
|
||||
|
||||
function toGatewayNode(row: UTSJSONObject): GatewayNode | null {
|
||||
try {
|
||||
const id = row.getString('id')
|
||||
const name = row.getString('name')
|
||||
const mqttClientId = row.getString('mqtt_client_id')
|
||||
const updatedAt = row.getString('updated_at')
|
||||
|
||||
if (id == null || name == null || mqttClientId == null || updatedAt == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: id,
|
||||
name: name,
|
||||
mqtt_client_id: mqttClientId,
|
||||
version: row.getString('version'),
|
||||
region: row.getString('region'),
|
||||
tags: JSON.stringify(row.get('tags')), // Simple stringify for display
|
||||
updated_at: updatedAt
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('toGatewayNode error', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function extractRecord(payload: any): UTSJSONObject | null {
|
||||
try {
|
||||
if (payload == null) return null
|
||||
const wrapper = payload as UTSJSONObject
|
||||
const directNew = wrapper.get('new') as UTSJSONObject | null
|
||||
if (directNew != null) return directNew
|
||||
const record = wrapper.get('record') as UTSJSONObject | null
|
||||
if (record != null) return record
|
||||
return null
|
||||
} catch (err) {
|
||||
console.error('extractRecord error', err)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractOldRecord(payload: any): UTSJSONObject | null {
|
||||
try {
|
||||
if (payload == null) return null
|
||||
const wrapper = payload as UTSJSONObject
|
||||
const directOld = wrapper.get('old') as UTSJSONObject | null
|
||||
if (directOld != null) return directOld
|
||||
const oldRecord = wrapper.get('old_record') as UTSJSONObject | null
|
||||
if (oldRecord != null) return oldRecord
|
||||
return null
|
||||
} catch (err) {
|
||||
console.error('extractOldRecord error', err)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export class GatewayRealtimeService {
|
||||
private static rt: AkSupaRealtime | null = null
|
||||
private static initializing: Promise<void> | null = null
|
||||
private static initialized: boolean = false
|
||||
private static subscriptions: Array<ActiveSubscription> = []
|
||||
private static reconnectAttempts: number = 0
|
||||
private static reconnectTimer: number | null = null
|
||||
private static reconnecting: boolean = false
|
||||
|
||||
private static async waitForRealtimeOpen(timeoutMs: number = 5000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (true) {
|
||||
if (this.rt != null && this.rt.isOpen == true) {
|
||||
return
|
||||
}
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error('Realtime socket not ready')
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 50)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private static clearReconnectTimer(): void {
|
||||
const timer = this.reconnectTimer
|
||||
if (timer != null) {
|
||||
clearTimeout(timer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private static handleSocketClose(origin: string | null): void {
|
||||
this.initialized = false
|
||||
this.rt = null
|
||||
if (this.subscriptions.length == 0) {
|
||||
this.clearReconnectTimer()
|
||||
this.reconnectAttempts = 0
|
||||
return
|
||||
}
|
||||
if (this.reconnecting == true) {
|
||||
return
|
||||
}
|
||||
console.warn('[GatewayRealtimeService] realtime closed, scheduling reconnect', origin ?? 'unknown')
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
|
||||
private static async scheduleReconnect(): Promise<void> {
|
||||
if (this.reconnectTimer != null) return
|
||||
if (this.subscriptions.every((sub) => sub.guard.active !== true)) {
|
||||
this.subscriptions = []
|
||||
this.reconnectAttempts = 0
|
||||
return
|
||||
}
|
||||
const attempt = this.reconnectAttempts + 1
|
||||
this.reconnectAttempts = attempt
|
||||
const baseDelay = Math.min(Math.pow(2, attempt - 1) * 1000, 10000)
|
||||
const jitter = Math.floor(Math.random() * 200)
|
||||
const delay = baseDelay + jitter
|
||||
console.log('[GatewayRealtimeService] reconnect scheduled in', delay, 'ms (attempt', attempt, ')')
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
const runner = async () => {
|
||||
this.reconnectTimer = null
|
||||
if (this.subscriptions.every((sub) => sub.guard.active !== true)) {
|
||||
this.subscriptions = []
|
||||
this.reconnectAttempts = 0
|
||||
return
|
||||
}
|
||||
this.reconnecting = true
|
||||
try {
|
||||
await this.ensureRealtime()
|
||||
await this.waitForRealtimeOpen(3000)
|
||||
this.reconnectAttempts = 0
|
||||
await this.resubscribeAll()
|
||||
} catch (err) {
|
||||
console.error('reconnect attempt failed', err)
|
||||
this.scheduleReconnect()
|
||||
} finally {
|
||||
this.reconnecting = false
|
||||
}
|
||||
}
|
||||
runner().catch((err) => {
|
||||
console.error('reconnect timer runner error', err)
|
||||
})
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private static async resubscribeAll(): Promise<void> {
|
||||
let hadFailure = false
|
||||
for (let i = 0; i < this.subscriptions.length; i++) {
|
||||
const record = this.subscriptions[i]
|
||||
if (record.guard.active !== true) continue
|
||||
try {
|
||||
await this.performSubscription(record)
|
||||
} catch (err) {
|
||||
console.error('resubscribe error', err)
|
||||
record.handlers?.onError?.(err)
|
||||
hadFailure = true
|
||||
}
|
||||
}
|
||||
if (hadFailure) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private static removeSubscription(record: ActiveSubscription): void {
|
||||
const index = this.subscriptions.indexOf(record)
|
||||
if (index >= 0) {
|
||||
this.subscriptions.splice(index, 1)
|
||||
}
|
||||
if (this.subscriptions.length == 0) {
|
||||
this.clearReconnectTimer()
|
||||
this.reconnectAttempts = 0
|
||||
}
|
||||
}
|
||||
|
||||
private static async performSubscription(record: ActiveSubscription): Promise<void> {
|
||||
if (record.guard.active !== true) return
|
||||
const handlers = record.handlers
|
||||
const maxAttempts = 2
|
||||
let attempt = 0
|
||||
|
||||
// 1. Fetch Initial Data
|
||||
try {
|
||||
await ensureSupaSession()
|
||||
const { data, error } = await supa.from('chat_gateway_nodes').select('*').order('name', { ascending: true })
|
||||
if (error != null) {
|
||||
throw new Error(error.message)
|
||||
}
|
||||
const nodes: GatewayNode[] = []
|
||||
if (data != null) {
|
||||
const list = data as UTSArray<UTSJSONObject>
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const node = toGatewayNode(list[i])
|
||||
if (node != null) {
|
||||
nodes.push(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (record.guard.active) {
|
||||
handlers.onInitial(nodes)
|
||||
}
|
||||
} catch (fetchErr) {
|
||||
console.error('Initial fetch failed', fetchErr)
|
||||
handlers.onError(fetchErr)
|
||||
}
|
||||
|
||||
// 2. Subscribe to Realtime
|
||||
while (attempt < maxAttempts) {
|
||||
attempt += 1
|
||||
if (record.guard.active !== true) return
|
||||
try {
|
||||
if (this.rt == null || this.rt.isOpen !== true) {
|
||||
await this.ensureRealtime()
|
||||
await this.waitForRealtimeOpen(2000)
|
||||
}
|
||||
const realtime = this.rt
|
||||
if (realtime == null) {
|
||||
throw new Error('Realtime client not initialized')
|
||||
}
|
||||
console.log('[GatewayRealtimeService] subscribing to chat_gateway_nodes')
|
||||
|
||||
realtime.subscribePostgresChanges({
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'chat_gateway_nodes',
|
||||
filter: 'mqtt_client_id=neq.null',
|
||||
topic: 'realtime:gateway_nodes',
|
||||
onChange: (payload: any) => {
|
||||
if (record.guard.active !== true) return
|
||||
const wrapper = payload as UTSJSONObject
|
||||
let eventType = wrapper.getString('type')
|
||||
if (eventType == null) {
|
||||
eventType = wrapper.getString('eventType')
|
||||
}
|
||||
|
||||
if (eventType == 'INSERT') {
|
||||
const row = extractRecord(payload)
|
||||
if (row != null) {
|
||||
const node = toGatewayNode(row)
|
||||
if (node != null) handlers.onInsert(node)
|
||||
}
|
||||
} else if (eventType == 'UPDATE') {
|
||||
const row = extractRecord(payload)
|
||||
if (row != null) {
|
||||
const node = toGatewayNode(row)
|
||||
if (node != null) handlers.onUpdate(node)
|
||||
}
|
||||
} else if (eventType == 'DELETE') {
|
||||
const oldRow = extractOldRecord(payload)
|
||||
if (oldRow != null) {
|
||||
const id = oldRow.getString('id')
|
||||
if (id != null) handlers.onDelete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log('[GatewayRealtimeService] subscription ready')
|
||||
return
|
||||
} catch (subscribeErr) {
|
||||
console.error('performSubscription error', subscribeErr, 'attempt', attempt)
|
||||
if (attempt >= maxAttempts) {
|
||||
throw subscribeErr
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 200)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async ensureRealtime(): Promise<void> {
|
||||
if (this.rt != null && this.rt.isOpen == true) {
|
||||
this.initialized = true
|
||||
return
|
||||
}
|
||||
if (this.initializing != null) {
|
||||
await this.initializing
|
||||
return
|
||||
}
|
||||
await ensureSupaSession()
|
||||
let token: string | null = null
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
if (session != null) {
|
||||
token = session.session?.access_token ?? null
|
||||
}
|
||||
} catch (_) { }
|
||||
|
||||
let resolveReady: ((value: void) => void) | null = null
|
||||
let rejectReady: ((reason: any) => void) | null = null
|
||||
const readyPromise = new Promise<void>((resolve, reject) => {
|
||||
resolveReady = resolve as (value: void) => void
|
||||
rejectReady = reject
|
||||
})
|
||||
|
||||
this.initializing = readyPromise
|
||||
const realtime = new AkSupaRealtime({
|
||||
url: WS_URL,
|
||||
channel: 'realtime:gateway_nodes',
|
||||
apikey: SUPA_KEY,
|
||||
token: token,
|
||||
onMessage: (_data: any) => { },
|
||||
onOpen: (_res: any) => {
|
||||
this.initialized = true
|
||||
this.reconnectAttempts = 0
|
||||
this.clearReconnectTimer()
|
||||
this.reconnecting = false
|
||||
const resolver = resolveReady
|
||||
resolveReady = null
|
||||
rejectReady = null
|
||||
this.initializing = null
|
||||
resolver?.()
|
||||
},
|
||||
onError: (err: any) => {
|
||||
if (resolveReady != null) {
|
||||
const rejector = rejectReady
|
||||
resolveReady = null
|
||||
rejectReady = null
|
||||
this.initializing = null
|
||||
rejector?.(err)
|
||||
}
|
||||
},
|
||||
onClose: (_res: any) => {
|
||||
if (resolveReady != null) {
|
||||
const rejector = rejectReady
|
||||
resolveReady = null
|
||||
rejectReady = null
|
||||
this.initializing = null
|
||||
rejector?.(new Error('Realtime connection closed before ready'))
|
||||
} else {
|
||||
this.handleSocketClose('close')
|
||||
}
|
||||
}
|
||||
})
|
||||
this.rt = realtime
|
||||
this.rt?.connect()
|
||||
try {
|
||||
await readyPromise
|
||||
} finally {
|
||||
this.initializing = null
|
||||
}
|
||||
}
|
||||
|
||||
static async subscribeGateways(handlers: GatewayRealtimeHandlers): Promise<GatewayRealtimeSubscription> {
|
||||
const guard: SubscriptionGuard = { active: true }
|
||||
const record: ActiveSubscription = {
|
||||
handlers: handlers,
|
||||
guard: guard
|
||||
}
|
||||
this.subscriptions.push(record)
|
||||
try {
|
||||
await this.performSubscription(record)
|
||||
} catch (err) {
|
||||
console.error('subscribeGateways failed', err)
|
||||
guard.active = false
|
||||
this.removeSubscription(record)
|
||||
handlers.onError(err)
|
||||
return { dispose: () => { } }
|
||||
}
|
||||
return {
|
||||
dispose: () => {
|
||||
if (guard.active !== true) return
|
||||
guard.active = false
|
||||
this.removeSubscription(record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static closeRealtime(): void {
|
||||
try {
|
||||
if (this.rt != null) {
|
||||
this.rt.close({ code: 1000, reason: 'manual close' })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('closeRealtime error', err)
|
||||
} finally {
|
||||
this.rt = null
|
||||
this.initialized = false
|
||||
this.initializing = null
|
||||
this.reconnecting = false
|
||||
this.clearReconnectTimer()
|
||||
this.reconnectAttempts = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default GatewayRealtimeService
|
||||
14
utils/i18nfun.uts
Normal file
14
utils/i18nfun.uts
Normal file
@@ -0,0 +1,14 @@
|
||||
import i18n from '@/i18n/index.uts'
|
||||
|
||||
// 包装一个带参数智能判断的 t 函数,支持缺省值
|
||||
export function tt(key: string, values: any | null = null, locale: string | null = null): string {
|
||||
const isLocale = typeof values === 'string'
|
||||
const _values = isLocale ? null : values
|
||||
const _locale = isLocale ? values : locale
|
||||
return i18n.global.t(key, _values, _locale)
|
||||
}
|
||||
|
||||
// 示例用法
|
||||
// tSmart('prev')
|
||||
// tSmart('prev', 'en-US')
|
||||
// tSmart('prev', {name: '张三'})
|
||||
552
utils/locationService.uts
Normal file
552
utils/locationService.uts
Normal file
@@ -0,0 +1,552 @@
|
||||
/**
|
||||
* 手环位置服务 - 处理基站ID、位置信息、围栏功能
|
||||
*/
|
||||
|
||||
import type { AkReqResponse } from '@/uni_modules/ak-req/index.uts'
|
||||
|
||||
// 基础坐标类型
|
||||
export type LocationCoordinate = {
|
||||
longitude: number
|
||||
latitude: number
|
||||
}
|
||||
|
||||
// 估算位置类型
|
||||
export type EstimatedLocation = {
|
||||
longitude: number
|
||||
latitude: number
|
||||
accuracy: number
|
||||
}
|
||||
|
||||
// 围栏中心点类型
|
||||
export type FenceCenter = {
|
||||
longitude: number
|
||||
latitude: number
|
||||
}
|
||||
|
||||
// 基站信息类型
|
||||
export type BaseStationInfo = {
|
||||
/** 基站ID */
|
||||
id: string
|
||||
/** 基站名称 */
|
||||
name: string
|
||||
/** 基站位置描述 */
|
||||
location: string
|
||||
/** 经度 */
|
||||
longitude: number
|
||||
/** 纬度 */
|
||||
latitude: number
|
||||
/** 信号强度 */
|
||||
signalStrength: number
|
||||
/** 是否在线 */
|
||||
isOnline: boolean
|
||||
/** 覆盖范围(米) */
|
||||
range: number
|
||||
}
|
||||
|
||||
// 位置信息类型
|
||||
export type LocationInfo = {
|
||||
/** 设备ID */
|
||||
deviceId: string
|
||||
/** 当前连接的基站 */
|
||||
baseStation: BaseStationInfo | null
|
||||
/** 估算位置 */
|
||||
estimatedLocation: EstimatedLocation | null
|
||||
/** 最后更新时间 */
|
||||
lastUpdate: string
|
||||
/** 位置状态 */
|
||||
status: 'online' | 'offline' | 'unknown'
|
||||
}
|
||||
|
||||
// 围栏信息类型
|
||||
export type FenceInfo = {
|
||||
/** 围栏ID */
|
||||
id: string
|
||||
/** 围栏名称 */
|
||||
name: string
|
||||
/** 围栏类型 */
|
||||
type: 'circle' | 'polygon'
|
||||
/** 围栏中心点(圆形围栏) */
|
||||
center?: FenceCenter
|
||||
/** 围栏半径(米,圆形围栏) */
|
||||
radius?: number
|
||||
/** 围栏顶点(多边形围栏) */
|
||||
points?: Array<LocationCoordinate>
|
||||
/** 是否激活 */
|
||||
isActive: boolean
|
||||
/** 围栏事件类型 */
|
||||
eventType: 'enter' | 'exit' | 'both'
|
||||
/** 创建时间 */
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 位置历史记录类型
|
||||
export type LocationHistoryItem = {
|
||||
/** 记录ID */
|
||||
id: string
|
||||
/** 设备ID */
|
||||
deviceId: string
|
||||
/** 基站信息 */
|
||||
baseStation: BaseStationInfo
|
||||
/** 记录时间 */
|
||||
timestamp: string
|
||||
/** 持续时间(秒) */
|
||||
duration: number
|
||||
/** 位置类型 */
|
||||
locationType: 'basestation' | 'estimated'
|
||||
}
|
||||
|
||||
// 围栏事件类型
|
||||
export type FenceEvent = {
|
||||
/** 事件ID */
|
||||
id: string
|
||||
/** 设备ID */
|
||||
deviceId: string
|
||||
/** 围栏信息 */
|
||||
fence: FenceInfo
|
||||
/** 事件类型 */
|
||||
eventType: 'enter' | 'exit'
|
||||
/** 事件时间 */
|
||||
timestamp: string
|
||||
/** 触发位置 */
|
||||
location: LocationCoordinate
|
||||
/** 是否已读 */
|
||||
isRead: boolean
|
||||
}
|
||||
|
||||
// FenceInfo 的手动 Partial 类型
|
||||
export type PartialFenceInfo = {
|
||||
id?: string
|
||||
name?: string
|
||||
type?: 'circle' | 'polygon'
|
||||
center?: FenceCenter
|
||||
radius?: number
|
||||
points?: Array<LocationCoordinate>
|
||||
isActive?: boolean
|
||||
eventType?: 'enter' | 'exit' | 'both'
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
// mock generic AkReqResponse for array data
|
||||
export function mockAkArrayResponse<T>(data: T[], status: number = 200): AkReqResponse<Array<T>> {
|
||||
return {
|
||||
status,
|
||||
data,
|
||||
headers: {} as UTSJSONObject,
|
||||
error: null,
|
||||
total: data.length,
|
||||
page: 1,
|
||||
limit: data.length,
|
||||
hasmore: false,
|
||||
origin: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置服务工厂函数
|
||||
*/
|
||||
export function createLocationResponse<T>(status: number, message: string, data: T[]): AkReqResponse<Array<T>> {
|
||||
return mockAkArrayResponse<T>(data, status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置服务错误响应工厂函数
|
||||
*/
|
||||
export function createLocationErrorResponse<T>(message: string): AkReqResponse<Array<T>> {
|
||||
return createLocationResponse<T>(500, message, [])
|
||||
}
|
||||
|
||||
/**
|
||||
* 手环位置服务类
|
||||
*/
|
||||
export class LocationService {
|
||||
/**
|
||||
* 获取设备当前位置信息
|
||||
*/
|
||||
static async getCurrentLocation(deviceId: string): Promise<AkReqResponse<LocationInfo>> {
|
||||
try {
|
||||
// 模拟API调用延迟
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
// 模拟数据
|
||||
const mockBaseStation: BaseStationInfo = {
|
||||
id: 'BS001',
|
||||
name: '教学楼A栋基站',
|
||||
location: '教学楼A栋1层',
|
||||
longitude: 116.397428,
|
||||
latitude: 39.90923,
|
||||
signalStrength: -45,
|
||||
isOnline: true,
|
||||
range: 50
|
||||
}
|
||||
const mockLocation: LocationInfo[] = [{
|
||||
deviceId: deviceId,
|
||||
baseStation: mockBaseStation,
|
||||
estimatedLocation: {
|
||||
longitude: 116.397428,
|
||||
latitude: 39.90923,
|
||||
accuracy: 15
|
||||
},
|
||||
lastUpdate: new Date().toISOString(),
|
||||
status: 'online'
|
||||
}]
|
||||
|
||||
return createLocationResponse(200, '获取位置成功', mockLocation)
|
||||
} catch (error) {
|
||||
console.error('获取设备位置失败:', error)
|
||||
return createLocationErrorResponse('获取位置信息失败')
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 获取可用基站列表
|
||||
*/
|
||||
static async getBaseStations(): Promise<AkReqResponse<BaseStationInfo[]>> {
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve()
|
||||
}, 800)
|
||||
})
|
||||
|
||||
const mockBaseStations: BaseStationInfo[] = [
|
||||
{
|
||||
id: 'BS001',
|
||||
name: '教学楼A栋基站',
|
||||
location: '教学楼A栋1层',
|
||||
longitude: 116.397428,
|
||||
latitude: 39.90923,
|
||||
signalStrength: -45,
|
||||
isOnline: true,
|
||||
range: 50
|
||||
},
|
||||
{
|
||||
id: 'BS002',
|
||||
name: '教学楼B栋基站',
|
||||
location: '教学楼B栋2层',
|
||||
longitude: 116.398428,
|
||||
latitude: 39.91023,
|
||||
signalStrength: -52,
|
||||
isOnline: true,
|
||||
range: 45
|
||||
},
|
||||
{
|
||||
id: 'BS003',
|
||||
name: '操场基站',
|
||||
location: '学校操场',
|
||||
longitude: 116.399428,
|
||||
latitude: 39.91123,
|
||||
signalStrength: -38,
|
||||
isOnline: true,
|
||||
range: 100
|
||||
},
|
||||
{
|
||||
id: 'BS004',
|
||||
name: '宿舍楼基站',
|
||||
location: '学生宿舍1号楼',
|
||||
longitude: 116.396428,
|
||||
latitude: 39.90823,
|
||||
signalStrength: -60,
|
||||
isOnline: false,
|
||||
range: 40
|
||||
}
|
||||
]
|
||||
|
||||
return createLocationResponse(200, '获取基站列表成功', mockBaseStations)
|
||||
} catch (error) {
|
||||
console.error('获取基站列表失败:', error)
|
||||
return createLocationErrorResponse('获取基站列表失败')
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 获取围栏列表
|
||||
*/
|
||||
static async getFences(deviceId: string): Promise<AkReqResponse<FenceInfo[]>> {
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve()
|
||||
}, 600)
|
||||
})
|
||||
const mockFences: FenceInfo[] = [ {
|
||||
id: 'FENCE001',
|
||||
name: '校园安全区域',
|
||||
type: 'circle',
|
||||
center: {
|
||||
longitude: 116.397928,
|
||||
latitude: 39.90973
|
||||
},
|
||||
radius: 200,
|
||||
isActive: true,
|
||||
eventType: 'exit',
|
||||
createdAt: '2024-01-15T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'FENCE002',
|
||||
name: '教学区域',
|
||||
type: 'polygon',
|
||||
points: [
|
||||
{ longitude: 116.397000, latitude: 39.909000 },
|
||||
{ longitude: 116.398000, latitude: 39.909000 },
|
||||
{ longitude: 116.398000, latitude: 39.910000 },
|
||||
{ longitude: 116.397000, latitude: 39.910000 }
|
||||
],
|
||||
isActive: true,
|
||||
eventType: 'both',
|
||||
createdAt: '2024-01-16T14:30:00Z'
|
||||
},
|
||||
{
|
||||
id: 'FENCE003',
|
||||
name: '危险区域',
|
||||
type: 'circle',
|
||||
center: {
|
||||
longitude: 116.395428,
|
||||
latitude: 39.90723
|
||||
},
|
||||
radius: 30,
|
||||
isActive: true,
|
||||
eventType: 'enter',
|
||||
createdAt: '2024-01-17T09:15:00Z'
|
||||
}
|
||||
]
|
||||
console.log(mockFences)
|
||||
return createLocationResponse<FenceInfo>(200, '获取围栏列表成功', mockFences)
|
||||
} catch (error) {
|
||||
console.error('获取围栏列表失败:', error)
|
||||
return createLocationErrorResponse('获取围栏列表失败')
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 获取位置历史记录
|
||||
*/
|
||||
static async getLocationHistory(deviceId: string, startDate: string, endDate: string): Promise<AkReqResponse<LocationHistoryItem[]>> {
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve()
|
||||
}, 1200)
|
||||
})
|
||||
|
||||
const mockHistory: LocationHistoryItem[] = [
|
||||
{
|
||||
id: 'HIST001',
|
||||
deviceId: deviceId,
|
||||
baseStation: {
|
||||
id: 'BS001',
|
||||
name: '教学楼A栋基站',
|
||||
location: '教学楼A栋1层',
|
||||
longitude: 116.397428,
|
||||
latitude: 39.90923,
|
||||
signalStrength: -45,
|
||||
isOnline: true,
|
||||
range: 50
|
||||
},
|
||||
timestamp: '2024-01-20T08:30:00Z',
|
||||
duration: 3600,
|
||||
locationType: 'basestation'
|
||||
},
|
||||
{
|
||||
id: 'HIST002',
|
||||
deviceId: deviceId,
|
||||
baseStation: {
|
||||
id: 'BS003',
|
||||
name: '操场基站',
|
||||
location: '学校操场',
|
||||
longitude: 116.399428,
|
||||
latitude: 39.91123,
|
||||
signalStrength: -38,
|
||||
isOnline: true,
|
||||
range: 100
|
||||
},
|
||||
timestamp: '2024-01-20T10:00:00Z',
|
||||
duration: 2400,
|
||||
locationType: 'basestation'
|
||||
},
|
||||
{
|
||||
id: 'HIST003',
|
||||
deviceId: deviceId,
|
||||
baseStation: {
|
||||
id: 'BS002',
|
||||
name: '教学楼B栋基站',
|
||||
location: '教学楼B栋2层',
|
||||
longitude: 116.398428,
|
||||
latitude: 39.91023,
|
||||
signalStrength: -52,
|
||||
isOnline: true,
|
||||
range: 45
|
||||
},
|
||||
timestamp: '2024-01-20T14:20:00Z',
|
||||
duration: 5400,
|
||||
locationType: 'basestation'
|
||||
}
|
||||
]
|
||||
|
||||
return createLocationResponse(200, '获取位置历史成功', mockHistory)
|
||||
} catch (error) {
|
||||
console.error('获取位置历史失败:', error)
|
||||
return createLocationErrorResponse('获取位置历史失败')
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 获取围栏事件记录
|
||||
*/
|
||||
static async getFenceEvents(deviceId: string, limit: number = 10): Promise<AkReqResponse<FenceEvent[]>> {
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve()
|
||||
}, 900)
|
||||
})
|
||||
const mockEvents: FenceEvent[] = [ {
|
||||
id: 'EVENT001',
|
||||
deviceId: deviceId,
|
||||
fence: {
|
||||
id: 'FENCE001',
|
||||
name: '校园安全区域',
|
||||
type: 'circle',
|
||||
center: {
|
||||
longitude: 116.397928,
|
||||
latitude: 39.90973
|
||||
},
|
||||
radius: 200,
|
||||
isActive: true,
|
||||
eventType: 'exit',
|
||||
createdAt: '2024-01-15T10:00:00Z'
|
||||
},
|
||||
eventType: 'enter',
|
||||
timestamp: '2024-01-20T07:45:00Z',
|
||||
location: {
|
||||
longitude: 116.397528,
|
||||
latitude: 39.90943
|
||||
},
|
||||
isRead: false
|
||||
},
|
||||
{
|
||||
id: 'EVENT002',
|
||||
deviceId: deviceId,
|
||||
fence: {
|
||||
id: 'FENCE002',
|
||||
name: '教学区域',
|
||||
type: 'polygon',
|
||||
points: [
|
||||
{ longitude: 116.397000, latitude: 39.909000 },
|
||||
{ longitude: 116.398000, latitude: 39.909000 },
|
||||
{ longitude: 116.398000, latitude: 39.910000 },
|
||||
{ longitude: 116.397000, latitude: 39.910000 }
|
||||
],
|
||||
isActive: true,
|
||||
eventType: 'both',
|
||||
createdAt: '2024-01-16T14:30:00Z'
|
||||
},
|
||||
eventType: 'enter',
|
||||
timestamp: '2024-01-20T08:30:00Z',
|
||||
location: {
|
||||
longitude: 116.397500,
|
||||
latitude: 39.909500
|
||||
},
|
||||
isRead: true
|
||||
}
|
||||
]
|
||||
|
||||
return createLocationResponse(200, '获取围栏事件成功', mockEvents)
|
||||
} catch (error) {
|
||||
console.error('获取围栏事件失败:', error)
|
||||
return createLocationErrorResponse('获取围栏事件失败')
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 创建围栏
|
||||
*/
|
||||
static async createFence(fence: PartialFenceInfo): Promise<AkReqResponse<FenceInfo>> {
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve()
|
||||
}, 1500)
|
||||
})
|
||||
|
||||
const newFence: FenceInfo[] = [{
|
||||
id: `FENCE${Date.now()}`,
|
||||
name: fence.name ?? '新围栏',
|
||||
type: fence.type ?? 'circle',
|
||||
center: fence.center,
|
||||
radius: fence.radius,
|
||||
points: fence.points,
|
||||
isActive: fence.isActive ?? true,
|
||||
eventType: fence.eventType ?? 'both',
|
||||
createdAt: new Date().toISOString()
|
||||
}]
|
||||
|
||||
return createLocationResponse(200, '创建围栏成功', newFence)
|
||||
} catch (error) {
|
||||
console.error('创建围栏失败:', error)
|
||||
return createLocationErrorResponse('创建围栏失败')
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 更新围栏
|
||||
*/
|
||||
static async updateFence(fenceId: string, updates: PartialFenceInfo): Promise<AkReqResponse<FenceInfo>> {
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve()
|
||||
}, 1200)
|
||||
})
|
||||
|
||||
// 模拟更新成功
|
||||
const updatedFence: FenceInfo[] = [{
|
||||
id: fenceId,
|
||||
name: updates.name ?? '更新的围栏',
|
||||
type: updates.type ?? 'circle',
|
||||
center: updates.center,
|
||||
radius: updates.radius,
|
||||
points: updates.points,
|
||||
isActive: updates.isActive ?? true,
|
||||
eventType: updates.eventType ?? 'both',
|
||||
createdAt: '2024-01-15T10:00:00Z'
|
||||
}]
|
||||
|
||||
return createLocationResponse(200, '更新围栏成功', updatedFence)
|
||||
} catch (error) {
|
||||
console.error('更新围栏失败:', error)
|
||||
return createLocationErrorResponse('更新围栏失败')
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 删除围栏
|
||||
*/
|
||||
static async deleteFence(fenceId: string): Promise<AkReqResponse<Array<boolean>>> {
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve()
|
||||
}, 800)
|
||||
})
|
||||
|
||||
return createLocationResponse(200, '删除围栏成功', [true])
|
||||
} catch (error) {
|
||||
console.error('删除围栏失败:', error)
|
||||
return createLocationErrorResponse('删除围栏失败')
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 标记围栏事件为已读
|
||||
*/
|
||||
static async markEventAsRead(eventId: string): Promise<AkReqResponse<Array<boolean>>> {
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
return createLocationResponse(200, '标记事件成功', [true])
|
||||
} catch (error) {
|
||||
console.error('标记事件失败:', error)
|
||||
return createLocationErrorResponse('标记事件失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
158
utils/mediaCacheService.uts
Normal file
158
utils/mediaCacheService.uts
Normal file
@@ -0,0 +1,158 @@
|
||||
export type MediaCacheIndex =UTSJSONObject
|
||||
|
||||
type DownloadFileResult = {
|
||||
statusCode?: number
|
||||
tempFilePath?: string
|
||||
tempFilePathArray?: string[]
|
||||
apFilePath?: string
|
||||
// UTS不支持索引签名,已移除
|
||||
}
|
||||
export class MediaCacheService {
|
||||
static STORAGE_KEY: string = 'media-cache-index'
|
||||
static index: MediaCacheIndex | null = null
|
||||
|
||||
static async init(): Promise<void> {
|
||||
if (this.index != null) return
|
||||
try {
|
||||
const res = await new Promise<UTSJSONObject | null>((resolve) => {
|
||||
uni.getStorage({
|
||||
key: this.STORAGE_KEY,
|
||||
success: (result) => {
|
||||
const json = result.data as UTSJSONObject
|
||||
const data = json['data'] as UTSJSONObject | null
|
||||
resolve(data)
|
||||
},
|
||||
fail: () => resolve(null)
|
||||
})
|
||||
})
|
||||
if (res != null) {
|
||||
this.index = res
|
||||
} else {
|
||||
this.index = {}
|
||||
}
|
||||
} catch (_) {
|
||||
this.index = {}
|
||||
}
|
||||
}
|
||||
|
||||
static async persist(): Promise<void> {
|
||||
try {
|
||||
// @ts-ignore
|
||||
await uni.setStorage({ key: this.STORAGE_KEY, data: this.index ?? {} })
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static async getCachedPath(url: string): Promise<string> {
|
||||
await this.init()
|
||||
if (this.index == null) return url
|
||||
const index = this.index!
|
||||
const existing = index[url] as string | null
|
||||
if (existing != null && await this.fileExists(existing)) {
|
||||
return existing
|
||||
}
|
||||
const saved = await this.downloadAndSave(url)
|
||||
if (saved != null) {
|
||||
this.index[url] = saved
|
||||
await this.persist()
|
||||
return saved
|
||||
}
|
||||
// fallback to remote url if save failed
|
||||
return url
|
||||
}
|
||||
|
||||
static async clear(url?: string): Promise<void> {
|
||||
await this.init()
|
||||
if (this.index == null) return
|
||||
if (url != null && url != '') {
|
||||
this.index[url] = null
|
||||
} else {
|
||||
this.index = {}
|
||||
}
|
||||
await this.persist()
|
||||
}
|
||||
|
||||
private static async fileExists(filePath: string): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
try {
|
||||
const fs = uni.getFileSystemManager()
|
||||
let settled = false
|
||||
const finish = (value: boolean) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
resolve(value)
|
||||
}
|
||||
fs.access({
|
||||
path: filePath,
|
||||
success: () => finish(true),
|
||||
fail: () => finish(false)
|
||||
})
|
||||
} catch (_) {
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private static async downloadAndSave(url: string): Promise<string | null> {
|
||||
const dlRes = await new Promise<DownloadFileResult | null>((resolve) => {
|
||||
try {
|
||||
uni.downloadFile({
|
||||
url,
|
||||
success: (res) => {
|
||||
const result: DownloadFileResult = {
|
||||
statusCode: res.statusCode,
|
||||
tempFilePath: res['tempFilePath'] as string | null,
|
||||
tempFilePathArray: res['tempFilePathArray'] as string[] | null,
|
||||
apFilePath: res['apFilePath'] as string | null
|
||||
}
|
||||
resolve(result)
|
||||
},
|
||||
fail: () => resolve(null)
|
||||
})
|
||||
} catch (_) {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
const statusCode = dlRes?.statusCode
|
||||
if (dlRes == null || statusCode == null || statusCode < 200 || statusCode >= 400) {
|
||||
return null
|
||||
}
|
||||
const tempFilePath = dlRes.tempFilePath ?? (dlRes.tempFilePathArray != null ? dlRes.tempFilePathArray[0] : null) ?? dlRes.apFilePath ?? null
|
||||
if (tempFilePath == null) return null
|
||||
const savedPath = await new Promise<string | null>((resolve) => {
|
||||
try {
|
||||
const fs = uni.getFileSystemManager()
|
||||
let settled = false
|
||||
const finish = (value: string | null) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
resolve(value)
|
||||
}
|
||||
const options: SaveFileOptions = {
|
||||
tempFilePath: tempFilePath,
|
||||
success: (res) => {
|
||||
const savedPath = res.savedFilePath
|
||||
if (savedPath != null && savedPath.length > 0) {
|
||||
finish(savedPath)
|
||||
return
|
||||
}
|
||||
finish(null)
|
||||
},
|
||||
fail: () => finish(null)
|
||||
}
|
||||
fs.saveFile(options as SaveFileOptions)
|
||||
} catch (_) {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
if (savedPath != null && savedPath.length > 0) {
|
||||
return savedPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default MediaCacheService
|
||||
582
utils/msgDataServiceReal.uts
Normal file
582
utils/msgDataServiceReal.uts
Normal file
@@ -0,0 +1,582 @@
|
||||
/**
|
||||
* 消息数据服务 - 真实 supadb 集成版本
|
||||
* 严格遵循 UTS Android 要求,使用真实的 AkSupa 组件
|
||||
*/
|
||||
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import type { AkReqResponse } from '@/uni_modules/ak-req/index.uts'
|
||||
import type {
|
||||
Message,
|
||||
MessageType,
|
||||
MessageRecipient,
|
||||
MessageGroup,
|
||||
MessageStats,
|
||||
MessageListParams,
|
||||
SendMessageParams,
|
||||
UserOption,
|
||||
MessageSenderType,
|
||||
MessageReceiverType,
|
||||
MessageContentType,
|
||||
MessageStatus,
|
||||
RecipientStatus,
|
||||
DeliveryMethod,
|
||||
GroupType,
|
||||
MemberRole,
|
||||
MemberStatus
|
||||
} from './msgTypes.uts'
|
||||
|
||||
// 定义 GroupMember 类型,如果在 msgTypes.uts 中没有的话
|
||||
type GroupMember = {
|
||||
id : string;
|
||||
group_id : string;
|
||||
user_id : string;
|
||||
role : MemberRole;
|
||||
status : MemberStatus;
|
||||
joined_at : string;
|
||||
updated_at : string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息数据服务- 真实 supadb 版本
|
||||
* 提供消息系统的所有数据操作
|
||||
* 统一使用 aksupainstance.uts
|
||||
*/
|
||||
export class MsgDataServiceReal {
|
||||
|
||||
/**
|
||||
* 获取消息类型列表
|
||||
*/
|
||||
static async getMessageTypes() : Promise<AkReqResponse<Array<MessageType>>> {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ak_message_types')
|
||||
.select('*', {})
|
||||
.eq('is_active', true)
|
||||
.order('priority', { ascending: false })
|
||||
.executeAs<MessageType>()
|
||||
|
||||
if (response.status >= 200 && response.status < 300 && response.data !== null) {
|
||||
console.log(response)
|
||||
return response
|
||||
}
|
||||
|
||||
// 如果没有数据,返回默认的消息类型
|
||||
return createErrorResponse<Array<MessageType>>([{
|
||||
id: 'default',
|
||||
code: 'default',
|
||||
name: '普通消息',
|
||||
description: '默认消息类型',
|
||||
icon: null,
|
||||
color: '#757575',
|
||||
priority: 50,
|
||||
is_system: false,
|
||||
is_active: true,
|
||||
auto_read_timeout: null,
|
||||
retention_days: 30,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}], '无消息类型数据', 200)
|
||||
} catch (error) {
|
||||
// 出错时也返回默认的消息类型
|
||||
return createErrorResponse<Array<MessageType>>([{
|
||||
id: 'default',
|
||||
code: 'default',
|
||||
name: '普通消息',
|
||||
description: '默认消息类型',
|
||||
icon: null,
|
||||
color: '#757575',
|
||||
priority: 50,
|
||||
is_system: false,
|
||||
is_active: true,
|
||||
auto_read_timeout: null,
|
||||
retention_days: 30,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}], typeof error === 'string' ? error : error?.message ?? '获取消息类型失败', 200)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 获取消息列表
|
||||
*/
|
||||
static async getMessages(params : MessageListParams) : Promise<AkReqResponse<Array<Message>>> {
|
||||
try {
|
||||
let query = supa
|
||||
.from('ak_messages')
|
||||
.select(`
|
||||
*,
|
||||
message_type:ak_message_types(*),
|
||||
recipients:ak_message_recipients(*)
|
||||
`, {})
|
||||
.eq('is_deleted', false) // 添加筛选条件
|
||||
if (params.message_type != null) {
|
||||
query = query.eq('message_type', params.message_type as string)
|
||||
}
|
||||
if (params.sender_type != null) {
|
||||
query = query.eq('sender_type', params.sender_type as string)
|
||||
}
|
||||
if (params.sender_id != null) {
|
||||
query = query.eq('sender_id', params.sender_id as string)
|
||||
}
|
||||
if (params.receiver_type != null) {
|
||||
query = query.eq('receiver_type', params.receiver_type as string)
|
||||
}
|
||||
if (params.receiver_id != null) {
|
||||
query = query.eq('receiver_id', params.receiver_id as string)
|
||||
}
|
||||
if (params.status != null) {
|
||||
query = query.eq('status', params.status as string)
|
||||
}
|
||||
if (params.is_urgent != null) {
|
||||
query = query.eq('is_urgent', params.is_urgent as boolean)
|
||||
}
|
||||
const keyword = params.search
|
||||
if (keyword != null && keyword !== '') {
|
||||
query = query.or(`title.ilike.%${keyword}%,content.ilike.%${keyword}%`)
|
||||
}
|
||||
const limit = params.limit ?? 20
|
||||
const offset = params.offset ?? 0
|
||||
// 分页和排序
|
||||
const page = Math.floor(offset / limit) + 1
|
||||
const response = await query
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
.executeAs<Message>()
|
||||
// .execute()
|
||||
console.log(response)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
return createErrorResponse<Array<UTSJSONObject>>([],
|
||||
typeof error === 'string' ? error : error?.message ?? '获取消息列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单条消息详情
|
||||
*/
|
||||
static async getMessageById(id : string) : Promise<AkReqResponse<Message>> {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ak_messages')
|
||||
.select(`
|
||||
*,
|
||||
message_type:ak_message_types(*),
|
||||
recipients:ak_message_recipients(*)
|
||||
`, {})
|
||||
.eq('id', id)
|
||||
.single()
|
||||
.executeAs<Message>()
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 500,
|
||||
data: null,
|
||||
error: new UniError(typeof error === 'string' ? error : error?.message ?? '获取消息内容失败'),
|
||||
origin: null,
|
||||
headers: {}
|
||||
} as AkReqResponse<Message>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消<E98081>?
|
||||
*/
|
||||
static async sendMessage(params : SendMessageParams) : Promise<AkReqResponse<Message>> {
|
||||
try {
|
||||
|
||||
|
||||
// 构建消息数据
|
||||
const messageData = {
|
||||
message_type_id: params.message_type_id,
|
||||
sender_type: params.sender_type,
|
||||
sender_id: params.sender_id,
|
||||
sender_name: params.sender_name,
|
||||
receiver_type: params.receiver_type,
|
||||
receiver_id: params.receiver_id,
|
||||
title: params.title,
|
||||
content: params.content,
|
||||
content_type: params.content_type !== null ? params.content_type : 'text',
|
||||
attachments: params.attachments,
|
||||
media_urls: params.media_urls,
|
||||
metadata: params.metadata,
|
||||
is_urgent: params.is_urgent !== null ? params.is_urgent : false,
|
||||
scheduled_at: params.scheduled_at,
|
||||
expires_at: params.expires_at,
|
||||
status: 'pending',
|
||||
is_deleted: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
// 插入消息
|
||||
const response = await supa
|
||||
.from('ak_messages')
|
||||
.insert(messageData)
|
||||
.single()
|
||||
.executeAs<Message>()
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const message = response.data as Message
|
||||
|
||||
// 创建接收记录
|
||||
// 批量接收人安全判断
|
||||
const receiversArr = params.receivers ?? []
|
||||
if (Array.isArray(receiversArr) && receiversArr.length > 0) {
|
||||
const recipients = receiversArr.map(receiverId => ({
|
||||
message_id: message.id,
|
||||
receiver_type: params.receiver_type,
|
||||
receiver_id: receiverId,
|
||||
status: 'unread',
|
||||
is_deleted: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}))
|
||||
for (const rec of recipients) {
|
||||
await supa
|
||||
.from('ak_message_recipients')
|
||||
.insert(rec)
|
||||
.executeAs<MessageRecipient>()
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} else {
|
||||
return response
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 500,
|
||||
data: null,
|
||||
error: new UniError(typeof error === 'string' ? error : error?.message ?? '发送消息失败'),
|
||||
headers: {},
|
||||
total: null,
|
||||
page: null,
|
||||
limit: null,
|
||||
hasmore: false,
|
||||
origin: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记消息为已读
|
||||
*/
|
||||
static async markAsRead(messageId : string, receiverId : string) : Promise<AkReqResponse<boolean>> {
|
||||
try {
|
||||
// 验证参数不能为空
|
||||
if (messageId == null || messageId === '' || receiverId == null || receiverId === '' || receiverId.trim() === '') {
|
||||
return createErrorResponse<boolean>(
|
||||
false,
|
||||
'参数错误:消息ID和接收者ID不能为空'
|
||||
)
|
||||
}
|
||||
|
||||
const response = await supa
|
||||
.from('ak_message_recipients')
|
||||
.update({
|
||||
status: 'read',
|
||||
read_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('message_id', messageId)
|
||||
.eq('recipient_id', receiverId)
|
||||
.executeAs<Array<MessageRecipient>>()
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
data: Array.isArray(response.data) ,
|
||||
headers: response.headers,
|
||||
error: response.error,
|
||||
total: response.total,
|
||||
page: response.page,
|
||||
limit: response.limit,
|
||||
hasmore: response.hasmore,
|
||||
origin: response.origin
|
||||
}
|
||||
} catch (error) {
|
||||
return createErrorResponse<boolean>(
|
||||
false,
|
||||
typeof error === 'string' ? error : error?.message ?? '标记已读失败'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
*/
|
||||
static async deleteMessage(messageId : string, userId : string) : Promise<AkReqResponse<boolean>> {
|
||||
try {
|
||||
// 软删除消息(对发送者)
|
||||
await supa
|
||||
.from('ak_messages')
|
||||
.update({
|
||||
is_deleted: true,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', messageId)
|
||||
.eq('sender_id', userId)
|
||||
.executeAs<Array<Message>>()
|
||||
|
||||
// 软删除接收记录(对接收者)
|
||||
await supa
|
||||
.from('ak_message_recipients')
|
||||
.update({
|
||||
is_deleted: true,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('message_id', messageId)
|
||||
.eq('recipient_id', userId)
|
||||
.executeAs<Array<MessageRecipient>>()
|
||||
const aaa = {
|
||||
status: 200,
|
||||
data: true,
|
||||
error: null,
|
||||
origin: null,
|
||||
headers: {}
|
||||
}
|
||||
return aaa
|
||||
} catch (error) {
|
||||
const aaa = {
|
||||
status: 500,
|
||||
data: false,
|
||||
error: new UniError(typeof error === 'string' ? error : error?.message ?? '删除消息失败'),
|
||||
origin: null,
|
||||
headers: {}
|
||||
}
|
||||
return aaa
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量操作消息
|
||||
*/
|
||||
static async batchOperation(
|
||||
messageIds : Array<string>,
|
||||
operation : string,
|
||||
userId : string
|
||||
) : Promise<AkReqResponse<boolean>> {
|
||||
try {
|
||||
// 验证参数不能为空
|
||||
if (userId == null || userId === '' || userId.trim() === '') {
|
||||
return createErrorResponse<boolean>(
|
||||
false,
|
||||
'参数错误:用户ID不能为空'
|
||||
)
|
||||
}
|
||||
|
||||
if (messageIds == null || messageIds.length === 0) {
|
||||
return createErrorResponse<boolean>(
|
||||
false,
|
||||
'参数错误:消息ID列表不能为空'
|
||||
)
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case 'read':
|
||||
await supa
|
||||
.from('ak_message_recipients')
|
||||
.update({
|
||||
status: 'read',
|
||||
read_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.in('message_id', messageIds.map(x => x as any))
|
||||
.eq('recipient_id', userId)
|
||||
.executeAs<Array<MessageRecipient>>()
|
||||
break
|
||||
|
||||
case 'delete':
|
||||
await supa
|
||||
.from('ak_message_recipients')
|
||||
.update({
|
||||
is_deleted: true,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.in('message_id', messageIds.map(x => x as any))
|
||||
.eq('recipient_id', userId)
|
||||
.executeAs<Array<MessageRecipient>>()
|
||||
break
|
||||
|
||||
default:
|
||||
return {
|
||||
status: 400,
|
||||
data: false,
|
||||
error: new UniError('不支持的批量操作'),
|
||||
origin: null,
|
||||
headers: {}
|
||||
} as AkReqResponse<boolean>
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
data: true,
|
||||
error: null,
|
||||
origin: null,
|
||||
headers: {}
|
||||
} as AkReqResponse<boolean>
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 500,
|
||||
data: false,
|
||||
error: new UniError(typeof error === 'string' ? error : error?.message ?? '批量操作失败'),
|
||||
origin: null,
|
||||
headers: {}
|
||||
} as AkReqResponse<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息统计信息
|
||||
*/
|
||||
static async getMessageStats(userId : string) : Promise<AkReqResponse<MessageStats>> {
|
||||
try {
|
||||
// 验证参数不能为空
|
||||
if (userId == null || userId === '' || userId.trim() === '') {
|
||||
return createErrorResponse<MessageStats>(
|
||||
{
|
||||
total_messages: 0,
|
||||
unread_messages: 0,
|
||||
sent_messages: 0,
|
||||
received_messages: 0,
|
||||
urgent_messages: 0,
|
||||
draft_messages: 0
|
||||
},
|
||||
'参数错误:用户ID不能为空'
|
||||
)
|
||||
}
|
||||
|
||||
// 获取未读消息
|
||||
const unreadResponse = await supa
|
||||
.from('ak_message_recipients')
|
||||
.select('id', { count: 'exact' })
|
||||
.eq('recipient_id', userId)
|
||||
.eq('status', 'unread')
|
||||
.eq('is_deleted', false)
|
||||
.executeAs<Array<MessageRecipient>>()
|
||||
|
||||
// 获取总消息数
|
||||
const totalResponse = await supa
|
||||
.from('ak_message_recipients')
|
||||
.select('id', { count: 'exact' })
|
||||
.eq('recipient_id', userId)
|
||||
.eq('is_deleted', false)
|
||||
.executeAs<Array<MessageRecipient>>()
|
||||
|
||||
// 获取紧急消息数
|
||||
const urgentResponse = await supa
|
||||
.from('ak_messages')
|
||||
.select('id', { count: 'exact' })
|
||||
.eq('receiver_id', userId)
|
||||
.eq('is_urgent', true)
|
||||
.eq('is_deleted', false)
|
||||
.executeAs<Array<Message>>()
|
||||
const stats : MessageStats = {
|
||||
total_messages: Array.isArray(totalResponse.data) ? (totalResponse.data as Array<any>).length : 0,
|
||||
unread_messages: Array.isArray(unreadResponse.data) ? (unreadResponse.data as Array<any>).length : 0,
|
||||
urgent_messages: Array.isArray(urgentResponse.data) ? (urgentResponse.data as Array<any>).length : 0,
|
||||
sent_messages: 0, // 可根据业务补充
|
||||
received_messages: 0, // 可根据业务补充
|
||||
draft_messages: 0 // 可根据业务补充
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
data: stats,
|
||||
error: null,
|
||||
origin: null,
|
||||
headers: {}
|
||||
} as AkReqResponse<MessageStats>
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 500,
|
||||
data: null,
|
||||
error: new UniError(typeof error === 'string' ? error : error?.message ?? '获取消息统计失败'),
|
||||
origin: null,
|
||||
headers: {}
|
||||
} as AkReqResponse<MessageStats>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索用户(用于消息接收者选择)
|
||||
*/
|
||||
static async searchUsers(keyword : string) : Promise<AkReqResponse<Array<UserOption>>> {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ak_users')
|
||||
.select('id, username, nickname, avatar', { count: 'exact' })
|
||||
.or(`username.ilike.%${keyword}%,nickname.ilike.%${keyword}%`)
|
||||
.limit(20)
|
||||
.executeAs<Array<UserOption>>()
|
||||
|
||||
return response
|
||||
} catch (error) { return {
|
||||
status: 500,
|
||||
data: [] as Array<UserOption>,
|
||||
error: new UniError(typeof error === 'string' ? error : error?.message ?? '搜索失败'),
|
||||
origin: null,
|
||||
headers: {}
|
||||
} as AkReqResponse<Array<UserOption>>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取群组列表
|
||||
*/
|
||||
static async getGroups() : Promise<AkReqResponse<Array<MessageGroup>>> {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ak_message_groups')
|
||||
.select('*', {})
|
||||
.eq('is_active', true)
|
||||
.executeAs<Array<MessageGroup>>()
|
||||
return response
|
||||
} catch (error) { return {
|
||||
status: 500,
|
||||
data: [] as Array<MessageGroup>,
|
||||
error: new UniError(typeof error === 'string' ? error : error?.message ?? '获取群组失败'),
|
||||
origin: null,
|
||||
headers: {}
|
||||
} as AkReqResponse<Array<MessageGroup>>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取群组成员
|
||||
*/
|
||||
static async getGroupMembers(groupId : string) : Promise<AkReqResponse<Array<GroupMember>>> {
|
||||
try {
|
||||
const response = await supa
|
||||
.from('ak_message_group_members')
|
||||
.select('*', {})
|
||||
.eq('group_id', groupId)
|
||||
.executeAs<Array<GroupMember>>()
|
||||
return response
|
||||
} catch (error) { return {
|
||||
status: 500,
|
||||
data: [] as Array<GroupMember>,
|
||||
error: new UniError(typeof error === 'string' ? error : error?.message ?? '获取群组成员失败'),
|
||||
origin: null,
|
||||
headers: {}
|
||||
} as AkReqResponse<Array<GroupMember>>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建标准错误响应
|
||||
*/
|
||||
function createErrorResponse<T>(data : T, errorMessage : string, status : number = 500) : AkReqResponse<T> {
|
||||
return {
|
||||
status: status,
|
||||
data: data,
|
||||
headers: {},
|
||||
error: new UniError(errorMessage),
|
||||
total: null,
|
||||
page: null,
|
||||
limit: null,
|
||||
hasmore: false,
|
||||
origin: null
|
||||
}
|
||||
}
|
||||
431
utils/msgSystemTest.uts
Normal file
431
utils/msgSystemTest.uts
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* 消息系统集成测试
|
||||
* 用于验证消息系统各项功能是否正常工作
|
||||
*/
|
||||
|
||||
import { MsgDataServiceReal } from '@/utils/msgDataServiceReal.uts'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import type {
|
||||
MessageQueryParams,
|
||||
SendMessageParams,
|
||||
MessageType,
|
||||
Message
|
||||
} from '@/utils/msgTypes.uts'
|
||||
|
||||
/**
|
||||
* 测试结果类型
|
||||
*/
|
||||
type TestResult = {
|
||||
name: string
|
||||
passed: boolean
|
||||
message: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息系统集成测试类
|
||||
*/
|
||||
export class MessageSystemIntegrationTest {
|
||||
private results: Array<TestResult> = []
|
||||
private testUserId: string = 'test-user-id'
|
||||
|
||||
/**
|
||||
* 运行所有测试
|
||||
*/
|
||||
async runAllTests(): Promise<Array<TestResult>> {
|
||||
console.log('Starting Message System Integration Tests...')
|
||||
|
||||
this.results = []
|
||||
|
||||
// 检查初始化
|
||||
await this.testSupabaseInitialization()
|
||||
|
||||
// 测试消息类型
|
||||
await this.testGetMessageTypes()
|
||||
|
||||
// 测试发送消息
|
||||
await this.testSendMessage()
|
||||
|
||||
// 测试获取消息列表
|
||||
await this.testGetMessages()
|
||||
|
||||
// 测试消息详情
|
||||
await this.testGetMessageById()
|
||||
|
||||
// 测试标记已读
|
||||
await this.testMarkAsRead()
|
||||
|
||||
// 测试批量操作
|
||||
await this.testBatchOperations()
|
||||
|
||||
// 测试统计功能
|
||||
await this.testGetStats()
|
||||
|
||||
// 测试搜索功能
|
||||
await this.testSearchUsers()
|
||||
|
||||
// 测试群组功能
|
||||
await this.testGetGroups()
|
||||
|
||||
this.printResults()
|
||||
return this.results
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个测试
|
||||
*/
|
||||
private async runTest(testName: string, testFunction: () => Promise<void>): Promise<void> {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
await testFunction()
|
||||
const duration = Date.now() - startTime
|
||||
this.results.push({
|
||||
name: testName,
|
||||
passed: true,
|
||||
message: 'Test passed',
|
||||
duration
|
||||
})
|
||||
console.log(`✓ ${testName} - ${duration}ms`)
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
this.results.push({
|
||||
name: testName,
|
||||
passed: false,
|
||||
message,
|
||||
duration
|
||||
})
|
||||
console.error(`✗ ${testName} - ${message} - ${duration}ms`)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 测试 Supabase 初始化
|
||||
*/ private async testSupabaseInitialization(): Promise<void> {
|
||||
await this.runTest('Supabase Initialization', async () => {
|
||||
// MsgDataServiceReal 现在直接使用 aksupainstance.uts 中的 supa 实例
|
||||
// 无需手动初始化
|
||||
|
||||
// 测试获取会话
|
||||
const session = supa.getSession()
|
||||
if (session.user === null) {
|
||||
console.warn('No active session - this is expected for anonymous users')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试获取消息类型
|
||||
*/
|
||||
private async testGetMessageTypes(): Promise<void> {
|
||||
await this.runTest('Get Message Types', async () => {
|
||||
const response = await MsgDataServiceReal.getMessageTypes()
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(`API Error: ${response.message}`)
|
||||
}
|
||||
|
||||
if (response.data === null || response.data.length === 0) {
|
||||
throw new Error('No message types returned')
|
||||
}
|
||||
|
||||
// 验证数据结构
|
||||
const firstType = response.data[0]
|
||||
if (!firstType.id || !firstType.code || !firstType.name) {
|
||||
throw new Error('Invalid message type structure')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试发送消息
|
||||
*/
|
||||
private async testSendMessage(): Promise<void> {
|
||||
await this.runTest('Send Message', async () => {
|
||||
const params: SendMessageParams = {
|
||||
message_type_id: '1',
|
||||
sender_type: 'user',
|
||||
sender_id: this.testUserId,
|
||||
sender_name: 'Test User',
|
||||
receiver_type: 'user',
|
||||
receiver_id: this.testUserId,
|
||||
title: 'Test Message',
|
||||
content: 'This is a test message',
|
||||
content_type: 'text',
|
||||
attachments: null,
|
||||
media_urls: null,
|
||||
metadata: null,
|
||||
is_urgent: false,
|
||||
scheduled_at: null,
|
||||
expires_at: null,
|
||||
receivers: [this.testUserId]
|
||||
}
|
||||
|
||||
const response = await MsgDataServiceReal.sendMessage(params)
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(`Send Message Error: ${response.message}`)
|
||||
}
|
||||
|
||||
if (response.data === null) {
|
||||
throw new Error('No message data returned')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试获取消息列表
|
||||
*/
|
||||
private async testGetMessages(): Promise<void> {
|
||||
await this.runTest('Get Messages', async () => {
|
||||
const params: MessageQueryParams = {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
message_type_id: null,
|
||||
receiver_id: this.testUserId,
|
||||
keyword: null,
|
||||
status: null,
|
||||
is_urgent: null
|
||||
}
|
||||
|
||||
const response = await MsgDataServiceReal.getMessages(params)
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(`Get Messages Error: ${response.message}`)
|
||||
}
|
||||
|
||||
if (response.data === null) {
|
||||
throw new Error('No paged response returned')
|
||||
}
|
||||
|
||||
// 验证分页结构
|
||||
if (!Array.isArray(response.data.items)) {
|
||||
throw new Error('Invalid items array')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试获取消息详情
|
||||
*/
|
||||
private async testGetMessageById(): Promise<void> {
|
||||
await this.runTest('Get Message By ID', async () => {
|
||||
// 首先获取一条消息ID
|
||||
const listResponse = await MsgDataServiceReal.getMessages({
|
||||
page: 1,
|
||||
limit: 1,
|
||||
message_type_id: null,
|
||||
receiver_id: this.testUserId,
|
||||
keyword: null,
|
||||
status: null,
|
||||
is_urgent: null
|
||||
})
|
||||
|
||||
if (!listResponse.success || listResponse.data === null || listResponse.data.items.length === 0) {
|
||||
console.warn('No messages found for detail test')
|
||||
return
|
||||
}
|
||||
|
||||
const messageId = listResponse.data.items[0].id
|
||||
const response = await MsgDataServiceReal.getMessageById(messageId)
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(`Get Message Detail Error: ${response.message}`)
|
||||
}
|
||||
|
||||
if (response.data === null) {
|
||||
throw new Error('No message detail returned')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试标记已读
|
||||
*/
|
||||
private async testMarkAsRead(): Promise<void> {
|
||||
await this.runTest('Mark As Read', async () => {
|
||||
// 首先获取一条未读消息
|
||||
const listResponse = await MsgDataServiceReal.getMessages({
|
||||
page: 1,
|
||||
limit: 1,
|
||||
message_type_id: null,
|
||||
receiver_id: this.testUserId,
|
||||
keyword: null,
|
||||
status: 'unread',
|
||||
is_urgent: null
|
||||
})
|
||||
|
||||
if (!listResponse.success || listResponse.data === null || listResponse.data.items.length === 0) {
|
||||
console.warn('No unread messages found for mark as read test')
|
||||
return
|
||||
}
|
||||
|
||||
const messageId = listResponse.data.items[0].id
|
||||
const response = await MsgDataServiceReal.markAsRead(messageId, this.testUserId)
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(`Mark As Read Error: ${response.message}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试批量操作
|
||||
*/
|
||||
private async testBatchOperations(): Promise<void> {
|
||||
await this.runTest('Batch Operations', async () => {
|
||||
// 获取一些消息ID
|
||||
const listResponse = await MsgDataServiceReal.getMessages({
|
||||
page: 1,
|
||||
limit: 3,
|
||||
message_type_id: null,
|
||||
receiver_id: this.testUserId,
|
||||
keyword: null,
|
||||
status: null,
|
||||
is_urgent: null
|
||||
})
|
||||
|
||||
if (!listResponse.success || listResponse.data === null || listResponse.data.items.length === 0) {
|
||||
console.warn('No messages found for batch operations test')
|
||||
return
|
||||
}
|
||||
|
||||
const messageIds = listResponse.data.items.map(msg => msg.id)
|
||||
|
||||
// 测试批量标记已读
|
||||
const readResponse = await MsgDataServiceReal.batchOperation(messageIds, 'read', this.testUserId)
|
||||
if (!readResponse.success) {
|
||||
throw new Error(`Batch Read Error: ${readResponse.message}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试统计功能
|
||||
*/
|
||||
private async testGetStats(): Promise<void> {
|
||||
await this.runTest('Get Statistics', async () => {
|
||||
const response = await MsgDataServiceReal.getMessageStats(this.testUserId)
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(`Get Stats Error: ${response.message}`)
|
||||
}
|
||||
|
||||
if (response.data === null) {
|
||||
throw new Error('No stats data returned')
|
||||
}
|
||||
|
||||
// 验证统计数据结构
|
||||
const stats = response.data
|
||||
if (typeof stats.total !== 'number' ||
|
||||
typeof stats.unread !== 'number' ||
|
||||
typeof stats.urgent !== 'number' ||
|
||||
typeof stats.read !== 'number') {
|
||||
throw new Error('Invalid stats data structure')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试搜索用户
|
||||
*/
|
||||
private async testSearchUsers(): Promise<void> {
|
||||
await this.runTest('Search Users', async () => {
|
||||
const response = await MsgDataServiceReal.searchUsers('test')
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(`Search Users Error: ${response.message}`)
|
||||
}
|
||||
|
||||
if (response.data === null) {
|
||||
throw new Error('No user search results returned')
|
||||
}
|
||||
|
||||
// 验证用户数据结构
|
||||
if (response.data.length > 0) {
|
||||
const firstUser = response.data[0]
|
||||
if (!firstUser.id || !firstUser.name) {
|
||||
throw new Error('Invalid user option structure')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试获取群组
|
||||
*/
|
||||
private async testGetGroups(): Promise<void> {
|
||||
await this.runTest('Get Groups', async () => {
|
||||
const response = await MsgDataServiceReal.getGroups(this.testUserId)
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(`Get Groups Error: ${response.message}`)
|
||||
}
|
||||
|
||||
if (response.data === null) {
|
||||
throw new Error('No groups data returned')
|
||||
}
|
||||
|
||||
// 验证群组数据结构
|
||||
if (response.data.length > 0) {
|
||||
const firstGroup = response.data[0]
|
||||
if (!firstGroup.id || !firstGroup.name) {
|
||||
throw new Error('Invalid group option structure')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印测试结果
|
||||
*/
|
||||
private printResults(): void {
|
||||
console.log('\n=== Message System Integration Test Results ===')
|
||||
|
||||
const passed = this.results.filter(r => r.passed).length
|
||||
const total = this.results.length
|
||||
const totalTime = this.results.reduce((sum, r) => sum + r.duration, 0)
|
||||
|
||||
console.log(`Total Tests: ${total}`)
|
||||
console.log(`Passed: ${passed}`)
|
||||
console.log(`Failed: ${total - passed}`)
|
||||
console.log(`Total Time: ${totalTime}ms`)
|
||||
console.log(`Success Rate: ${((passed / total) * 100).toFixed(2)}%`)
|
||||
|
||||
// 显示失败的测试
|
||||
const failed = this.results.filter(r => !r.passed)
|
||||
if (failed.length > 0) {
|
||||
console.log('\n=== Failed Tests ===')
|
||||
failed.forEach(test => {
|
||||
console.log(`✗ ${test.name}: ${test.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
console.log('\n=== Test Summary ===')
|
||||
this.results.forEach(test => {
|
||||
const status = test.passed ? '✓' : '✗'
|
||||
console.log(`${status} ${test.name} - ${test.duration}ms`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行集成测试的便捷函数
|
||||
*/
|
||||
export async function runMessageSystemTests(): Promise<Array<TestResult>> {
|
||||
const tester = new MessageSystemIntegrationTest()
|
||||
return await tester.runAllTests()
|
||||
}
|
||||
|
||||
/**
|
||||
* 在页面中运行测试的示例
|
||||
*
|
||||
* // 在任意页面的 script 中调用
|
||||
* import { runMessageSystemTests } from '@/utils/msgSystemTest.uts'
|
||||
*
|
||||
* // 在某个按钮点击事件中
|
||||
* async function testMessageSystem() {
|
||||
* const results = await runMessageSystemTests()
|
||||
* console.log('Test completed:', results)
|
||||
* }
|
||||
*/
|
||||
256
utils/msgTypes.uts
Normal file
256
utils/msgTypes.uts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 消息系统类型定义 - 严格遵循UTS Android要求
|
||||
* 基于 message_system.sql 数据库设计
|
||||
*/
|
||||
|
||||
// 基础类型定义
|
||||
export type MessageSenderType = 'user' | 'device' | 'system'
|
||||
export type MessageReceiverType = 'user' | 'group' | 'broadcast' | 'class'
|
||||
export type MessageContentType = 'text' | 'html' | 'markdown' | 'json'
|
||||
export type MessageStatus = 'draft' | 'sent' | 'delivered' | 'failed'
|
||||
export type RecipientStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed'
|
||||
export type DeliveryMethod = 'push' | 'email' | 'sms' | 'websocket'
|
||||
export type GroupType = 'normal' | 'class' | 'team' | 'system' | 'temporary'
|
||||
export type MemberRole = 'owner' | 'admin' | 'moderator' | 'member'
|
||||
export type MemberStatus = 'active' | 'muted' | 'banned' | 'left'
|
||||
|
||||
// 消息类型信息
|
||||
export type MessageType = {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
description: string | null
|
||||
icon: string | null
|
||||
color: string | null
|
||||
priority: number
|
||||
is_system: boolean
|
||||
is_active: boolean
|
||||
auto_read_timeout: number | null
|
||||
retention_days: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 消息主体
|
||||
export type Message = {
|
||||
id: string
|
||||
message_type_id: string
|
||||
sender_type: MessageSenderType
|
||||
sender_id: string | null
|
||||
sender_name: string | null
|
||||
receiver_type: MessageReceiverType
|
||||
receiver_id: string | null
|
||||
title: string | null
|
||||
content: string | null
|
||||
content_type: MessageContentType
|
||||
attachments: UTSJSONObject | null
|
||||
media_urls: UTSJSONObject | null
|
||||
metadata: UTSJSONObject | null
|
||||
device_data: UTSJSONObject | null
|
||||
location_data: UTSJSONObject | null
|
||||
priority: number
|
||||
expires_at: string | null
|
||||
is_broadcast: boolean
|
||||
is_urgent: boolean
|
||||
conversation_id: string | null
|
||||
parent_message_id: string | null
|
||||
thread_count: number
|
||||
status: MessageStatus
|
||||
total_recipients: number
|
||||
delivered_count: number
|
||||
read_count: number
|
||||
reply_count: number
|
||||
delivery_options: UTSJSONObject | null
|
||||
push_notification: boolean
|
||||
email_notification: boolean
|
||||
sms_notification: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
scheduled_at: string | null
|
||||
delivered_at: string | null
|
||||
}
|
||||
|
||||
// 消息接收记录
|
||||
export type MessageRecipient = {
|
||||
id: string
|
||||
message_id: string
|
||||
recipient_type: MessageSenderType
|
||||
recipient_id: string
|
||||
recipient_name: string | null
|
||||
status: RecipientStatus
|
||||
delivery_method: DeliveryMethod | null
|
||||
sent_at: string | null
|
||||
delivered_at: string | null
|
||||
read_at: string | null
|
||||
replied_at: string | null
|
||||
delivery_attempts: number
|
||||
last_attempt_at: string | null
|
||||
failure_reason: string | null
|
||||
device_token: string | null
|
||||
is_starred: boolean
|
||||
is_archived: boolean
|
||||
is_deleted: boolean
|
||||
deleted_at: string | null
|
||||
read_duration_sec: number | null
|
||||
interaction_data: UTSJSONObject | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 消息群组
|
||||
export type MessageGroup = {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
group_type: GroupType
|
||||
owner_id: string | null
|
||||
avatar_url: string | null
|
||||
is_active: boolean
|
||||
is_public: boolean
|
||||
member_limit: number
|
||||
message_limit_per_day: number
|
||||
file_size_limit_mb: number
|
||||
settings: UTSJSONObject
|
||||
permissions: UTSJSONObject
|
||||
auto_archive_days: number
|
||||
auto_delete_days: number
|
||||
related_class_id: string | null
|
||||
related_school_id: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 群组成员
|
||||
export type GroupMember = {
|
||||
id: string
|
||||
group_id: string
|
||||
user_id: string
|
||||
role: MemberRole
|
||||
permissions: UTSJSONObject
|
||||
status: MemberStatus
|
||||
nickname: string | null
|
||||
is_muted: boolean
|
||||
muted_until: string | null
|
||||
muted_by: string | null
|
||||
mute_reason: string | null
|
||||
last_read_message_id: string | null
|
||||
last_read_at: string | null
|
||||
unread_count: number
|
||||
notification_enabled: boolean
|
||||
mention_only: boolean
|
||||
message_count: number
|
||||
join_count: number
|
||||
joined_at: string
|
||||
left_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 消息统计信息
|
||||
export type MessageStats = {
|
||||
total_messages: number
|
||||
unread_messages: number
|
||||
sent_messages: number
|
||||
received_messages: number
|
||||
urgent_messages: number
|
||||
draft_messages: number
|
||||
}
|
||||
|
||||
// 查询参数类型
|
||||
export type MessageListParams = {
|
||||
limit?: number
|
||||
offset?: number
|
||||
message_type?: string
|
||||
sender_type?: MessageSenderType
|
||||
sender_id?: string
|
||||
receiver_type?: MessageReceiverType
|
||||
receiver_id?: string
|
||||
status?: MessageStatus
|
||||
is_urgent?: boolean
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
search?: string
|
||||
}
|
||||
|
||||
// 发送消息参数
|
||||
export type SendMessageParams = {
|
||||
message_type_id: string
|
||||
receiver_type: MessageReceiverType
|
||||
receiver_id?: string
|
||||
receivers?: string[] // 新增,支持批量接收人
|
||||
title?: string
|
||||
content: string
|
||||
content_type?: MessageContentType
|
||||
priority?: number
|
||||
is_urgent?: boolean
|
||||
push_notification?: boolean
|
||||
email_notification?: boolean
|
||||
sms_notification?: boolean
|
||||
scheduled_at?: string
|
||||
expires_at?: string
|
||||
attachments?: UTSJSONObject
|
||||
media_urls?: UTSJSONObject // 新增,兼容服务端参数
|
||||
metadata?: UTSJSONObject
|
||||
// 新增,兼容服务端参数
|
||||
sender_type?: MessageSenderType
|
||||
sender_id?: string
|
||||
sender_name?: string
|
||||
}
|
||||
|
||||
// 用户选项类型
|
||||
export type UserOption = {
|
||||
id: string
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string | null
|
||||
}
|
||||
|
||||
// 扩展的Message类型,包含接收者信息用于UI显示
|
||||
export type MessageWithRecipient = {
|
||||
// 从Message类型复制的所有字段
|
||||
id: string
|
||||
message_type_id: string
|
||||
sender_type: MessageSenderType
|
||||
sender_id: string | null
|
||||
sender_name: string | null
|
||||
receiver_type: MessageReceiverType
|
||||
receiver_id: string | null
|
||||
title: string | null
|
||||
content: string | null
|
||||
content_type: MessageContentType
|
||||
attachments: UTSJSONObject | null
|
||||
media_urls: UTSJSONObject | null
|
||||
metadata: UTSJSONObject | null
|
||||
device_data: UTSJSONObject | null
|
||||
location_data: UTSJSONObject | null
|
||||
priority: number
|
||||
expires_at: string | null
|
||||
is_broadcast: boolean
|
||||
is_urgent: boolean
|
||||
conversation_id: string | null
|
||||
parent_message_id: string | null
|
||||
thread_count: number
|
||||
status: MessageStatus
|
||||
total_recipients: number
|
||||
delivered_count: number
|
||||
read_count: number
|
||||
reply_count: number
|
||||
delivery_options: UTSJSONObject | null
|
||||
push_notification: boolean
|
||||
email_notification: boolean
|
||||
sms_notification: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
scheduled_at: string | null
|
||||
delivered_at: string | null
|
||||
// 从MessageRecipient中添加的字段
|
||||
is_read?: boolean
|
||||
is_starred?: boolean
|
||||
is_archived?: boolean
|
||||
is_deleted?: boolean
|
||||
read_at?: string | null
|
||||
replied_at?: string | null
|
||||
// 扩展字段
|
||||
message_type?: string // 消息类型代码,用于兼容
|
||||
recipients?: Array<MessageRecipient> // 接收者列表
|
||||
}
|
||||
356
utils/msgUtils.uts
Normal file
356
utils/msgUtils.uts
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* 消息系统工具函数 - 严格遵循UTS Android要求
|
||||
*/
|
||||
|
||||
import {
|
||||
Message,
|
||||
MessageRecipient,
|
||||
MessageType,
|
||||
MessageSenderType,
|
||||
MessageReceiverType,
|
||||
MessageStatus,
|
||||
RecipientStatus
|
||||
} from './msgTypes.uts'
|
||||
|
||||
export class MsgUtils {
|
||||
|
||||
/**
|
||||
* 格式化时间显示
|
||||
*/
|
||||
static formatTime(timeStr: string | null): string {
|
||||
if (timeStr === null || timeStr === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
// 小于1分钟
|
||||
if (diff < 60000) {
|
||||
return '刚刚'
|
||||
}
|
||||
|
||||
// 小于1小时
|
||||
if (diff < 3600000) {
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
return `${minutes}分钟前`
|
||||
}
|
||||
|
||||
// 小于1天
|
||||
if (diff < 86400000) {
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
return `${hours}小时前`
|
||||
}
|
||||
|
||||
// 小于7天
|
||||
if (diff < 604800000) {
|
||||
const days = Math.floor(diff / 86400000)
|
||||
return `${days}天前`
|
||||
}
|
||||
|
||||
// 大于7天显示具体日期
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
return `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化完整时间
|
||||
*/
|
||||
static formatFullTime(timeStr: string | null): string {
|
||||
if (timeStr === null || timeStr === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const date = new Date(timeStr)
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
const hours = date.getHours()
|
||||
const minutes = date.getMinutes()
|
||||
|
||||
return `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息类型显示文本
|
||||
*/
|
||||
static getMessageTypeText(code: string): string {
|
||||
const typeMap = new Map<string, string>()
|
||||
typeMap.set('system', '系统消息')
|
||||
typeMap.set('device', '设备消息')
|
||||
typeMap.set('training', '训练提醒')
|
||||
typeMap.set('social', '社交消息')
|
||||
typeMap.set('assignment', '作业通知')
|
||||
typeMap.set('achievement', '成就通知')
|
||||
typeMap.set('chat', '即时消息')
|
||||
typeMap.set('announcement', '公告通知')
|
||||
typeMap.set('reminder', '提醒消息')
|
||||
typeMap.set('alert', '警报消息')
|
||||
|
||||
return typeMap.get(code) ?? '未知类型'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取发送者类型显示文本
|
||||
*/
|
||||
static getSenderTypeText(senderType: MessageSenderType): string {
|
||||
const typeMap = new Map<string, string>()
|
||||
typeMap.set('user', '用户')
|
||||
typeMap.set('device', '设备')
|
||||
typeMap.set('system', '系统')
|
||||
|
||||
return typeMap.get(senderType) ?? '未知'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息状态显示文本
|
||||
*/
|
||||
static getStatusText(status: MessageStatus): string {
|
||||
const statusMap = new Map<string, string>()
|
||||
statusMap.set('draft', '草稿')
|
||||
statusMap.set('sent', '已发送')
|
||||
statusMap.set('delivered', '已送达')
|
||||
statusMap.set('failed', '发送失败')
|
||||
|
||||
return statusMap.get(status) ?? '未知状态'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取接收状态显示文本
|
||||
*/
|
||||
static getRecipientStatusText(status: RecipientStatus): string {
|
||||
const statusMap = new Map<string, string>()
|
||||
statusMap.set('pending', '待发送')
|
||||
statusMap.set('sent', '已发送')
|
||||
statusMap.set('delivered', '已送达')
|
||||
statusMap.set('read', '已读')
|
||||
statusMap.set('failed', '失败')
|
||||
|
||||
return statusMap.get(status) ?? '未知'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取优先级颜色
|
||||
*/
|
||||
static getPriorityColor(priority: number): string {
|
||||
if (priority >= 90) {
|
||||
return '#ff4757' // 紧急 - 红色
|
||||
} else if (priority >= 70) {
|
||||
return '#ffa726' // 高 - 橙色
|
||||
} else if (priority >= 50) {
|
||||
return '#42a5f5' // 普通 - 蓝色
|
||||
} else {
|
||||
return '#66bb6a' // 低 - 绿色
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息摘要
|
||||
*/
|
||||
static getMessageSummary(content: string | null, maxLength: number): string {
|
||||
if (content === null || content === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (content.length <= maxLength) {
|
||||
return content
|
||||
}
|
||||
|
||||
return content.substring(0, maxLength) + '...'
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查消息是否已过期
|
||||
*/
|
||||
static isMessageExpired(expiresAt: string | null): boolean {
|
||||
if (expiresAt === null || expiresAt === '') {
|
||||
return false
|
||||
}
|
||||
|
||||
return new Date(expiresAt).getTime() < Date.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查消息是否未读
|
||||
*/
|
||||
static isMessageUnread(recipient: MessageRecipient): boolean {
|
||||
return recipient.status !== 'read'
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证发送参数
|
||||
*/
|
||||
static validateSendParams(title: string | null, content: string | null): string | null {
|
||||
if (content === null || content.trim() === '') {
|
||||
return '消息内容不能为空'
|
||||
}
|
||||
|
||||
if (content.length > 5000) {
|
||||
return '消息内容不能超过5000个字符'
|
||||
}
|
||||
|
||||
if (title !== null && title.length > 200) {
|
||||
return '消息标题不能超过200个字符'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取UTSJSONObject中的字符串值
|
||||
*/
|
||||
static getStringFromJson(obj: UTSJSONObject | null, key: string): string | null {
|
||||
if (obj === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const value = obj.getString(key)
|
||||
return value === '' ? null : value
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取UTSJSONObject中的数字值
|
||||
*/
|
||||
static getNumberFromJson(obj: UTSJSONObject | null, key: string): number | null {
|
||||
if (obj === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return obj.getNumber(key)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取UTSJSONObject中的布尔值
|
||||
*/
|
||||
static getBooleanFromJson(obj: UTSJSONObject | null, key: string): boolean {
|
||||
if (obj === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return obj.getBoolean(key) ?? false
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建本地存储键
|
||||
*/
|
||||
static createStorageKey(key: string): string {
|
||||
return `akmon_msg_${key}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存到本地存储
|
||||
*/
|
||||
static saveToLocal(key: string, data: UTSJSONObject): void {
|
||||
try {
|
||||
const jsonStr = JSON.stringify(data)
|
||||
uni.setStorageSync(this.createStorageKey(key), jsonStr)
|
||||
} catch (e) {
|
||||
console.error('保存到本地存储失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从本地存储读取
|
||||
*/
|
||||
static getFromLocal(key: string): UTSJSONObject | null {
|
||||
try {
|
||||
const jsonStr = uni.getStorageSync(this.createStorageKey(key))
|
||||
if (jsonStr !== null && jsonStr !== '') {
|
||||
return JSON.parse(jsonStr as string) as UTSJSONObject
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('从本地存储读取失败:', e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除本地存储
|
||||
*/
|
||||
static clearLocal(key: string): void {
|
||||
try {
|
||||
uni.removeStorageSync(this.createStorageKey(key))
|
||||
} catch (e) {
|
||||
console.error('清除本地存储失败:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MessageItem组件需要的独立导出函数
|
||||
|
||||
/**
|
||||
* 格式化时间显示 - 独立函数导出
|
||||
*/
|
||||
export function formatTime(timeStr: string | null): string {
|
||||
return MsgUtils.formatTime(timeStr)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息类型名称 - 独立函数导出
|
||||
*/
|
||||
export function getTypeName(typeCode: string): string {
|
||||
// 默认消息类型映射
|
||||
const typeMap = new Map<string, string>()
|
||||
typeMap.set('system', '系统消息')
|
||||
typeMap.set('notification', '通知')
|
||||
typeMap.set('announcement', '公告')
|
||||
typeMap.set('reminder', '提醒')
|
||||
typeMap.set('alert', '警报')
|
||||
typeMap.set('update', '更新')
|
||||
typeMap.set('message', '消息')
|
||||
typeMap.set('chat', '聊天')
|
||||
typeMap.set('default', '普通消息')
|
||||
|
||||
return typeMap.get(typeCode) ?? '消息'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息类型颜色 - 独立函数导出
|
||||
*/
|
||||
export function getTypeColor(typeCode: string): string {
|
||||
// 默认消息类型颜色映射
|
||||
const colorMap = new Map<string, string>()
|
||||
colorMap.set('system', '#9c27b0') // 紫色
|
||||
colorMap.set('notification', '#2196f3') // 蓝色
|
||||
colorMap.set('announcement', '#ff9800') // 橙色
|
||||
colorMap.set('reminder', '#4caf50') // 绿色
|
||||
colorMap.set('alert', '#f44336') // 红色
|
||||
colorMap.set('update', '#00bcd4') // 青色
|
||||
colorMap.set('message', '#607d8b') // 蓝灰色
|
||||
colorMap.set('chat', '#795548') // 棕色
|
||||
colorMap.set('default', '#757575') // 灰色
|
||||
|
||||
return colorMap.get(typeCode) ?? '#757575'
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断文本 - 独立函数导出
|
||||
*/
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
return MsgUtils.getMessageSummary(text, maxLength)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期 - 独立函数导出
|
||||
*/
|
||||
export function formatDate(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getDate().toString().padStart(2, '0')
|
||||
return y + '-' + m + '-' + d
|
||||
}
|
||||
143
utils/permissionService.uts
Normal file
143
utils/permissionService.uts
Normal file
@@ -0,0 +1,143 @@
|
||||
// 权限服务:判断用户是否有某权限、获取用户权限列表等
|
||||
// 可根据实际业务扩展
|
||||
|
||||
import supa from '../components/supadb/aksupainstance.uts'
|
||||
|
||||
export type PermissionCheckOptions = {
|
||||
userId: string;
|
||||
permissionCode: string;
|
||||
scopeType?: string;
|
||||
scopeId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否拥有某个权限
|
||||
* @param options userId, permissionCode, scopeType, scopeId
|
||||
* @returns boolean
|
||||
*/
|
||||
export async function hasPermission(options: PermissionCheckOptions): Promise<boolean> {
|
||||
const { userId, permissionCode, scopeType, scopeId } = options
|
||||
// 优先查缓存(仅全局权限,不含scopeType/scopeId)
|
||||
if ((scopeType == null) && (scopeId == null) && Array.isArray(userPermissionCache[userId])) {
|
||||
return (userPermissionCache[userId] as Array<any>).includes(permissionCode)
|
||||
}
|
||||
// 查询用户角色-权限关系
|
||||
let query = supa
|
||||
.from('ak_user_roles')
|
||||
.select('id, role_id, scope_type, scope_id, ak_roles!inner(id, name)', null)
|
||||
.eq('user_id', userId)
|
||||
if (scopeType != null) query = query.eq('scope_type', scopeType)
|
||||
if (scopeId != null) query = query.eq('scope_id', scopeId)
|
||||
const result= await query.execute()
|
||||
let data = result.data
|
||||
let error = result.error
|
||||
|
||||
if (error != null || data == null) return false
|
||||
// 检查是否有对应权限
|
||||
let arr: any[] = []
|
||||
if (Array.isArray(data)) {
|
||||
arr = data
|
||||
}
|
||||
else{
|
||||
arr = new Array(data);
|
||||
}
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
let ur = arr[i] as String
|
||||
let hasPerm = false
|
||||
// let perms = (typeof ur.get === "function") ? ur.get("ak_role_permissions") : ur["ak_role_permissions"];
|
||||
// if (perms != null) {
|
||||
// for (let j = 0; j < perms.length; j++) {
|
||||
// let rp = perms[j]
|
||||
// if (rp.ak_permissions && rp.ak_permissions.code === permissionCode) {
|
||||
// hasPerm = true
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
if (hasPerm) {
|
||||
// 如果是全局权限,写入缓存
|
||||
if (scopeType == null && scopeId == null) {
|
||||
// if (!Array.isArray(userPermissionCache[userId])) userPermissionCache[userId] = []
|
||||
// if (!(userPermissionCache[userId] as Array<any>).includes(permissionCode)) {
|
||||
// (userPermissionCache[userId] as Array<any>).push(permissionCode)
|
||||
// }
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 用户权限缓存(内存,key为userId)
|
||||
let userPermissionCache = {}
|
||||
|
||||
export function clearCache(userId?: string) {
|
||||
if (userId) {
|
||||
delete userPermissionCache[userId]
|
||||
} else {
|
||||
for (const k in userPermissionCache) delete userPermissionCache[k]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户所有权限code列表
|
||||
* @param userId 用户ID
|
||||
* @returns string[]
|
||||
*/
|
||||
export async function getUserPermissions(userId: string): Promise<string[]> {
|
||||
if (Array.isArray(userPermissionCache[userId])) {
|
||||
return userPermissionCache[userId] as string[]
|
||||
}
|
||||
const result = await supa
|
||||
.from('ak_user_roles')
|
||||
.select('id, role_id, ak_roles!inner(id, name)', null)
|
||||
.eq('user_id', userId)
|
||||
.execute();
|
||||
let data = result["data"]
|
||||
let error = result["error"]
|
||||
if (error != null || data == null) return []
|
||||
let arr: any[] = []
|
||||
if (Array.isArray(data)) {
|
||||
arr = data
|
||||
} else{
|
||||
arr = new Array(data);
|
||||
}
|
||||
const codes = new Set<string>()
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
let ur = arr[i]
|
||||
// if (ur.ak_role_permissions) {
|
||||
// for (let j = 0; j < ur.ak_role_permissions.length; j++) {
|
||||
// let rp = ur.ak_role_permissions[j]
|
||||
// if (rp.ak_permissions && rp.ak_permissions.code) {
|
||||
// codes.add(rp.ak_permissions.code)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
const resultArr = Array.from(codes)
|
||||
userPermissionCache[userId] = resultArr
|
||||
return resultArr
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有权限
|
||||
* @returns 权限对象数组
|
||||
*/
|
||||
export async function getAllPermissions(): Promise<any[]> {
|
||||
let result = await supa
|
||||
.from('ak_permissions')
|
||||
.select('*', null)
|
||||
.execute();
|
||||
let data = result["data"]
|
||||
let error = result["error"]
|
||||
if (error != null || data == null) return []
|
||||
let arr: any[] = []
|
||||
if (Array.isArray(data)) {
|
||||
arr = data
|
||||
} else{
|
||||
arr = new Array(data);
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
|
||||
174
utils/sapi.uts
Normal file
174
utils/sapi.uts
Normal file
@@ -0,0 +1,174 @@
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import type { UserProfile } from '@/pages/user/types.uts'
|
||||
import { AkReqResponse, AkReqUploadOptions, AkReq } from '@/uni_modules/ak-req/index.uts'
|
||||
|
||||
|
||||
/**
|
||||
* 创建基础用户资料
|
||||
* 当用户首次登录但在 ak_users 表中没有资料时调用
|
||||
* @param supaUser - Supabase 认证用户对象
|
||||
* @returns Promise<UserProfile | null> - 创建的用户资料或null(如果失败)
|
||||
*/
|
||||
export async function createBasicUserProfile(supaUser: UTSJSONObject): Promise<UserProfile | null> { try {
|
||||
// 从 supaUser 中提取基础信息
|
||||
const userId = supaUser.getString('id')
|
||||
const email = supaUser.getString('email')
|
||||
|
||||
if (userId == null || email == null) {
|
||||
console.error('创建用户资料失败:缺少用户ID或邮箱')
|
||||
return null
|
||||
}
|
||||
|
||||
// 从邮箱中提取用户名(@符号前的部分)
|
||||
const emailParts = email.split('@')
|
||||
const username = emailParts.length > 0 ? emailParts[0] : 'user'
|
||||
|
||||
// 构建基础用户资料
|
||||
const basicProfile: UTSJSONObject = {
|
||||
id: userId,
|
||||
username: username,
|
||||
email: email,
|
||||
gender: null,
|
||||
birthday: null,
|
||||
height_cm: null,
|
||||
weight_kg: null,
|
||||
bio: null,
|
||||
avatar_url: null,
|
||||
preferred_language: null, // 默认语言
|
||||
role: 'student', // 默认角色
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString() } as UTSJSONObject
|
||||
|
||||
console.log('正在创建用户资料:', basicProfile)
|
||||
console.log('用户ID:', userId)
|
||||
console.log('用户邮箱:', email)
|
||||
console.log('用户名:', username)
|
||||
|
||||
// 插入到 ak_users 表
|
||||
console.log('准备插入数据到 ak_users 表')
|
||||
console.log('表名: ak_users')
|
||||
console.log('数据:', JSON.stringify(basicProfile.toMap()))
|
||||
const result = await supa.from('ak_users').insert(basicProfile).execute()
|
||||
|
||||
console.log('插入用户资料结果:', result)
|
||||
if ((result.status === 201 || result.status === 200) && result.data != null) {
|
||||
// 插入成功,返回创建的用户资料
|
||||
let insertedUser: UTSJSONObject | null = null
|
||||
const data = result.data
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length > 0) {
|
||||
insertedUser = data[0] as UTSJSONObject
|
||||
}
|
||||
} else if (data != null) {
|
||||
insertedUser = data as UTSJSONObject
|
||||
}
|
||||
|
||||
if (insertedUser != null) {
|
||||
const userProfile: UserProfile = {
|
||||
id: insertedUser.getString('id') ?? '',
|
||||
username: insertedUser.getString('username') ?? '',
|
||||
email: insertedUser.getString('email') ?? '',
|
||||
gender: insertedUser.getString('gender'),
|
||||
birthday: insertedUser.getString('birthday'),
|
||||
height_cm: insertedUser.getNumber('height_cm'),
|
||||
weight_kg: insertedUser.getNumber('weight_kg'),
|
||||
bio: insertedUser.getString('bio'),
|
||||
avatar_url: insertedUser.getString('avatar_url'),
|
||||
preferred_language: insertedUser.getString('preferred_language'),
|
||||
role: insertedUser.getString('role')
|
||||
}
|
||||
|
||||
console.log('用户资料创建成功:', userProfile)
|
||||
return userProfile
|
||||
} else {
|
||||
// 如果 insert 返回的数据为空,我们使用原始数据构建 UserProfile
|
||||
console.log('插入成功但返回数据为空,使用原始数据构建用户资料')
|
||||
const userProfile: UserProfile = {
|
||||
id: userId,
|
||||
username: username,
|
||||
email: email,
|
||||
gender: null,
|
||||
birthday: null,
|
||||
height_cm: null,
|
||||
weight_kg: null,
|
||||
bio: null,
|
||||
avatar_url: null,
|
||||
preferred_language: 'zh-CN',
|
||||
role: 'student'
|
||||
}
|
||||
|
||||
console.log('用户资料创建成功:', userProfile)
|
||||
return userProfile
|
||||
}
|
||||
}
|
||||
|
||||
console.error('创建用户资料失败:插入操作未成功')
|
||||
return null
|
||||
|
||||
} catch (error) {
|
||||
console.error('创建用户资料时发生错误:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并创建用户资料
|
||||
* 如果用户在 ak_users 表中不存在,则创建基础资料
|
||||
* @param supaUser - Supabase 认证用户对象
|
||||
* @returns Promise<UserProfile | null> - 用户资料或null
|
||||
*/
|
||||
export async function ensureUserProfile(supaUser: UTSJSONObject): Promise<UserProfile | null> { try {
|
||||
const userId = supaUser.getString('id')
|
||||
if (userId == null) {
|
||||
console.error('用户ID不存在')
|
||||
return null
|
||||
}
|
||||
|
||||
// 首先尝试查询现有用户资料
|
||||
const existingResult = await supa
|
||||
.from('ak_users')
|
||||
.select('*',{})
|
||||
.eq('id', userId)
|
||||
.execute()
|
||||
|
||||
if (existingResult.status === 200 && existingResult.data != null) {
|
||||
const data = existingResult.data
|
||||
let existingUser: UTSJSONObject | null = null
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length > 0) {
|
||||
existingUser = data[0] as UTSJSONObject
|
||||
}
|
||||
} else if (data != null) {
|
||||
existingUser = data as UTSJSONObject
|
||||
}
|
||||
|
||||
// 如果找到现有用户资料,直接返回
|
||||
if (existingUser != null) {
|
||||
const userProfile: UserProfile = {
|
||||
id: existingUser.getString('id'),
|
||||
username: existingUser.getString('username') ?? '',
|
||||
email: existingUser.getString('email') ?? '',
|
||||
gender: existingUser.getString('gender'),
|
||||
birthday: existingUser.getString('birthday'),
|
||||
height_cm: existingUser.getNumber('height_cm'),
|
||||
weight_kg: existingUser.getNumber('weight_kg'),
|
||||
bio: existingUser.getString('bio'),
|
||||
avatar_url: existingUser.getString('avatar_url'),
|
||||
preferred_language: existingUser.getString('preferred_language'),
|
||||
role: existingUser.getString('role')
|
||||
}
|
||||
return userProfile
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到用户资料,创建新的
|
||||
console.log('未找到用户资料,正在创建新的基础资料...')
|
||||
return await createBasicUserProfile(supaUser)
|
||||
|
||||
} catch (error) {
|
||||
console.error('检查用户资料时发生错误:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
457
utils/store.uts
Normal file
457
utils/store.uts
Normal file
@@ -0,0 +1,457 @@
|
||||
import supa, { supaReady } from '@/components/supadb/aksupainstance.uts'
|
||||
import type { UserProfile, UserStats } from '@/pages/user/types.uts'
|
||||
import type { DeviceInfo } from '@/pages/sense/types.uts'
|
||||
import { SenseDataService, type DeviceParams } from '@/pages/sense/senseDataService.uts'
|
||||
import { reactive } from 'vue'
|
||||
import { ensureUserProfile } from './sapi.uts'
|
||||
|
||||
// 设备状态类型
|
||||
export type DeviceState = {
|
||||
devices : Array<DeviceInfo>
|
||||
currentDevice : DeviceInfo | null
|
||||
isLoading : boolean
|
||||
lastUpdated : number | null
|
||||
}
|
||||
|
||||
//定义一个大写的State类型
|
||||
export type State = {
|
||||
globalNum : number
|
||||
userProfile ?: UserProfile
|
||||
isLoggedIn : boolean // 新增字段
|
||||
deviceState : DeviceState // 新增设备状态
|
||||
// 如有需要,可增加更多属性
|
||||
}
|
||||
|
||||
// 实例化为state
|
||||
export const state = reactive({
|
||||
globalNum: 0,
|
||||
userProfile: { username: '', email: '' },
|
||||
isLoggedIn: false,
|
||||
deviceState: {
|
||||
devices: [],
|
||||
currentDevice: null,
|
||||
isLoading: false,
|
||||
lastUpdated: null
|
||||
} as DeviceState
|
||||
} as State)
|
||||
// 定义修改属性值的方法
|
||||
export const setGlobalNum = (num : number) => {
|
||||
state.globalNum = num
|
||||
}
|
||||
// 新增:设置登录状态的方法
|
||||
export const setIsLoggedIn = (val : boolean) => {
|
||||
state.isLoggedIn = val
|
||||
}
|
||||
// 定义全局设置用户信息的方法
|
||||
export const setUserProfile = (profile : UserProfile) => {
|
||||
state.userProfile = profile
|
||||
}
|
||||
|
||||
// 获取当前用户信息(含补全 profile)
|
||||
export async function getCurrentUser() : Promise<UserProfile | null> {
|
||||
try {
|
||||
await supaReady
|
||||
} catch (_) {}
|
||||
|
||||
const sessionInfo = supa.getSession()
|
||||
if (sessionInfo.user == null) {
|
||||
state.userProfile = { username: '', email: '' }
|
||||
state.isLoggedIn = false // 未登录
|
||||
return null
|
||||
}
|
||||
const userId = sessionInfo.user?.getString("id")
|
||||
if (userId == null) {
|
||||
state.userProfile = { username: '', email: '' }
|
||||
state.isLoggedIn = false // 未登录
|
||||
return null
|
||||
} // 查询 ak_users 表补全 profile
|
||||
const res = await supa.from('ak_users').select('*', {}).eq('id', userId).execute()
|
||||
console.log(res)
|
||||
if (res.status >= 200 && res.status < 300 && (res.data != null)) {
|
||||
let user : UTSJSONObject | null = null;
|
||||
const data = res.data as any;
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length > 0) {
|
||||
user = data[0] as UTSJSONObject;
|
||||
}
|
||||
} else if (data != null) {
|
||||
user = data as UTSJSONObject;
|
||||
} console.log(user)
|
||||
if (user == null) {
|
||||
console.log('用户资料为空,尝试创建基础资料...') // 如果用户资料为空,尝试创建基础用户资料
|
||||
const sessionUser = sessionInfo.user
|
||||
if (sessionUser != null) {
|
||||
const createdProfile = await ensureUserProfile(sessionUser)
|
||||
if (createdProfile != null) {
|
||||
state.userProfile = createdProfile
|
||||
state.isLoggedIn = true
|
||||
return createdProfile
|
||||
} else {
|
||||
console.error('创建用户资料失败')
|
||||
state.userProfile = { username: '', email: '' }
|
||||
state.isLoggedIn = false
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
console.error('会话用户信息为空')
|
||||
state.userProfile = { username: '', email: '' }
|
||||
state.isLoggedIn = false
|
||||
return null
|
||||
}
|
||||
}
|
||||
console.log(user)
|
||||
// 直接用 getString/getNumber,无需兜底属性
|
||||
const profile : UserProfile = {
|
||||
id: user.getString('id'),
|
||||
username: user.getString('username') ?? "",
|
||||
email: user.getString('email') ?? "",
|
||||
gender: user.getString('gender'),
|
||||
birthday: user.getString('birthday'),
|
||||
height_cm: user.getNumber('height_cm'),
|
||||
weight_kg: user.getNumber('weight_kg'),
|
||||
bio: user.getString('bio'),
|
||||
avatar_url: user.getString('avatar_url'),
|
||||
preferred_language: user.getString('preferred_language'),
|
||||
role: user.getString('role'),
|
||||
school_id: user.getString('school_id'),
|
||||
grade_id: user.getString('grade_id'),
|
||||
class_id: user.getString('class_id')
|
||||
}
|
||||
state.userProfile = profile
|
||||
state.isLoggedIn = true // 登录成功
|
||||
return profile
|
||||
} else {
|
||||
state.userProfile = { username: '', email: '' }
|
||||
state.isLoggedIn = false // 未登录
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 登出并清空用户信息
|
||||
export function logout() {
|
||||
supa.signOut()
|
||||
state.userProfile = { username: '', email: '' }
|
||||
state.isLoggedIn = false // 登出
|
||||
}
|
||||
|
||||
// 获取当前用户ID(优先级:state.userProfile.id > session > localStorage)
|
||||
export function getCurrentUserId() : string {
|
||||
try {
|
||||
const profile = state.userProfile
|
||||
if (profile != null && profile.id != null) {
|
||||
const profileId = profile.id
|
||||
if (profileId != null) {
|
||||
return profileId
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
if (session != null) {
|
||||
const curuser = session.user
|
||||
const userId = curuser?.getString('id')
|
||||
if (userId != null) return userId
|
||||
}
|
||||
} catch (e) { }
|
||||
return ''
|
||||
}
|
||||
|
||||
// 获取当前用户的class_id
|
||||
export function getCurrentUserClassId() : string | null {
|
||||
try {
|
||||
const profile = state.userProfile
|
||||
if (profile != null && profile.class_id != null) {
|
||||
return profile.class_id
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取用户class_id失败:', e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// User store API for component compatibility
|
||||
export function getUserStore() {
|
||||
return {
|
||||
getUserId() : string | null {
|
||||
const sessionInfo = supa.getSession()
|
||||
return sessionInfo.user?.getString("id") ?? null
|
||||
},
|
||||
|
||||
getUserName() : string | null {
|
||||
return state.userProfile?.username ?? null
|
||||
},
|
||||
|
||||
getUserRole() : string | null {
|
||||
// Default role logic - can be enhanced based on your needs
|
||||
const sessionInfo = supa.getSession()
|
||||
if (sessionInfo.user == null) return null
|
||||
|
||||
// You can add role detection logic here
|
||||
// For now, return a default role
|
||||
return 'teacher' // or determine from user profile/database
|
||||
},
|
||||
|
||||
getProfile() : UserProfile | null {
|
||||
return state.userProfile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 设备状态管理方法 ==========
|
||||
|
||||
/**
|
||||
* 设置设备加载状态
|
||||
*/
|
||||
export const setDeviceLoading = (loading : boolean) => {
|
||||
state.deviceState.isLoading = loading
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置设备列表
|
||||
*/
|
||||
export const setDevices = (devices : Array<DeviceInfo>) => {
|
||||
state.deviceState.devices = devices
|
||||
state.deviceState.lastUpdated = Date.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加设备到列表
|
||||
*/
|
||||
export const addDevice = (device : DeviceInfo) => {
|
||||
const existingIndex = state.deviceState.devices.findIndex(d => d.id === device.id)
|
||||
if (existingIndex >= 0) {
|
||||
// 更新现有设备
|
||||
state.deviceState.devices[existingIndex] = device
|
||||
} else {
|
||||
// 添加新设备
|
||||
state.deviceState.devices.push(device)
|
||||
}
|
||||
state.deviceState.lastUpdated = Date.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从列表中移除设备
|
||||
*/
|
||||
export const removeDevice = (deviceId : string) => {
|
||||
const index = state.deviceState.devices.findIndex(d => d.id === deviceId)
|
||||
if (index >= 0) {
|
||||
state.deviceState.devices.splice(index, 1)
|
||||
// 如果移除的是当前设备,清空当前设备
|
||||
if (state.deviceState.currentDevice?.id === deviceId) {
|
||||
state.deviceState.currentDevice = null
|
||||
}
|
||||
state.deviceState.lastUpdated = Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新设备信息
|
||||
*/
|
||||
export const updateDevice = (device : DeviceInfo) => {
|
||||
const index = state.deviceState.devices.findIndex(d => d.id === device.id)
|
||||
if (index >= 0) {
|
||||
state.deviceState.devices[index] = device
|
||||
// 如果更新的是当前设备,也更新当前设备
|
||||
if (state.deviceState.currentDevice?.id === device.id) {
|
||||
state.deviceState.currentDevice = device
|
||||
}
|
||||
state.deviceState.lastUpdated = Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前选中的设备
|
||||
*/
|
||||
export const setCurrentDevice = (device : DeviceInfo | null) => {
|
||||
state.deviceState.currentDevice = device
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据设备ID获取设备信息
|
||||
*/
|
||||
export const getDeviceById = (deviceId : string) : DeviceInfo | null => {
|
||||
return state.deviceState.devices.find(d => d.id === deviceId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取在线设备列表
|
||||
*/
|
||||
export const getOnlineDevices = () : Array<DeviceInfo> => {
|
||||
return state.deviceState.devices.filter(d => d.status === 'online')
|
||||
}
|
||||
|
||||
/**
|
||||
* 从服务器加载设备列表
|
||||
*/
|
||||
export const loadDevices = async (forceRefresh : boolean) : Promise<boolean> => {
|
||||
const userId = getCurrentUserId()
|
||||
if (userId == null || userId === '') {
|
||||
console.log('用户未登录,无法加载设备列表')
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果不是强制刷新且数据较新(5分钟内),直接返回
|
||||
const now = Date.now()
|
||||
const lastUpdated = state.deviceState.lastUpdated
|
||||
if (forceRefresh == false && lastUpdated != null && (now - lastUpdated < 5 * 60 * 1000)) {
|
||||
console.log('设备数据较新,跳过刷新')
|
||||
return true
|
||||
}
|
||||
setDeviceLoading(true)
|
||||
try {
|
||||
const result = await SenseDataService.getDevices({ user_id: userId })
|
||||
if (result.error === null && result.data != null) {
|
||||
const devices = result.data as Array<DeviceInfo>
|
||||
setDevices(devices)
|
||||
console.log(`加载设备列表成功,共${devices.length}个设备`)
|
||||
return true
|
||||
} else {
|
||||
console.log('加载设备列表失败:', result.error?.message ?? '未知错误')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('加载设备列表异常:', error)
|
||||
return false
|
||||
} finally {
|
||||
setDeviceLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从服务器加载设备列表 - 带默认参数的重载版本
|
||||
*/
|
||||
export const loadDevicesWithDefault = async () : Promise<boolean> => {
|
||||
return await loadDevices(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定新设备
|
||||
*/
|
||||
export const bindNewDevice = async (deviceData : UTSJSONObject) : Promise<boolean> => {
|
||||
const userId = getCurrentUserId()
|
||||
if (userId == null) {
|
||||
console.log('用户未登录,无法绑定设备')
|
||||
return false
|
||||
}
|
||||
|
||||
// 确保设备数据中包含用户ID
|
||||
deviceData.set('user_id', userId)
|
||||
try {
|
||||
const result = await SenseDataService.bindDevice(deviceData)
|
||||
if (result.error === null && result.data != null) {
|
||||
// 添加到本地状态
|
||||
addDevice(result.data as DeviceInfo)
|
||||
const deviceName = (result.data as DeviceInfo).device_name ?? '未知设备'
|
||||
console.log('设备绑定成功:', deviceName)
|
||||
return true
|
||||
} else {
|
||||
console.log('设备绑定失败:', result.error?.message ?? '未知错误')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('设备绑定异常:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑设备
|
||||
*/
|
||||
export const unbindDevice = async (deviceId : string) : Promise<boolean> => {
|
||||
try {
|
||||
const result = await SenseDataService.unbindDevice(deviceId)
|
||||
if (result.error === null) {
|
||||
// 从本地状态中移除
|
||||
removeDevice(deviceId)
|
||||
console.log('设备解绑成功')
|
||||
return true
|
||||
} else {
|
||||
console.log('设备解绑失败:', result.error?.message ?? '未知错误')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('设备解绑异常:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新设备配置
|
||||
*/
|
||||
export const updateDeviceConfig = async (deviceId : string, configData : UTSJSONObject) : Promise<boolean> => {
|
||||
try {
|
||||
const result = await SenseDataService.updateDevice(deviceId, configData)
|
||||
if (result.error === null && result.data != null) {
|
||||
// 更新本地状态
|
||||
updateDevice(result.data as DeviceInfo)
|
||||
console.log('设备配置更新成功')
|
||||
return true
|
||||
} else {
|
||||
console.log('设备配置更新失败:', result.error?.message ?? '未知错误')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('设备配置更新异常:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 设备管理 API ==========
|
||||
|
||||
/**
|
||||
* 获取设备管理相关的API
|
||||
*/
|
||||
export function getDeviceStore() {
|
||||
return {
|
||||
// 获取设备状态
|
||||
getDevices() : Array<DeviceInfo> {
|
||||
return state.deviceState.devices
|
||||
},
|
||||
|
||||
getCurrentDevice() : DeviceInfo | null {
|
||||
return state.deviceState.currentDevice
|
||||
},
|
||||
|
||||
isLoading() : boolean {
|
||||
return state.deviceState.isLoading
|
||||
},
|
||||
getLastUpdated() : number | null {
|
||||
return state.deviceState.lastUpdated
|
||||
},
|
||||
|
||||
// 设备操作方法
|
||||
async loadDevices(forceRefresh : boolean) : Promise<boolean> {
|
||||
return await loadDevices(forceRefresh)
|
||||
},
|
||||
|
||||
async refreshDevices() : Promise<boolean> {
|
||||
return await loadDevicesWithDefault()
|
||||
},
|
||||
|
||||
async bindDevice(deviceData : UTSJSONObject) : Promise<boolean> {
|
||||
return await bindNewDevice(deviceData)
|
||||
},
|
||||
|
||||
async unbindDevice(deviceId : string) : Promise<boolean> {
|
||||
return await unbindDevice(deviceId)
|
||||
},
|
||||
|
||||
async updateDevice(deviceId : string, configData : UTSJSONObject) : Promise<boolean> {
|
||||
return await updateDeviceConfig(deviceId, configData)
|
||||
},
|
||||
|
||||
// 设备查询方法
|
||||
getDeviceById(deviceId : string) : DeviceInfo | null {
|
||||
return getDeviceById(deviceId)
|
||||
},
|
||||
|
||||
getOnlineDevices() : Array<DeviceInfo> {
|
||||
return getOnlineDevices()
|
||||
},
|
||||
|
||||
// 设备选择
|
||||
setCurrentDevice(device : DeviceInfo | null) {
|
||||
setCurrentDevice(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
435
utils/trainingRealtimeService.uts
Normal file
435
utils/trainingRealtimeService.uts
Normal file
@@ -0,0 +1,435 @@
|
||||
import supa, { supaReady } from '@/components/supadb/aksupainstance.uts'
|
||||
import AkSupaRealtime from '@/components/supadb/aksuparealtime.uts'
|
||||
import { WS_URL, SUPA_KEY } from '@/ak/config.uts'
|
||||
|
||||
export type TrainingStreamEvent = {
|
||||
training_id: string
|
||||
event_type: string
|
||||
class_id?: string | null
|
||||
student_id?: string | null
|
||||
device_id?: string | null
|
||||
ack?: boolean | null
|
||||
status?: string | null
|
||||
metrics?: UTSJSONObject | null
|
||||
payload?: UTSJSONObject | null
|
||||
ingest_source?: string | null
|
||||
ingest_note?: string | null
|
||||
recorded_at?: string | null
|
||||
ingested_at?: string | null
|
||||
}
|
||||
|
||||
export type TrainingRealtimeHandlers = {
|
||||
onAck?: (event: TrainingStreamEvent) => void
|
||||
onLiveMetrics?: (event: TrainingStreamEvent) => void
|
||||
onStateChange?: (event: TrainingStreamEvent) => void
|
||||
onEvent?: (event: TrainingStreamEvent) => void
|
||||
onError?: (err: any) => void
|
||||
}
|
||||
|
||||
export type TrainingRealtimeSubscription = {
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export type TrainingRealtimeSubscriptionParams = {
|
||||
trainingId: string
|
||||
classId?: string | null
|
||||
}
|
||||
|
||||
type SubscriptionGuard = {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
type ActiveSubscription = {
|
||||
params: TrainingRealtimeSubscriptionParams
|
||||
handlers: TrainingRealtimeHandlers
|
||||
guard: SubscriptionGuard
|
||||
}
|
||||
|
||||
async function ensureSupaSession(): Promise<void> {
|
||||
try {
|
||||
await supaReady
|
||||
} catch (err) {
|
||||
console.error('Supabase session initialization error', err)
|
||||
}
|
||||
}
|
||||
|
||||
function extractRecord(payload: any): UTSJSONObject | null {
|
||||
try {
|
||||
if (payload == null) return null
|
||||
const wrapper = payload as UTSJSONObject
|
||||
const directNew = wrapper.get('new') as UTSJSONObject | null
|
||||
if (directNew != null) return directNew
|
||||
const record = wrapper.get('record') as UTSJSONObject | null
|
||||
if (record != null) return record
|
||||
const dataSection = wrapper.get('data') as UTSJSONObject | null
|
||||
if (dataSection != null) {
|
||||
const dataRecord = dataSection.get('record') as UTSJSONObject | null
|
||||
if (dataRecord != null) return dataRecord
|
||||
const dataNew = dataSection.get('new') as UTSJSONObject | null
|
||||
if (dataNew != null) return dataNew
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('extractRecord error', err)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function toStreamEvent(row: UTSJSONObject | null): TrainingStreamEvent | null {
|
||||
if (row == null) return null
|
||||
try {
|
||||
const trainingId = row.getString('training_id') as string | null
|
||||
const eventType = row.getString('event_type') as string | null
|
||||
if (trainingId == null || trainingId == '' || eventType == null || eventType == '') {
|
||||
return null
|
||||
}
|
||||
const studentId = row.getString('student_id') as string | null
|
||||
const classId = row.getString('class_id') as string | null
|
||||
const deviceId = row.getString('device_id') as string | null
|
||||
const ackRaw = row.get('ack')
|
||||
const status = row.getString('status') as string | null
|
||||
const metrics = row.get('metrics') as UTSJSONObject | null
|
||||
const payload = row.get('payload') as UTSJSONObject | null
|
||||
const ingestSource = row.getString('ingest_source') as string | null
|
||||
const ingestNote = row.getString('ingest_note') as string | null
|
||||
const recordedAt = row.getString('recorded_at') as string | null
|
||||
const ingestedAt = row.getString('ingested_at') as string | null
|
||||
const ack = typeof ackRaw == 'boolean' ? (ackRaw as boolean) : null
|
||||
return {
|
||||
training_id: trainingId,
|
||||
event_type: eventType,
|
||||
class_id: classId ?? null,
|
||||
student_id: studentId ?? null,
|
||||
device_id: deviceId ?? null,
|
||||
ack: ack,
|
||||
status: status ?? null,
|
||||
metrics: metrics ?? null,
|
||||
payload: payload ?? null,
|
||||
ingest_source: ingestSource ?? null,
|
||||
ingest_note: ingestNote ?? null,
|
||||
recorded_at: recordedAt ?? null,
|
||||
ingested_at: ingestedAt ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('toStreamEvent error', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export class TrainingRealtimeService {
|
||||
private static rt: AkSupaRealtime | null = null
|
||||
private static initializing: Promise<void> | null = null
|
||||
private static initialized: boolean = false
|
||||
private static subscriptions: Array<ActiveSubscription> = []
|
||||
private static reconnectAttempts: number = 0
|
||||
private static reconnectTimer: number | null = null
|
||||
private static reconnecting: boolean = false
|
||||
|
||||
private static async waitForRealtimeOpen(timeoutMs: number = 5000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (true) {
|
||||
if (this.rt != null && this.rt.isOpen == true) {
|
||||
return
|
||||
}
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error('Realtime socket not ready')
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 50)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private static clearReconnectTimer(): void {
|
||||
const timer = this.reconnectTimer
|
||||
if (timer != null) {
|
||||
clearTimeout(timer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private static handleSocketClose(origin: string | null): void {
|
||||
this.initialized = false
|
||||
this.rt = null
|
||||
if (this.subscriptions.length == 0) {
|
||||
this.clearReconnectTimer()
|
||||
this.reconnectAttempts = 0
|
||||
return
|
||||
}
|
||||
if (this.reconnecting == true) {
|
||||
return
|
||||
}
|
||||
console.warn('[TrainingRealtimeService] realtime closed, scheduling reconnect', origin ?? 'unknown')
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
|
||||
private static async scheduleReconnect(): Promise<void> {
|
||||
if (this.reconnectTimer != null) return
|
||||
if (this.subscriptions.every((sub) => sub.guard.active !== true)) {
|
||||
this.subscriptions = []
|
||||
this.reconnectAttempts = 0
|
||||
return
|
||||
}
|
||||
const attempt = this.reconnectAttempts + 1
|
||||
this.reconnectAttempts = attempt
|
||||
const baseDelay = Math.min(Math.pow(2, attempt - 1) * 1000, 10000)
|
||||
const jitter = Math.floor(Math.random() * 200)
|
||||
const delay = baseDelay + jitter
|
||||
console.log('[TrainingRealtimeService] reconnect scheduled in', delay, 'ms (attempt', attempt, ')')
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
const runner = async () => {
|
||||
this.reconnectTimer = null
|
||||
if (this.subscriptions.every((sub) => sub.guard.active !== true)) {
|
||||
this.subscriptions = []
|
||||
this.reconnectAttempts = 0
|
||||
return
|
||||
}
|
||||
this.reconnecting = true
|
||||
try {
|
||||
await this.ensureRealtime()
|
||||
await this.waitForRealtimeOpen(3000)
|
||||
this.reconnectAttempts = 0
|
||||
await this.resubscribeAll()
|
||||
} catch (err) {
|
||||
console.error('reconnect attempt failed', err)
|
||||
this.scheduleReconnect()
|
||||
} finally {
|
||||
this.reconnecting = false
|
||||
}
|
||||
}
|
||||
runner().catch((err) => {
|
||||
console.error('reconnect timer runner error', err)
|
||||
})
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private static async resubscribeAll(): Promise<void> {
|
||||
let hadFailure = false
|
||||
for (let i = 0; i < this.subscriptions.length; i++) {
|
||||
const record = this.subscriptions[i]
|
||||
if (record.guard.active !== true) continue
|
||||
try {
|
||||
await this.performSubscription(record)
|
||||
} catch (err) {
|
||||
console.error('resubscribe error', err)
|
||||
record.handlers?.onError?.(err)
|
||||
hadFailure = true
|
||||
}
|
||||
}
|
||||
if (hadFailure) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private static removeSubscription(record: ActiveSubscription): void {
|
||||
const index = this.subscriptions.indexOf(record)
|
||||
if (index >= 0) {
|
||||
this.subscriptions.splice(index, 1)
|
||||
}
|
||||
if (this.subscriptions.length == 0) {
|
||||
this.clearReconnectTimer()
|
||||
this.reconnectAttempts = 0
|
||||
}
|
||||
}
|
||||
|
||||
private static async performSubscription(record: ActiveSubscription): Promise<void> {
|
||||
if (record.guard.active !== true) return
|
||||
const params = record.params
|
||||
const handlers = record.handlers
|
||||
const filter = `training_id=eq.${params.trainingId}`
|
||||
const maxAttempts = 2
|
||||
let attempt = 0
|
||||
while (attempt < maxAttempts) {
|
||||
attempt += 1
|
||||
if (record.guard.active !== true) return
|
||||
try {
|
||||
if (this.rt == null || this.rt.isOpen !== true) {
|
||||
await this.ensureRealtime()
|
||||
await this.waitForRealtimeOpen(2000)
|
||||
}
|
||||
const realtime = this.rt
|
||||
if (realtime == null) {
|
||||
throw new Error('Realtime client not initialized')
|
||||
}
|
||||
console.log('[TrainingRealtimeService] realtime ready, preparing subscription', params)
|
||||
console.log('[TrainingRealtimeService] subscribing to training_stream_events', filter, 'attempt', attempt)
|
||||
realtime.subscribePostgresChanges({
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'training_stream_events',
|
||||
filter: filter,
|
||||
onChange: (payload: any) => {
|
||||
if (record.guard.active !== true) return
|
||||
const recordPayload = extractRecord(payload)
|
||||
const event = toStreamEvent(recordPayload)
|
||||
if (event == null) return
|
||||
console.log('[TrainingRealtimeService] incoming event', event?.event_type ?? 'unknown', event?.training_id ?? '')
|
||||
if (params.classId != null && params.classId !== '') {
|
||||
if ((event.class_id ?? '') !== params.classId) {
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
handlers?.onEvent?.(event)
|
||||
switch (event.event_type) {
|
||||
case 'ack':
|
||||
case 'ack_pending':
|
||||
case 'ack_fail':
|
||||
handlers?.onAck?.(event)
|
||||
break
|
||||
case 'telemetry':
|
||||
case 'metrics':
|
||||
handlers?.onLiveMetrics?.(event)
|
||||
break
|
||||
case 'state':
|
||||
case 'session':
|
||||
case 'summary':
|
||||
handlers?.onStateChange?.(event)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch (handlerErr) {
|
||||
console.error('training event handler error', handlerErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log('[TrainingRealtimeService] subscription ready', params.trainingId)
|
||||
return
|
||||
} catch (subscribeErr) {
|
||||
console.error('performSubscription error', subscribeErr, 'attempt', attempt)
|
||||
if (attempt >= maxAttempts) {
|
||||
throw subscribeErr
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 200)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async ensureRealtime(): Promise<void> {
|
||||
if (this.rt != null && this.rt.isOpen == true) {
|
||||
this.initialized = true
|
||||
return
|
||||
}
|
||||
if (this.initializing != null) {
|
||||
await this.initializing
|
||||
return
|
||||
}
|
||||
await ensureSupaSession()
|
||||
let token: string | null = null
|
||||
try {
|
||||
const session = supa.getSession()
|
||||
if (session != null) {
|
||||
token = session.session?.access_token ?? null
|
||||
}
|
||||
} catch (_) { }
|
||||
|
||||
let resolveReady: ((value: void) => void) | null = null
|
||||
let rejectReady: ((reason: any) => void) | null = null
|
||||
const readyPromise = new Promise<void>((resolve, reject) => {
|
||||
resolveReady = resolve as (value: void) => void
|
||||
rejectReady = reject
|
||||
})
|
||||
|
||||
this.initializing = readyPromise
|
||||
const realtime = new AkSupaRealtime({
|
||||
url: WS_URL,
|
||||
channel: 'realtime:training_stream',
|
||||
apikey: SUPA_KEY,
|
||||
token: token,
|
||||
onMessage: (_data: any) => { },
|
||||
onOpen: (_res: any) => {
|
||||
this.initialized = true
|
||||
this.reconnectAttempts = 0
|
||||
this.clearReconnectTimer()
|
||||
this.reconnecting = false
|
||||
const resolver = resolveReady
|
||||
resolveReady = null
|
||||
rejectReady = null
|
||||
this.initializing = null
|
||||
resolver?.()
|
||||
},
|
||||
onError: (err: any) => {
|
||||
if (resolveReady != null) {
|
||||
const rejector = rejectReady
|
||||
resolveReady = null
|
||||
rejectReady = null
|
||||
this.initializing = null
|
||||
rejector?.(err)
|
||||
}
|
||||
},
|
||||
onClose: (_res: any) => {
|
||||
if (resolveReady != null) {
|
||||
const rejector = rejectReady
|
||||
resolveReady = null
|
||||
rejectReady = null
|
||||
this.initializing = null
|
||||
rejector?.(new Error('Realtime connection closed before ready'))
|
||||
} else {
|
||||
this.handleSocketClose('close')
|
||||
}
|
||||
}
|
||||
})
|
||||
this.rt = realtime
|
||||
this.rt?.connect()
|
||||
try {
|
||||
await readyPromise
|
||||
} finally {
|
||||
this.initializing = null
|
||||
}
|
||||
}
|
||||
|
||||
static async subscribeTrainingSession(params: TrainingRealtimeSubscriptionParams, handlers: TrainingRealtimeHandlers): Promise<TrainingRealtimeSubscription> {
|
||||
if (params.trainingId == null || params.trainingId == '') {
|
||||
throw new Error('trainingId is required')
|
||||
}
|
||||
const guard: SubscriptionGuard = { active: true }
|
||||
const record: ActiveSubscription = {
|
||||
params: {
|
||||
trainingId: params.trainingId,
|
||||
classId: params.classId ?? null
|
||||
},
|
||||
handlers: handlers,
|
||||
guard: guard
|
||||
}
|
||||
this.subscriptions.push(record)
|
||||
try {
|
||||
await this.performSubscription(record)
|
||||
} catch (err) {
|
||||
console.error('subscribeTrainingSession failed', err)
|
||||
guard.active = false
|
||||
this.removeSubscription(record)
|
||||
handlers?.onError?.(err)
|
||||
return { dispose: () => { } }
|
||||
}
|
||||
console.log('[TrainingRealtimeService] subscription active', params.trainingId)
|
||||
return {
|
||||
dispose: () => {
|
||||
if (guard.active !== true) return
|
||||
guard.active = false
|
||||
this.removeSubscription(record)
|
||||
console.log('[TrainingRealtimeService] subscription disposed', params.trainingId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static closeRealtime(): void {
|
||||
try {
|
||||
if (this.rt != null) {
|
||||
this.rt.close({ code: 1000, reason: 'manual close' })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('closeRealtime error', err)
|
||||
} finally {
|
||||
this.rt = null
|
||||
this.initialized = false
|
||||
this.initializing = null
|
||||
this.reconnecting = false
|
||||
this.clearReconnectTimer()
|
||||
this.reconnectAttempts = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TrainingRealtimeService
|
||||
159
utils/utils.uts
Normal file
159
utils/utils.uts
Normal file
@@ -0,0 +1,159 @@
|
||||
// 通用 UTSJSONObject 转任意 type 的函数
|
||||
// UTS 2024
|
||||
|
||||
import i18n from '../i18n/index.uts';
|
||||
|
||||
/**
|
||||
* 切换应用语言设置
|
||||
* @param locale 语言代码,如 'zh-CN' 或 'en-US'
|
||||
*/
|
||||
export function switchLocale(locale: string) {
|
||||
// 设置存储
|
||||
uni.setStorageSync('uVueI18nLocale', locale);
|
||||
|
||||
// 设置 i18n 语言
|
||||
try {
|
||||
i18n.global.locale.value = locale;
|
||||
} catch (err) {
|
||||
console.error('Failed to switch locale:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前语言设置
|
||||
* @returns 当前语言代码
|
||||
*/
|
||||
export function getCurrentLocale(): string {
|
||||
const locale = uni.getStorageSync('uVueI18nLocale') as string;
|
||||
if (locale == null || locale == '') {
|
||||
return 'zh-CN';
|
||||
}
|
||||
return locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保语言设置正确初始化
|
||||
*/
|
||||
export function ensureLocaleInitialized() {
|
||||
const currentLocale = getCurrentLocale();
|
||||
if (currentLocale == null || currentLocale == '') {
|
||||
switchLocale('zh-CN');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 将任意错误对象转换为标准的 UniError
|
||||
* @param error 任意类型的错误对象
|
||||
* @param defaultMessage 默认错误消息
|
||||
* @returns 标准化的 UniError 对象
|
||||
*/
|
||||
export function toUniError(error: any, defaultMessage: string = '操作失败'): UniError {
|
||||
// 如果已经是 UniError,直接返回
|
||||
if (error instanceof UniError) {
|
||||
return error
|
||||
}
|
||||
let errorMessage = defaultMessage
|
||||
let errorCode = -1
|
||||
|
||||
try {
|
||||
// 如果是普通 Error 对象
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message != null && error.message != '' ? error.message : defaultMessage
|
||||
}
|
||||
// 如果是字符串
|
||||
else if (typeof error === 'string') {
|
||||
errorMessage = error
|
||||
} // 如果是对象,尝试提取错误信息
|
||||
else if (error != null && typeof error === 'object') {
|
||||
const errorObj = error as UTSJSONObject
|
||||
let message: string = ''
|
||||
|
||||
// 逐个检查字段,避免使用 || 操作符
|
||||
if (errorObj['message'] != null) {
|
||||
const msgValue = errorObj['message']
|
||||
if (typeof msgValue === 'string') {
|
||||
message = msgValue
|
||||
}
|
||||
} else if (errorObj['errMsg'] != null) {
|
||||
const msgValue = errorObj['errMsg']
|
||||
if (typeof msgValue === 'string') {
|
||||
message = msgValue
|
||||
}
|
||||
} else if (errorObj['error'] != null) {
|
||||
const msgValue = errorObj['error']
|
||||
if (typeof msgValue === 'string') {
|
||||
message = msgValue
|
||||
}
|
||||
} else if (errorObj['details'] != null) {
|
||||
const msgValue = errorObj['details']
|
||||
if (typeof msgValue === 'string') {
|
||||
message = msgValue
|
||||
}
|
||||
} else if (errorObj['msg'] != null) {
|
||||
const msgValue = errorObj['msg']
|
||||
if (typeof msgValue === 'string') {
|
||||
message = msgValue
|
||||
}
|
||||
}
|
||||
|
||||
if (message != '') {
|
||||
errorMessage = message
|
||||
}
|
||||
|
||||
// 尝试提取错误码
|
||||
let code: number = 0
|
||||
if (errorObj['code'] != null) {
|
||||
const codeValue = errorObj['code']
|
||||
if (typeof codeValue === 'number') {
|
||||
code = codeValue
|
||||
}
|
||||
} else if (errorObj['errCode'] != null) {
|
||||
const codeValue = errorObj['errCode']
|
||||
if (typeof codeValue === 'number') {
|
||||
code = codeValue
|
||||
}
|
||||
} else if (errorObj['status'] != null) {
|
||||
const codeValue = errorObj['status']
|
||||
if (typeof codeValue === 'number') {
|
||||
code = codeValue
|
||||
}
|
||||
}
|
||||
|
||||
if (code != 0) {
|
||||
errorCode = code
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error converting to UniError:', e)
|
||||
errorMessage = defaultMessage
|
||||
}
|
||||
// 创建标准 UniError
|
||||
const uniError = new UniError('AppError', errorCode, errorMessage)
|
||||
return uniError
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应式状态管理
|
||||
* @returns 响应式状态对象
|
||||
*/
|
||||
export function responsiveState() {
|
||||
const screenInfo = uni.getSystemInfoSync()
|
||||
const screenWidth = screenInfo.screenWidth
|
||||
|
||||
return {
|
||||
isLargeScreen: screenWidth >= 768,
|
||||
isSmallScreen: screenWidth < 576,
|
||||
screenWidth: screenWidth,
|
||||
cardColumns: screenWidth >= 768 ? 3 : screenWidth >= 576 ? 2 : 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容 UTS Android 的剪贴板写入
|
||||
* @param text 要写入剪贴板的文本
|
||||
*/
|
||||
export function setClipboard(text: string): void {
|
||||
// #ifdef WEB
|
||||
uni.setClipboardData({ data: text });
|
||||
// #endif
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user