feat(P21PWA2-1,P21PWA2-2,P21PWA2-3): 深链路由守卫/离线审批队列/SW缓存策略升级
这个提交包含在:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户