feat(P11-P12): 落地治理开关与移动端MVP(G0-G6/H1/M1-M4)
- 新增 Feature Flag 模型、评估服务与管理 API(erp_sync/invoice_verification)\n- 完成 ERP 与验真灰度收口(路由/服务/作业统一判定)\n- 补齐 pay 与手动 sync 的 department_id 灰度上下文\n- 增加管理后台开关页、PWA 基础设施与移动拍照/审批页面\n- 增加回归脚本、安全基线与压测脚本,更新文档与执行合同\n\nIssue: G0,G1,G2,G3,G4,G5,G6,H1,M1,M2,M3,M4
这个提交包含在:
@@ -0,0 +1,126 @@
|
||||
export type MobileUploadQueueItem = {
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
createdAt: string;
|
||||
dataUrl: string;
|
||||
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;
|
||||
}
|
||||
|
||||
function toDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error("file_reader_invalid_result"));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error("file_reader_failed"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function fromDataUrl(dataUrl: string, fallbackFileName: string, fallbackMimeType = "application/octet-stream") {
|
||||
const match = dataUrl.match(/^data:(.*?);base64,(.*)$/);
|
||||
if (!match) {
|
||||
throw new Error("invalid_data_url");
|
||||
}
|
||||
|
||||
const mimeType = match[1] || fallbackMimeType;
|
||||
const base64 = match[2];
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
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 = {
|
||||
id: crypto.randomUUID(),
|
||||
fileName: file.name,
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
createdAt: new Date().toISOString(),
|
||||
dataUrl,
|
||||
ownerEmployeeId,
|
||||
};
|
||||
|
||||
await withStore("readwrite", async (store) => {
|
||||
await requestPromise(store.put(item));
|
||||
return true;
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function listQueuedInvoiceUploads() {
|
||||
return withStore("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 requestPromise(store.delete(id));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function queuedItemToFile(item: MobileUploadQueueItem) {
|
||||
return fromDataUrl(item.dataUrl, item.fileName, item.mimeType);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户