Initial commit of akmon project

This commit is contained in:
2026-01-20 08:04:15 +08:00
commit 77a2bab985
1309 changed files with 343305 additions and 0 deletions

View 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 平台上获得了真正的类型转换支持。

View 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
View 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
View 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
View 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
}

View 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

View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}
}
}

View 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
View 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
}