实现企微原生材料预览(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
+7
@@ -14,6 +14,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@wecom/jssdk": "2.4.2",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -3486,6 +3487,12 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@wecom/jssdk": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@wecom/jssdk/-/jssdk-2.4.2.tgz",
|
||||
"integrity": "sha512-zlvP0fpkfUjStQNCLynOZyk9jGtnYvbqI94e3/86EyAJexY0X/Bb6bzgTvrFFa6jpz8LG2DXTYSnFhibneWG1g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@zxing/browser": {
|
||||
"version": "0.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.1.5.tgz",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@wecom/jssdk": "2.4.2",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -99,6 +99,20 @@ export async function fetchWecomConfig() {
|
||||
return apiRequestData<WecomEntryConfig>("/api/auth/wecom/config", { auth: false });
|
||||
}
|
||||
|
||||
export type WecomJsSdkSignature = {
|
||||
corpId: string;
|
||||
timestamp: number;
|
||||
nonceStr: string;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
export async function fetchWecomJsSdkSignature(url: string) {
|
||||
return apiRequestData<WecomJsSdkSignature>("/api/auth/wecom/js-sdk-signature", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startWecomBind(returnTo = "/settings/profile") {
|
||||
return apiRequestData<{ loginUrl: string }>("/api/auth/wecom/bind", {
|
||||
method: "POST",
|
||||
|
||||
@@ -254,7 +254,13 @@ export async function bindInvoice(
|
||||
}
|
||||
|
||||
export async function fetchInvoiceSignedUrl(invoiceId: string) {
|
||||
return apiRequestData<{ signedUrl: string; expiresAt: string }>(
|
||||
return apiRequestData<{
|
||||
signedUrl: string;
|
||||
expiresAt: string;
|
||||
originalName: string | null;
|
||||
mimeType: string | null;
|
||||
fileSize: number | null;
|
||||
}>(
|
||||
`/api/invoice-pool/${invoiceId}/signed-url`,
|
||||
{
|
||||
method: "POST",
|
||||
|
||||
@@ -175,6 +175,8 @@ export type ReimbursementDetail = {
|
||||
sellerName: string | null;
|
||||
totalAmount: string | null;
|
||||
originalName: string | null;
|
||||
mimeType: string | null;
|
||||
fileSize: number | null;
|
||||
linkedItemId: string | null;
|
||||
}>;
|
||||
attachments: Array<{
|
||||
@@ -525,6 +527,23 @@ export async function downloadReimbursementAttachment(reimbursementId: string, a
|
||||
return apiRequestBlob(buildAttachmentFileUrl(reimbursementAttachmentFileUrl(reimbursementId, attachmentId), reason));
|
||||
}
|
||||
|
||||
export async function fetchReimbursementAttachmentPreviewUrl(
|
||||
reimbursementId: string,
|
||||
attachmentId: string,
|
||||
reason?: string,
|
||||
) {
|
||||
return apiRequestData<{
|
||||
signedUrl: string;
|
||||
expiresAt: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
}>(`/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/preview-url`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(reason ? { reason } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export async function linkReimbursementProject(reimbursementId: string, projectId: string | null) {
|
||||
return apiRequestData<{ projectId: string | null; project: { id: string; projectNo: string; name: string } | null }>(`/api/reimbursements/${reimbursementId}/link-project`, {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { fetchWecomConfig, fetchWecomJsSdkSignature } from "../api/auth";
|
||||
import { isWecomWebView } from "./runtimeContext";
|
||||
|
||||
export type NativePreviewFile = {
|
||||
url: string;
|
||||
name: string | null | undefined;
|
||||
size: number | null | undefined;
|
||||
};
|
||||
|
||||
let configuredSdkPromise: Promise<typeof import("@wecom/jssdk")> | null = null;
|
||||
|
||||
function currentPageUrl() {
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = "";
|
||||
return url.href;
|
||||
}
|
||||
|
||||
async function configuredWecomSdk() {
|
||||
if (!configuredSdkPromise) {
|
||||
configuredSdkPromise = Promise.all([import("@wecom/jssdk"), fetchWecomConfig()])
|
||||
.then(([ww, config]) => {
|
||||
if (!config.corpId) {
|
||||
throw new Error("企业微信文件预览未配置");
|
||||
}
|
||||
ww.register({
|
||||
corpId: config.corpId,
|
||||
jsApiList: ["previewFile"],
|
||||
async getConfigSignature(url) {
|
||||
const signed = await fetchWecomJsSdkSignature(url || currentPageUrl());
|
||||
return {
|
||||
timestamp: signed.timestamp,
|
||||
nonceStr: signed.nonceStr,
|
||||
signature: signed.signature,
|
||||
};
|
||||
},
|
||||
});
|
||||
return ww;
|
||||
})
|
||||
.catch((error) => {
|
||||
configuredSdkPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return configuredSdkPromise;
|
||||
}
|
||||
|
||||
export async function previewFileInWecom(file: NativePreviewFile) {
|
||||
if (!isWecomWebView()) {
|
||||
return false;
|
||||
}
|
||||
const name = file.name?.trim();
|
||||
const size = Number(file.size);
|
||||
if (!name || !/\.[a-z0-9]{1,10}$/i.test(name) || !Number.isSafeInteger(size) || size <= 0) {
|
||||
throw new Error("文件信息不完整,暂时无法预览");
|
||||
}
|
||||
const ww = await configuredWecomSdk();
|
||||
await ww.previewFile({ url: file.url, name, size });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function openFileInBrowser(url: string) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
archiveInvoice,
|
||||
bindInvoice,
|
||||
fetchBindableInvoices,
|
||||
fetchInvoiceSignedUrl,
|
||||
fetchInvoicePoolList,
|
||||
unbindInvoice,
|
||||
uploadInvoice,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
deleteReimbursement,
|
||||
deleteReimbursementItem,
|
||||
downloadReimbursementAttachment,
|
||||
fetchReimbursementAttachmentPreviewUrl,
|
||||
fetchReimbursementDetail,
|
||||
MAX_REIMBURSEMENT_ATTACHMENT_FILES,
|
||||
precheckReimbursement,
|
||||
@@ -65,6 +67,8 @@ import { TEST_IDS } from "../lib/testIds";
|
||||
import { withUploadTimeout } from "../lib/uploadTimeout";
|
||||
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { isWecomWebView } from "../lib/runtimeContext";
|
||||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||
|
||||
type ItemFormState = {
|
||||
mode: "create" | "edit";
|
||||
@@ -678,10 +682,37 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
|
||||
|
||||
const openInvoiceMutation = useMutation({
|
||||
mutationFn: async (invoiceId: string) => {
|
||||
const result = await fetchInvoiceSignedUrl(invoiceId);
|
||||
const openedInWecom = await previewFileInWecom({
|
||||
url: result.signedUrl,
|
||||
name: result.originalName,
|
||||
size: result.fileSize,
|
||||
});
|
||||
if (!openedInWecom) {
|
||||
openFileInBrowser(result.signedUrl);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const openAttachmentMutation = useMutation({
|
||||
mutationFn: ({ attachmentId, reason }: { attachmentId: string; reason?: string }) =>
|
||||
downloadReimbursementAttachment(reimbursementId, attachmentId, reason),
|
||||
mutationFn: async ({ attachment, reason }: {
|
||||
attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields;
|
||||
reason?: string;
|
||||
}) => {
|
||||
if (isWecomWebView()) {
|
||||
const result = await fetchReimbursementAttachmentPreviewUrl(reimbursementId, attachment.id, reason);
|
||||
await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
|
||||
return null;
|
||||
}
|
||||
return downloadReimbursementAttachment(reimbursementId, attachment.id, reason);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (!result) return;
|
||||
const url = URL.createObjectURL(result.blob);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
@@ -691,12 +722,12 @@ export function MobileReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
async function handleOpenAttachment(attachment: { id: string; originalName?: string } & AttachmentGovernanceFields) {
|
||||
async function handleOpenAttachment(attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields) {
|
||||
const reason = await requestDownloadReason(attachment);
|
||||
if (reason === null) {
|
||||
return;
|
||||
}
|
||||
openAttachmentMutation.mutate({ attachmentId: attachment.id, reason });
|
||||
openAttachmentMutation.mutate({ attachment, reason });
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -1028,6 +1059,16 @@ export function MobileReimbursementDetailPage() {
|
||||
移除
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
className="mt-3 w-full"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={openInvoiceMutation.isPending}
|
||||
onClick={() => openInvoiceMutation.mutate(invoice.id)}
|
||||
>
|
||||
查看原件
|
||||
</Button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
@@ -1117,7 +1158,7 @@ export function MobileReimbursementDetailPage() {
|
||||
disabled={openAttachmentMutation.isPending}
|
||||
onClick={() => void handleOpenAttachment(attachment)}
|
||||
>
|
||||
查看
|
||||
查看原件
|
||||
</Button>
|
||||
{canManageAttachments ? (
|
||||
<Button
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
deleteReimbursement,
|
||||
deleteReimbursementItem,
|
||||
downloadReimbursementAttachment,
|
||||
fetchReimbursementAttachmentPreviewUrl,
|
||||
fetchReimbursementDetail,
|
||||
MAX_REIMBURSEMENT_ATTACHMENT_FILES,
|
||||
precheckReimbursement,
|
||||
@@ -26,10 +27,13 @@ import { remindWorkflowTask } from "../api/workflow";
|
||||
import {
|
||||
bindInvoice,
|
||||
fetchBindableInvoices,
|
||||
fetchInvoiceSignedUrl,
|
||||
unbindInvoice,
|
||||
uploadInvoice,
|
||||
} from "../api/invoicePool";
|
||||
import { toast } from "sonner";
|
||||
import { isWecomWebView } from "../lib/runtimeContext";
|
||||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
@@ -674,10 +678,37 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
|
||||
|
||||
const openInvoiceMutation = useMutation({
|
||||
mutationFn: async (invoiceId: string) => {
|
||||
const result = await fetchInvoiceSignedUrl(invoiceId);
|
||||
const openedInWecom = await previewFileInWecom({
|
||||
url: result.signedUrl,
|
||||
name: result.originalName,
|
||||
size: result.fileSize,
|
||||
});
|
||||
if (!openedInWecom) {
|
||||
openFileInBrowser(result.signedUrl);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(buildActionErrorMessage(error, "打开发票失败"));
|
||||
},
|
||||
});
|
||||
|
||||
const openAttachmentMutation = useMutation({
|
||||
mutationFn: ({ attachmentId, reason }: { attachmentId: string; reason?: string }) =>
|
||||
downloadReimbursementAttachment(reimbursementId, attachmentId, reason),
|
||||
mutationFn: async ({ attachment, reason }: {
|
||||
attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields;
|
||||
reason?: string;
|
||||
}) => {
|
||||
if (isWecomWebView()) {
|
||||
const result = await fetchReimbursementAttachmentPreviewUrl(reimbursementId, attachment.id, reason);
|
||||
await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
|
||||
return null;
|
||||
}
|
||||
return downloadReimbursementAttachment(reimbursementId, attachment.id, reason);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (!result) return;
|
||||
const url = URL.createObjectURL(result.blob);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
@@ -687,12 +718,12 @@ export function ReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
async function handleOpenAttachment(attachment: { id: string; originalName?: string } & AttachmentGovernanceFields) {
|
||||
async function handleOpenAttachment(attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields) {
|
||||
const reason = await requestDownloadReason(attachment);
|
||||
if (reason === null) {
|
||||
return;
|
||||
}
|
||||
openAttachmentMutation.mutate({ attachmentId: attachment.id, reason });
|
||||
openAttachmentMutation.mutate({ attachment, reason });
|
||||
}
|
||||
|
||||
if (!reimbursementId) {
|
||||
@@ -1423,6 +1454,15 @@ export function ReimbursementDetailPage() {
|
||||
<span className="badge-neutral">
|
||||
{resolveInvoiceStatusLabel(item.status)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={openInvoiceMutation.isPending}
|
||||
onClick={() => openInvoiceMutation.mutate(item.id)}
|
||||
>
|
||||
查看原件
|
||||
</Button>
|
||||
{canUnbindInvoices ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1515,7 +1555,7 @@ export function ReimbursementDetailPage() {
|
||||
disabled={openAttachmentMutation.isPending}
|
||||
onClick={() => void handleOpenAttachment(attachment)}
|
||||
>
|
||||
查看
|
||||
查看原件
|
||||
</Button>
|
||||
{canEditItems ? (
|
||||
<Button
|
||||
|
||||
@@ -78,6 +78,8 @@ function buildInvoice() {
|
||||
sellerName: "北京悦海堂餐饮管理有限公司",
|
||||
totalAmount: "1683.00",
|
||||
originalName: "北京悦海堂发票.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 4096,
|
||||
linkedItemId: "item-r111",
|
||||
};
|
||||
}
|
||||
@@ -223,6 +225,16 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/auth/wecom/js-sdk-signature" && request.method() === "POST") {
|
||||
await fulfillJson(route, {
|
||||
corpId: "ww-test",
|
||||
timestamp: 1785484800,
|
||||
nonceStr: "synthetic-r111-nonce",
|
||||
signature: "1111111111111111111111111111111111111111",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -327,6 +339,33 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
pathName === `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/preview-url`
|
||||
&& request.method() === "POST"
|
||||
) {
|
||||
await fulfillJson(route, {
|
||||
signedUrl: `https://oa.hyinda.com/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file?previewToken=synthetic`,
|
||||
expiresAt: "2026-07-31T10:02:00.000Z",
|
||||
originalName: "付款截图.png",
|
||||
mimeType: "image/png",
|
||||
fileSize: 2048,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
pathName === `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file`
|
||||
&& request.method() === "GET"
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "image/png",
|
||||
headers: { "content-disposition": 'inline; filename="attachment.png"' },
|
||||
body: Buffer.from("synthetic-r111-attachment"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/invoice-pool/bindable") {
|
||||
await fulfillJson(route, {
|
||||
items: state.availableInvoices,
|
||||
@@ -405,6 +444,17 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/invoice-pool/${pendingInvoiceId}/signed-url` && request.method() === "POST") {
|
||||
await fulfillJson(route, {
|
||||
signedUrl: `https://oa.hyinda.com/api/invoice-pool/${pendingInvoiceId}/file?token=synthetic`,
|
||||
expiresAt: "2026-07-31T10:02:00.000Z",
|
||||
originalName: "北京悦海堂发票.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 4096,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/invoice-pool/${pendingInvoiceId}/unbind`) {
|
||||
state.invoices = [];
|
||||
await fulfillJson(route, {
|
||||
@@ -515,6 +565,14 @@ async function collectVisualMetrics(page: Page) {
|
||||
test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
test("PC 报销详情上传发票后自动添加,并提供附件材料", async ({ page }) => {
|
||||
const state = buildReimbursementState();
|
||||
await page.addInitScript(() => {
|
||||
const openCalls: unknown[][] = [];
|
||||
Object.defineProperty(window, "__r111OpenCalls", { value: openCalls, configurable: true });
|
||||
window.open = ((...args: unknown[]) => {
|
||||
openCalls.push(args);
|
||||
return null;
|
||||
}) as typeof window.open;
|
||||
});
|
||||
await mockApis(page, state);
|
||||
await login(page);
|
||||
|
||||
@@ -540,6 +598,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();
|
||||
await expect(page.getByText("明细:业务招待 · 客户招待")).toBeVisible();
|
||||
await page.getByRole("button", { name: "查看原件" }).first().click();
|
||||
await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(1);
|
||||
const invoiceOpenUrl = await page.evaluate(
|
||||
() => String((window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls[0]?.[0] ?? ""),
|
||||
);
|
||||
expect(invoiceOpenUrl).toContain(`/api/invoice-pool/${pendingInvoiceId}/file?token=`);
|
||||
|
||||
await page.locator("#reimbursement-attachment-upload").setInputFiles({
|
||||
name: "付款截图.png",
|
||||
@@ -548,6 +612,8 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
});
|
||||
await page.getByRole("button", { name: "上传附件" }).click();
|
||||
await expect(page.getByText("付款截图.png")).toBeVisible();
|
||||
await page.getByTestId("reimbursement-attachments").getByRole("button", { name: "查看原件" }).click();
|
||||
await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(2);
|
||||
|
||||
const bodyText = await page.locator("body").innerText();
|
||||
expect(bodyText).not.toContain("绑定整单");
|
||||
@@ -564,6 +630,26 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
test("企业微信手机端保持移动布局,发票和附件能力与 PC 一致", async ({ browser }) => {
|
||||
const state = buildReimbursementState();
|
||||
const mobile = await newWecomPage(browser);
|
||||
await mobile.page.addInitScript(() => {
|
||||
const bridgeCalls: Array<{ name: string; params: Record<string, unknown> }> = [];
|
||||
Object.defineProperty(window, "__r111BridgeCalls", { value: bridgeCalls, configurable: true });
|
||||
Object.defineProperty(window, "WeixinJSBridge", {
|
||||
configurable: true,
|
||||
value: {
|
||||
invoke(name: string, params: Record<string, unknown>, callback: (result: Record<string, unknown>) => void) {
|
||||
bridgeCalls.push({ name, params });
|
||||
callback({ err_msg: `${name}:ok` });
|
||||
},
|
||||
on() {},
|
||||
},
|
||||
});
|
||||
const openCalls: unknown[][] = [];
|
||||
Object.defineProperty(window, "__r111OpenCalls", { value: openCalls, configurable: true });
|
||||
window.open = ((...args: unknown[]) => {
|
||||
openCalls.push(args);
|
||||
return null;
|
||||
}) as typeof window.open;
|
||||
});
|
||||
await mockApis(mobile.page, state);
|
||||
|
||||
try {
|
||||
@@ -627,6 +713,10 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await mobile.page.keyboard.press("Escape");
|
||||
await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();
|
||||
await mobile.page.getByRole("button", { name: "查看原件" }).first().click();
|
||||
await expect.poll(() => mobile.page.evaluate(() => (
|
||||
window as typeof window & { __r111BridgeCalls: Array<{ name: string }> }
|
||||
).__r111BridgeCalls.filter((call) => call.name === "previewFile").length)).toBe(1);
|
||||
|
||||
await mobile.page.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: "选择" }).click();
|
||||
await mobile.page.locator("[data-testid='mobile-reimbursement-attachments'] input[type='file']").setInputFiles({
|
||||
@@ -636,6 +726,32 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
});
|
||||
await mobile.page.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: "上传附件" }).click();
|
||||
await expect(mobile.page.getByText("付款截图.png")).toBeVisible();
|
||||
await mobile.page.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: "查看原件" }).click();
|
||||
await expect.poll(() => mobile.page.evaluate(() => (
|
||||
window as typeof window & { __r111BridgeCalls: Array<{ name: string }> }
|
||||
).__r111BridgeCalls.filter((call) => call.name === "previewFile").length)).toBe(2);
|
||||
const nativePreviewCalls = await mobile.page.evaluate(() => (
|
||||
window as typeof window & { __r111BridgeCalls: Array<{ name: string; params: Record<string, unknown> }> }
|
||||
).__r111BridgeCalls.filter((call) => call.name === "previewFile"));
|
||||
expect(nativePreviewCalls).toEqual([
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
url: expect.stringContaining(`/api/invoice-pool/${pendingInvoiceId}/file?token=`),
|
||||
name: "北京悦海堂发票.pdf",
|
||||
size: 4096,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
url: expect.stringContaining(`/attachments/${attachmentId}/file?previewToken=`),
|
||||
name: "付款截图.png",
|
||||
size: 2048,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(await mobile.page.evaluate(() => (
|
||||
window as typeof window & { __r111OpenCalls: unknown[][] }
|
||||
).__r111OpenCalls.length)).toBe(0);
|
||||
|
||||
const bodyText = await mobile.page.locator("body").innerText();
|
||||
expect(bodyText).not.toContain("绑定整单");
|
||||
|
||||
在新工单中引用
屏蔽一个用户