Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
@@ -0,0 +1,313 @@
import { ref, onMounted, onUnmounted } from 'vue'
const DB_NAME = 'zq-smart-table-offline'
const DB_VERSION = 1
const STORE_TABLE_DATA = 'tableData'
const STORE_PENDING_OPS = 'pendingOps'
export interface PendingOperation {
id: string
type: 'updateCell' | 'createRecord' | 'deleteRecord'
tableId: string
recordId: string
payload: Record<string, any>
timestamp: number
}
let _db: IDBDatabase | null = null
function openDB(): Promise<IDBDatabase> {
if (_db) return Promise.resolve(_db)
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_TABLE_DATA)) {
db.createObjectStore(STORE_TABLE_DATA, { keyPath: 'id' })
}
if (!db.objectStoreNames.contains(STORE_PENDING_OPS)) {
const store = db.createObjectStore(STORE_PENDING_OPS, { keyPath: 'id' })
store.createIndex('byTable', 'tableId', { unique: false })
store.createIndex('byTimestamp', 'timestamp', { unique: false })
}
}
req.onsuccess = () => {
_db = req.result
resolve(_db)
}
req.onerror = () => reject(req.error)
})
}
function promisify<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
function waitTx(tx: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
function genId(): string {
return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
}
// ==================== Module-level exports (no Vue dependency) ====================
export async function cacheTableData(tableId: string, data: any) {
try {
const db = await openDB()
const store = db.transaction(STORE_TABLE_DATA, 'readwrite').objectStore(STORE_TABLE_DATA)
await promisify(store.put({ id: tableId, data, cachedAt: Date.now() }))
} catch {
// silently fail
}
}
export async function getCachedTableData(tableId: string): Promise<any | null> {
try {
const db = await openDB()
const store = db.transaction(STORE_TABLE_DATA, 'readonly').objectStore(STORE_TABLE_DATA)
const result = await promisify(store.get(tableId))
if (!result) return null
const maxAge = 24 * 60 * 60 * 1000
if (Date.now() - result.cachedAt > maxAge) return null
return result.data
} catch {
return null
}
}
export async function addPendingOp(op: Omit<PendingOperation, 'id' | 'timestamp'>) {
try {
const db = await openDB()
if (op.type === 'updateCell') {
const readTx = db.transaction(STORE_PENDING_OPS, 'readonly')
const readStore = readTx.objectStore(STORE_PENDING_OPS)
const existing: PendingOperation[] = await promisify(
readStore.index('byTable').getAll(op.tableId),
)
const toRemove = existing
.filter(
(prev) =>
prev.type === 'updateCell' &&
prev.recordId === op.recordId &&
prev.payload.fieldId === op.payload.fieldId,
)
.map((prev) => prev.id)
const writeTx = db.transaction(STORE_PENDING_OPS, 'readwrite')
const writeStore = writeTx.objectStore(STORE_PENDING_OPS)
for (const id of toRemove) {
writeStore.delete(id)
}
writeStore.put({ ...op, id: genId(), timestamp: Date.now() })
await waitTx(writeTx)
} else {
const tx = db.transaction(STORE_PENDING_OPS, 'readwrite')
const store = tx.objectStore(STORE_PENDING_OPS)
store.put({ ...op, id: genId(), timestamp: Date.now() })
await waitTx(tx)
}
} catch {
// silently fail
}
}
export async function getPendingOps(): Promise<PendingOperation[]> {
try {
const db = await openDB()
const store = db.transaction(STORE_PENDING_OPS, 'readonly').objectStore(STORE_PENDING_OPS)
return await promisify(store.index('byTimestamp').getAll())
} catch {
return []
}
}
export async function getPendingCount(): Promise<number> {
try {
const db = await openDB()
const store = db.transaction(STORE_PENDING_OPS, 'readonly').objectStore(STORE_PENDING_OPS)
return await promisify(store.count())
} catch {
return 0
}
}
export async function removePendingOps(ids: string[]) {
if (ids.length === 0) return
try {
const db = await openDB()
const tx = db.transaction(STORE_PENDING_OPS, 'readwrite')
const store = tx.objectStore(STORE_PENDING_OPS)
for (const id of ids) {
store.delete(id)
}
await waitTx(tx)
} catch {
// silently fail
}
}
export async function syncAllPendingOps(): Promise<number> {
const ops = await getPendingOps()
if (ops.length === 0) return 0
const {
updateCellApi,
batchUpdateCellsApi,
createRecordApi,
deleteRecordApi,
batchUpdateMultiRecordCellsApi,
} = await import('#/api/smart-table')
const cellOpsByTable: Record<string, { recordId: string; fieldId: string; value: unknown; opId: string }[]> = {}
const otherOps: PendingOperation[] = []
for (const op of ops) {
if (op.type === 'updateCell') {
if (!cellOpsByTable[op.tableId]) cellOpsByTable[op.tableId] = []
cellOpsByTable[op.tableId]!.push({
recordId: op.recordId,
fieldId: op.payload.fieldId as string,
value: op.payload.value,
opId: op.id,
})
} else {
otherOps.push(op)
}
}
let synced = 0
for (const [tableId, cellOps] of Object.entries(cellOpsByTable)) {
const byRecord: Record<string, Record<string, unknown>> = {}
for (const cop of cellOps) {
if (!byRecord[cop.recordId]) byRecord[cop.recordId] = {}
byRecord[cop.recordId]![cop.fieldId] = cop.value
}
const recordIds = Object.keys(byRecord)
try {
if (recordIds.length > 1) {
await batchUpdateMultiRecordCellsApi(
tableId,
recordIds.map((rid) => ({ record_id: rid, cells: byRecord[rid]! })),
)
} else if (recordIds.length === 1) {
const rid = recordIds[0]!
const cells = byRecord[rid]!
const fieldIds = Object.keys(cells)
if (fieldIds.length === 1) {
await updateCellApi(rid, fieldIds[0]!, cells[fieldIds[0]!])
} else {
await batchUpdateCellsApi(rid, cells)
}
}
await removePendingOps(cellOps.map((c) => c.opId))
synced += cellOps.length
} catch {
// keep pending ops for retry
}
}
for (const op of otherOps) {
try {
if (op.type === 'createRecord') {
await createRecordApi(op.tableId, op.payload.values)
} else if (op.type === 'deleteRecord') {
await deleteRecordApi(op.recordId)
}
await removePendingOps([op.id])
synced++
} catch {
// keep for retry
}
}
return synced
}
export async function clearOfflineCache(tableId?: string) {
try {
const db = await openDB()
const store = db.transaction(STORE_TABLE_DATA, 'readwrite').objectStore(STORE_TABLE_DATA)
if (tableId) {
await promisify(store.delete(tableId))
} else {
await promisify(store.clear())
}
} catch {
// silently fail
}
}
// ==================== Vue Composable (for component usage with lifecycle) ====================
export function useOfflineCache() {
const isOnline = ref(navigator.onLine)
const pendingCount = ref(0)
const syncing = ref(false)
async function handleOnline() {
isOnline.value = true
await doSync()
}
function handleOffline() {
isOnline.value = false
}
async function refreshCount() {
pendingCount.value = await getPendingCount()
}
async function doSync() {
if (syncing.value || !isOnline.value) return
syncing.value = true
try {
await syncAllPendingOps()
} finally {
syncing.value = false
await refreshCount()
}
}
onMounted(async () => {
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
try {
await openDB()
await refreshCount()
} catch {
// IndexedDB not available
}
})
onUnmounted(() => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
})
return {
isOnline,
pendingCount,
syncing,
cacheTableData,
getCachedTableData,
addPendingOp,
syncPendingOps: doSync,
clearCache: clearOfflineCache,
refreshCount,
}
}
@@ -0,0 +1,371 @@
/**
* 文件URL管理 Composable
* 用于管理文件的临时访问URL,支持缓存和自动刷新
*
* 支持两种访问模式:
* - 私有文件:需要临时访问令牌,URL会自动缓存和刷新
* - 公开文件:直接返回URL,无需认证
*/
import { ref, watch } from 'vue';
import { getFileUrlWithToken, getPublicFileUrl } from '#/api/core/file';
// 全局URL缓存(临时令牌URL)
const urlCache = new Map<string, { expiresAt: number; url: string }>();
// 全局 Blob ObjectURL 缓存(图片等二进制内容,长期有效)
const blobCache = new Map<string, string>();
// 正在进行的请求去重(避免同一 fileId 并发重复请求)
const pendingRequests = new Map<string, Promise<string>>();
// Blob 缓存最大条目数,防止内存无限增长
const BLOB_CACHE_MAX_SIZE = 500;
// 默认过期时间(秒)
const DEFAULT_EXPIRES_IN = 3600;
// 提前刷新时间(秒)- 在过期前5分钟刷新
const REFRESH_BEFORE = 300;
/**
* 清理过期的缓存
*/
function cleanupExpiredCache() {
const now = Date.now();
for (const [key, value] of urlCache.entries()) {
if (value.expiresAt < now) {
urlCache.delete(key);
}
}
}
/**
* 获取公开文件的URL(无需认证,同步返回)
* @param fileId 文件ID
* @returns string 文件访问URL
*/
export function getFileUrlPublic(fileId: string): string {
if (!fileId) return '';
return getPublicFileUrl(fileId);
}
/**
* 获取临时令牌URL(内部方法,不含 Blob 缓存)
*/
async function getTokenUrl(
fileId: string,
expiresIn: number = DEFAULT_EXPIRES_IN,
): Promise<string> {
const now = Date.now();
const cached = urlCache.get(fileId);
if (cached && cached.expiresAt - REFRESH_BEFORE * 1000 > now) {
return cached.url;
}
const result = await getFileUrlWithToken(fileId, expiresIn);
const expiresAt = new Date(result.expiresAt).getTime();
const url = `/basic-api${result.url}`;
urlCache.set(fileId, { url, expiresAt });
return url;
}
/**
* 获取文件的访问URL(带 Blob 缓存 + 令牌URL缓存)
*
* 缓存策略:
* 1. 如果 Blob 缓存命中(ObjectURL),直接返回,零网络请求
* 2. 否则获取临时令牌URLfetch 下载为 Blob,生成 ObjectURL 缓存
* 3. 如果 fetch 失败,降级返回临时令牌URL
* 4. 并发请求同一 fileId 会自动去重
*
* @param fileId 文件ID
* @param expiresIn 过期时间(秒)
* @returns Promise<string> 文件访问URL
*/
export async function getFileUrl(
fileId: string,
expiresIn: number = DEFAULT_EXPIRES_IN,
): Promise<string> {
if (!fileId) return '';
// 1. Blob 缓存命中,直接返回
const blobUrl = blobCache.get(fileId);
if (blobUrl) {
return blobUrl;
}
// 2. 去重:如果已有相同 fileId 的请求在进行中,等待它完成
const pending = pendingRequests.get(fileId);
if (pending) {
return pending;
}
// 3. 发起新请求
const request = (async () => {
try {
const tokenUrl = await getTokenUrl(fileId, expiresIn);
// 尝试 fetch 并缓存为 Blob ObjectURL
try {
const response = await fetch(tokenUrl);
if (response.ok) {
const blob = await response.blob();
// 限制缓存大小:超出时清理最早的条目
if (blobCache.size >= BLOB_CACHE_MAX_SIZE) {
const firstKey = blobCache.keys().next().value;
if (firstKey) {
const oldUrl = blobCache.get(firstKey);
if (oldUrl) URL.revokeObjectURL(oldUrl);
blobCache.delete(firstKey);
}
}
const objectUrl = URL.createObjectURL(blob);
blobCache.set(fileId, objectUrl);
return objectUrl;
}
} catch {
// fetch 失败,降级返回令牌URL
}
return tokenUrl;
} catch (error) {
console.error('获取文件URL失败:', error);
const cached = urlCache.get(fileId);
if (cached) return cached.url;
return '';
} finally {
pendingRequests.delete(fileId);
}
})();
pendingRequests.set(fileId, request);
return request;
}
/**
* 批量获取文件URL
* @param fileIds 文件ID数组
* @param expiresIn 过期时间(秒)
* @returns Promise<Map<string, string>> 文件ID到URL的映射
*/
export async function getFileUrls(
fileIds: string[],
expiresIn: number = DEFAULT_EXPIRES_IN,
): Promise<Map<string, string>> {
const result = new Map<string, string>();
const promises = fileIds.map(async (fileId) => {
const url = await getFileUrl(fileId, expiresIn);
result.set(fileId, url);
});
await Promise.all(promises);
return result;
}
/**
* 清除文件URL缓存
* @param fileId 可选,指定文件ID;不传则清除所有缓存
*/
export function clearFileUrlCache(fileId?: string) {
if (fileId) {
urlCache.delete(fileId);
const blobUrl = blobCache.get(fileId);
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
blobCache.delete(fileId);
}
} else {
urlCache.clear();
for (const url of blobCache.values()) {
URL.revokeObjectURL(url);
}
blobCache.clear();
}
}
/**
* useFileUrl Composable
* 用于在组件中响应式地获取文件URL
*
* @example
* ```vue
* <script setup>
* const { url, loading, refresh } = useFileUrl(fileId);
* </script>
* <template>
* <img v-if="!loading" :src="url" />
* </template>
* ```
*/
export function useFileUrl(
fileId: (() => string | undefined) | string | undefined,
options: {
expiresIn?: number;
immediate?: boolean;
} = {},
) {
const { expiresIn = DEFAULT_EXPIRES_IN, immediate = true } = options;
const url = ref('');
const loading = ref(false);
const error = ref<Error | null>(null);
const getFileIdValue = () => {
if (typeof fileId === 'function') {
return fileId();
}
return fileId;
};
async function refresh() {
const id = getFileIdValue();
if (!id) {
url.value = '';
return;
}
loading.value = true;
error.value = null;
try {
// 清除缓存以强制刷新
clearFileUrlCache(id);
url.value = await getFileUrl(id, expiresIn);
} catch (error_) {
error.value = error_ as Error;
console.error('获取文件URL失败:', error_);
} finally {
loading.value = false;
}
}
async function load() {
const id = getFileIdValue();
if (!id) {
url.value = '';
return;
}
loading.value = true;
error.value = null;
try {
url.value = await getFileUrl(id, expiresIn);
} catch (error_) {
error.value = error_ as Error;
console.error('获取文件URL失败:', error_);
} finally {
loading.value = false;
}
}
// 监听fileId变化
if (typeof fileId === 'function') {
watch(fileId, (newId) => {
if (newId) {
load();
} else {
url.value = '';
}
});
}
// 立即加载
if (immediate) {
load();
}
return {
url,
loading,
error,
refresh,
load,
};
}
/**
* useFileUrls Composable
* 用于批量获取多个文件的URL
*/
export function useFileUrls(
fileIds: (() => string[]) | string[],
options: {
expiresIn?: number;
immediate?: boolean;
} = {},
) {
const { expiresIn = DEFAULT_EXPIRES_IN, immediate = true } = options;
const urls = ref<Map<string, string>>(new Map());
const loading = ref(false);
const error = ref<Error | null>(null);
const getFileIdsValue = () => {
if (typeof fileIds === 'function') {
return fileIds();
}
return fileIds;
};
async function load() {
const ids = getFileIdsValue();
if (!ids || ids.length === 0) {
urls.value = new Map();
return;
}
loading.value = true;
error.value = null;
try {
urls.value = await getFileUrls(ids, expiresIn);
} catch (error_) {
error.value = error_ as Error;
console.error('批量获取文件URL失败:', error_);
} finally {
loading.value = false;
}
}
async function refresh() {
const ids = getFileIdsValue();
ids.forEach((id) => clearFileUrlCache(id));
await load();
}
// 监听fileIds变化
if (typeof fileIds === 'function') {
watch(fileIds, () => {
load();
});
}
// 立即加载
if (immediate) {
load();
}
/**
* 获取单个文件的URL
*/
function getUrl(fileId: string): string {
return urls.value.get(fileId) || '';
}
return {
urls,
loading,
error,
refresh,
load,
getUrl,
};
}
// 定期清理过期缓存(每5分钟)
setInterval(cleanupExpiredCache, 5 * 60 * 1000);
export default useFileUrl;
@@ -0,0 +1,404 @@
import type { WebSocketManager } from '#/api/core/websocket';
/**
* 消息通知 Composable
* 集成 WebSocket 实时推送、消息和公告 API
*/
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAccessStore } from '@vben/stores';
import {
getUnreadAnnouncementCountApi,
getUserAnnouncementListApi,
markAnnouncementReadApi,
} from '#/api/core/announcement';
import {
clearReadMessagesApi,
getMessageListApi,
getUnreadCountApi,
markAllAsReadApi,
markAsReadApi,
} from '#/api/core/message';
import { createNotificationWebSocket } from '#/api/core/websocket';
import { $t } from '#/locales';
// 通知项类型
export interface NotificationItem {
id: string;
avatar: string;
title: string;
message: string;
date: string;
isRead: boolean;
linkType?: string;
linkId?: string;
priority?: number;
isTop?: boolean;
senderId?: string;
senderName?: string;
}
// 公告项类型
export interface AnnouncementItem {
id: string;
title: string;
summary: string;
content: string;
date: string;
isRead: boolean;
priority: number;
isTop: boolean;
publisherName: string;
}
// WebSocket 连接状态
const wsConnected = ref(false);
let wsManagerInstance: null | WebSocketManager = null;
// 消息数据
const notifications = ref<NotificationItem[]>([]);
const messageUnreadCount = ref(0);
const unreadByType = ref<Record<string, number>>({});
// 公告数据
const announcements = ref<AnnouncementItem[]>([]);
const announcementUnreadCount = ref(0);
// 当前激活的 Tab
const activeTab = ref<'announcement' | 'chat' | 'message'>('message');
// 消息类型图标映射
const typeAvatarMap: Record<string, string> = {
system: 'https://avatar.vercel.sh/system?text=SYS',
workflow: 'https://avatar.vercel.sh/workflow?text=WF',
todo: 'https://avatar.vercel.sh/todo?text=TD',
announcement: 'https://avatar.vercel.sh/announcement?text=AN',
};
export function useNotification() {
const router = useRouter();
const accessStore = useAccessStore();
// 总未读数量
const totalUnreadCount = computed(
() => messageUnreadCount.value + announcementUnreadCount.value,
);
// 是否显示红点
const showDot = computed(() => totalUnreadCount.value > 0);
// 加载消息列表
async function loadMessages() {
try {
const res = await getMessageListApi({ page: 1, pageSize: 10 });
notifications.value = (res.items || []).map((msg) => ({
id: msg.id,
avatar: typeAvatarMap[msg.msg_type] ?? typeAvatarMap.system!,
title: msg.title,
message: msg.content,
date: formatDate(msg.created_at),
isRead: msg.status === 'read',
linkType: msg.link_type,
linkId: msg.link_id,
senderId: msg.sender_id,
senderName: msg.sender_name,
}));
} catch (error) {
console.error('加载消息失败:', error);
}
}
// 加载未读数量
async function loadUnreadCount() {
try {
const res = await getUnreadCountApi();
messageUnreadCount.value = res.total;
unreadByType.value = res.by_type;
} catch (error) {
console.error('加载未读数量失败:', error);
}
}
// 加载公告列表
async function loadAnnouncements() {
try {
const res = await getUserAnnouncementListApi({ page: 1, pageSize: 10 });
announcements.value = (res.items || []).map((item) => ({
id: item.id,
title: item.title,
summary: item.summary,
content: item.content,
date: formatDate(item.publish_time || ''),
isRead: item.is_read,
priority: item.priority,
isTop: item.is_top,
publisherName: item.publisher_name,
}));
} catch (error) {
console.error('加载公告失败:', error);
}
}
// 加载公告未读数量
async function loadAnnouncementUnreadCount() {
try {
const res = await getUnreadAnnouncementCountApi();
announcementUnreadCount.value = res.count;
} catch (error) {
console.error('加载公告未读数量失败:', error);
}
}
// 标记消息已读并从列表中移除
async function markAsRead(item: NotificationItem) {
// 先跳转
handleNavigate(item);
// 从列表中移除
const index = notifications.value.findIndex((n) => n.id === item.id);
if (index !== -1) {
notifications.value.splice(index, 1);
}
// 如果未读,调用API标记已读
if (!item.isRead) {
try {
await markAsReadApi(item.id as string);
messageUnreadCount.value = Math.max(0, messageUnreadCount.value - 1);
} catch (error) {
console.error('标记已读失败:', error);
}
}
}
// 标记公告已读并从列表中移除
async function markAnnouncementAsRead(item: AnnouncementItem) {
// 先跳转
viewAnnouncementDetail(item);
// 从列表中移除
const index = announcements.value.findIndex((a) => a.id === item.id);
if (index !== -1) {
announcements.value.splice(index, 1);
}
// 如果未读,调用API标记已读
if (!item.isRead) {
try {
await markAnnouncementReadApi(item.id);
announcementUnreadCount.value = Math.max(
0,
announcementUnreadCount.value - 1,
);
} catch (error) {
console.error('标记公告已读失败:', error);
}
}
}
// 标记全部消息已读
async function markAllAsRead() {
try {
await markAllAsReadApi();
notifications.value.forEach((item) => (item.isRead = true));
messageUnreadCount.value = 0;
unreadByType.value = {};
} catch (error) {
console.error('标记全部已读失败:', error);
}
}
// 清空已读消息
async function clearReadMessages() {
try {
await clearReadMessagesApi();
// 重新加载消息列表和未读数量
await loadMessages();
await loadUnreadCount();
} catch (error) {
console.error('清空已读消息失败:', error);
}
}
// 跳转到消息关联页面
function handleNavigate(item: NotificationItem) {
const linkType = item.linkType;
const linkId = item.linkId;
if (!linkType || !linkId) return;
// 根据关联类型跳转(新开tab
let path = '';
switch (linkType) {
case 'announcement': {
path = `/message/announcement-list`;
break;
}
case 'workflow_instance': {
path = `/app/workflow_center/workflow/initiated?id=${linkId}`;
break;
}
case 'workflow_task': {
path = `/app/workflow_center/workflow/pending?id=${linkId}`;
break;
}
// No default
}
if (path) {
const url = router.resolve(path).href;
window.open(url, '_blank');
}
}
// 查看公告详情
function viewAnnouncementDetail(_item: AnnouncementItem) {
// 跳转到公告列表页(新开tab
const url = router.resolve(`/message/announcement-list`).href;
window.open(url, '_blank');
}
// 查看全部(根据当前 Tab 跳转)
function viewAllMessages() {
if (activeTab.value === 'announcement') {
router.push('/message/announcement-list');
} else {
router.push('/message/list');
}
}
// 连接 WebSocket
function connectWebSocket() {
const token = accessStore.accessToken;
if (!token) return;
// 使用统一的 WebSocketManager
wsManagerInstance = createNotificationWebSocket({
onOpen: () => {
wsConnected.value = true;
// 发送订阅消息
wsManagerInstance?.send({ type: 'subscribe' });
},
onMessage: (message) => {
handleWebSocketMessage(message);
},
onClose: () => {
wsConnected.value = false;
},
onError: () => {
wsConnected.value = false;
},
});
wsManagerInstance.connect().catch((error) => {
console.error('WebSocket 连接失败:', error);
});
}
// 处理 WebSocket 消息
function handleWebSocketMessage(data: any) {
if (data.type === 'notification') {
// 收到新通知
const msgData = data.data;
const newNotification: NotificationItem = {
id: msgData.id,
avatar: typeAvatarMap[msgData.msg_type] ?? typeAvatarMap.system!,
title: msgData.title,
message: msgData.content,
date: $t('message.drawer.justNow'),
isRead: false,
linkType: msgData.link_type,
linkId: msgData.link_id,
senderId: msgData.sender_id,
senderName: msgData.sender_name,
};
// 添加到列表头部
notifications.value.unshift(newNotification);
// 只保留最近10条
if (notifications.value.length > 10) {
notifications.value.pop();
}
// 更新未读数量
messageUnreadCount.value += 1;
} else if (data.type === 'announcement') {
// 收到新公告推送,刷新公告列表和未读数
loadAnnouncements();
loadAnnouncementUnreadCount();
}
}
// 断开 WebSocket
function disconnectWebSocket() {
if (wsManagerInstance) {
wsManagerInstance.close();
wsManagerInstance = null;
}
}
// 格式化日期
function formatDate(dateStr: string): string {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60_000) return $t('message.drawer.justNow');
if (diff < 3_600_000)
return $t('message.drawer.minutesAgo', {
count: Math.floor(diff / 60_000),
});
if (diff < 86_400_000)
return $t('message.drawer.hoursAgo', {
count: Math.floor(diff / 3_600_000),
});
if (diff < 604_800_000)
return $t('message.drawer.daysAgo', {
count: Math.floor(diff / 86_400_000),
});
return date.toLocaleDateString();
}
// 初始化
function init() {
loadMessages();
loadUnreadCount();
loadAnnouncements();
loadAnnouncementUnreadCount();
connectWebSocket();
}
// 清理
function cleanup() {
disconnectWebSocket();
}
return {
// 消息
notifications,
messageUnreadCount,
unreadByType,
// 公告
announcements,
announcementUnreadCount,
// 通用
activeTab,
totalUnreadCount,
showDot,
wsConnected,
// 方法
loadMessages,
loadUnreadCount,
loadAnnouncements,
loadAnnouncementUnreadCount,
markAsRead,
markAnnouncementAsRead,
markAllAsRead,
clearReadMessages,
viewAllMessages,
init,
cleanup,
};
}