diff --git a/client/src/pages/MobileCapturePage.tsx b/client/src/pages/MobileCapturePage.tsx index fc31e2e3..1054abf8 100644 --- a/client/src/pages/MobileCapturePage.tsx +++ b/client/src/pages/MobileCapturePage.tsx @@ -196,26 +196,73 @@ export function MobileCapturePage() { - - { - setSelectedFiles(Array.from(event.target.files ?? [])); - }} - multiple - type="file" - /> +
+ + { + setSelectedFiles(Array.from(event.target.files ?? [])); + }} + type="file" + /> + + + { + setSelectedFiles(Array.from(event.target.files ?? [])); + }} + type="file" + /> + + + { + setSelectedFiles(Array.from(event.target.files ?? [])); + }} + type="file" + /> +
当前文件: diff --git a/client/src/pages/MobileMePage.tsx b/client/src/pages/MobileMePage.tsx index 78c037ab..f38d9e6c 100644 --- a/client/src/pages/MobileMePage.tsx +++ b/client/src/pages/MobileMePage.tsx @@ -103,7 +103,7 @@ export function MobileMePage() {
{me?.id ? ( - + ) : null}
diff --git a/client/src/pages/MobileNotificationsPage.tsx b/client/src/pages/MobileNotificationsPage.tsx new file mode 100644 index 00000000..6581d489 --- /dev/null +++ b/client/src/pages/MobileNotificationsPage.tsx @@ -0,0 +1,232 @@ +import { Link, useSearchParams } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { fetchNotifications, markAllNotificationsRead, markNotificationRead } from "../api/notifications"; +import { isApiError } from "../api/types"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { PageHeader } from "../components/common/PageHeader"; +import { Pagination } from "../components/common/Pagination"; +import { PageScaffold } from "../components/patterns/PageScaffold"; +import { Button } from "../components/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; +import { resolveNotificationTargetLink } from "../lib/notificationLinks"; +import { resolveMobileEquivalentPath } from "../lib/runtimeContext"; +import { resolveErrorMessage } from "../lib/resolveErrorMessage"; + +const PAGE_SIZE = 20; + +const NOTIFICATION_TYPE_OPTIONS = [ + { label: "全部", value: "" }, + { label: "审批待办", value: "workflow.task_assigned" }, + { label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid" }, + { label: "请假", value: "leave.approved,leave.rejected" }, + { label: "采购", value: "purchase_request.approved,purchase_request.rejected" }, + { label: "差旅", value: "travel_request.approved,travel_request.rejected" }, + { label: "资产", value: "asset.assigned,asset.returned,asset.transferred" }, + { label: "盘点", value: "inventory_check.completed" }, +]; + +function resolveNotificationError(error: unknown) { + if (isApiError(error)) { + return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; + } + return "加载通知失败"; +} + +function resolveMobileNotificationTarget(item: Parameters[0]) { + const targetLink = resolveNotificationTargetLink(item); + if (!targetLink) return null; + const [pathname, search = ""] = targetLink.split("?"); + return resolveMobileEquivalentPath(pathname, search ? `?${search}` : ""); +} + +function formatTime(value: string) { + return value.slice(0, 19).replace("T", " "); +} + +export function MobileNotificationsPage() { + const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); + + const unreadOnlyRaw = searchParams.get("unreadOnly") ?? "0"; + const unreadOnly = unreadOnlyRaw === "1" || unreadOnlyRaw === "true"; + const page = Number(searchParams.get("page") ?? "1") || 1; + const selectedType = searchParams.get("type") ?? ""; + + const updateFilters = (next: { unreadOnly?: boolean; page?: number; type?: string }) => { + const params = new URLSearchParams(); + params.set("unreadOnly", (next.unreadOnly ?? unreadOnly) ? "1" : "0"); + params.set("page", String(next.page ?? page)); + const nextType = next.type ?? selectedType; + if (nextType) { + params.set("type", nextType); + } + setSearchParams(params); + }; + + const listQuery = useQuery({ + queryKey: ["notifications", "list", unreadOnly, selectedType, page], + queryFn: () => + fetchNotifications({ + unreadOnly, + page, + pageSize: PAGE_SIZE, + ...(selectedType ? { type: selectedType } : {}), + }), + }); + + const markReadMutation = useMutation({ + mutationFn: async (notificationId: string) => markNotificationRead(notificationId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["notifications"] }); + }, + }); + + const markAllReadMutation = useMutation({ + mutationFn: () => markAllNotificationsRead(), + onSuccess: async () => { + toast.success("已全部标为已读"); + await queryClient.invalidateQueries({ queryKey: ["notifications"] }); + }, + onError: (error) => { + toast.error(resolveErrorMessage(error)); + }, + }); + + const notificationItems = listQuery.data?.items ?? []; + const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); + + return ( + +
+ 未读 {listQuery.data?.unreadCount ?? 0}} + actions={ + + } + /> + +
+
+ + updateFilters({ + unreadOnly: value === "unread", + page: 1, + }) + } + > + + + 全部 + + + 未读 + + + + + +
+ + {listQuery.isLoading ? ( +
加载中...
+ ) : null} + + {listQuery.isError ? ( +

+ {resolveNotificationError(listQuery.error)} +

+ ) : null} + + {!listQuery.isLoading && !listQuery.isError && !notificationItems.length ? ( +
暂无通知
+ ) : null} + + {notificationItems.map((item) => { + const targetLink = resolveMobileNotificationTarget(item); + return ( +
+
+
+
{item.title}
+ {item.body ?
{item.body}
: null} +
+ + {item.readAt ? "已读" : "未读"} + +
+ +
{formatTime(item.createdAt)}
+
+ {targetLink ? ( + + ) : null} + + {!item.readAt ? ( + + ) : null} +
+
+ ); + })} + + + updateFilters({ + page: nextPage, + }) + } + testIdPrefix="mobile-notifications" + /> +
+
+
+ ); +} + +export default MobileNotificationsPage; diff --git a/client/src/pages/MobileProfilePage.tsx b/client/src/pages/MobileProfilePage.tsx new file mode 100644 index 00000000..b3e0fe57 --- /dev/null +++ b/client/src/pages/MobileProfilePage.tsx @@ -0,0 +1,222 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useSearchParams } from "react-router-dom"; +import { fetchWecomConfig, startWecomBind, unbindWecom, updateNotificationPreferences } from "../api/auth"; +import { isApiError } from "../api/types"; +import { useAuth } from "../auth/AuthContext"; +import { PageHeader } from "../components/common/PageHeader"; +import { PageScaffold } from "../components/patterns/PageScaffold"; +import { SectionPanel } from "../components/patterns/SectionPanel"; +import { Button } from "../components/ui/button"; +import { + resolveWecomBindNotice, + resolveWecomEntryStatus, + type WecomNotice, +} from "../lib/wecomEntry"; + +function noticeClasses(tone: WecomNotice["tone"]) { + if (tone === "success") { + return "border-emerald-200 bg-emerald-50 text-emerald-700"; + } + if (tone === "warning") { + return "border-amber-200 bg-amber-50 text-amber-700"; + } + return "border-zinc-200 bg-zinc-50 text-zinc-700"; +} + +function statusTone(status?: string) { + if (status === "active") return "badge-success"; + if (status === "inactive") return "badge-warning"; + return "badge-neutral"; +} + +function resolveProfileError(error: unknown) { + if (isApiError(error)) { + return `${error.code}: ${error.message}`; + } + if (error instanceof Error) { + return error.message; + } + return "请求失败,请稍后重试。"; +} + +function resolveBinding( + binding: { + externalUserId?: string; + userId?: string; + createdAt?: string | null; + boundAt?: string | null; + } | null | undefined, +) { + return { + externalUserId: binding?.externalUserId ?? binding?.userId ?? "", + createdAt: binding?.createdAt ?? binding?.boundAt ?? null, + }; +} + +function maskExternalUserId(value: string) { + if (!value) return "-"; + if (value.length <= 8) { + return value; + } + return `${value.slice(0, 4)}****${value.slice(-4)}`; +} + +export function MobileProfilePage() { + const { me, refreshMe } = useAuth(); + const [searchParams] = useSearchParams(); + const wecomConfigQuery = useQuery({ + queryKey: ["auth", "wecom-config"], + queryFn: () => fetchWecomConfig(), + staleTime: 60_000, + }); + + const bindMutation = useMutation({ + mutationFn: async () => startWecomBind("/mobile/profile"), + onSuccess: (payload) => { + window.location.assign(payload.loginUrl); + }, + onError: (error) => { + toast.error(resolveProfileError(error)); + }, + }); + + const unbindMutation = useMutation({ + mutationFn: () => unbindWecom(), + onSuccess: async () => { + await refreshMe(); + toast.success("企业微信已解绑"); + }, + onError: (error) => { + toast.error(resolveProfileError(error)); + }, + }); + + const notificationPreferenceMutation = useMutation({ + mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }), + onSuccess: async () => { + await refreshMe(); + toast.success("外部推送偏好已更新"); + }, + onError: (error) => { + toast.error(resolveProfileError(error)); + }, + }); + + const binding = resolveBinding(me?.wecomBinding); + const isBound = Boolean(binding.externalUserId); + const wecomStatus = resolveWecomEntryStatus(wecomConfigQuery.data); + const bindNotice = resolveWecomBindNotice(searchParams.get("wecomBind")); + + return ( + +
+ {me?.status === "active" ? "账号正常" : "账号待确认"}} + /> + + {bindNotice ? ( +
+
{bindNotice.title}
+
{bindNotice.description}
+
+ ) : null} + + +
+
+
姓名
+
{me?.name ?? "-"}
+
+
+
员工号
+
{me?.employeeNo ?? "-"}
+
+
+
邮箱
+
{me?.email ?? "-"}
+
+
+
手机号
+
{me?.phone ?? "-"}
+
+
+
+ + + + + + +
+
+
接入状态
+
{wecomStatus.label}
+
{wecomStatus.description}
+
+ +
+
+
+
当前绑定
+
{isBound ? "已绑定" : "未绑定"}
+
+ {isBound ? "可用" : "待绑定"} +
+ {isBound ? ( +
+
绑定标识:{maskExternalUserId(binding.externalUserId)}
+
绑定时间:{binding.createdAt ? new Date(binding.createdAt).toLocaleString() : "-"}
+
+ ) : null} +
+ +
+ + +
+
+
+ + +
+
+ {me?.status === "active" ? "active" : me?.status ?? "unknown"} + {(me?.roleCodes ?? []).map((role) => ( + {role} + ))} +
+

角色和权限由管理员统一维护。

+
+
+
+
+ ); +} + +export default MobileProfilePage; diff --git a/client/src/pages/MobileReimbursementsPage.tsx b/client/src/pages/MobileReimbursementsPage.tsx index cae2c3e6..9b0436cd 100644 --- a/client/src/pages/MobileReimbursementsPage.tsx +++ b/client/src/pages/MobileReimbursementsPage.tsx @@ -4,12 +4,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { createReimbursement, + deleteReimbursement, fetchReimbursements, type ReimbursementListItem, type ReimbursementStatus, } from "../api/reimbursements"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; +import { ConfirmDialog } from "../components/common/ConfirmDialog"; import { Button } from "../components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; import { PageHeader } from "../components/common/PageHeader"; @@ -48,7 +50,17 @@ function formatDate(value: string | null | undefined) { return value || "未填写日期"; } -function ReimbursementCard({ item }: { item: ReimbursementListItem }) { +function ReimbursementCard({ + item, + canDeleteDraft, + isDeleting, + onDelete, +}: { + item: ReimbursementListItem; + canDeleteDraft: boolean; + isDeleting: boolean; + onDelete: (item: ReimbursementListItem) => void; +}) { const actionLabel = item.status === "draft" || item.status === "rejected" ? "继续编辑" : "查看详情"; const displayCode = resolveReimbursementDisplayCode(item); @@ -75,9 +87,23 @@ function ReimbursementCard({ item }: { item: ReimbursementListItem }) { {item.description ?

{item.description}

: null} - +
+ + {canDeleteDraft ? ( + + ) : null} +
); } @@ -87,7 +113,9 @@ export function MobileReimbursementsPage() { const queryClient = useQueryClient(); const { permissions } = useAuth(); const [status, setStatus] = useState("draft"); + const [deleteTarget, setDeleteTarget] = useState(null); const canCreateExpense = hasPermission(permissions, "expense.create"); + const canDeleteExpense = hasPermission(permissions, "expense.delete_own"); const listQuery = useQuery({ queryKey: ["mobile", "reimbursements", status], @@ -106,6 +134,21 @@ export function MobileReimbursementsPage() { }, }); + const deleteMutation = useMutation({ + mutationFn: (reimbursementId: string) => deleteReimbursement(reimbursementId), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["mobile", "reimbursements"] }), + queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), + ]); + setDeleteTarget(null); + toast.success("草稿已删除"); + }, + onError: (error) => { + toast.error(resolveErrorMessage(error)); + }, + }); + const items = listQuery.data?.items ?? []; const total = listQuery.data?.total ?? 0; const totalAmount = useMemo( @@ -166,10 +209,35 @@ export function MobileReimbursementsPage() {
暂无报销记录
) : null} {items.map((item) => ( - + ))}
+ + { + if (!open) { + setDeleteTarget(null); + } + }} + title="删除草稿?" + description="删除后无法恢复,已添加发票会回到可用状态。" + variant="destructive" + confirmLabel="删除草稿" + onConfirm={async () => { + if (!deleteTarget) { + return; + } + await deleteMutation.mutateAsync(deleteTarget.id); + }} + /> ); } diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 5d7e64df..adc68b1a 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -33,7 +33,9 @@ import { MobileCapturePage, MobileLeavePage, MobileMePage, + MobileNotificationsPage, MobilePortalPage, + MobileProfilePage, MobileReimbursementDetailPage, MobileReimbursementsPage, NotificationChannelsPage, @@ -303,9 +305,9 @@ export function AppRouter() { )} /> - } /> + )} /> - )} /> + )} /> import("./MobileAssetScanPage")); export const MobileCapturePage = lazy(() => import("./MobileCapturePage")); export const MobileLeavePage = lazy(() => import("./MobileLeavePage")); export const MobileMePage = lazy(() => import("./MobileMePage")); +export const MobileNotificationsPage = lazy(() => import("./MobileNotificationsPage")); export const MobilePortalPage = lazy(() => import("./MobilePortalPage")); +export const MobileProfilePage = lazy(() => import("./MobileProfilePage")); export const MobileReimbursementDetailPage = lazy(() => import("./MobileReimbursementDetailPage")); export const MobileReimbursementsPage = lazy(() => import("./MobileReimbursementsPage")); export const NotificationChannelsPage = lazy(() => import("./NotificationChannelsPage")); diff --git a/client/tests/e2e/r124-mobile-entry-matrix.spec.ts b/client/tests/e2e/r124-mobile-entry-matrix.spec.ts new file mode 100644 index 00000000..27f6ca1f --- /dev/null +++ b/client/tests/e2e/r124-mobile-entry-matrix.spec.ts @@ -0,0 +1,547 @@ +import { mkdirSync } from "node:fs"; +import path from "node:path"; +import { expect, test, type Browser, type Page, type Route } from "@playwright/test"; + +const ROOT_DIR = path.resolve(process.cwd(), ".."); +const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r124-mobile-entry-matrix"); +const WECOM_UA = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN"; + +type MockReimbursement = { + id: string; + formNumber: string; + status: "draft" | "reviewing"; + reimbursementDate: string; + totalAmount: string; + description: string; +}; + +type MockNotification = { + id: string; + type: string; + title: string; + body: string | null; + targetType: string | null; + targetId: string | null; + readAt: string | null; + createdAt: string; +}; + +function envelope(data: unknown) { + return { requestId: "mock-r124-mobile-entry-matrix", data }; +} + +async function fulfillJson(route: Route, data: unknown, status = 200) { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(envelope(data)), + }); +} + +function screenshotPath(fileName: string) { + mkdirSync(SCREENSHOT_DIR, { recursive: true }); + return path.join(SCREENSHOT_DIR, fileName.endsWith(".png") ? fileName : `${fileName}.png`); +} + +function buildMe() { + return { + id: "employee-r124", + employeeNo: "E124", + name: "测试员工", + phone: "13800000000", + email: "employee@hyinda.com", + departmentId: "dept-r124", + directManagerId: null, + status: "active", + notificationPushEnabled: true, + roleCodes: ["manager"], + profileCompleteness: { + hasEmergencyContact: true, + hasBankAccount: true, + hasAvatar: false, + hasIdNumber: false, + score: 60, + }, + wecomBinding: { + externalUserId: "wxwork-r124-user", + createdAt: "2026-05-08T08:00:00.000Z", + }, + }; +} + +function navigationItem(key: string, href: string, label: string, icon: string) { + return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null }; +} + +function buildState() { + return { + reimbursements: [ + { + id: "r124-draft", + formNumber: "BX20260508024", + status: "draft", + reimbursementDate: "2026-05-08", + totalAmount: "168.00", + description: "R124 删除草稿", + }, + { + id: "r124-reviewing", + formNumber: "BX20260508025", + status: "reviewing", + reimbursementDate: "2026-05-08", + totalAmount: "266.00", + description: "R124 审批中不可删除", + }, + ] satisfies MockReimbursement[], + notifications: [ + { + id: "notification-r124-1", + type: "workflow.task_assigned", + title: "报销待审批", + body: "BX20260508025 等待处理", + targetType: "workflow_task", + targetId: "task-r124", + readAt: null, + createdAt: "2026-05-08T08:00:00.000Z", + }, + { + id: "notification-r124-2", + type: "reimbursement.approved", + title: "报销已通过", + body: "BX20260508024 已通过", + targetType: "reimbursement_form", + targetId: "r124-draft", + readAt: "2026-05-08T08:10:00.000Z", + createdAt: "2026-05-08T07:50:00.000Z", + }, + ] satisfies MockNotification[], + }; +} + +function reimbursementItem(item: MockReimbursement) { + return { + ...item, + payment: { + paymentVoucherNo: null, + paymentDate: null, + paymentChannel: null, + paidAt: null, + paidBy: null, + }, + owner: { id: "employee-r124", employeeNo: "E124", name: "测试员工" }, + submitter: { id: "employee-r124", employeeNo: "E124", name: "测试员工" }, + project: null, + pendingWorkflow: null, + createdAt: "2026-05-08T08:00:00.000Z", + updatedAt: "2026-05-08T08:00:00.000Z", + }; +} + +async function mockApis(page: Page, state: ReturnType) { + await page.route((url) => url.pathname.startsWith("/api/"), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const pathName = url.pathname; + + if (pathName === "/api/auth/sso/providers") { + await fulfillJson(route, []); + return; + } + + if (pathName === "/api/auth/wecom/config") { + await fulfillJson(route, { + enabled: true, + entryEnabled: true, + oauthEnabled: true, + corpId: "ww-test", + agentId: "1000002", + loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile", + bindEnabled: true, + defaultReturnTo: "/mobile", + status: "ready", + statusReasons: [], + }); + return; + } + + if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") { + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" }, + body: JSON.stringify(envelope({ accessToken: "mock-r124-token", expiresIn: 900, mustChangePassword: false })), + }); + return; + } + + if (pathName === "/api/me") { + await fulfillJson(route, buildMe()); + return; + } + + if (pathName === "/api/me/notification-preferences") { + await fulfillJson(route, { notificationPushEnabled: true }); + return; + } + + if (pathName === "/api/system/navigation") { + await fulfillJson(route, { + workspace: { + id: "workspace-r124", + persisted: true, + profile: "small_team_internal", + orgMode: "single_company", + primaryEntryMode: "wecom", + notificationStrategy: "in_app", + devicePriority: "mobile", + moduleVisibility: {}, + createdAt: null, + updatedAt: null, + updatedBy: null, + }, + sections: { + main: [ + navigationItem("portal", "/portal", "工作台", "home_app_logo"), + navigationItem("expense", "/reimbursements", "报销", "request_quote"), + navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"), + ], + admin: [], + backup: [], + }, + recommendedRolePresets: [], + }); + return; + } + + if (pathName === "/api/portal/home") { + await fulfillJson(route, { + workspace: { id: "workspace-r124", persisted: true, profile: "small_team_internal" }, + greeting: { name: "测试员工" }, + cards: [], + pendingTasks: { count: 1, href: "/workflow/tasks" }, + myApplications: [], + riskSummary: null, + announcements: [], + quickLinks: [{ key: "expense", title: "新建报销", href: "/reimbursements" }], + todayPresence: null, + teamPresence: null, + weeklyReport: null, + weeklySummary: null, + todoSummary: null, + projectSummary: null, + peopleLifecycle: null, + adoptionSummary: null, + recentActivities: [], + teamStatusBoard: [], + }); + return; + } + + if (pathName === "/api/attendance/me/today") { + await fulfillJson(route, { workDate: "2026-05-08", needClockIn: false, exemptionReason: null, item: null }); + return; + } + + if (pathName === "/api/workflow/tasks") { + await fulfillJson(route, { + page: 1, + pageSize: 100, + total: 1, + items: [ + { + id: "task-r124", + instanceId: "instance-r124", + stepId: "step-r124", + stepName: "主管审批", + assigneeId: "employee-r124", + assigneeRole: null, + status: "pending", + outcome: null, + createdAt: "2026-05-08T08:00:00.000Z", + completedAt: null, + entityType: "reimbursement", + entityId: "r124-reviewing", + reference: "BX20260508025", + allowedActions: ["approve", "reject", "return", "transfer"], + }, + ], + }); + return; + } + + if (pathName === "/api/workflow/approvers") { + await fulfillJson(route, { page: 1, pageSize: 100, total: 0, items: [] }); + return; + } + + if (pathName === "/api/notifications" && request.method() === "GET") { + const unreadOnly = url.searchParams.get("unreadOnly") === "1" || url.searchParams.get("unreadOnly") === "true"; + const items = state.notifications.filter((item) => !unreadOnly || !item.readAt); + await fulfillJson(route, { + page: 1, + pageSize: Number(url.searchParams.get("pageSize") ?? 20), + total: items.length, + unreadOnly, + unreadCount: state.notifications.filter((item) => !item.readAt).length, + items, + }); + return; + } + + const markReadMatch = pathName.match(/^\/api\/notifications\/([^/]+)\/mark-read$/); + if (markReadMatch && request.method() === "POST") { + const item = state.notifications.find((candidate) => candidate.id === markReadMatch[1]); + if (item) { + item.readAt = "2026-05-08T08:20:00.000Z"; + } + await fulfillJson(route, { id: markReadMatch[1], readAt: item?.readAt ?? null }); + return; + } + + if (pathName === "/api/notifications/mark-all-read" && request.method() === "POST") { + for (const item of state.notifications) { + item.readAt = item.readAt ?? "2026-05-08T08:20:00.000Z"; + } + await fulfillJson(route, { updatedCount: state.notifications.length }); + return; + } + + if (pathName === "/api/reimbursements" && request.method() === "GET") { + const status = url.searchParams.get("status"); + const items = state.reimbursements.filter((item) => !status || item.status === status).map(reimbursementItem); + await fulfillJson(route, { + page: 1, + pageSize: Number(url.searchParams.get("pageSize") ?? 20), + total: items.length, + scope: "own", + items, + }); + return; + } + + const reimbursementDeleteMatch = pathName.match(/^\/api\/reimbursements\/([^/]+)$/); + if (reimbursementDeleteMatch && request.method() === "DELETE") { + state.reimbursements = state.reimbursements.filter((item) => item.id !== reimbursementDeleteMatch[1]); + await fulfillJson(route, { + reimbursementId: reimbursementDeleteMatch[1], + outcome: "deleted", + unboundInvoiceCount: 0, + }); + return; + } + + if (pathName === "/api/leave-types") { + await fulfillJson(route, { + items: [ + { + id: "leave-type-r124", + code: "annual", + name: "年假", + description: null, + enabled: true, + requiresAttachment: false, + attachmentMimeTypes: [], + sortOrder: 1, + annualQuotaDays: 5, + policyDescription: null, + }, + ], + }); + return; + } + + if (pathName === "/api/leave-balances") { + await fulfillJson(route, { + items: [ + { + employeeId: "employee-r124", + leaveType: { id: "leave-type-r124", name: "年假" }, + annualQuotaDays: 5, + used: 1, + reserved: 0, + available: 4, + }, + ], + }); + return; + } + + if (pathName === "/api/leave-requests") { + await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] }); + return; + } + + if (pathName === "/api/employees/employee-r124/profile") { + await fulfillJson(route, { + employee: { + id: "employee-r124", + employeeNo: "E124", + name: "测试员工", + phone: "13800000000", + email: "employee@hyinda.com", + status: "active", + departmentId: "dept-r124", + department: { id: "dept-r124", name: "研发部" }, + directManagerId: null, + directManager: null, + title: "工程师", + employmentStatus: "active", + hireDate: "2026-01-01", + avatarUrl: null, + roleCodes: ["manager"], + createdAt: null, + updatedAt: null, + }, + }); + return; + } + + await fulfillJson(route, { message: `unhandled mock api: ${pathName}` }, 404); + }); +} + +async function loginForMobile(page: Page) { + await page.goto("/login"); + const accountLoginButton = page.getByRole("button", { name: "使用账号密码登录" }); + if (await accountLoginButton.isVisible({ timeout: 1_000 }).catch(() => false)) { + await accountLoginButton.click(); + } + await expect(page.getByTestId("login-identifier")).toBeVisible(); + await page.getByTestId("login-identifier").fill("employee@hyinda.com"); + await page.getByTestId("login-password").fill("password"); + await page.getByTestId("login-submit").click(); + await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 }); + await page.waitForLoadState("networkidle").catch(() => undefined); +} + +async function newWecomPage(browser: Browser) { + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + userAgent: WECOM_UA, + locale: "zh-CN", + serviceWorkers: "block", + }); + return { context, page: await context.newPage() }; +} + +async function collectMobileMetrics(page: Page) { + return page.evaluate(() => { + function isVisible(element: Element) { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; + } + + return { + width: window.innerWidth, + scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0), + tableCount: Array.from(document.querySelectorAll("table")).filter(isVisible).length, + pcShellGridCount: document.querySelectorAll(".page-shell-grid").length, + text: document.body?.innerText ?? "", + }; + }); +} + +async function expectMobilePageContract(page: Page) { + await page.waitForLoadState("networkidle").catch(() => undefined); + const metrics = await collectMobileMetrics(page); + expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.width + 2); + expect(metrics.tableCount).toBe(0); + expect(metrics.pcShellGridCount).toBe(0); + expect(metrics.text).not.toContain("返回仪表盘"); +} + +test.describe("R124 企业微信移动入口矩阵", () => { + test.skip(process.env.E2E_MODE === "live", "R124 本地移动专项只在临时本地环境运行"); + + test("底部入口、我的二级入口、PDF 上传和草稿删除不回退到 PC 体验", async ({ browser }) => { + const state = buildState(); + const mobile = await newWecomPage(browser); + + try { + await mockApis(mobile.page, state); + await loginForMobile(mobile.page); + + await mobile.page.goto("/mobile"); + await expect(mobile.page.getByTestId("mobile-nav-home")).toBeVisible(); + await expectMobilePageContract(mobile.page); + + await mobile.page.getByTestId("mobile-nav-capture").click(); + await expect(mobile.page).toHaveURL(/\/mobile\/capture$/); + await expect(mobile.page.getByTestId("mobile-capture-camera-file")).toBeAttached(); + await expect(mobile.page.getByTestId("mobile-capture-image-file")).toBeAttached(); + await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).toBeAttached(); + await expect(mobile.page.getByTestId("mobile-capture-camera-file")).toHaveAttribute("capture", "environment"); + await expect(mobile.page.getByTestId("mobile-capture-camera-file")).toHaveAttribute("accept", "image/*"); + await expect(mobile.page.getByTestId("mobile-capture-image-file")).not.toHaveAttribute("capture", /.+/); + await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).toHaveAttribute("accept", "application/pdf"); + await expect(mobile.page.getByTestId("mobile-capture-pdf-file")).not.toHaveAttribute("capture", /.+/); + await mobile.page.getByTestId("mobile-capture-pdf-file").setInputFiles({ + name: "r124-invoice.pdf", + mimeType: "application/pdf", + buffer: Buffer.from("r124-pdf"), + }); + await expect(mobile.page.getByText("r124-invoice.pdf")).toBeVisible(); + await mobile.page.screenshot({ path: screenshotPath("mobile-capture") }); + await expectMobilePageContract(mobile.page); + + await mobile.page.getByTestId("mobile-nav-approvals").click(); + await expect(mobile.page).toHaveURL(/\/mobile\/approvals$/); + await expect(mobile.page.getByText("审批").first()).toBeVisible(); + await expectMobilePageContract(mobile.page); + + await mobile.page.getByTestId("mobile-nav-me").click(); + await expect(mobile.page).toHaveURL(/\/mobile\/me$/); + await expect(mobile.page.getByTestId("mobile-more-links")).toBeVisible(); + await mobile.page.screenshot({ path: screenshotPath("mobile-me") }); + await expectMobilePageContract(mobile.page); + + await mobile.page.getByRole("link", { name: /个人设置/ }).click(); + await expect(mobile.page).toHaveURL(/\/mobile\/profile$/); + await expect(mobile.page.getByTestId("mobile-profile-page")).toBeVisible(); + await expect(mobile.page.getByTestId("mobile-profile-wecom-card")).toBeVisible(); + await mobile.page.screenshot({ path: screenshotPath("mobile-profile") }); + await expectMobilePageContract(mobile.page); + + await mobile.page.goto("/mobile/me"); + await mobile.page.getByRole("link", { name: /通知中心/ }).click(); + await expect(mobile.page).toHaveURL(/\/mobile\/notifications/); + await expect(mobile.page.getByTestId("mobile-notifications-page")).toBeVisible(); + await expect(mobile.page.getByTestId("mobile-notification-card")).toHaveCount(2); + await mobile.page.screenshot({ path: screenshotPath("mobile-notifications") }); + await expectMobilePageContract(mobile.page); + + await mobile.page.goto("/mobile/me"); + await mobile.page.getByRole("link", { name: /请假/ }).click(); + await expect(mobile.page).toHaveURL(/\/mobile\/leave$/); + await expect(mobile.page.getByTestId("mobile-leave-form")).toBeVisible(); + await expectMobilePageContract(mobile.page); + + await mobile.page.goto("/mobile/me"); + await mobile.page.getByRole("link", { name: /报销/ }).click(); + await expect(mobile.page).toHaveURL(/\/mobile\/reimbursements$/); + await expect(mobile.page.getByTestId("mobile-reimbursements-list")).toBeVisible(); + const draftCard = mobile.page.getByTestId("mobile-reimbursement-card").filter({ hasText: "R124 删除草稿" }).first(); + await expect(draftCard).toBeVisible(); + await expect(draftCard.getByTestId("mobile-reimbursement-delete")).toBeVisible(); + + await mobile.page.getByTestId("mobile-reimbursements-tab-reviewing").click(); + const reviewingCard = mobile.page.getByTestId("mobile-reimbursement-card").filter({ hasText: "R124 审批中不可删除" }).first(); + await expect(reviewingCard).toBeVisible(); + await expect(reviewingCard.getByTestId("mobile-reimbursement-delete")).toHaveCount(0); + + await mobile.page.getByTestId("mobile-reimbursements-tab-draft").click(); + await draftCard.getByTestId("mobile-reimbursement-delete").click(); + await expect(mobile.page.getByRole("dialog")).toBeVisible(); + await mobile.page.getByRole("button", { name: "删除草稿" }).last().click(); + await expect(mobile.page.getByTestId("mobile-reimbursement-card").filter({ hasText: "R124 删除草稿" })).toHaveCount(0); + await mobile.page.screenshot({ path: screenshotPath("mobile-reimbursements-after-delete") }); + await expectMobilePageContract(mobile.page); + } finally { + await mobile.context.close(); + } + }); +}); diff --git a/deploy/trialrun/deploy.sh b/deploy/trialrun/deploy.sh index f1e5d2c8..b0e9b1c8 100755 --- a/deploy/trialrun/deploy.sh +++ b/deploy/trialrun/deploy.sh @@ -138,7 +138,14 @@ echo ">>> 4.3 构建最新 init 镜像" echo ">>> 4.4 执行 init" "${compose_cmd[@]}" run --rm -T init >> 4.5 等待 /ready" +echo ">>> 4.5 校验 Prisma migration status" +if ! "${compose_cmd[@]}" exec -T server sh -lc 'cd /app/server && npx prisma migrate status'; then + echo "[ERROR] Prisma migration status 未通过,停止部署" >&2 + "${compose_cmd[@]}" logs --tail=200 server || true + exit 1 +fi + +echo ">>> 4.6 等待 /ready" ready_ok=0 for i in $(seq 1 60); do if curl -fsS http://127.0.0.1:3000/ready >/dev/null; then @@ -154,21 +161,21 @@ if [[ "$ready_ok" != "1" ]]; then exit 1 fi -echo ">>> 4.6 执行 external deps smoke" +echo ">>> 4.7 执行 external deps smoke" if ! "${compose_cmd[@]}" exec -T server node src/scripts/smoke-external-deps.js; then echo "[ERROR] external deps smoke 失败,输出最近 server 日志" >&2 "${compose_cmd[@]}" logs --tail=200 server || true exit 1 fi -echo ">>> 4.7 执行 runtime smoke" +echo ">>> 4.8 执行 runtime smoke" if ! "${compose_cmd[@]}" exec -T server node src/scripts/smoke-trialrun-runtime.js; then echo "[ERROR] runtime smoke 失败,输出最近 server 日志" >&2 "${compose_cmd[@]}" logs --tail=200 server || true exit 1 fi -echo ">>> 4.8 当前服务状态" +echo ">>> 4.9 当前服务状态" "${compose_cmd[@]}" ps REMOTE diff --git a/issues/2026-05-08_13-37-47-r124-wecom-mobile-entry-hardening.csv b/issues/2026-05-08_13-37-47-r124-wecom-mobile-entry-hardening.csv new file mode 100644 index 00000000..43c03fd9 --- /dev/null +++ b/issues/2026-05-08_13-37-47-r124-wecom-mobile-entry-hardening.csv @@ -0,0 +1,8 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +A1,R124执行合同与移动入口基线,"固定本轮范围:报销详情 SYS_001、企业微信移动入口矩阵、报销列表删除、票据 PDF 上传、部署迁移门禁。","1) Issue CSV 校验通过;2) 明确所有企业微信可达入口;3) 已知问题进入验收门禁;4) 不混入无关模块重构。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-08_13-37-47-r124-wecom-mobile-entry-hardening.csv;业务流程:企业微信用户进入首页、审批、票据、我的、报销、通知、个人设置。","python|manual",DONE,DONE,DONE,"issues/2026-05-08_13-37-47-r124-wecom-mobile-entry-hardening.csv","[]","用户已确认执行合同;CSV 校验通过;本轮包含受控数据库迁移。" +A2,试运行数据库迁移补齐与门禁,"修复公网报销详情因 reimbursement_attachments 表缺失返回 SYS_001,并防止部署成功但迁移未应用。","1) prisma migrate status 无 pending;2) reimbursement_attachments 表存在;3) /api/reimbursements/:id 不再 500;4) deploy 脚本会在 pending migration 时失败。","远端执行 prisma migrate deploy;验证 migrate status、to_regclass、/ready、runtime smoke、报销详情接口。","ssh|prisma|curl",DONE,DONE,DONE,"deploy/trialrun/deploy.sh|server/prisma/migrations","[""A1""]","受控 schema 迁移已应用;deploy 脚本已加入 migrate status 门禁;回滚路径为停止部署并保留现有数据库。" +A3,移动报销列表删除草稿,"企业微信移动报销列表补齐草稿删除能力,与 PC 报销列表一致。","1) 草稿显示删除;2) 非草稿不显示删除;3) 二次确认;4) 删除后列表刷新;5) 无权限用户不显示。","Playwright mobile/wecom:新建草稿->列表删除->确认->草稿消失;非草稿无删除。","typescript|playwright",DONE,DONE,DONE,"client/src/pages/MobileReimbursementsPage.tsx|client/tests/e2e","[""A1""]","复用 DELETE /api/reimbursements/:id;R124 Playwright 已覆盖草稿删除和非草稿无删除。" +A4,移动票据上传区分拍照图片PDF,"底部票据页不再用一个 capture input 承担所有上传,避免 PDF 选择被摄像头拦截。","1) 有拍照上传、选择图片、选择 PDF;2) PDF input accept=application/pdf 且无 capture;3) 拍照 input 带 capture;4) 上传、离线队列仍可用。","Playwright mobile/wecom:检查三个入口属性;选择 PDF 后显示文件名;上传状态正常。","typescript|playwright",DONE,DONE,DONE,"client/src/pages/MobileCapturePage.tsx|client/tests/e2e","[""A1""]","不改 OCR provider;R124 Playwright 已覆盖 PDF 文件名回显;静态规则阻止 PDF input 带 capture。" +A5,企业微信移动入口去 PC 化,"核查并修复 /mobile/profile、/mobile/notifications 等企业微信可达入口复用 PC 页面的问题。","1) /mobile/profile 使用移动页面或移动包装;2) /mobile/notifications 使用移动列表;3) 移动入口无桌面表格和 PC 操作栏;4) 我的页二级入口均可达。","Playwright mobile/wecom:逐个打开我的页入口,检查无 table、无横向溢出、底部导航存在。","typescript|playwright",DONE,DONE,DONE,"client/src/router/index.tsx|client/src/pages/Mobile*.tsx|client/tests/e2e","[""A1""]","新增 MobileProfilePage 和 MobileNotificationsPage;我的页员工档案入口改为移动账号资料;静态路由契约已通过。" +A6,移动入口矩阵防回归,"新增静态和浏览器检查,防止后续 /mobile 路由重新挂 PC 页面或出现已知文案问题。","1) 静态脚本列出 /mobile 路由组件;2) 禁止已知 PC 页面直挂企业微信主入口;3) Playwright 覆盖底部 4 入口和我的二级入口;4) 已知旧文案扫描为 0。","命令:node scripts/qa/check-mobile-route-contract.mjs;Playwright r124-mobile-entry-matrix;check-ui-design-rules。","node|playwright",DONE,DONE,DONE,"scripts/qa|client/tests/e2e|client/src/router/index.tsx","[""A3"",""A4"",""A5""]","新增静态路由契约和 R124 Playwright;截图保存在 output/playwright/r124-mobile-entry-matrix。" +A7,本地回归提交与公网部署复验,"完成 A2-A6 后执行本地回归、提交、部署和公网复验。","1) server prisma validate/node --check 通过;2) client typecheck/build 通过;3) R109/R111/R124 Playwright 通过;4) 部署成功;5) /ready、migrate status、runtime smoke、bundle 扫描通过;6) 最终明确可去企业微信测试。","命令:server checks、client checks、Playwright、deploy/trialrun/deploy.sh、curl /ready、public bundle scan、远端 migrate status。","npm|playwright|bash|curl|ssh",DONE,DONE,DONE,"client|server|deploy/trialrun|issues/2026-05-08_13-37-47-r124-wecom-mobile-entry-hardening.csv","[""A2"",""A3"",""A4"",""A5"",""A6""]","已部署到 https://oa.hyinda.com;LIVE_SMOKE_* 未配置因此部署脚本跳过网页 live smoke;R109/R110 因本机 3000 后端未运行未本地执行。" diff --git a/scripts/qa/check-mobile-route-contract.mjs b/scripts/qa/check-mobile-route-contract.mjs new file mode 100644 index 00000000..09421c29 --- /dev/null +++ b/scripts/qa/check-mobile-route-contract.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const rootDir = process.cwd(); +const routerPath = path.join(rootDir, "client/src/router/index.tsx"); +const pagesDir = path.join(rootDir, "client/src/pages"); +const findings = []; + +function toPosix(value) { + return value.split(path.sep).join("/"); +} + +function addFinding({ rule, file, line = 1, message, evidence }) { + findings.push({ + rule, + file: toPosix(path.relative(rootDir, file)), + line, + message, + evidence, + }); +} + +function lineNumber(source, index) { + return source.slice(0, index).split("\n").length; +} + +function collectFiles(dir) { + if (!existsSync(dir)) return []; + const files = []; + for (const entry of readdirSync(dir)) { + const fullPath = path.join(dir, entry); + const stat = statSync(fullPath); + if (stat.isDirectory()) { + files.push(...collectFiles(fullPath)); + continue; + } + if (/Mobile.*\.tsx$/.test(entry)) { + files.push(fullPath); + } + } + return files; +} + +function extractRouteBlock(source, routePath) { + const marker = `path="${routePath}"`; + const start = source.indexOf(marker); + if (start < 0) return null; + const nextRoute = source.indexOf("\n = 0 ? nextRoute : source.indexOf("\n ", start); + return { + start, + block: source.slice(start, end >= 0 ? end : start + 1200), + }; +} + +function scanRouter() { + if (!existsSync(routerPath)) { + addFinding({ + rule: "mobile.router.exists", + file: routerPath, + message: "找不到 client/src/router/index.tsx", + }); + return; + } + + const source = readFileSync(routerPath, "utf8"); + const mobileRoutePaths = Array.from(source.matchAll(/path="(mobile[^"]*)"/g)).map((match) => match[1]); + const routeComponents = []; + + for (const routePath of mobileRoutePaths) { + const route = extractRouteBlock(source, routePath); + if (!route) continue; + const components = Array.from(route.block.matchAll(/<([A-Z][A-Za-z0-9]+Page)\s*\/>/g)).map((match) => match[1]); + routeComponents.push({ routePath, components }); + + for (const component of components) { + if (!component.startsWith("Mobile")) { + addFinding({ + rule: "mobile.router.no_pc_page", + file: routerPath, + line: lineNumber(source, route.start), + message: `/mobile 路由不能直挂 PC 页面组件:${routePath} -> ${component}`, + evidence: component, + }); + } + } + } + + const requiredRoutes = new Map([ + ["mobile/profile", "MobileProfilePage"], + ["mobile/notifications", "MobileNotificationsPage"], + ["mobile/capture", "MobileCapturePage"], + ["mobile/reimbursements", "MobileReimbursementsPage"], + ["mobile/me", "MobileMePage"], + ["mobile/approvals", "MobileApprovalsPage"], + ]); + + for (const [routePath, expectedComponent] of requiredRoutes) { + const route = routeComponents.find((item) => item.routePath === routePath); + if (!route) { + addFinding({ + rule: "mobile.router.required_route", + file: routerPath, + message: `缺少企业微信移动入口路由:${routePath}`, + }); + continue; + } + if (!route.components.includes(expectedComponent)) { + addFinding({ + rule: "mobile.router.required_component", + file: routerPath, + line: lineNumber(source, source.indexOf(`path="${routePath}"`)), + message: `${routePath} 应挂载 ${expectedComponent}`, + evidence: route.components.join(", ") || "(none)", + }); + } + } + + console.log("[mobile-route-contract] routes"); + for (const item of routeComponents) { + console.log(` ${item.routePath} -> ${item.components.join(", ") || "(no page component found)"}`); + } +} + +function scanMobilePages() { + for (const file of collectFiles(pagesDir)) { + const source = readFileSync(file, "utf8"); + const patterns = [ + { + regex: /\bfrom ["']\.\.\/components\/ui\/table["']/g, + rule: "mobile.page.no_table_component", + message: "移动页面不应导入桌面表格组件。", + }, + { + regex: /<\s*(?:Table|table)\b/g, + rule: "mobile.page.no_visible_table_markup", + message: "移动页面不应渲染桌面表格。", + }, + { + regex: /page-shell-grid/g, + rule: "mobile.page.no_pc_shell_grid", + message: "移动页面不应使用 PC 页面双栏 shell 布局。", + }, + ]; + + for (const pattern of patterns) { + let match = pattern.regex.exec(source); + while (match) { + addFinding({ + rule: pattern.rule, + file, + line: lineNumber(source, match.index), + message: pattern.message, + evidence: match[0], + }); + match = pattern.regex.exec(source); + } + } + } +} + +scanRouter(); +scanMobilePages(); + +if (findings.length) { + console.log(`[mobile-route-contract] ${findings.length} finding(s)`); + for (const finding of findings) { + console.log(`ERROR ${finding.rule} ${finding.file}:${finding.line} ${finding.message}`); + if (finding.evidence) { + console.log(` evidence: ${finding.evidence}`); + } + } + process.exitCode = 1; +} else { + console.log("[mobile-route-contract] no findings"); +} diff --git a/scripts/qa/check-ui-design-rules.mjs b/scripts/qa/check-ui-design-rules.mjs index 6d2582b5..3024730b 100644 --- a/scripts/qa/check-ui-design-rules.mjs +++ b/scripts/qa/check-ui-design-rules.mjs @@ -16,6 +16,9 @@ const scanRoots = [ const sourceExtensions = new Set([".js", ".jsx", ".ts", ".tsx"]); const forbiddenCopy = ["先处理", "清爽"]; const userFacingBusinessTermFiles = new Set([ + "client/src/pages/MobileCapturePage.tsx", + "client/src/pages/MobileNotificationsPage.tsx", + "client/src/pages/MobileProfilePage.tsx", "client/src/pages/MobileReimbursementDetailPage.tsx", "client/src/pages/ReimbursementDetailPage.tsx", "client/src/components/travel/TravelPaymentTrackCard.tsx", @@ -127,6 +130,30 @@ function scanNativeFileInputs(file, source) { } } +function scanMobileCapturePdfInputs(file, source) { + const normalized = toPosix(path.relative(rootDir, file)); + if (normalized !== "client/src/pages/MobileCapturePage.tsx") { + return; + } + + const fileInputPattern = /]*\btype=["']file["'][^>]*>/g; + let match = fileInputPattern.exec(source); + while (match) { + const inputSource = match[0]; + if (/application\/pdf/i.test(inputSource) && /\bcapture\b/i.test(inputSource)) { + addFinding({ + rule: "form.no_pdf_capture_input", + severity: "error", + file, + line: lineNumber(source, match.index), + message: "PDF 上传入口不能带 capture,否则企业微信容易直接打开摄像头。", + evidence: inputSource.replace(/\s+/g, " ").slice(0, 160), + }); + } + match = fileInputPattern.exec(source); + } +} + function scanUserFacingBusinessTerms(file, source) { const normalized = toPosix(path.relative(rootDir, file)); if (!userFacingBusinessTermFiles.has(normalized)) { @@ -226,6 +253,7 @@ function scanFile(file) { scanUserFacingBusinessTerms(file, source); scanMobileDarkControls(file, source); scanNativeFileInputs(file, source); + scanMobileCapturePdfInputs(file, source); scanRawBusinessIdentifiers(file, source); scanNavigationTitleCoverage(file, source); }