Build lightweight AI agent admin
This commit is contained in:
@@ -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. 否则获取临时令牌URL,fetch 下载为 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user