R109/A2,A3,A4 改进移动报销和发票上传体验

这个提交包含在:
2026-05-08 01:04:43 +08:00
父节点 06c9ab1be7
当前提交 23dfa32ee0
共修改 8 个文件,包含 1104 行新增和 5 行删除
+7 -1
查看文件
@@ -170,7 +170,12 @@ export async function fetchBindableInvoices(query: {
); );
} }
export async function uploadInvoice(file: File, ocrType?: string, ownerEmployeeId?: string) { export async function uploadInvoice(
file: File,
ocrType?: string,
ownerEmployeeId?: string,
options: { signal?: AbortSignal } = {},
) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
if (ocrType) { if (ocrType) {
@@ -185,6 +190,7 @@ export async function uploadInvoice(file: File, ocrType?: string, ownerEmployeeI
{ {
method: "POST", method: "POST",
body: formData, body: formData,
signal: options.signal,
}, },
); );
} }
+18
查看文件
@@ -0,0 +1,18 @@
export async function withUploadTimeout<T>(
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs = 45_000,
) {
const controller = new AbortController();
const timerId = window.setTimeout(() => controller.abort(), timeoutMs);
try {
return await operation(controller.signal);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error("上传超时,请检查网络后重试");
}
throw error;
} finally {
window.clearTimeout(timerId);
}
}
+3 -2
查看文件
@@ -8,6 +8,7 @@ import { PageHeader } from "../components/common/PageHeader";
import { PageScaffold } from "../components/patterns/PageScaffold"; import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Spinner } from "../components/ui/spinner"; import { Spinner } from "../components/ui/spinner";
import { withUploadTimeout } from "../lib/uploadTimeout";
import { import {
enqueueInvoiceUpload, enqueueInvoiceUpload,
listQueuedInvoiceUploads, listQueuedInvoiceUploads,
@@ -58,7 +59,7 @@ export function MobileCapturePage() {
for (const [index, file] of files.entries()) { for (const [index, file] of files.entries()) {
try { try {
await uploadInvoice(file, "vat"); await withUploadTimeout((signal) => uploadInvoice(file, "vat", undefined, { signal }));
result.succeeded += 1; result.succeeded += 1;
} catch (err) { } catch (err) {
if (isLikelyNetworkError(err)) { if (isLikelyNetworkError(err)) {
@@ -118,7 +119,7 @@ export function MobileCapturePage() {
for (const item of items) { for (const item of items) {
try { try {
const file = queuedItemToFile(item); const file = queuedItemToFile(item);
await uploadInvoice(file, "vat"); await withUploadTimeout((signal) => uploadInvoice(file, "vat", undefined, { signal }));
await removeQueuedInvoiceUpload(item.id); await removeQueuedInvoiceUpload(item.id);
succeeded += 1; succeeded += 1;
} catch { } catch {
文件差异内容过多而无法显示 加载差异
@@ -76,7 +76,7 @@ function ReimbursementCard({ item }: { item: ReimbursementListItem }) {
{item.description ? <p className="mt-3 line-clamp-2 text-sm text-zinc-500">{item.description}</p> : null} {item.description ? <p className="mt-3 line-clamp-2 text-sm text-zinc-500">{item.description}</p> : null}
<Button asChild className="mt-4 w-full" type="button"> <Button asChild className="mt-4 w-full" type="button">
<Link to={`/reimbursements/${item.id}`}>{actionLabel}</Link> <Link to={`/mobile/reimbursements/${item.id}`}>{actionLabel}</Link>
</Button> </Button>
</article> </article>
); );
@@ -99,7 +99,7 @@ export function MobileReimbursementsPage() {
onSuccess: async (response) => { onSuccess: async (response) => {
await queryClient.invalidateQueries({ queryKey: ["mobile", "reimbursements"] }); await queryClient.invalidateQueries({ queryKey: ["mobile", "reimbursements"] });
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] }); await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
navigate(`/reimbursements/${response.id}`); navigate(`/mobile/reimbursements/${response.id}`);
}, },
onError: (error) => { onError: (error) => {
toast.error(resolveErrorMessage(error)); toast.error(resolveErrorMessage(error));
+10
查看文件
@@ -34,6 +34,7 @@ import {
MobileLeavePage, MobileLeavePage,
MobileMePage, MobileMePage,
MobilePortalPage, MobilePortalPage,
MobileReimbursementDetailPage,
MobileReimbursementsPage, MobileReimbursementsPage,
NotificationChannelsPage, NotificationChannelsPage,
NotificationsPage, NotificationsPage,
@@ -320,6 +321,15 @@ export function AppRouter() {
} }
/> />
<Route
path="mobile/reimbursements/:reimbursementId"
element={
<RequirePermission anyOf={["expense.view_own", "expense.view_all"]}>
{withRouteFallback(<MobileReimbursementDetailPage />)}
</RequirePermission>
}
/>
<Route <Route
path="mobile/leave" path="mobile/leave"
element={ element={
@@ -0,0 +1 @@
export { MobileReimbursementDetailPage as default } from "../../pages/MobileReimbursementDetailPage";
+1
查看文件
@@ -21,6 +21,7 @@ export const MobileCapturePage = lazy(() => import("./MobileCapturePage"));
export const MobileLeavePage = lazy(() => import("./MobileLeavePage")); export const MobileLeavePage = lazy(() => import("./MobileLeavePage"));
export const MobileMePage = lazy(() => import("./MobileMePage")); export const MobileMePage = lazy(() => import("./MobileMePage"));
export const MobilePortalPage = lazy(() => import("./MobilePortalPage")); export const MobilePortalPage = lazy(() => import("./MobilePortalPage"));
export const MobileReimbursementDetailPage = lazy(() => import("./MobileReimbursementDetailPage"));
export const MobileReimbursementsPage = lazy(() => import("./MobileReimbursementsPage")); export const MobileReimbursementsPage = lazy(() => import("./MobileReimbursementsPage"));
export const NotificationChannelsPage = lazy(() => import("./NotificationChannelsPage")); export const NotificationChannelsPage = lazy(() => import("./NotificationChannelsPage"));
export const NotificationsPage = lazy(() => import("./NotificationsPage")); export const NotificationsPage = lazy(() => import("./NotificationsPage"));