R109/A2,A3,A4 改进移动报销和发票上传体验
这个提交包含在:
@@ -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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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"));
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户