feat(P21PWA2-1,P21PWA2-2,P21PWA2-3): 深链路由守卫/离线审批队列/SW缓存策略升级

这个提交包含在:
2026-03-12 12:34:56 +08:00
父节点 102d3dcc2b
当前提交 9112383d23
共修改 10 个文件,包含 975 行新增和 163 行删除
+59
查看文件
@@ -0,0 +1,59 @@
export function requestPromise<T>(request: IDBRequest<T>) {
return new Promise<T>((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("indexeddb_request_failed"));
});
}
type IndexedDbStoreOptions = {
dbName: string;
dbVersion: number;
upgrade: (db: IDBDatabase, request: IDBOpenDBRequest) => void;
};
export function createIndexedDbStore(options: IndexedDbStoreOptions) {
let dbPromise: Promise<IDBDatabase> | null = null;
function getDatabase() {
if (!dbPromise) {
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(options.dbName, options.dbVersion);
request.onupgradeneeded = () => {
options.upgrade(request.result, request);
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("indexeddb_open_failed"));
});
}
return dbPromise;
}
async function withStore<T>(
storeName: string,
mode: IDBTransactionMode,
executor: (store: IDBObjectStore) => Promise<T> | T,
) {
const db = await getDatabase();
return new Promise<T>((resolve, reject) => {
const tx = db.transaction(storeName, mode);
const store = tx.objectStore(storeName);
Promise.resolve(executor(store))
.then((result) => {
tx.oncomplete = () => resolve(result);
tx.onerror = () => reject(tx.error ?? new Error("indexeddb_tx_failed"));
tx.onabort = () => reject(tx.error ?? new Error("indexeddb_tx_aborted"));
})
.catch((error) => reject(error));
});
}
return {
getDatabase,
withStore,
};
}
+14 -51
查看文件
@@ -1,4 +1,5 @@
import type { InventoryCheckResult } from "../api/inventoryChecks";
import { createIndexedDbStore, requestPromise } from "./indexedDbStore";
export type QueuedInventoryScanItem = {
id: string;
@@ -11,54 +12,16 @@ export type QueuedInventoryScanItem = {
lastError: string | null;
};
const DB_NAME = "hyb-oa-mobile-inventory";
const DB_VERSION = 1;
const STORE_NAME = "inventory_scan_queue";
let dbPromise: Promise<IDBDatabase> | null = null;
function getDatabase() {
if (!dbPromise) {
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: "id" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("indexeddb_open_failed"));
});
}
return dbPromise;
}
function requestPromise<T>(request: IDBRequest<T>) {
return new Promise<T>((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("indexeddb_request_failed"));
});
}
async function withStore<T>(mode: IDBTransactionMode, executor: (store: IDBObjectStore) => Promise<T>) {
const db = await getDatabase();
return new Promise<T>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, mode);
const store = tx.objectStore(STORE_NAME);
executor(store)
.then((result) => {
tx.oncomplete = () => resolve(result);
tx.onerror = () => reject(tx.error ?? new Error("indexeddb_tx_failed"));
})
.catch((error) => reject(error));
});
}
const database = createIndexedDbStore({
dbName: "hyb-oa-mobile-inventory",
dbVersion: 1,
upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: "id" });
}
},
});
export async function enqueueInventoryScan(input: {
inventoryCheckId: string;
@@ -79,7 +42,7 @@ export async function enqueueInventoryScan(input: {
lastError: null,
};
await withStore("readwrite", async (store) => {
await database.withStore(STORE_NAME, "readwrite", async (store) => {
await requestPromise(store.put(item));
return true;
});
@@ -88,21 +51,21 @@ export async function enqueueInventoryScan(input: {
}
export async function listQueuedInventoryScans() {
return withStore("readonly", async (store) => {
return database.withStore(STORE_NAME, "readonly", async (store) => {
const records = (await requestPromise(store.getAll())) as QueuedInventoryScanItem[];
return records.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
});
}
export async function removeQueuedInventoryScan(id: string) {
await withStore("readwrite", async (store) => {
await database.withStore(STORE_NAME, "readwrite", async (store) => {
await requestPromise(store.delete(id));
return true;
});
}
export async function updateQueuedInventoryScanError(id: string, lastError: string | null) {
await withStore("readwrite", async (store) => {
await database.withStore(STORE_NAME, "readwrite", async (store) => {
const existing = (await requestPromise(store.get(id))) as QueuedInventoryScanItem | undefined;
if (!existing) {
return false;
+14 -50
查看文件
@@ -1,3 +1,5 @@
import { createIndexedDbStore, requestPromise } from "./indexedDbStore";
export type MobileUploadQueueItem = {
id: string;
fileName: string;
@@ -7,31 +9,16 @@ export type MobileUploadQueueItem = {
ownerEmployeeId: string | null;
};
const DB_NAME = "hyb-oa-mobile";
const DB_VERSION = 1;
const STORE_NAME = "invoice_upload_queue";
let dbPromise: Promise<IDBDatabase> | null = null;
function getDatabase() {
if (!dbPromise) {
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: "id" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("indexeddb_open_failed"));
});
}
return dbPromise;
}
const database = createIndexedDbStore({
dbName: "hyb-oa-mobile",
dbVersion: 1,
upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: "id" });
}
},
});
function toDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
@@ -65,29 +52,6 @@ function fromDataUrl(dataUrl: string, fallbackFileName: string, fallbackMimeType
return new File([bytes], fallbackFileName, { type: mimeType });
}
async function withStore<T>(mode: IDBTransactionMode, executor: (store: IDBObjectStore) => Promise<T>) {
const db = await getDatabase();
return new Promise<T>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, mode);
const store = tx.objectStore(STORE_NAME);
executor(store)
.then((result) => {
tx.oncomplete = () => resolve(result);
tx.onerror = () => reject(tx.error ?? new Error("indexeddb_tx_failed"));
})
.catch((error) => reject(error));
});
}
function requestPromise<T>(request: IDBRequest<T>) {
return new Promise<T>((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("indexeddb_request_failed"));
});
}
export async function enqueueInvoiceUpload(file: File, ownerEmployeeId: string | null = null) {
const dataUrl = await toDataUrl(file);
const item: MobileUploadQueueItem = {
@@ -99,7 +63,7 @@ export async function enqueueInvoiceUpload(file: File, ownerEmployeeId: string |
ownerEmployeeId,
};
await withStore("readwrite", async (store) => {
await database.withStore(STORE_NAME, "readwrite", async (store) => {
await requestPromise(store.put(item));
return true;
});
@@ -108,14 +72,14 @@ export async function enqueueInvoiceUpload(file: File, ownerEmployeeId: string |
}
export async function listQueuedInvoiceUploads() {
return withStore("readonly", async (store) => {
return database.withStore(STORE_NAME, "readonly", async (store) => {
const records = (await requestPromise(store.getAll())) as MobileUploadQueueItem[];
return records.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
});
}
export async function removeQueuedInvoiceUpload(id: string) {
await withStore("readwrite", async (store) => {
await database.withStore(STORE_NAME, "readwrite", async (store) => {
await requestPromise(store.delete(id));
return true;
});
+208
查看文件
@@ -0,0 +1,208 @@
import type { WorkflowTaskDetail } from "../api/workflow";
import { createIndexedDbStore, requestPromise } from "./indexedDbStore";
export type WorkflowDraftAction = "approve" | "reject";
export type WorkflowDraftQueueStatus = "queued" | "failed" | "conflicted";
export type WorkflowTaskReferenceSnapshot = {
stepName: string | null;
entityType: string | null;
entityId: string | null;
reference: string | null;
};
export type WorkflowDraftQueueItem = {
id: string;
taskId: string;
action: WorkflowDraftAction;
comment: string | null;
status: WorkflowDraftQueueStatus;
createdAt: string;
updatedAt: string;
lastError: string | null;
ownerEmployeeId: string | null;
taskSnapshot: WorkflowTaskReferenceSnapshot;
};
type CachedWorkflowTaskDetail = {
id: string;
ownerEmployeeId: string | null;
savedAt: string;
taskSnapshot: WorkflowTaskReferenceSnapshot;
detail: WorkflowTaskDetail;
};
const QUEUE_STORE_NAME = "workflow_action_queue";
const DETAIL_CACHE_STORE_NAME = "workflow_task_cache";
const database = createIndexedDbStore({
dbName: "hyb-oa-mobile-workflow",
dbVersion: 1,
upgrade(db) {
if (!db.objectStoreNames.contains(QUEUE_STORE_NAME)) {
db.createObjectStore(QUEUE_STORE_NAME, { keyPath: "id" });
}
if (!db.objectStoreNames.contains(DETAIL_CACHE_STORE_NAME)) {
db.createObjectStore(DETAIL_CACHE_STORE_NAME, { keyPath: "id" });
}
},
});
function matchesOwner(recordOwnerId: string | null | undefined, ownerEmployeeId: string | null) {
return (recordOwnerId ?? null) === (ownerEmployeeId ?? null);
}
export function resolveWorkflowTaskReferenceSnapshot(detail: WorkflowTaskDetail): WorkflowTaskReferenceSnapshot {
const entityType = detail.workflowInstance?.entityType ?? null;
const entityId =
detail.reimbursement?.id ?? detail.leaveRequest?.id ?? detail.purchaseRequest?.id ?? detail.travelRequest?.id ?? detail.workflowInstance?.id ?? null;
const reference =
detail.reimbursement?.formNumber ??
detail.purchaseRequest?.requestNo ??
detail.travelRequest?.requestNo ??
detail.leaveRequest?.id ??
detail.task.id;
return {
stepName: detail.task.stepName ?? null,
entityType,
entityId,
reference,
};
}
export async function cacheWorkflowTaskDetail(detail: WorkflowTaskDetail, ownerEmployeeId: string | null) {
const item: CachedWorkflowTaskDetail = {
id: detail.task.id,
ownerEmployeeId,
savedAt: new Date().toISOString(),
taskSnapshot: resolveWorkflowTaskReferenceSnapshot(detail),
detail,
};
await database.withStore(DETAIL_CACHE_STORE_NAME, "readwrite", async (store) => {
await requestPromise(store.put(item));
return true;
});
return item;
}
export async function getCachedWorkflowTaskDetail(taskId: string, ownerEmployeeId: string | null) {
return database.withStore(DETAIL_CACHE_STORE_NAME, "readonly", async (store) => {
const item = (await requestPromise(store.get(taskId))) as CachedWorkflowTaskDetail | undefined;
if (!item || !matchesOwner(item.ownerEmployeeId, ownerEmployeeId)) {
return null;
}
return item;
});
}
export async function listCachedWorkflowTaskDetails(ownerEmployeeId: string | null) {
return database.withStore(DETAIL_CACHE_STORE_NAME, "readonly", async (store) => {
const records = (await requestPromise(store.getAll())) as CachedWorkflowTaskDetail[];
return records
.filter((item) => matchesOwner(item.ownerEmployeeId, ownerEmployeeId))
.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
});
}
export async function enqueueWorkflowActionDraft(input: {
taskId: string;
action: WorkflowDraftAction;
comment?: string | null;
ownerEmployeeId: string | null;
detail?: WorkflowTaskDetail | null;
taskSnapshot?: WorkflowTaskReferenceSnapshot | null;
}) {
const id = input.taskId;
const now = new Date().toISOString();
const nextSnapshot = input.detail
? resolveWorkflowTaskReferenceSnapshot(input.detail)
: input.taskSnapshot ?? {
stepName: null,
entityType: null,
entityId: null,
reference: input.taskId,
};
await database.withStore(QUEUE_STORE_NAME, "readwrite", async (store) => {
const existing = (await requestPromise(store.get(id))) as WorkflowDraftQueueItem | undefined;
const item: WorkflowDraftQueueItem = {
id,
taskId: input.taskId,
action: input.action,
comment: input.comment?.trim() ? input.comment.trim() : null,
status: "queued",
createdAt: existing && matchesOwner(existing.ownerEmployeeId, input.ownerEmployeeId) ? existing.createdAt : now,
updatedAt: now,
lastError: null,
ownerEmployeeId: input.ownerEmployeeId,
taskSnapshot: nextSnapshot,
};
await requestPromise(store.put(item));
return true;
});
if (input.detail) {
await cacheWorkflowTaskDetail(input.detail, input.ownerEmployeeId);
}
return getWorkflowActionDraft(input.taskId, input.ownerEmployeeId);
}
export async function listWorkflowActionDrafts(ownerEmployeeId: string | null) {
return database.withStore(QUEUE_STORE_NAME, "readonly", async (store) => {
const records = (await requestPromise(store.getAll())) as WorkflowDraftQueueItem[];
return records
.filter((item) => matchesOwner(item.ownerEmployeeId, ownerEmployeeId))
.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt));
});
}
export async function getWorkflowActionDraft(taskId: string, ownerEmployeeId: string | null) {
return database.withStore(QUEUE_STORE_NAME, "readonly", async (store) => {
const item = (await requestPromise(store.get(taskId))) as WorkflowDraftQueueItem | undefined;
if (!item || !matchesOwner(item.ownerEmployeeId, ownerEmployeeId)) {
return null;
}
return item;
});
}
export async function removeWorkflowActionDraft(taskId: string, ownerEmployeeId: string | null) {
await database.withStore(QUEUE_STORE_NAME, "readwrite", async (store) => {
const existing = (await requestPromise(store.get(taskId))) as WorkflowDraftQueueItem | undefined;
if (!existing || !matchesOwner(existing.ownerEmployeeId, ownerEmployeeId)) {
return false;
}
await requestPromise(store.delete(taskId));
return true;
});
}
export async function updateWorkflowActionDraftStatus(input: {
taskId: string;
ownerEmployeeId: string | null;
status: WorkflowDraftQueueStatus;
lastError: string | null;
}) {
return database.withStore(QUEUE_STORE_NAME, "readwrite", async (store) => {
const existing = (await requestPromise(store.get(input.taskId))) as WorkflowDraftQueueItem | undefined;
if (!existing || !matchesOwner(existing.ownerEmployeeId, input.ownerEmployeeId)) {
return false;
}
await requestPromise(
store.put({
...existing,
status: input.status,
lastError: input.lastError,
updatedAt: new Date().toISOString(),
}),
);
return true;
});
}