From 9112383d2385453862880c59f63ca16a2fac80fe Mon Sep 17 00:00:00 2001 From: shunli Date: Thu, 12 Mar 2026 12:34:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(P21PWA2-1,P21PWA2-2,P21PWA2-3):=20?= =?UTF-8?q?=E6=B7=B1=E9=93=BE=E8=B7=AF=E7=94=B1=E5=AE=88=E5=8D=AB/?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E5=AE=A1=E6=89=B9=E9=98=9F=E5=88=97/SW?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E7=AD=96=E7=95=A5=E5=8D=87=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/auth/AuthContext.tsx | 147 ++++++- client/src/mobile/indexedDbStore.ts | 59 +++ client/src/mobile/inventoryScanQueue.ts | 65 +-- client/src/mobile/uploadQueue.ts | 64 +-- client/src/mobile/workflowActionQueue.ts | 208 +++++++++ client/src/pages/MobileApprovalsPage.tsx | 461 ++++++++++++++++++-- client/src/pages/WorkflowTaskDetailPage.tsx | 7 +- client/src/pwa.ts | 65 ++- client/src/router/guards.tsx | 20 +- client/vite.config.ts | 42 +- 10 files changed, 975 insertions(+), 163 deletions(-) create mode 100644 client/src/mobile/indexedDbStore.ts create mode 100644 client/src/mobile/workflowActionQueue.ts diff --git a/client/src/auth/AuthContext.tsx b/client/src/auth/AuthContext.tsx index 326753a2..b363e259 100644 --- a/client/src/auth/AuthContext.tsx +++ b/client/src/auth/AuthContext.tsx @@ -11,6 +11,7 @@ import { import { configureHttpAuth } from "../api/http"; import type { AuthState, MeResponse } from "../api/types"; import { changePassword as changePasswordApi } from "../api/auth"; +import { clearPwaRuntimeCaches } from "../pwa"; import { fetchCurrentEmployee, loginWithPassword, @@ -19,12 +20,20 @@ import { } from "./authService"; import { resolvePermissionsForRoles } from "./permissions"; +type OfflineAuthSnapshot = { + version: 1; + me: MeResponse; + permissions: string[]; + savedAt: string; +}; + type AuthContextValue = AuthState & { login: (identifier: string, password: string) => Promise<{ mustChangePassword: boolean }>; changePassword: (oldPassword: string, newPassword: string) => Promise; logout: () => Promise; bootstrapByRefresh: () => Promise; clearSession: () => void; + offlineAccessMode: boolean; }; const AuthContext = createContext(null); @@ -38,13 +47,23 @@ const EMPTY_STATE: AuthState = { }; const MUST_CHANGE_PASSWORD_STORAGE_KEY = "hyb-oa.must_change_password"; +const OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY = "hyb-oa.auth_snapshot"; +const OFFLINE_AUTH_SNAPSHOT_VERSION = 1; +const OFFLINE_AUTH_SNAPSHOT_TTL_MS = 24 * 60 * 60 * 1000; + +function isWindowAvailable() { + return typeof window !== "undefined"; +} function readMustChangePasswordFromStorage() { - return typeof window !== "undefined" && window.localStorage.getItem(MUST_CHANGE_PASSWORD_STORAGE_KEY) === "1"; + return isWindowAvailable() && window.localStorage.getItem(MUST_CHANGE_PASSWORD_STORAGE_KEY) === "1"; } function writeMustChangePasswordToStorage(value: boolean) { - if (typeof window === "undefined") return; + if (!isWindowAvailable()) { + return; + } + if (value) { window.localStorage.setItem(MUST_CHANGE_PASSWORD_STORAGE_KEY, "1"); } else { @@ -52,22 +71,77 @@ function writeMustChangePasswordToStorage(value: boolean) { } } -function buildStateWithUser( - accessToken: string, - me: MeResponse, - mustChangePassword: boolean, -): AuthState { - return { - accessToken, +function readOfflineAuthSnapshot() { + if (!isWindowAvailable()) { + return null; + } + + try { + const rawValue = window.localStorage.getItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY); + if (!rawValue) { + return null; + } + + const parsed = JSON.parse(rawValue) as Partial | null; + if (!parsed || parsed.version !== OFFLINE_AUTH_SNAPSHOT_VERSION || !parsed.me || !Array.isArray(parsed.permissions) || typeof parsed.savedAt !== "string") { + window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY); + return null; + } + + const savedAt = new Date(parsed.savedAt); + if (Number.isNaN(savedAt.getTime()) || Date.now() - savedAt.getTime() > OFFLINE_AUTH_SNAPSHOT_TTL_MS) { + window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY); + return null; + } + + return { + version: OFFLINE_AUTH_SNAPSHOT_VERSION, + me: parsed.me, + permissions: parsed.permissions.map((item) => String(item)), + savedAt: savedAt.toISOString(), + } satisfies OfflineAuthSnapshot; + } catch { + window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY); + return null; + } +} + +function writeOfflineAuthSnapshot(me: MeResponse, permissions: string[]) { + if (!isWindowAvailable()) { + return; + } + + const snapshot: OfflineAuthSnapshot = { + version: OFFLINE_AUTH_SNAPSHOT_VERSION, me, - permissions: resolvePermissionsForRoles(me.roleCodes ?? []), - mustChangePassword, + permissions, + savedAt: new Date().toISOString(), + }; + + window.localStorage.setItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY, JSON.stringify(snapshot)); +} + +function clearOfflineAuthSnapshot() { + if (!isWindowAvailable()) { + return; + } + + window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY); +} + +function buildStateWithOfflineSnapshot(snapshot: OfflineAuthSnapshot): AuthState { + return { + accessToken: null, + me: snapshot.me, + permissions: snapshot.permissions, + mustChangePassword: false, ready: true, }; } export function AuthProvider({ children }: PropsWithChildren) { const [state, setState] = useState(EMPTY_STATE); + const [offlineAccessMode, setOfflineAccessMode] = useState(false); const tokenRef = useRef(null); useEffect(() => { @@ -76,7 +150,10 @@ export function AuthProvider({ children }: PropsWithChildren) { const clearSession = useCallback(() => { tokenRef.current = null; + setOfflineAccessMode(false); writeMustChangePasswordToStorage(false); + clearOfflineAuthSnapshot(); + void clearPwaRuntimeCaches(); setState((previous) => ({ ...previous, accessToken: null, @@ -89,23 +166,48 @@ export function AuthProvider({ children }: PropsWithChildren) { const commitAuthenticatedUser = useCallback( (accessToken: string, me: MeResponse, mustChangePassword: boolean) => { + const permissions = resolvePermissionsForRoles(me.roleCodes ?? []); tokenRef.current = accessToken; + setOfflineAccessMode(false); writeMustChangePasswordToStorage(mustChangePassword); - setState(buildStateWithUser(accessToken, me, mustChangePassword)); + writeOfflineAuthSnapshot(me, permissions); + setState({ + accessToken, + me, + permissions, + mustChangePassword, + ready: true, + }); }, [], ); + const activateOfflineSnapshot = useCallback((snapshot: OfflineAuthSnapshot) => { + tokenRef.current = null; + setOfflineAccessMode(true); + setState(buildStateWithOfflineSnapshot(snapshot)); + }, []); + const bootstrapByRefresh = useCallback(async () => { + const offlineSnapshot = readOfflineAuthSnapshot(); + if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) { + activateOfflineSnapshot(offlineSnapshot); + return; + } + try { const refreshed = await refreshSession(); const me = await fetchCurrentEmployee(refreshed.accessToken); const mustChangePassword = readMustChangePasswordFromStorage(); commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword); } catch { + if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) { + activateOfflineSnapshot(offlineSnapshot); + return; + } clearSession(); } - }, [clearSession, commitAuthenticatedUser]); + }, [activateOfflineSnapshot, clearSession, commitAuthenticatedUser]); const login = useCallback( async (identifier: string, password: string) => { @@ -142,6 +244,7 @@ export function AuthProvider({ children }: PropsWithChildren) { try { const refreshed = await refreshSession(); tokenRef.current = refreshed.accessToken; + setOfflineAccessMode(false); setState((previous) => ({ ...previous, accessToken: refreshed.accessToken, @@ -161,6 +264,21 @@ export function AuthProvider({ children }: PropsWithChildren) { void bootstrapByRefresh(); }, [bootstrapByRefresh]); + useEffect(() => { + if (!offlineAccessMode) { + return undefined; + } + + const handleOnline = () => { + void bootstrapByRefresh(); + }; + + window.addEventListener("online", handleOnline); + return () => { + window.removeEventListener("online", handleOnline); + }; + }, [bootstrapByRefresh, offlineAccessMode]); + const value = useMemo( () => ({ ...state, @@ -169,8 +287,9 @@ export function AuthProvider({ children }: PropsWithChildren) { logout, bootstrapByRefresh, clearSession, + offlineAccessMode, }), - [bootstrapByRefresh, changePassword, clearSession, login, logout, state], + [bootstrapByRefresh, changePassword, clearSession, login, logout, offlineAccessMode, state], ); return {children}; diff --git a/client/src/mobile/indexedDbStore.ts b/client/src/mobile/indexedDbStore.ts new file mode 100644 index 00000000..c70881d8 --- /dev/null +++ b/client/src/mobile/indexedDbStore.ts @@ -0,0 +1,59 @@ +export function requestPromise(request: IDBRequest) { + return new Promise((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 | 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( + storeName: string, + mode: IDBTransactionMode, + executor: (store: IDBObjectStore) => Promise | T, + ) { + const db = await getDatabase(); + + return new Promise((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, + }; +} diff --git a/client/src/mobile/inventoryScanQueue.ts b/client/src/mobile/inventoryScanQueue.ts index bf65fc7c..6d59879a 100644 --- a/client/src/mobile/inventoryScanQueue.ts +++ b/client/src/mobile/inventoryScanQueue.ts @@ -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 | 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(request: IDBRequest) { - return new Promise((resolve, reject) => { - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error ?? new Error("indexeddb_request_failed")); - }); -} - -async function withStore(mode: IDBTransactionMode, executor: (store: IDBObjectStore) => Promise) { - const db = await getDatabase(); - - return new Promise((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; diff --git a/client/src/mobile/uploadQueue.ts b/client/src/mobile/uploadQueue.ts index 18fce3c0..929dee2a 100644 --- a/client/src/mobile/uploadQueue.ts +++ b/client/src/mobile/uploadQueue.ts @@ -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 | 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((resolve, reject) => { @@ -65,29 +52,6 @@ function fromDataUrl(dataUrl: string, fallbackFileName: string, fallbackMimeType return new File([bytes], fallbackFileName, { type: mimeType }); } -async function withStore(mode: IDBTransactionMode, executor: (store: IDBObjectStore) => Promise) { - const db = await getDatabase(); - - return new Promise((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(request: IDBRequest) { - return new Promise((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; }); diff --git a/client/src/mobile/workflowActionQueue.ts b/client/src/mobile/workflowActionQueue.ts new file mode 100644 index 00000000..d4fa02b3 --- /dev/null +++ b/client/src/mobile/workflowActionQueue.ts @@ -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; + }); +} diff --git a/client/src/pages/MobileApprovalsPage.tsx b/client/src/pages/MobileApprovalsPage.tsx index d4512267..ea0350f7 100644 --- a/client/src/pages/MobileApprovalsPage.tsx +++ b/client/src/pages/MobileApprovalsPage.tsx @@ -1,5 +1,6 @@ -import { useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { mapApiErrorMessage } from "../api/errorMessages"; import { isApiError } from "../api/types"; @@ -11,14 +12,29 @@ import { rejectWorkflowTask, returnWorkflowTask, transferWorkflowTask, + type WorkflowTaskDetail, } from "../api/workflow"; +import { useAuth } from "../auth/AuthContext"; import { PageHeader } from "../components/common/PageHeader"; import { Button } from "../components/ui/button"; import { Spinner } from "../components/ui/spinner"; import { Textarea } from "../components/ui/textarea"; +import { + cacheWorkflowTaskDetail, + enqueueWorkflowActionDraft, + getCachedWorkflowTaskDetail, + listCachedWorkflowTaskDetails, + listWorkflowActionDrafts, + removeWorkflowActionDraft, + resolveWorkflowTaskReferenceSnapshot, + updateWorkflowActionDraftStatus, + type WorkflowDraftQueueItem, +} from "../mobile/workflowActionQueue"; type MobileAction = "approve" | "reject" | "return" | "transfer"; +type ReplayClassification = "failed" | "conflicted"; + function entityLabel(entityType: string | undefined) { if (entityType === "leave") { return "请假"; @@ -35,53 +51,203 @@ function entityLabel(entityType: string | undefined) { return entityType || "未知"; } +function resolveErrorMessage(error: unknown) { + if (isApiError(error)) { + return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; + } + if (error instanceof Error) { + return error.message; + } + return "动作执行失败"; +} + +function classifyReplayError(error: unknown): ReplayClassification { + if (!isApiError(error)) { + return "failed"; + } + + if ( + (error.status === 409 && error.details?.task === "already_processed") || + error.status === 403 || + error.status === 404 + ) { + return "conflicted"; + } + + return "failed"; +} + +function queueStatusLabel(status: WorkflowDraftQueueItem["status"]) { + if (status === "queued") { + return "待同步"; + } + if (status === "failed") { + return "同步失败"; + } + return "存在冲突"; +} + +function formatDateTime(value: string) { + try { + return new Date(value).toLocaleString("zh-CN"); + } catch { + return value; + } +} + export function MobileApprovalsPage() { const queryClient = useQueryClient(); - const [selectedTaskId, setSelectedTaskId] = useState(""); + const { me, accessToken, offlineAccessMode } = useAuth(); + const [searchParams, setSearchParams] = useSearchParams(); + const deepLinkTaskId = searchParams.get("taskId")?.trim() ?? ""; + + const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId); const [error, setError] = useState(""); + const [message, setMessage] = useState(""); const [comment, setComment] = useState(""); const [transferToApproverId, setTransferToApproverId] = useState(""); const [transferReason, setTransferReason] = useState(""); + const [isOnline, setIsOnline] = useState(typeof navigator === "undefined" ? true : navigator.onLine); + const autoSyncRequestedRef = useRef(false); + + const ownerEmployeeId = me?.id ?? null; + const canUseRemote = isOnline && Boolean(accessToken); + + useEffect(() => { + setSelectedTaskId(deepLinkTaskId); + }, [deepLinkTaskId]); + + function updateTaskSelection(taskId: string) { + setSelectedTaskId(taskId); + const nextParams = new URLSearchParams(searchParams); + if (taskId) { + nextParams.set("taskId", taskId); + } else { + nextParams.delete("taskId"); + } + setSearchParams(nextParams, { replace: true }); + } const tasksQuery = useQuery({ + enabled: canUseRemote, queryKey: ["mobile", "workflow", "tasks"], queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 30 }), }); const detailQuery = useQuery({ - enabled: Boolean(selectedTaskId), + enabled: Boolean(selectedTaskId) && canUseRemote, queryKey: ["mobile", "workflow", "task-detail", selectedTaskId], queryFn: () => fetchWorkflowTaskDetail(selectedTaskId), }); + const cachedTasksQuery = useQuery({ + enabled: Boolean(ownerEmployeeId), + queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId], + queryFn: () => listCachedWorkflowTaskDetails(ownerEmployeeId), + }); + + const cachedDetailQuery = useQuery({ + enabled: Boolean(selectedTaskId) && Boolean(ownerEmployeeId), + queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, selectedTaskId], + queryFn: () => getCachedWorkflowTaskDetail(selectedTaskId, ownerEmployeeId), + }); + + const queueQuery = useQuery({ + enabled: Boolean(ownerEmployeeId), + queryKey: ["mobile", "workflow", "draft-queue", ownerEmployeeId], + queryFn: () => listWorkflowActionDrafts(ownerEmployeeId), + }); + + useEffect(() => { + if (!detailQuery.data || !ownerEmployeeId) { + return; + } + + void cacheWorkflowTaskDetail(detailQuery.data, ownerEmployeeId).then(async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] }), + queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, detailQuery.data?.task.id] }), + ]); + }); + }, [detailQuery.data, ownerEmployeeId, queryClient]); + + const selectedTask = useMemo(() => { + if (detailQuery.data) { + return detailQuery.data; + } + if (!canUseRemote) { + return cachedDetailQuery.data?.detail ?? null; + } + return null; + }, [cachedDetailQuery.data?.detail, canUseRemote, detailQuery.data]); + + const canTransfer = + canUseRemote && + selectedTask?.workflowInstance?.entityType === "reimbursement" && + selectedTask.allowedActions.includes("transfer"); + const approversQuery = useQuery({ - enabled: detailQuery.data?.allowedActions?.includes("transfer") ?? false, + enabled: Boolean(canTransfer), queryKey: ["mobile", "workflow", "approvers"], queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 100 }), }); + const currentQueueItems = queueQuery.data ?? []; + const retryableQueueItems = useMemo( + () => currentQueueItems.filter((item) => item.status !== "conflicted"), + [currentQueueItems], + ); + const actionMutation = useMutation({ mutationFn: async (action: MobileAction) => { if (!selectedTaskId) { throw new Error("请选择任务"); } + if (!selectedTask) { + throw new Error("当前待办尚未缓存,请联网加载后再操作"); + } + if (!selectedTask.allowedActions.includes(action)) { + throw new Error("当前待办不允许该动作"); + } + + if (!canUseRemote) { + if (action === "return" || action === "transfer") { + throw new Error("离线仅支持通过 / 驳回草稿,退回与转审需联网执行"); + } + if (action === "reject" && !comment.trim()) { + throw new Error("驳回必须填写意见"); + } + + await enqueueWorkflowActionDraft({ + taskId: selectedTaskId, + action, + comment: action === "reject" ? comment.trim() : null, + ownerEmployeeId, + detail: selectedTask, + }); + + return { mode: "queued" as const, action }; + } if (action === "approve") { - return approveWorkflowTask(selectedTaskId); + await approveWorkflowTask(selectedTaskId); + return { mode: "submitted" as const, action }; } if (action === "reject") { if (!comment.trim()) { throw new Error("驳回必须填写意见"); } - return rejectWorkflowTask(selectedTaskId, comment.trim()); + await rejectWorkflowTask(selectedTaskId, comment.trim()); + return { mode: "submitted" as const, action }; } if (action === "return") { if (!comment.trim()) { throw new Error("退回必须填写意见"); } - return returnWorkflowTask(selectedTaskId, comment.trim()); + await returnWorkflowTask(selectedTaskId, comment.trim()); + return { mode: "submitted" as const, action }; } if (!transferToApproverId) { @@ -91,65 +257,195 @@ export function MobileApprovalsPage() { throw new Error("转审理由至少 10 个字"); } - return transferWorkflowTask(selectedTaskId, transferToApproverId, transferReason.trim()); + await transferWorkflowTask(selectedTaskId, transferToApproverId, transferReason.trim()); + return { mode: "submitted" as const, action }; }, - onSuccess: async () => { + onSuccess: async (result) => { + if (result.mode === "queued") { + await queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "draft-queue", ownerEmployeeId] }); + setError(""); + setMessage(result.action === "approve" ? "当前离线,通过草稿已加入待同步队列。" : "当前离线,驳回草稿已加入待同步队列。"); + toast.success(result.action === "approve" ? "已加入通过草稿队列" : "已加入驳回草稿队列"); + return; + } + await Promise.all([ queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }), queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-detail", selectedTaskId] }), + queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] }), ]); setComment(""); setTransferReason(""); setTransferToApproverId(""); setError(""); + setMessage(""); toast.success("操作成功"); }, - onError: (err) => { - const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "动作执行失败"; - setError(msg); - toast.error(msg); + onError: (actionError) => { + const nextError = resolveErrorMessage(actionError); + setError(nextError); + setMessage(""); + toast.error(nextError); }, }); + const syncMutation = useMutation({ + mutationFn: async (items: WorkflowDraftQueueItem[]) => { + let succeeded = 0; + let failed = 0; + let conflicted = 0; + + for (const item of items.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt))) { + try { + if (item.action === "approve") { + await approveWorkflowTask(item.taskId); + } else { + await rejectWorkflowTask(item.taskId, item.comment ?? ""); + } + await removeWorkflowActionDraft(item.taskId, ownerEmployeeId); + succeeded += 1; + } catch (syncError) { + const classification = classifyReplayError(syncError); + const lastError = resolveErrorMessage(syncError); + await updateWorkflowActionDraftStatus({ + taskId: item.taskId, + ownerEmployeeId, + status: classification, + lastError, + }); + + if (classification === "conflicted") { + conflicted += 1; + } else { + failed += 1; + } + } + } + + return { succeeded, failed, conflicted }; + }, + onSuccess: async (result) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "draft-queue", ownerEmployeeId] }), + queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }), + queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-detail", selectedTaskId] }), + ]); + setError(""); + setMessage(`草稿同步完成:成功 ${result.succeeded},失败 ${result.failed},冲突 ${result.conflicted}`); + if (result.succeeded || result.failed || result.conflicted) { + toast.success(`草稿同步完成:成功 ${result.succeeded},失败 ${result.failed},冲突 ${result.conflicted}`); + } + }, + onError: (syncError) => { + const nextError = resolveErrorMessage(syncError); + setError(nextError); + setMessage(""); + toast.error(nextError); + }, + }); + + useEffect(() => { + const onOnline = () => { + setIsOnline(true); + autoSyncRequestedRef.current = true; + }; + const onOffline = () => { + setIsOnline(false); + }; + + window.addEventListener("online", onOnline); + window.addEventListener("offline", onOffline); + + return () => { + window.removeEventListener("online", onOnline); + window.removeEventListener("offline", onOffline); + }; + }, []); + + useEffect(() => { + if (!autoSyncRequestedRef.current || !canUseRemote || syncMutation.isPending) { + return; + } + + autoSyncRequestedRef.current = false; + if (retryableQueueItems.length) { + syncMutation.mutate(retryableQueueItems); + } + }, [canUseRemote, retryableQueueItems, syncMutation]); + const pendingTasks = tasksQuery.data?.items ?? []; - const selectedTask = detailQuery.data; - const canTransfer = - selectedTask?.workflowInstance?.entityType === "reimbursement" && - selectedTask.allowedActions.includes("transfer"); + const cachedTasks = cachedTasksQuery.data ?? []; + const visibleTaskCards = canUseRemote + ? pendingTasks.map((task) => ({ + id: task.id, + title: task.stepName, + subtitle: `业务:${entityLabel(task.workflowInstance?.entityType)}`, + status: task.status, + })) + : cachedTasks.map((item) => ({ + id: item.detail.task.id, + title: item.taskSnapshot.stepName ?? item.detail.task.stepName, + subtitle: `业务:${entityLabel(item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType)} · 缓存于 ${formatDateTime(item.savedAt)}`, + status: item.detail.task.status, + })); + + const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null; + const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote; return (
- + + + {!isOnline ? ( +

+ 当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存待办,并起草通过 / 驳回草稿。 +

+ ) : null} {error ? (

{error}

) : null} + {message ? ( +

{message}

+ ) : null} +
-

待办任务

+
+

待办任务

+ {isOnline ? "在线" : "离线"} +
- {tasksQuery.isLoading ?
加载中...
: null} + {canUseRemote && tasksQuery.isLoading ? ( +
加载中...
+ ) : null} - {!tasksQuery.isLoading && pendingTasks.length === 0 ? ( -

暂无待办任务。

+ {!canUseRemote ? ( +

离线状态下展示已缓存待办;若未缓存任务详情,可通过深链任务号联网后重试。

+ ) : null} + + {!visibleTaskCards.length ? ( +

{canUseRemote ? "暂无待办任务。" : "暂无已缓存待办。"}

) : null}
- {pendingTasks.map((task) => ( + {visibleTaskCards.map((task) => ( ))}
@@ -160,16 +456,27 @@ export function MobileApprovalsPage() { {!selectedTaskId ?

请先选择任务。

: null} - {selectedTaskId && detailQuery.isLoading ? ( + {selectedTaskId && canUseRemote && detailQuery.isLoading ? (

加载任务详情中...

) : null} - {selectedTask && ( + {selectedTaskId && canUseRemote && detailQuery.isError ? ( +

{resolveErrorMessage(detailQuery.error)}

+ ) : null} + + {isOfflineUncachedDeepLink ? ( +

+ 离线未缓存该待办,请联网后重试。任务号:{selectedTaskId} +

+ ) : null} + + {selectedTask ? ( <>
任务状态:{selectedTask.task.status}
步骤:{selectedTask.task.stepName}
对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}
+
业务参考:{selectedReference?.reference ?? "-"}
{selectedTask.reimbursement ? ( <>
报销单号:{selectedTask.reimbursement.formNumber ?? "-"}
@@ -205,11 +512,15 @@ export function MobileApprovalsPage() {