DG7/MRF7 移动信息架构与展示去重治理
这个提交包含在:
@@ -41,9 +41,10 @@ Default behavior:
|
|||||||
6. Verify with the narrowest relevant automated tests, then expand by risk.
|
6. Verify with the narrowest relevant automated tests, then expand by risk.
|
||||||
7. Use Playwright screenshots or visual metrics for PC/mobile/WeCom UI changes. New or changed mobile pages must join the unified responsive system and the 320/360/375/390/393/414/430px 统一视觉矩阵 rather than adding page-specific layout fixes.
|
7. Use Playwright screenshots or visual metrics for PC/mobile/WeCom UI changes. New or changed mobile pages must join the unified responsive system and the 320/360/375/390/393/414/430px 统一视觉矩阵 rather than adding page-specific layout fixes.
|
||||||
8. Dedicated `/mobile` pages must use the shared mobile slot/card/list/sticky-action system from `docs/product-design-rules.md`: fixed icon/avatar/status slots, `minmax(0, 1fr)` copy slots, `mobile-card`/mobile list cards, mobile tokens, and no desktop `card-std p-4` as a mobile main container.
|
8. Dedicated `/mobile` pages must use the shared mobile slot/card/list/sticky-action system from `docs/product-design-rules.md`: fixed icon/avatar/status slots, `minmax(0, 1fr)` copy slots, `mobile-card`/mobile list cards, mobile tokens, and no desktop `card-std p-4` as a mobile main container.
|
||||||
9. Update Issue CSV statuses only after the stated `Test_Method` passes.
|
9. Mobile homepage, approval, notification, and business cards must follow the display dedupe rules in `docs/product-design-rules.md`: one pending/progress summary per page, notifications as event stream with direct target links, bottom badges as counts only, and one visible business identifier/status per card or row.
|
||||||
10. Commit in Chinese batches with Issue IDs.
|
10. Update Issue CSV statuses only after the stated `Test_Method` passes.
|
||||||
11. 面向用户可测试的改动,最终必须明确“是否已部署”。只有部署与公网检查通过后,才能告知用户可以去企业微信或公网试运行环境测试。
|
11. Commit in Chinese batches with Issue IDs.
|
||||||
|
12. 面向用户可测试的改动,最终必须明确“是否已部署”。只有部署与公网检查通过后,才能告知用户可以去企业微信或公网试运行环境测试。
|
||||||
|
|
||||||
## Default Verification Matrix
|
## Default Verification Matrix
|
||||||
|
|
||||||
@@ -107,4 +108,5 @@ Final responses should include:
|
|||||||
- Role-specific pages should use one shared OA/mobile framework and tailor summaries, entries, actions, and sensitive fields by role. Do not create unrelated UI systems for employee, approver, finance, admin, or CEO views.
|
- Role-specific pages should use one shared OA/mobile framework and tailor summaries, entries, actions, and sensitive fields by role. Do not create unrelated UI systems for employee, approver, finance, admin, or CEO views.
|
||||||
- After MUD6, `R6-ISSUE-001` is treated as resolved based on representative real-device confirmation; future mobile regressions should be recorded as new Issues rather than reopening the old WCM6/MME6 gate unless the same production behavior returns.
|
- After MUD6, `R6-ISSUE-001` is treated as resolved based on representative real-device confirmation; future mobile regressions should be recorded as new Issues rather than reopening the old WCM6/MME6 gate unless the same production behavior returns.
|
||||||
- After AP6, approval reminder, overdue reminder, and workflow productivity summary are current product facts, not open planning items. Reuse `POST /api/workflow/tasks/:taskId/remind`, `GET /api/workflow/tasks/summary`, `job:workflow-overdue-reminders`, existing workflow history, audit logs, notifications, and WeCom personal app delivery semantics. Do not add a parallel reminder channel, change approval status from reminders, or expand into complex BPM/SLA escalation without a new Issue.
|
- After AP6, approval reminder, overdue reminder, and workflow productivity summary are current product facts, not open planning items. Reuse `POST /api/workflow/tasks/:taskId/remind`, `GET /api/workflow/tasks/summary`, `job:workflow-overdue-reminders`, existing workflow history, audit logs, notifications, and WeCom personal app delivery semantics. Do not add a parallel reminder channel, change approval status from reminders, or expand into complex BPM/SLA escalation without a new Issue.
|
||||||
|
- After DG7/MRF7, mobile approval is an "approval and progress" entry: employees may use it for submitted-item progress, while approvers use it for assigned tasks. Do not reintroduce disabled approval tabs for ordinary employees, duplicated home pending lists, or notification cards that only bounce users back to the notification center when a business target exists.
|
||||||
- After RI6, LP6, ME6 automation, QG6, and R7/OPS7/DOC7 draft materials, keep `ME6-3` open until representative real-device testing passes or the user explicitly accepts that risk. R7 draft rows may be `DONE`, but `R7E-*` true execution rows must stay `TODO` until the user confirms participants, roles, window, rollback contact, issue owner, and daily closeout. Do not treat QG6 or public mobile visual automation as a real-device substitute, and do not start real R7 from documentation alone.
|
- After RI6, LP6, ME6 automation, QG6, and R7/OPS7/DOC7 draft materials, keep `ME6-3` open until representative real-device testing passes or the user explicitly accepts that risk. R7 draft rows may be `DONE`, but `R7E-*` true execution rows must stay `TODO` until the user confirms participants, roles, window, rollback contact, issue owner, and daily closeout. Do not treat QG6 or public mobile visual automation as a real-device substitute, and do not start real R7 from documentation alone.
|
||||||
|
|||||||
@@ -37,12 +37,11 @@ export function MobileBottomNav() {
|
|||||||
>
|
>
|
||||||
<div className="relative z-[71] grid grid-cols-4 gap-1">
|
<div className="relative z-[71] grid grid-cols-4 gap-1">
|
||||||
{MOBILE_NAV_ITEMS.map((item) => {
|
{MOBILE_NAV_ITEMS.map((item) => {
|
||||||
const disabled = item.key === "approvals"
|
const disabled = item.anyOf
|
||||||
? !canAccessApprovals
|
|
||||||
: item.anyOf
|
|
||||||
? !hasAnyPermission(permissions, [...item.anyOf])
|
? !hasAnyPermission(permissions, [...item.anyOf])
|
||||||
: false;
|
: false;
|
||||||
const badge = item.key === "approvals" && pendingCount > 0 ? resolveBadgeLabel(pendingCount) : null;
|
const badge = item.key === "approvals" && pendingCount > 0 ? resolveBadgeLabel(pendingCount) : null;
|
||||||
|
const label = item.key === "approvals" && !canAccessApprovals ? "进度" : item.label;
|
||||||
|
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return (
|
return (
|
||||||
@@ -53,14 +52,14 @@ export function MobileBottomNav() {
|
|||||||
className="touch-target flex h-12 min-h-0 min-w-0 select-none flex-col items-center justify-center gap-0.5 rounded-xl px-0.5 py-1 text-[11px] font-medium leading-none text-zinc-400"
|
className="touch-target flex h-12 min-h-0 min-w-0 select-none flex-col items-center justify-center gap-0.5 rounded-xl px-0.5 py-1 text-[11px] font-medium leading-none text-zinc-400"
|
||||||
disabled
|
disabled
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
title={`${item.label}不可用`}
|
title={`${label}不可用`}
|
||||||
>
|
>
|
||||||
<MobileIcon name={item.icon} className="size-5" />
|
<MobileIcon name={item.icon} className="size-5" />
|
||||||
<span
|
<span
|
||||||
className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
data-testid={`mobile-nav-${item.key}-label`}
|
data-testid={`mobile-nav-${item.key}-label`}
|
||||||
>
|
>
|
||||||
{item.label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -83,26 +82,26 @@ export function MobileBottomNav() {
|
|||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<>
|
<>
|
||||||
<span>
|
<span className="relative">
|
||||||
<MobileIcon name={item.icon} className="size-5" />
|
<MobileIcon name={item.icon} className="size-5" />
|
||||||
|
{badge ? (
|
||||||
|
<span
|
||||||
|
data-testid={`mobile-nav-${item.key}-badge`}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute left-[calc(100%+1px)] top-0 min-w-4 -translate-y-1/2 rounded-full px-1 text-center text-[10px] font-semibold leading-4 shadow-sm",
|
||||||
|
isActive ? "bg-red-500 text-white" : "bg-red-500 text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
data-testid={`mobile-nav-${item.key}-label`}
|
data-testid={`mobile-nav-${item.key}-label`}
|
||||||
>
|
>
|
||||||
{item.label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{badge ? (
|
|
||||||
<span
|
|
||||||
data-testid={`mobile-nav-${item.key}-badge`}
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none absolute right-1 top-1 min-w-4 rounded-full px-1 text-[10px] font-semibold leading-4 shadow-sm",
|
|
||||||
isActive ? "bg-red-500 text-white" : "bg-red-500 text-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
|||||||
{ path: "/mobile/assets/scan", title: "移动资产扫描", group: "移动工作台" },
|
{ path: "/mobile/assets/scan", title: "移动资产扫描", group: "移动工作台" },
|
||||||
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
|
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
|
||||||
{ path: "/mobile/capture", title: "移动 PDF 发票上传", group: "移动工作台" },
|
{ path: "/mobile/capture", title: "移动 PDF 发票上传", group: "移动工作台" },
|
||||||
|
{ path: "/mobile/leave", title: "移动请假", group: "移动工作台" },
|
||||||
{ path: "/mobile/me", title: "移动我的", group: "移动工作台" },
|
{ path: "/mobile/me", title: "移动我的", group: "移动工作台" },
|
||||||
|
{ path: "/mobile/purchase-requests", title: "移动采购详情", group: "移动工作台" },
|
||||||
|
{ path: "/mobile/reimbursements", title: "移动报销", group: "移动工作台" },
|
||||||
{ path: "/mobile", title: "移动首页", group: "移动工作台" },
|
{ path: "/mobile", title: "移动首页", group: "移动工作台" },
|
||||||
{ path: "/settings/profile", title: "个人设置", group: "账号设置" },
|
{ path: "/settings/profile", title: "个人设置", group: "账号设置" },
|
||||||
{ path: "/system/workspace", title: "工作区设置", group: "管理入口" },
|
{ path: "/system/workspace", title: "工作区设置", group: "管理入口" },
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { resolveMobileEquivalentPath, shouldPreferMobileWorkplace } from "../lib
|
|||||||
export function AppShellLayout() {
|
export function AppShellLayout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isMobileRoute = location.pathname.startsWith("/mobile");
|
const isMobileRoute = location.pathname.startsWith("/mobile");
|
||||||
const isMobileDeepRoute = /^\/mobile\/reimbursements\/[^/]+$/.test(location.pathname);
|
const isMobileDeepRoute = /^\/mobile\/(?:reimbursements|approvals|leave|purchase-requests)\/[^/]+$/.test(location.pathname);
|
||||||
const showMobileBottomNav = isMobileRoute && !isMobileDeepRoute;
|
const showMobileBottomNav = isMobileRoute && !isMobileDeepRoute;
|
||||||
const mobileEquivalentPath = !isMobileRoute && shouldPreferMobileWorkplace()
|
const mobileEquivalentPath = !isMobileRoute && shouldPreferMobileWorkplace()
|
||||||
? resolveMobileEquivalentPath(location.pathname, location.search)
|
? resolveMobileEquivalentPath(location.pathname, location.search)
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { isUuidLike } from "./reimbursementDisplay";
|
||||||
|
|
||||||
|
export type BusinessEntityType = "reimbursement" | "leave" | "purchase_request" | "travel_request" | string | null | undefined;
|
||||||
|
|
||||||
|
const ENTITY_NOUN_LABELS: Record<string, string> = {
|
||||||
|
reimbursement: "报销单",
|
||||||
|
leave: "请假",
|
||||||
|
purchase_request: "采购单",
|
||||||
|
travel_request: "差旅单",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENTITY_SHORT_LABELS: Record<string, string> = {
|
||||||
|
reimbursement: "报销",
|
||||||
|
leave: "请假",
|
||||||
|
purchase_request: "采购",
|
||||||
|
travel_request: "差旅",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function businessEntityLabel(entityType: BusinessEntityType, options: { noun?: boolean } = {}) {
|
||||||
|
if (!entityType) {
|
||||||
|
return options.noun ? "业务单据" : "业务";
|
||||||
|
}
|
||||||
|
const labels = options.noun ? ENTITY_NOUN_LABELS : ENTITY_SHORT_LABELS;
|
||||||
|
return labels[entityType] ?? String(entityType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compactBusinessReference(value: string | null | undefined) {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed || isUuidLike(trimmed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function businessPrimaryLabel(input: {
|
||||||
|
entityType: BusinessEntityType;
|
||||||
|
reference?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
fallback?: string;
|
||||||
|
}) {
|
||||||
|
const reference = compactBusinessReference(input.reference);
|
||||||
|
if (reference) {
|
||||||
|
return `${businessEntityLabel(input.entityType, { noun: true })} ${reference}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = input.title?.trim();
|
||||||
|
if (title && !isUuidLike(title)) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.fallback ?? businessEntityLabel(input.entityType, { noun: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinBusinessMeta(items: Array<string | null | undefined>) {
|
||||||
|
return items.map((item) => item?.trim()).filter((item): item is string => Boolean(item)).join(" · ");
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@ type NotificationTarget = {
|
|||||||
targetId: string | null;
|
targetId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function encodeSegment(value: string) {
|
||||||
|
return encodeURIComponent(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveNotificationTargetLink(item: NotificationTarget) {
|
export function resolveNotificationTargetLink(item: NotificationTarget) {
|
||||||
if (!item.targetType || !item.targetId) return null;
|
if (!item.targetType || !item.targetId) return null;
|
||||||
|
|
||||||
@@ -41,6 +45,36 @@ export function resolveNotificationTargetLink(item: NotificationTarget) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveMobileNotificationTargetLink(item: NotificationTarget) {
|
||||||
|
if (!item.targetType || !item.targetId) return null;
|
||||||
|
|
||||||
|
if (item.targetType === "workflow_task") {
|
||||||
|
return `/mobile/approvals/${encodeSegment(item.targetId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.targetType === "reimbursement_form") {
|
||||||
|
return `/mobile/reimbursements/${encodeSegment(item.targetId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.targetType === "leave_request") {
|
||||||
|
return `/mobile/leave/${encodeSegment(item.targetId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.targetType === "purchase_request") {
|
||||||
|
return `/mobile/purchase-requests/${encodeSegment(item.targetId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.targetType === "asset" || item.targetType === "asset_card" || item.targetType === "inventory_check") {
|
||||||
|
return "/mobile/assets/scan";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.targetType === "employee") {
|
||||||
|
return "/mobile/profile";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatNotificationRelativeTime(value: string | null) {
|
export function formatNotificationRelativeTime(value: string | null) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return "-";
|
return "-";
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function encodeSegment(value: string) {
|
|||||||
|
|
||||||
export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
||||||
const normalizedPath = pathname.replace(/\/+$/, "") || "/";
|
const normalizedPath = pathname.replace(/\/+$/, "") || "/";
|
||||||
|
const searchParams = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
||||||
|
|
||||||
if (normalizedPath === "/" || normalizedPath === "/portal" || normalizedPath === "/dashboard") {
|
if (normalizedPath === "/" || normalizedPath === "/portal" || normalizedPath === "/dashboard") {
|
||||||
return "/mobile";
|
return "/mobile";
|
||||||
@@ -71,6 +72,10 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedPath === "/leave-requests") {
|
if (normalizedPath === "/leave-requests") {
|
||||||
|
const leaveRequestId = searchParams.get("highlight") ?? searchParams.get("id") ?? searchParams.get("requestId");
|
||||||
|
if (leaveRequestId) {
|
||||||
|
return `/mobile/leave/${encodeSegment(leaveRequestId)}`;
|
||||||
|
}
|
||||||
return "/mobile/leave";
|
return "/mobile/leave";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +85,7 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
|||||||
|
|
||||||
const workflowTaskMatch = normalizedPath.match(/^\/workflow\/tasks\/([^/]+)$/);
|
const workflowTaskMatch = normalizedPath.match(/^\/workflow\/tasks\/([^/]+)$/);
|
||||||
if (workflowTaskMatch?.[1]) {
|
if (workflowTaskMatch?.[1]) {
|
||||||
return `/mobile/approvals?${new URLSearchParams({ taskId: workflowTaskMatch[1] }).toString()}`;
|
return `/mobile/approvals/${encodeSegment(workflowTaskMatch[1])}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedPath === "/workflow/tasks") {
|
if (normalizedPath === "/workflow/tasks") {
|
||||||
@@ -104,7 +109,6 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
|||||||
if (
|
if (
|
||||||
normalizedPath.startsWith("/projects") ||
|
normalizedPath.startsWith("/projects") ||
|
||||||
normalizedPath.startsWith("/contracts-registry") ||
|
normalizedPath.startsWith("/contracts-registry") ||
|
||||||
normalizedPath.startsWith("/purchase-requests") ||
|
|
||||||
normalizedPath.startsWith("/travel-requests") ||
|
normalizedPath.startsWith("/travel-requests") ||
|
||||||
normalizedPath.startsWith("/payment-batches") ||
|
normalizedPath.startsWith("/payment-batches") ||
|
||||||
normalizedPath.startsWith("/ap-operations") ||
|
normalizedPath.startsWith("/ap-operations") ||
|
||||||
@@ -119,5 +123,18 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
|||||||
return "/mobile";
|
return "/mobile";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedPath === "/purchase-requests") {
|
||||||
|
const purchaseRequestId = searchParams.get("highlight") ?? searchParams.get("id") ?? searchParams.get("requestId");
|
||||||
|
if (purchaseRequestId) {
|
||||||
|
return `/mobile/purchase-requests/${encodeSegment(purchaseRequestId)}`;
|
||||||
|
}
|
||||||
|
return "/mobile";
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseRequestDetailMatch = normalizedPath.match(/^\/purchase-requests\/([^/]+)$/);
|
||||||
|
if (purchaseRequestDetailMatch?.[1]) {
|
||||||
|
return `/mobile/purchase-requests/${encodeSegment(purchaseRequestDetailMatch[1])}`;
|
||||||
|
}
|
||||||
|
|
||||||
return search ? `${pathname}${search}` : pathname;
|
return search ? `${pathname}${search}` : pathname;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave";
|
||||||
|
import { fetchPurchaseRequests, type PurchaseRequestSummary } from "../api/purchaseRequests";
|
||||||
|
import { fetchReimbursements, type ReimbursementListItem } from "../api/reimbursements";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import {
|
import {
|
||||||
approveWorkflowTask,
|
approveWorkflowTask,
|
||||||
@@ -26,6 +29,9 @@ import { Spinner } from "../components/ui/spinner";
|
|||||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||||
import { Textarea } from "../components/ui/textarea";
|
import { Textarea } from "../components/ui/textarea";
|
||||||
import { MOBILE_APPROVAL_PERMISSIONS } from "../features/mobile/navigation";
|
import { MOBILE_APPROVAL_PERMISSIONS } from "../features/mobile/navigation";
|
||||||
|
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
|
||||||
|
import { businessPrimaryLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
|
||||||
|
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||||
import {
|
import {
|
||||||
cacheWorkflowTaskDetail,
|
cacheWorkflowTaskDetail,
|
||||||
enqueueWorkflowActionDraft,
|
enqueueWorkflowActionDraft,
|
||||||
@@ -61,6 +67,14 @@ type VisibleTaskCard = {
|
|||||||
detail: WorkflowTaskDetail | null;
|
detail: WorkflowTaskDetail | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EmployeeProgressCard = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
status: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
|
||||||
const ENTITY_FILTER_OPTIONS: Array<{ value: EntityFilter; label: string }> = [
|
const ENTITY_FILTER_OPTIONS: Array<{ value: EntityFilter; label: string }> = [
|
||||||
{ value: "all", label: "全部" },
|
{ value: "all", label: "全部" },
|
||||||
{ value: "leave", label: "请假" },
|
{ value: "leave", label: "请假" },
|
||||||
@@ -120,15 +134,8 @@ function businessStatusLabel(status: string | null | undefined) {
|
|||||||
return status ? labels[status] ?? status : "-";
|
return status ? labels[status] ?? status : "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInternalId(value: string | null | undefined) {
|
|
||||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function publicReference(value: string | null | undefined) {
|
function publicReference(value: string | null | undefined) {
|
||||||
if (!value || isInternalId(value)) {
|
return compactBusinessReference(value);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveErrorMessage(error: unknown) {
|
function resolveErrorMessage(error: unknown) {
|
||||||
@@ -209,13 +216,19 @@ function toVisibleRemoteTask(task: WorkflowTaskSummary): VisibleTaskCard {
|
|||||||
const summary = task.businessSummary;
|
const summary = task.businessSummary;
|
||||||
const reference = publicReference(summary?.reference ?? task.workflowInstance?.entityId);
|
const reference = publicReference(summary?.reference ?? task.workflowInstance?.entityId);
|
||||||
const metric = summary?.amount ?? summary?.duration ?? null;
|
const metric = summary?.amount ?? summary?.duration ?? null;
|
||||||
|
const title = businessPrimaryLabel({
|
||||||
|
entityType,
|
||||||
|
reference,
|
||||||
|
title: summary?.title ?? task.stepName,
|
||||||
|
fallback: task.stepName,
|
||||||
|
});
|
||||||
|
const subtitle = task.status === "completed"
|
||||||
|
? joinBusinessMeta([`节点:${task.stepName}`, `完成于 ${formatDateTime(task.completedAt)}`])
|
||||||
|
: joinBusinessMeta([`节点:${task.stepName}`, summary?.applicantName ? `申请人:${summary.applicantName}` : null]);
|
||||||
return {
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
title: summary?.title ?? task.stepName,
|
title,
|
||||||
subtitle:
|
subtitle,
|
||||||
task.status === "completed"
|
|
||||||
? `业务:${entityLabel(entityType)} · ${reference ?? "未生成业务编号"} · 完成于 ${formatDateTime(task.completedAt)}`
|
|
||||||
: `业务:${entityLabel(entityType)} · ${reference ?? "未生成业务编号"}`,
|
|
||||||
status: task.status,
|
status: task.status,
|
||||||
entityType,
|
entityType,
|
||||||
entityId: task.workflowInstance?.entityId ?? null,
|
entityId: task.workflowInstance?.entityId ?? null,
|
||||||
@@ -252,11 +265,66 @@ function matchesSearch(card: VisibleTaskCard, keyword: string) {
|
|||||||
return haystack.includes(searchValue);
|
return haystack.includes(searchValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toEmployeeProgressCards(input: {
|
||||||
|
reimbursements: ReimbursementListItem[];
|
||||||
|
leaveRequests: LeaveRequestItem[];
|
||||||
|
purchaseRequests: PurchaseRequestSummary[];
|
||||||
|
}): EmployeeProgressCard[] {
|
||||||
|
const reimbursementCards = input.reimbursements.map((item) => ({
|
||||||
|
id: `reimbursement-${item.id}`,
|
||||||
|
title: businessPrimaryLabel({
|
||||||
|
entityType: "reimbursement",
|
||||||
|
reference: item.formNumber,
|
||||||
|
title: resolveReimbursementDisplayCode(item),
|
||||||
|
}),
|
||||||
|
subtitle: joinBusinessMeta([
|
||||||
|
item.owner?.name ? `报销人:${item.owner.name}` : null,
|
||||||
|
item.totalAmount ? `金额:${item.totalAmount}` : null,
|
||||||
|
item.pendingWorkflow?.task?.stepName ? `节点:${item.pendingWorkflow.task.stepName}` : null,
|
||||||
|
]),
|
||||||
|
status: item.status,
|
||||||
|
to: `/mobile/reimbursements/${encodeURIComponent(item.id)}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const leaveCards = input.leaveRequests.map((item) => ({
|
||||||
|
id: `leave-${item.id}`,
|
||||||
|
title: joinBusinessMeta(["请假", item.leaveType?.name]) || "请假",
|
||||||
|
subtitle: joinBusinessMeta([
|
||||||
|
`${item.startDate} 至 ${item.endDate}`,
|
||||||
|
`${item.requestedDays} 天`,
|
||||||
|
item.pendingWorkflow?.task?.stepName ? `节点:${item.pendingWorkflow.task.stepName}` : null,
|
||||||
|
]),
|
||||||
|
status: item.status,
|
||||||
|
to: `/mobile/leave/${encodeURIComponent(item.id)}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const purchaseCards = input.purchaseRequests.map((item) => ({
|
||||||
|
id: `purchase-${item.id}`,
|
||||||
|
title: businessPrimaryLabel({
|
||||||
|
entityType: "purchase_request",
|
||||||
|
reference: item.requestNo,
|
||||||
|
title: resolvePurchaseDisplayCode(item),
|
||||||
|
}),
|
||||||
|
subtitle: joinBusinessMeta([
|
||||||
|
item.applicant?.name ? `申请人:${item.applicant.name}` : null,
|
||||||
|
item.totalAmount ? `金额:${item.totalAmount}` : null,
|
||||||
|
item.updatedAt ? `更新:${formatDateTime(item.updatedAt)}` : null,
|
||||||
|
]),
|
||||||
|
status: item.status,
|
||||||
|
to: `/mobile/purchase-requests/${encodeURIComponent(item.id)}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...reimbursementCards, ...leaveCards, ...purchaseCards].slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
export function MobileApprovalsPage() {
|
export function MobileApprovalsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const routeParams = useParams();
|
||||||
const { me, permissions, accessToken, offlineAccessMode } = useAuth();
|
const { me, permissions, accessToken, offlineAccessMode } = useAuth();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const deepLinkTaskId = searchParams.get("taskId")?.trim() ?? "";
|
const deepLinkTaskId = routeParams.taskId?.trim() || searchParams.get("taskId")?.trim() || "";
|
||||||
|
const isPushDetail = Boolean(deepLinkTaskId);
|
||||||
|
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId);
|
const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId);
|
||||||
const [taskTab, setTaskTab] = useState<MobileTaskTab>("pending");
|
const [taskTab, setTaskTab] = useState<MobileTaskTab>("pending");
|
||||||
@@ -274,13 +342,30 @@ export function MobileApprovalsPage() {
|
|||||||
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
|
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
|
||||||
const [isOnline, setIsOnline] = useState(true);
|
const [isOnline, setIsOnline] = useState(true);
|
||||||
const autoSyncRequestedRef = useRef(false);
|
const autoSyncRequestedRef = useRef(false);
|
||||||
const detailSectionRef = useRef<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const ownerEmployeeId = me?.id ?? null;
|
const ownerEmployeeId = me?.id ?? null;
|
||||||
const canAccessRemoteApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
|
const canAccessRemoteApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
|
||||||
const canUseRemote = isOnline && Boolean(accessToken) && canAccessRemoteApprovals;
|
const canUseRemote = isOnline && Boolean(accessToken) && canAccessRemoteApprovals;
|
||||||
const remoteTaskStatus = taskTab === "completed" ? "completed" : "pending";
|
const remoteTaskStatus = taskTab === "completed" ? "completed" : "pending";
|
||||||
|
|
||||||
|
const reimbursementProgressQuery = useQuery({
|
||||||
|
enabled: !canAccessRemoteApprovals,
|
||||||
|
queryKey: ["mobile", "approvals", "progress", "reimbursements"],
|
||||||
|
queryFn: () => fetchReimbursements({ scope: "own", page: 1, pageSize: 5 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const leaveProgressQuery = useQuery({
|
||||||
|
enabled: !canAccessRemoteApprovals,
|
||||||
|
queryKey: ["mobile", "approvals", "progress", "leave"],
|
||||||
|
queryFn: () => fetchLeaveRequests({ scope: "own", page: 1, pageSize: 5 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const purchaseProgressQuery = useQuery({
|
||||||
|
enabled: !canAccessRemoteApprovals,
|
||||||
|
queryKey: ["mobile", "approvals", "progress", "purchase"],
|
||||||
|
queryFn: () => fetchPurchaseRequests({ mine: true, page: 1, pageSize: 5 }),
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedTaskId(deepLinkTaskId);
|
setSelectedTaskId(deepLinkTaskId);
|
||||||
}, [deepLinkTaskId]);
|
}, [deepLinkTaskId]);
|
||||||
@@ -293,30 +378,8 @@ export function MobileApprovalsPage() {
|
|||||||
}
|
}
|
||||||
}, [taskTab]);
|
}, [taskTab]);
|
||||||
|
|
||||||
function updateTaskSelection(taskId: string) {
|
|
||||||
setSelectedTaskId(taskId);
|
|
||||||
const nextParams = new URLSearchParams(searchParams);
|
|
||||||
if (taskId) {
|
|
||||||
nextParams.set("taskId", taskId);
|
|
||||||
} else {
|
|
||||||
nextParams.delete("taskId");
|
|
||||||
}
|
|
||||||
setSearchParams(nextParams, { replace: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollDetailIntoViewOnMobile() {
|
|
||||||
if (typeof window === "undefined" || !window.matchMedia("(max-width: 767px)").matches) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
detailSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTaskSelection(taskId: string) {
|
function handleTaskSelection(taskId: string) {
|
||||||
updateTaskSelection(taskId);
|
navigate(`/mobile/approvals/${encodeURIComponent(taskId)}`);
|
||||||
scrollDetailIntoViewOnMobile();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function invalidateWorkflowQueries(taskId: string | null = selectedTaskId) {
|
async function invalidateWorkflowQueries(taskId: string | null = selectedTaskId) {
|
||||||
@@ -391,8 +454,12 @@ export function MobileApprovalsPage() {
|
|||||||
.filter((item) => item.detail.task.status === "pending")
|
.filter((item) => item.detail.task.status === "pending")
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
id: item.detail.task.id,
|
id: item.detail.task.id,
|
||||||
title: item.taskSnapshot.stepName ?? item.detail.task.stepName,
|
title: businessPrimaryLabel({
|
||||||
subtitle: `业务:${entityLabel(item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType)} · 缓存于 ${formatDateTime(item.savedAt)}`,
|
entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType,
|
||||||
|
reference: item.taskSnapshot.reference,
|
||||||
|
title: item.taskSnapshot.stepName ?? item.detail.task.stepName,
|
||||||
|
}),
|
||||||
|
subtitle: joinBusinessMeta([`节点:${item.taskSnapshot.stepName ?? item.detail.task.stepName}`, `缓存于 ${formatDateTime(item.savedAt)}`]),
|
||||||
status: item.detail.task.status,
|
status: item.detail.task.status,
|
||||||
entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType ?? null,
|
entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType ?? null,
|
||||||
entityId: item.taskSnapshot.entityId,
|
entityId: item.taskSnapshot.entityId,
|
||||||
@@ -432,21 +499,50 @@ export function MobileApprovalsPage() {
|
|||||||
const selectedTask = detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail;
|
const selectedTask = detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail;
|
||||||
const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null;
|
const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null;
|
||||||
const selectedDisplayReference = selectedTaskCard?.reference ?? publicReference(selectedReference?.reference);
|
const selectedDisplayReference = selectedTaskCard?.reference ?? publicReference(selectedReference?.reference);
|
||||||
|
const selectedDetailReference = selectedDisplayReference ??
|
||||||
|
publicReference(selectedTask?.reimbursement?.formNumber) ??
|
||||||
|
publicReference(selectedTask?.purchaseRequest?.requestNo) ??
|
||||||
|
publicReference(selectedTask?.travelRequest?.requestNo);
|
||||||
|
const selectedDetailTitle = selectedTask
|
||||||
|
? businessPrimaryLabel({
|
||||||
|
entityType: selectedTask.workflowInstance?.entityType,
|
||||||
|
reference: selectedDetailReference,
|
||||||
|
title: selectedTaskCard?.title ?? selectedTask.task.stepName,
|
||||||
|
})
|
||||||
|
: "";
|
||||||
|
const selectedApplicantName = selectedTaskCard?.applicantName ??
|
||||||
|
selectedTask?.reimbursement?.submitter?.name ??
|
||||||
|
selectedTask?.leaveRequest?.owner?.name ??
|
||||||
|
selectedTask?.purchaseRequest?.applicant?.name ??
|
||||||
|
selectedTask?.travelRequest?.applicant?.name ??
|
||||||
|
null;
|
||||||
|
const selectedMetric = selectedTaskCard?.metric ??
|
||||||
|
selectedTask?.reimbursement?.totalAmount ??
|
||||||
|
(selectedTask?.leaveRequest ? `${selectedTask.leaveRequest.requestedDays} 天` : null) ??
|
||||||
|
selectedTask?.purchaseRequest?.totalAmount ??
|
||||||
|
selectedTask?.travelRequest?.estimatedAmount ??
|
||||||
|
null;
|
||||||
const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending");
|
const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending");
|
||||||
const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote;
|
const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote;
|
||||||
|
const employeeProgressCards = useMemo(
|
||||||
|
() =>
|
||||||
|
toEmployeeProgressCards({
|
||||||
|
reimbursements: reimbursementProgressQuery.data?.items ?? [],
|
||||||
|
leaveRequests: leaveProgressQuery.data?.items ?? [],
|
||||||
|
purchaseRequests: purchaseProgressQuery.data?.items ?? [],
|
||||||
|
}),
|
||||||
|
[leaveProgressQuery.data?.items, purchaseProgressQuery.data?.items, reimbursementProgressQuery.data?.items],
|
||||||
|
);
|
||||||
|
const employeeProgressLoading =
|
||||||
|
reimbursementProgressQuery.isLoading || leaveProgressQuery.isLoading || purchaseProgressQuery.isLoading;
|
||||||
|
const employeeProgressError =
|
||||||
|
reimbursementProgressQuery.error ?? leaveProgressQuery.error ?? purchaseProgressQuery.error ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const visibleIds = new Set(filteredTaskCards.map((task) => task.id));
|
const visibleIds = new Set(filteredTaskCards.map((task) => task.id));
|
||||||
setSelectedBatchTaskIds((current) => current.filter((taskId) => visibleIds.has(taskId)));
|
setSelectedBatchTaskIds((current) => current.filter((taskId) => visibleIds.has(taskId)));
|
||||||
}, [filteredTaskCards]);
|
}, [filteredTaskCards]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedTaskId || !filteredTaskCards.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateTaskSelection(filteredTaskCards[0].id);
|
|
||||||
}, [filteredTaskCards, selectedTaskId]);
|
|
||||||
|
|
||||||
const canTransfer = Boolean(
|
const canTransfer = Boolean(
|
||||||
canUseRemote &&
|
canUseRemote &&
|
||||||
selectedTaskAllowsActions &&
|
selectedTaskAllowsActions &&
|
||||||
@@ -566,7 +662,7 @@ export function MobileApprovalsPage() {
|
|||||||
setTransferToApproverId("");
|
setTransferToApproverId("");
|
||||||
setError("");
|
setError("");
|
||||||
setMessage("");
|
setMessage("");
|
||||||
updateTaskSelection("");
|
navigate("/mobile/approvals", { replace: true });
|
||||||
toast.success("操作成功");
|
toast.success("操作成功");
|
||||||
},
|
},
|
||||||
onError: (actionError) => {
|
onError: (actionError) => {
|
||||||
@@ -656,7 +752,7 @@ export function MobileApprovalsPage() {
|
|||||||
setError(result.failures.length ? result.failures.slice(0, 3).join(";") : "");
|
setError(result.failures.length ? result.failures.slice(0, 3).join(";") : "");
|
||||||
setMessage(summary);
|
setMessage(summary);
|
||||||
if (canUseRemote && result.taskIds.includes(selectedTaskId)) {
|
if (canUseRemote && result.taskIds.includes(selectedTaskId)) {
|
||||||
updateTaskSelection("");
|
navigate("/mobile/approvals", { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.failed > 0) {
|
if (result.failed > 0) {
|
||||||
@@ -793,11 +889,16 @@ export function MobileApprovalsPage() {
|
|||||||
return (
|
return (
|
||||||
<PageScaffold className="space-y-4">
|
<PageScaffold className="space-y-4">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="移动审批"
|
title={isPushDetail ? "审批详情" : canAccessRemoteApprovals ? "审批" : "审批进度"}
|
||||||
subtitle={deepLinkTaskId ? "已定位指定待办。" : "处理待办。"}
|
subtitle={isPushDetail ? "查看并处理指定待办。" : canAccessRemoteApprovals ? "待我审批和已处理。" : "查看我发起的申请进度。"}
|
||||||
|
actions={isPushDetail ? (
|
||||||
|
<Button asChild size="sm" type="button" variant="secondary">
|
||||||
|
<Link to="/mobile/approvals">返回</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isOnline ? (
|
{!isOnline && canAccessRemoteApprovals ? (
|
||||||
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
|
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
|
||||||
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存待办,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
|
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存待办,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
|
||||||
</p>
|
</p>
|
||||||
@@ -811,6 +912,43 @@ export function MobileApprovalsPage() {
|
|||||||
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
|
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{!isPushDetail && !canAccessRemoteApprovals ? (
|
||||||
|
<section className="mobile-card space-y-3" data-testid="mobile-approvals-progress">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">我发起的</h3>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">报销、请假、采购进度</p>
|
||||||
|
</div>
|
||||||
|
<span className="badge-neutral">{employeeProgressCards.length} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{employeeProgressLoading ? <div className="mobile-empty-state">加载中...</div> : null}
|
||||||
|
{employeeProgressError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{resolveErrorMessage(employeeProgressError)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{!employeeProgressLoading && !employeeProgressError && !employeeProgressCards.length ? (
|
||||||
|
<div className="mobile-empty-state">暂无申请进度</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{employeeProgressCards.map((item) => (
|
||||||
|
<Link className="mobile-list-card block" data-testid="mobile-approval-progress-card" key={item.id} to={item.to}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-zinc-900">{item.title}</div>
|
||||||
|
{item.subtitle ? <div className="mt-1 truncate text-xs text-zinc-500">{item.subtitle}</div> : null}
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isPushDetail && canAccessRemoteApprovals ? (
|
||||||
<section className="mobile-card space-y-4" data-testid="mobile-approvals-list">
|
<section className="mobile-card space-y-4" data-testid="mobile-approvals-list">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -986,9 +1124,8 @@ export function MobileApprovalsPage() {
|
|||||||
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
|
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
|
||||||
{task.reference ? <div className="mt-1 text-xs text-zinc-500">业务单号:{task.reference}</div> : null}
|
|
||||||
<div className="mt-1 text-xs text-zinc-500">
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
申请人:{task.applicantName ?? "-"} · 金额/天数:{task.metric ?? "-"} · 业务状态:{businessStatusLabel(task.businessStatus)}
|
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
|
||||||
</div>
|
</div>
|
||||||
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
||||||
</button>
|
</button>
|
||||||
@@ -1011,9 +1148,8 @@ export function MobileApprovalsPage() {
|
|||||||
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
|
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
|
||||||
{task.reference ? <div className="mt-1 text-xs text-zinc-500">业务单号:{task.reference}</div> : null}
|
|
||||||
<div className="mt-1 text-xs text-zinc-500">
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
申请人:{task.applicantName ?? "-"} · 金额/天数:{task.metric ?? "-"} · 业务状态:{businessStatusLabel(task.businessStatus)}
|
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
|
||||||
</div>
|
</div>
|
||||||
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
||||||
</button>
|
</button>
|
||||||
@@ -1021,8 +1157,10 @@ export function MobileApprovalsPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section ref={detailSectionRef} className="mobile-card scroll-mt-4 space-y-3" data-testid="mobile-approvals-detail">
|
{isPushDetail ? (
|
||||||
|
<section className="mobile-card scroll-mt-4 space-y-3" data-testid="mobile-approvals-detail">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">处理</h3>
|
<h3 className="text-base font-semibold text-zinc-900">处理</h3>
|
||||||
|
|
||||||
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
|
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
|
||||||
@@ -1046,44 +1184,37 @@ export function MobileApprovalsPage() {
|
|||||||
{selectedTask ? (
|
{selectedTask ? (
|
||||||
<>
|
<>
|
||||||
<div className="mobile-list-card text-sm">
|
<div className="mobile-list-card text-sm">
|
||||||
<div>任务状态:{workflowStatusLabel(selectedTask.task.status)}</div>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>步骤:{selectedTask.task.stepName}</div>
|
<div className="min-w-0">
|
||||||
<div>对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
|
<div className="truncate font-semibold text-zinc-900">{selectedDetailTitle}</div>
|
||||||
<div>业务单号:{selectedDisplayReference ?? "-"}</div>
|
<div className="mt-1 text-xs text-zinc-500">{joinBusinessMeta([`节点:${selectedTask.task.stepName}`, `创建:${formatDateTime(selectedTask.task.createdAt)}`])}</div>
|
||||||
<div>业务状态:{businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}</div>
|
</div>
|
||||||
<div>申请人:{selectedTaskCard?.applicantName ?? "-"}</div>
|
<StatusBadge status={selectedTask.task.status} label={workflowStatusLabel(selectedTask.task.status)} />
|
||||||
<div>金额/天数:{selectedTaskCard?.metric ?? "-"}</div>
|
</div>
|
||||||
<div>创建时间:{formatDateTime(selectedTask.task.createdAt)}</div>
|
<div className="mt-3 space-y-1 text-zinc-600">
|
||||||
<div>完成时间:{formatDateTime(selectedTask.task.completedAt)}</div>
|
<div>{joinBusinessMeta([`类型:${entityLabel(selectedTask.workflowInstance?.entityType)}`, `业务状态:${businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}`])}</div>
|
||||||
|
<div>{joinBusinessMeta([selectedApplicantName ? `申请人:${selectedApplicantName}` : null, selectedMetric ? `金额/天数:${selectedMetric}` : null])}</div>
|
||||||
|
{selectedTask.task.completedAt ? <div>完成时间:{formatDateTime(selectedTask.task.completedAt)}</div> : null}
|
||||||
|
</div>
|
||||||
{selectedTask.reimbursement ? (
|
{selectedTask.reimbursement ? (
|
||||||
<>
|
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||||
<div>报销单号:{selectedTask.reimbursement.formNumber ?? "-"}</div>
|
{joinBusinessMeta([`报销日期:${selectedTask.reimbursement.reimbursementDate ?? "-"}`, selectedTask.reimbursement.description])}
|
||||||
<div>金额:{selectedTask.reimbursement.totalAmount ?? "-"}</div>
|
</div>
|
||||||
<div>提交人:{selectedTask.reimbursement.submitter?.name ?? "-"}</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
{selectedTask.leaveRequest ? (
|
{selectedTask.leaveRequest ? (
|
||||||
<>
|
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||||
<div>假期类型:{selectedTask.leaveRequest.leaveType?.name ?? "-"}</div>
|
{joinBusinessMeta([`假期类型:${selectedTask.leaveRequest.leaveType?.name ?? "-"}`, `${selectedTask.leaveRequest.startDate ?? "-"} 至 ${selectedTask.leaveRequest.endDate ?? "-"}`])}
|
||||||
<div>申请人:{selectedTask.leaveRequest.owner?.name ?? "-"}</div>
|
</div>
|
||||||
<div>请假天数:{selectedTask.leaveRequest.requestedDays}</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
{selectedTask.purchaseRequest ? (
|
{selectedTask.purchaseRequest ? (
|
||||||
<>
|
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||||
<div>采购单号:{selectedTask.purchaseRequest.requestNo ?? "-"}</div>
|
{joinBusinessMeta([`供应商:${selectedTask.purchaseRequest.supplier?.name ?? "-"}`, selectedTask.purchaseRequest.title])}
|
||||||
<div>申请人:{selectedTask.purchaseRequest.applicant?.name ?? "-"}</div>
|
</div>
|
||||||
<div>供应商:{selectedTask.purchaseRequest.supplier?.name ?? "-"}</div>
|
|
||||||
<div>金额:{selectedTask.purchaseRequest.totalAmount ?? "-"}</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
{selectedTask.travelRequest ? (
|
{selectedTask.travelRequest ? (
|
||||||
<>
|
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||||
<div>差旅单号:{selectedTask.travelRequest.requestNo ?? "-"}</div>
|
{joinBusinessMeta([`出差类型:${selectedTask.travelRequest.tripType === "international" ? "国际" : "国内"}`, selectedTask.travelRequest.purpose])}
|
||||||
<div>申请人:{selectedTask.travelRequest.applicant?.name ?? "-"}</div>
|
</div>
|
||||||
<div>出差类型:{selectedTask.travelRequest.tripType === "international" ? "国际" : "国内"}</div>
|
|
||||||
<div>预估金额:{selectedTask.travelRequest.estimatedAmount ?? "-"}</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1191,6 +1322,7 @@ export function MobileApprovalsPage() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="mobile-card space-y-3" data-testid="mobile-approvals-queue">
|
<section className="mobile-card space-y-3" data-testid="mobile-approvals-queue">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
createLeaveRequest,
|
createLeaveRequest,
|
||||||
fetchLeaveBalances,
|
fetchLeaveBalances,
|
||||||
|
fetchLeaveRequestDetail,
|
||||||
fetchLeaveRequests,
|
fetchLeaveRequests,
|
||||||
fetchLeaveTypes,
|
fetchLeaveTypes,
|
||||||
patchLeaveRequest,
|
patchLeaveRequest,
|
||||||
@@ -60,11 +62,13 @@ function LeaveRecordCard({
|
|||||||
isSaving,
|
isSaving,
|
||||||
onEdit,
|
onEdit,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
showActions = true,
|
||||||
}: {
|
}: {
|
||||||
item: LeaveRequestItem;
|
item: LeaveRequestItem;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
onEdit: (item: LeaveRequestItem) => void;
|
onEdit: (item: LeaveRequestItem) => void;
|
||||||
onSubmit: (item: LeaveRequestItem) => void;
|
onSubmit: (item: LeaveRequestItem) => void;
|
||||||
|
showActions?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const canEdit = item.status === "draft" || item.status === "rejected";
|
const canEdit = item.status === "draft" || item.status === "rejected";
|
||||||
const canSubmit = item.status === "draft" || item.status === "rejected";
|
const canSubmit = item.status === "draft" || item.status === "rejected";
|
||||||
@@ -104,7 +108,7 @@ function LeaveRecordCard({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canEdit || canSubmit ? (
|
{showActions && (canEdit || canSubmit) ? (
|
||||||
<div className="mobile-two-action-grid mt-4">
|
<div className="mobile-two-action-grid mt-4">
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<Button variant="secondary" type="button" disabled={isSaving} onClick={() => onEdit(item)}>
|
<Button variant="secondary" type="button" disabled={isSaving} onClick={() => onEdit(item)}>
|
||||||
@@ -124,6 +128,7 @@ function LeaveRecordCard({
|
|||||||
|
|
||||||
export function MobileLeavePage() {
|
export function MobileLeavePage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { leaveRequestId = "" } = useParams();
|
||||||
const { permissions } = useAuth();
|
const { permissions } = useAuth();
|
||||||
const canCreateLeave = hasPermission(permissions, "leave.create");
|
const canCreateLeave = hasPermission(permissions, "leave.create");
|
||||||
const [statusFilter, setStatusFilter] = useState<LeaveStatusFilter>("all");
|
const [statusFilter, setStatusFilter] = useState<LeaveStatusFilter>("all");
|
||||||
@@ -158,6 +163,12 @@ export function MobileLeavePage() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
enabled: Boolean(leaveRequestId),
|
||||||
|
queryKey: ["mobile", "leave", "detail", leaveRequestId],
|
||||||
|
queryFn: () => fetchLeaveRequestDetail(leaveRequestId),
|
||||||
|
});
|
||||||
|
|
||||||
const invalidateLeaveQueries = async () => {
|
const invalidateLeaveQueries = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["mobile", "leave"] }),
|
queryClient.invalidateQueries({ queryKey: ["mobile", "leave"] }),
|
||||||
@@ -265,6 +276,35 @@ export function MobileLeavePage() {
|
|||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (leaveRequestId) {
|
||||||
|
return (
|
||||||
|
<PageScaffold className="space-y-3 pb-4">
|
||||||
|
<PageHeader
|
||||||
|
title="请假详情"
|
||||||
|
actions={
|
||||||
|
<Button asChild size="sm" type="button" variant="secondary">
|
||||||
|
<Link to="/mobile/leave">返回</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{detailQuery.isLoading ? <div className="mobile-card text-sm text-zinc-500">加载中...</div> : null}
|
||||||
|
{detailQuery.isError ? (
|
||||||
|
<div className="mobile-card text-sm text-red-600">{resolveErrorMessage(detailQuery.error)}</div>
|
||||||
|
) : null}
|
||||||
|
{detailQuery.data ? (
|
||||||
|
<LeaveRecordCard
|
||||||
|
item={detailQuery.data}
|
||||||
|
isSaving={false}
|
||||||
|
onEdit={startEditing}
|
||||||
|
onSubmit={(nextItem) => submitMutation.mutate(nextItem)}
|
||||||
|
showActions={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</PageScaffold>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageScaffold className="space-y-3 pb-4">
|
<PageScaffold className="space-y-3 pb-4">
|
||||||
<PageHeader title="请假" />
|
<PageHeader title="请假" />
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ function MobileActionLink({
|
|||||||
|
|
||||||
export function MobileMePage() {
|
export function MobileMePage() {
|
||||||
const { me, permissions, refreshMe, logout } = useAuth();
|
const { me, permissions, refreshMe, logout } = useAuth();
|
||||||
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
|
||||||
const canAccessAdmin = hasAnyPermission(permissions, [
|
const canAccessAdmin = hasAnyPermission(permissions, [
|
||||||
"*",
|
"*",
|
||||||
"system.settings",
|
"system.settings",
|
||||||
@@ -63,6 +62,7 @@ export function MobileMePage() {
|
|||||||
]);
|
]);
|
||||||
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||||||
const canAccessProjects = hasAnyPermission(permissions, ["project.view_own", "project.view_all", "project.manage"]);
|
const canAccessProjects = hasAnyPermission(permissions, ["project.view_own", "project.view_all", "project.manage"]);
|
||||||
|
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
||||||
|
|
||||||
const profileQuery = useQuery({
|
const profileQuery = useQuery({
|
||||||
enabled: Boolean(me?.id),
|
enabled: Boolean(me?.id),
|
||||||
@@ -74,7 +74,7 @@ export function MobileMePage() {
|
|||||||
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
|
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await refreshMe();
|
await refreshMe();
|
||||||
toast.success("外部推送偏好已更新");
|
toast.success("企业微信消息提醒偏好已更新");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(resolveErrorMessage(error));
|
toast.error(resolveErrorMessage(error));
|
||||||
@@ -124,7 +124,7 @@ export function MobileMePage() {
|
|||||||
<MobileActionLink to="/mobile/notifications" label="通知中心" icon="notifications" />
|
<MobileActionLink to="/mobile/notifications" label="通知中心" icon="notifications" />
|
||||||
<MobileActionLink to="/mobile/leave" label="请假" icon="event_available" />
|
<MobileActionLink to="/mobile/leave" label="请假" icon="event_available" />
|
||||||
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
|
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
|
||||||
{canAccessApprovals ? <MobileActionLink to="/mobile/approvals" label="审批" icon="fact_check" /> : null}
|
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "审批" : "审批进度"} icon="fact_check" />
|
||||||
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
|
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
|
||||||
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
|
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +140,7 @@ export function MobileMePage() {
|
|||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<SectionPanel title="外部通知推送">
|
<SectionPanel title="企业微信消息提醒">
|
||||||
<label className="flex items-center gap-3 text-sm text-zinc-800">
|
<label className="flex items-center gap-3 text-sm text-zinc-800">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -149,8 +149,11 @@ export function MobileMePage() {
|
|||||||
disabled={!me || notificationPreferenceMutation.isPending}
|
disabled={!me || notificationPreferenceMutation.isPending}
|
||||||
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
|
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
接收外部渠道推送
|
接收企业微信消息提醒
|
||||||
</label>
|
</label>
|
||||||
|
<p className="mt-2 text-xs leading-5 text-zinc-500">
|
||||||
|
关闭后仍保留 OA 站内通知,不影响企业微信登录、绑定和工作台入口。
|
||||||
|
</p>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
|
|
||||||
<Button type="button" variant="secondary" className="w-full border-red-200 text-red-600 hover:bg-red-50" onClick={() => void logout()}>
|
<Button type="button" variant="secondary" className="w-full border-red-200 text-red-600 hover:bg-red-50" onClick={() => void logout()}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Link, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { fetchNotifications, markAllNotificationsRead, markNotificationRead } from "../api/notifications";
|
import { fetchNotifications, markAllNotificationsRead, markNotificationRead, type NotificationItem } from "../api/notifications";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
@@ -9,8 +9,7 @@ import { Pagination } from "../components/common/Pagination";
|
|||||||
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 { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||||
import { resolveNotificationTargetLink } from "../lib/notificationLinks";
|
import { resolveMobileNotificationTargetLink } from "../lib/notificationLinks";
|
||||||
import { resolveMobileEquivalentPath } from "../lib/runtimeContext";
|
|
||||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -33,19 +32,13 @@ function resolveNotificationError(error: unknown) {
|
|||||||
return "加载通知失败";
|
return "加载通知失败";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMobileNotificationTarget(item: Parameters<typeof resolveNotificationTargetLink>[0]) {
|
|
||||||
const targetLink = resolveNotificationTargetLink(item);
|
|
||||||
if (!targetLink) return null;
|
|
||||||
const [pathname, search = ""] = targetLink.split("?");
|
|
||||||
return resolveMobileEquivalentPath(pathname, search ? `?${search}` : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(value: string) {
|
function formatTime(value: string) {
|
||||||
return value.slice(0, 19).replace("T", " ");
|
return value.slice(0, 19).replace("T", " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MobileNotificationsPage() {
|
export function MobileNotificationsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const unreadOnlyRaw = searchParams.get("unreadOnly") ?? "0";
|
const unreadOnlyRaw = searchParams.get("unreadOnly") ?? "0";
|
||||||
@@ -82,6 +75,21 @@ export function MobileNotificationsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openNotificationTarget = async (item: NotificationItem, targetLink: string | null) => {
|
||||||
|
if (!targetLink) {
|
||||||
|
toast.info("这条通知没有可直达的业务详情");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!item.readAt) {
|
||||||
|
await markReadMutation.mutateAsync(item.id);
|
||||||
|
}
|
||||||
|
navigate(targetLink);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(resolveErrorMessage(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const markAllReadMutation = useMutation({
|
const markAllReadMutation = useMutation({
|
||||||
mutationFn: () => markAllNotificationsRead(),
|
mutationFn: () => markAllNotificationsRead(),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -174,9 +182,15 @@ export function MobileNotificationsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{notificationItems.map((item) => {
|
{notificationItems.map((item) => {
|
||||||
const targetLink = resolveMobileNotificationTarget(item);
|
const targetLink = resolveMobileNotificationTargetLink(item);
|
||||||
return (
|
return (
|
||||||
<article data-testid="mobile-notification-card" key={item.id} className="mobile-list-card">
|
<button
|
||||||
|
data-testid="mobile-notification-card"
|
||||||
|
key={item.id}
|
||||||
|
className="mobile-list-card w-full text-left"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void openNotificationTarget(item, targetLink)}
|
||||||
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-semibold text-zinc-900">{item.title}</div>
|
<div className="text-sm font-semibold text-zinc-900">{item.title}</div>
|
||||||
@@ -188,28 +202,8 @@ export function MobileNotificationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 text-xs text-zinc-500">{formatTime(item.createdAt)}</div>
|
<div className="mt-3 text-xs text-zinc-500">{formatTime(item.createdAt)}</div>
|
||||||
<div className="mobile-two-action-grid mt-3">
|
<div className="mt-3 text-xs text-primary">{targetLink ? "查看详情" : "暂无直达详情"}</div>
|
||||||
{targetLink ? (
|
</button>
|
||||||
<Button asChild size="sm" variant="secondary" data-testid="mobile-notification-target-link">
|
|
||||||
<Link to={targetLink}>查看</Link>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!item.readAt ? (
|
|
||||||
<Button
|
|
||||||
className={targetLink ? "" : "col-span-2"}
|
|
||||||
data-testid="mobile-notification-mark-read"
|
|
||||||
size="sm"
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={markReadMutation.isPending}
|
|
||||||
onClick={() => markReadMutation.mutate(item.id)}
|
|
||||||
>
|
|
||||||
标为已读
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { isApiError } from "../api/types";
|
|||||||
import { fetchWorkflowTasks, fetchWorkflowTaskSummary } from "../api/workflow";
|
import { fetchWorkflowTasks, fetchWorkflowTaskSummary } from "../api/workflow";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
|
import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions";
|
||||||
|
import { businessEntityLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
|
||||||
|
import { resolveMobileNotificationTargetLink } from "../lib/notificationLinks";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||||
@@ -22,7 +24,6 @@ import {
|
|||||||
MOBILE_LEAVE_PERMISSIONS,
|
MOBILE_LEAVE_PERMISSIONS,
|
||||||
MOBILE_REIMBURSEMENT_PERMISSIONS,
|
MOBILE_REIMBURSEMENT_PERMISSIONS,
|
||||||
} from "../features/mobile/navigation";
|
} from "../features/mobile/navigation";
|
||||||
import { buildMobileApprovalsPath } from "../pwa";
|
|
||||||
|
|
||||||
type MobileAction = {
|
type MobileAction = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -97,14 +98,6 @@ function attendanceExemptionLabel(reason: unknown) {
|
|||||||
return "无需打卡";
|
return "无需打卡";
|
||||||
}
|
}
|
||||||
|
|
||||||
function workflowEntityLabel(entityType: string | null | undefined) {
|
|
||||||
if (entityType === "leave") return "请假";
|
|
||||||
if (entityType === "reimbursement") return "报销";
|
|
||||||
if (entityType === "purchase_request") return "采购";
|
|
||||||
if (entityType === "travel_request") return "差旅";
|
|
||||||
return "审批";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAttendanceAction(attendanceToday: AttendanceTodayResponse | null) {
|
function resolveAttendanceAction(attendanceToday: AttendanceTodayResponse | null) {
|
||||||
if (!attendanceToday) {
|
if (!attendanceToday) {
|
||||||
return null;
|
return null;
|
||||||
@@ -180,7 +173,7 @@ export function MobilePortalPage() {
|
|||||||
|
|
||||||
const notificationsQuery = useQuery({
|
const notificationsQuery = useQuery({
|
||||||
queryKey: ["mobile", "portal", "notifications"],
|
queryKey: ["mobile", "portal", "notifications"],
|
||||||
queryFn: () => fetchNotifications({ page: 1, pageSize: 3 }),
|
queryFn: () => fetchNotifications({ page: 1, pageSize: 6 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const attendanceClockInMutation = useMutation({
|
const attendanceClockInMutation = useMutation({
|
||||||
@@ -219,25 +212,22 @@ export function MobilePortalPage() {
|
|||||||
const pendingApprovalCount = canAccessApprovals
|
const pendingApprovalCount = canAccessApprovals
|
||||||
? workflowSummaryQuery.data?.pendingTotal ?? pendingCountQuery.data?.total ?? 0
|
? workflowSummaryQuery.data?.pendingTotal ?? pendingCountQuery.data?.total ?? 0
|
||||||
: 0;
|
: 0;
|
||||||
const overdueApprovalCount = canAccessApprovals ? workflowSummaryQuery.data?.overdueTotal ?? 0 : 0;
|
|
||||||
const workflowEntityTypeCount = workflowSummaryQuery.data?.byEntityType?.length ?? 0;
|
const workflowEntityTypeCount = workflowSummaryQuery.data?.byEntityType?.length ?? 0;
|
||||||
const pendingTasks = pendingCountQuery.data?.items ?? [];
|
const pendingTasks = pendingCountQuery.data?.items ?? [];
|
||||||
const notifications = notificationsQuery.data?.items ?? [];
|
const notifications = notificationsQuery.data?.items ?? [];
|
||||||
|
const homeNotifications = notifications
|
||||||
|
.filter((item) => !(canAccessApprovals && item.type.startsWith("workflow.task_")))
|
||||||
|
.slice(0, 3);
|
||||||
const unreadNotificationCount = notifications.filter((item) => !item.readAt).length;
|
const unreadNotificationCount = notifications.filter((item) => !item.readAt).length;
|
||||||
|
|
||||||
const actions: MobileAction[] = [
|
const primaryActions = [
|
||||||
canAccessApprovals
|
canAccessReimbursements
|
||||||
? {
|
? {
|
||||||
key: "approvals",
|
key: "reimbursements",
|
||||||
label: "审批",
|
label: "报销",
|
||||||
meta: overdueApprovalCount > 0
|
meta: "新建与进度",
|
||||||
? `${pendingApprovalCount} 待处理 / ${overdueApprovalCount} 超时`
|
icon: "request_quote",
|
||||||
: pendingApprovalCount > 0
|
to: "/mobile/reimbursements",
|
||||||
? `${pendingApprovalCount} 条待处理`
|
|
||||||
: "暂无待办",
|
|
||||||
icon: "fact_check",
|
|
||||||
to: "/mobile/approvals",
|
|
||||||
tone: pendingApprovalCount > 0 ? "warning" : "primary",
|
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
canAccessCapture
|
canAccessCapture
|
||||||
@@ -250,15 +240,14 @@ export function MobilePortalPage() {
|
|||||||
tone: "primary",
|
tone: "primary",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
canAccessReimbursements
|
{
|
||||||
? {
|
key: "approvals",
|
||||||
key: "reimbursements",
|
label: canAccessApprovals ? "审批" : "进度",
|
||||||
label: "报销",
|
meta: canAccessApprovals ? "待办与已处理" : "我的申请进度",
|
||||||
meta: "新建与进度",
|
icon: "fact_check",
|
||||||
icon: "request_quote",
|
to: "/mobile/approvals",
|
||||||
to: "/mobile/reimbursements",
|
tone: "primary",
|
||||||
}
|
},
|
||||||
: null,
|
|
||||||
{
|
{
|
||||||
key: "me",
|
key: "me",
|
||||||
label: "我的",
|
label: "我的",
|
||||||
@@ -266,6 +255,8 @@ export function MobilePortalPage() {
|
|||||||
icon: "person",
|
icon: "person",
|
||||||
to: "/mobile/me",
|
to: "/mobile/me",
|
||||||
},
|
},
|
||||||
|
].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
|
||||||
|
const secondaryActions = [
|
||||||
canAccessLeave
|
canAccessLeave
|
||||||
? {
|
? {
|
||||||
key: "leave",
|
key: "leave",
|
||||||
@@ -275,16 +266,69 @@ export function MobilePortalPage() {
|
|||||||
to: "/mobile/leave",
|
to: "/mobile/leave",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
|
].filter((item): item is MobileAction => Boolean(item));
|
||||||
|
const actions = [...primaryActions, ...secondaryActions].slice(0, 4);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageScaffold className="space-y-4">
|
<PageScaffold className="space-y-4">
|
||||||
<PageHeader title="今天" />
|
<PageHeader title="今天" />
|
||||||
|
|
||||||
|
{canAccessApprovals && pendingApprovalCount > 0 ? (
|
||||||
|
<section className="mobile-card" data-testid="mobile-pending-summary">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<div className="text-base font-semibold text-zinc-900">待处理</div>
|
||||||
|
<span className="badge-warning">{pendingApprovalCount} 待批</span>
|
||||||
|
</div>
|
||||||
|
{workflowSummaryQuery.data ? (
|
||||||
|
<div className="mb-3 grid grid-cols-3 gap-2 text-center text-xs">
|
||||||
|
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
||||||
|
<div className="font-semibold text-zinc-900">{workflowSummaryQuery.data.pendingTotal}</div>
|
||||||
|
<div className="mt-1 text-zinc-500">待办</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
||||||
|
<div className={workflowSummaryQuery.data.overdueTotal > 0 ? "font-semibold text-amber-700" : "font-semibold text-zinc-900"}>
|
||||||
|
{workflowSummaryQuery.data.overdueTotal}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-zinc-500">超时</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
||||||
|
<div className="font-semibold text-zinc-900">{workflowEntityTypeCount}</div>
|
||||||
|
<div className="mt-1 text-zinc-500">类型</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pendingTasks.slice(0, 3).map((task) => {
|
||||||
|
const entityType = task.workflowInstance?.entityType;
|
||||||
|
const reference = compactBusinessReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={task.id}
|
||||||
|
to={`/mobile/approvals/${encodeURIComponent(task.id)}`}
|
||||||
|
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0 truncate font-medium text-zinc-900">
|
||||||
|
{reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })}
|
||||||
|
</div>
|
||||||
|
<span className="badge-warning shrink-0">待处理</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate text-xs text-zinc-500">
|
||||||
|
{joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
|
||||||
|
<Link to="/mobile/approvals">进入审批</Link>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="mobile-card" data-testid="mobile-today-focus">
|
<section className="mobile-card" data-testid="mobile-today-focus">
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<div className="text-base font-semibold text-zinc-900">待处理</div>
|
<div className="text-base font-semibold text-zinc-900">常用</div>
|
||||||
{pendingApprovalCount > 0 ? <span className="badge-warning">{pendingApprovalCount} 待批</span> : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mobile-icon-grid" data-testid="mobile-action-grid">
|
<div className="mobile-icon-grid" data-testid="mobile-action-grid">
|
||||||
{actions.map((action) => (
|
{actions.map((action) => (
|
||||||
@@ -338,58 +382,6 @@ export function MobilePortalPage() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canAccessApprovals ? (
|
|
||||||
<section className="mobile-card" data-testid="mobile-pending-tasks">
|
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
|
||||||
<div className="text-base font-semibold text-zinc-900">待审批</div>
|
|
||||||
<Button asChild size="sm" type="button" variant="secondary">
|
|
||||||
<Link to="/mobile/approvals">全部</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pendingCountQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null}
|
|
||||||
{pendingCountQuery.isError ? (
|
|
||||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
|
||||||
{resolveErrorMessage(pendingCountQuery.error)}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{workflowSummaryQuery.data ? (
|
|
||||||
<div className="mb-3 grid grid-cols-3 gap-2 text-center text-xs">
|
|
||||||
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
|
||||||
<div className="font-semibold text-zinc-900">{workflowSummaryQuery.data.pendingTotal}</div>
|
|
||||||
<div className="mt-1 text-zinc-500">待办</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
|
||||||
<div className={workflowSummaryQuery.data.overdueTotal > 0 ? "font-semibold text-amber-700" : "font-semibold text-zinc-900"}>
|
|
||||||
{workflowSummaryQuery.data.overdueTotal}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-zinc-500">超时</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
|
||||||
<div className="font-semibold text-zinc-900">{workflowEntityTypeCount}</div>
|
|
||||||
<div className="mt-1 text-zinc-500">类型</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{!pendingCountQuery.isLoading && !pendingTasks.length ? (
|
|
||||||
<p className="text-sm text-zinc-500">没有待处理审批。</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{pendingTasks.map((task) => (
|
|
||||||
<Link
|
|
||||||
key={task.id}
|
|
||||||
to={buildMobileApprovalsPath(task.id)}
|
|
||||||
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<div className="font-medium text-zinc-900">{task.stepName}</div>
|
|
||||||
<div className="mt-1 text-xs text-zinc-500">{workflowEntityLabel(task.workflowInstance?.entityType)}</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className="mobile-card" data-testid="mobile-notifications">
|
<section className="mobile-card" data-testid="mobile-notifications">
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<div className="text-base font-semibold text-zinc-900">通知</div>
|
<div className="text-base font-semibold text-zinc-900">通知</div>
|
||||||
@@ -404,15 +396,15 @@ export function MobilePortalPage() {
|
|||||||
{resolveErrorMessage(notificationsQuery.error)}
|
{resolveErrorMessage(notificationsQuery.error)}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{!notificationsQuery.isLoading && !notifications.length ? (
|
{!notificationsQuery.isLoading && !homeNotifications.length ? (
|
||||||
<p className="text-sm text-zinc-500">暂无通知。</p>
|
<p className="text-sm text-zinc-500">暂无通知。</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{notifications.map((item) => (
|
{homeNotifications.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.id}
|
key={item.id}
|
||||||
to="/mobile/notifications"
|
to={resolveMobileNotificationTargetLink(item) ?? "/mobile/notifications"}
|
||||||
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
|
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function MobileProfilePage() {
|
|||||||
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
|
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await refreshMe();
|
await refreshMe();
|
||||||
toast.success("外部推送偏好已更新");
|
toast.success("企业微信消息提醒偏好已更新");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(resolveProfileError(error));
|
toast.error(resolveProfileError(error));
|
||||||
@@ -144,9 +144,9 @@ export function MobileProfilePage() {
|
|||||||
</dl>
|
</dl>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
|
|
||||||
<SectionPanel title="外部通知推送">
|
<SectionPanel title="企业微信消息提醒">
|
||||||
<label className="mobile-list-card flex min-h-12 items-center justify-between gap-3 text-sm text-zinc-800">
|
<label className="mobile-list-card flex min-h-12 items-center justify-between gap-3 text-sm text-zinc-800">
|
||||||
<span>接收外部渠道推送</span>
|
<span>接收企业微信消息提醒</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="h-5 w-5 rounded border-zinc-300"
|
className="h-5 w-5 rounded border-zinc-300"
|
||||||
@@ -155,6 +155,9 @@ export function MobileProfilePage() {
|
|||||||
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
|
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<p className="mt-2 text-xs leading-5 text-zinc-500">
|
||||||
|
关闭后仍保留 OA 站内通知,不影响企业微信登录、绑定和工作台入口。
|
||||||
|
</p>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
|
|
||||||
<SectionPanel title="企业微信绑定">
|
<SectionPanel title="企业微信绑定">
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchPurchaseRequestDetail } from "../api/purchaseRequests";
|
||||||
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import {
|
||||||
|
formatApprovalHistoryTime,
|
||||||
|
resolvePurchaseDisplayCode,
|
||||||
|
resolvePurchaseStatusHint,
|
||||||
|
workflowActionLabel,
|
||||||
|
} from "../lib/approvalDisplay";
|
||||||
|
import { joinBusinessMeta } from "../lib/businessDisplay";
|
||||||
|
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||||
|
|
||||||
|
function formatCurrency(value: string | null | undefined, currencyCode = "CNY") {
|
||||||
|
const amount = Number(value ?? 0);
|
||||||
|
if (!Number.isFinite(amount)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: currencyCode }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined) {
|
||||||
|
return value ? value.slice(0, 10) : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number) {
|
||||||
|
if (value < 1024) {
|
||||||
|
return `${value} B`;
|
||||||
|
}
|
||||||
|
if (value < 1024 * 1024) {
|
||||||
|
return `${(value / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobilePurchaseRequestDetailPage() {
|
||||||
|
const { purchaseRequestId = "" } = useParams();
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
enabled: Boolean(purchaseRequestId),
|
||||||
|
queryKey: ["mobile", "purchase-request", "detail", purchaseRequestId],
|
||||||
|
queryFn: () => fetchPurchaseRequestDetail(purchaseRequestId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const detail = detailQuery.data ?? null;
|
||||||
|
const displayCode = detail ? resolvePurchaseDisplayCode(detail) : "采购详情";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageScaffold className="space-y-3 pb-4">
|
||||||
|
<PageHeader
|
||||||
|
title="采购详情"
|
||||||
|
meta={detail ? <StatusBadge status={detail.status} /> : null}
|
||||||
|
actions={
|
||||||
|
<Button asChild size="sm" type="button" variant="secondary">
|
||||||
|
<Link to="/mobile/approvals">返回</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!purchaseRequestId ? (
|
||||||
|
<div className="mobile-card text-sm text-red-600">缺少采购申请 ID。</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{detailQuery.isLoading ? <div className="mobile-card text-sm text-zinc-500">加载中...</div> : null}
|
||||||
|
|
||||||
|
{detailQuery.isError ? (
|
||||||
|
<div className="mobile-card text-sm text-red-600">{resolveErrorMessage(detailQuery.error)}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{detail ? (
|
||||||
|
<>
|
||||||
|
<section className="mobile-card" data-testid="mobile-purchase-detail-summary">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="truncate text-base font-semibold text-zinc-900">{displayCode}</h2>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">{detail.title || "未命名采购申请"}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={detail.status} />
|
||||||
|
</div>
|
||||||
|
<div className="mobile-info-grid mt-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-zinc-500">金额</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-zinc-900">
|
||||||
|
{formatCurrency(detail.totalAmount, detail.currencyCode)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-zinc-500">申请人</div>
|
||||||
|
<div className="mt-1 truncate font-medium text-zinc-900">{detail.applicant?.name ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs leading-5 text-zinc-600">
|
||||||
|
{resolvePurchaseStatusHint(detail)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mobile-card" data-testid="mobile-purchase-detail-fields">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">业务信息</h3>
|
||||||
|
<div className="mt-3 space-y-2 text-sm text-zinc-700">
|
||||||
|
<div>{joinBusinessMeta([`供应商:${detail.supplier?.name ?? "-"}`, `期望到货:${formatDate(detail.expectedDeliveryDate)}`])}</div>
|
||||||
|
<div>{joinBusinessMeta([`法体:${detail.legalEntity?.name ?? "-"}`, `成本中心:${detail.costCenter?.name ?? "-"}`])}</div>
|
||||||
|
<div>{joinBusinessMeta([`预算池:${detail.budgetPool?.name ?? "未匹配"}`, `当前节点:${detail.pendingWorkflow?.task?.stepName ?? "-"}`])}</div>
|
||||||
|
{detail.reason ? <p className="leading-6 text-zinc-500">{detail.reason}</p> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mobile-card" data-testid="mobile-purchase-detail-items">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">采购明细</h3>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{detail.items.length ? detail.items.map((item) => (
|
||||||
|
<article className="mobile-list-card text-sm" key={item.id}>
|
||||||
|
<div className="font-medium text-zinc-900">{item.description}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
|
{joinBusinessMeta([`数量:${item.quantity}`, `单价:${formatCurrency(item.unitPrice, detail.currencyCode)}`, `金额:${formatCurrency(item.lineAmount, detail.currencyCode)}`])}
|
||||||
|
</div>
|
||||||
|
{item.specification ? <div className="mt-1 text-xs text-zinc-500">{item.specification}</div> : null}
|
||||||
|
</article>
|
||||||
|
)) : <div className="mobile-empty-state">暂无采购明细</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mobile-card" data-testid="mobile-purchase-detail-attachments">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">附件</h3>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{detail.attachments.length ? detail.attachments.map((attachment) => (
|
||||||
|
<article className="mobile-list-card text-sm" key={attachment.id}>
|
||||||
|
<div className="truncate font-medium text-zinc-900">{attachment.originalName}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
|
{joinBusinessMeta([formatBytes(attachment.fileSize), attachment.uploader?.name, formatDate(attachment.createdAt)])}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)) : <div className="mobile-empty-state">暂无附件</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mobile-card" data-testid="mobile-purchase-detail-approval-history">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">审批记录</h3>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{detail.approvalHistory.length ? detail.approvalHistory.map((item) => (
|
||||||
|
<article className="mobile-list-card text-sm" key={item.id}>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</div>
|
||||||
|
<span className="text-xs text-zinc-500">{formatApprovalHistoryTime(item.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">{item.actorName ?? item.actorEmployeeNo ?? "-"}</div>
|
||||||
|
{item.comment ? <div className="mt-1 text-xs text-zinc-500">{item.comment}</div> : null}
|
||||||
|
</article>
|
||||||
|
)) : <div className="mobile-empty-state">暂无审批记录</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</PageScaffold>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobilePurchaseRequestDetailPage;
|
||||||
@@ -73,7 +73,7 @@ export function ProfilePage() {
|
|||||||
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
|
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await refreshMe();
|
await refreshMe();
|
||||||
toast.success("外部推送偏好已更新");
|
toast.success("企业微信消息提醒偏好已更新");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(resolveProfileError(error));
|
toast.error(resolveProfileError(error));
|
||||||
@@ -137,7 +137,7 @@ export function ProfilePage() {
|
|||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<SectionPanel title="外部通知推送" description="控制是否接收企微等外部渠道推送;关闭后仅保留站内通知,不影响企业微信绑定与工作台登录。">
|
<SectionPanel title="企业微信消息提醒" description="控制是否接收企业微信个人应用消息;关闭后仍保留 OA 站内通知,不影响企业微信绑定与工作台登录。">
|
||||||
<div className="space-y-3 text-sm text-zinc-600">
|
<div className="space-y-3 text-sm text-zinc-600">
|
||||||
<label className="flex items-center gap-3 text-zinc-800">
|
<label className="flex items-center gap-3 text-zinc-800">
|
||||||
<input
|
<input
|
||||||
@@ -147,7 +147,7 @@ export function ProfilePage() {
|
|||||||
disabled={!me || notificationPreferenceMutation.isPending}
|
disabled={!me || notificationPreferenceMutation.isPending}
|
||||||
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
|
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
接收外部渠道推送
|
接收企业微信消息提醒
|
||||||
</label>
|
</label>
|
||||||
<p>适用于审批待办、报销付款、资产流转等业务提醒。关闭后不会停止站内通知,也不会移除你的业务权限。</p>
|
<p>适用于审批待办、报销付款、资产流转等业务提醒。关闭后不会停止站内通知,也不会移除你的业务权限。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-2
@@ -65,8 +65,7 @@ export function buildMobileApprovalsPath(taskId?: string | null) {
|
|||||||
return "/mobile/approvals";
|
return "/mobile/approvals";
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({ taskId: normalizedTaskId });
|
return `/mobile/approvals/${encodeURIComponent(normalizedTaskId)}`;
|
||||||
return `/mobile/approvals?${params.toString()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractWorkflowTaskId(pathname: string) {
|
export function extractWorkflowTaskId(pathname: string) {
|
||||||
@@ -79,6 +78,10 @@ export function isOfflineSnapshotAllowedPath(pathname: string) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (/^\/mobile\/approvals\/[^/]+$/.test(pathname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return Boolean(extractWorkflowTaskId(pathname));
|
return Boolean(extractWorkflowTaskId(pathname));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+54
-2
@@ -36,6 +36,7 @@ import {
|
|||||||
MobileNotificationsPage,
|
MobileNotificationsPage,
|
||||||
MobilePortalPage,
|
MobilePortalPage,
|
||||||
MobileProfilePage,
|
MobileProfilePage,
|
||||||
|
MobilePurchaseRequestDetailPage,
|
||||||
MobileReimbursementDetailPage,
|
MobileReimbursementDetailPage,
|
||||||
MobileReimbursementsPage,
|
MobileReimbursementsPage,
|
||||||
NotificationChannelsPage,
|
NotificationChannelsPage,
|
||||||
@@ -345,6 +346,26 @@ export function AppRouter() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="mobile/leave/:leaveRequestId"
|
||||||
|
element={
|
||||||
|
<RequirePermission anyOf={["leave.view_own", "leave.view_all"]}>
|
||||||
|
{withRouteFallback(<MobileLeavePage />)}
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="mobile/purchase-requests/:purchaseRequestId"
|
||||||
|
element={
|
||||||
|
<RequirePermission
|
||||||
|
anyOf={["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"]}
|
||||||
|
>
|
||||||
|
{withRouteFallback(<MobilePurchaseRequestDetailPage />)}
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="mobile/assets/scan"
|
path="mobile/assets/scan"
|
||||||
element={
|
element={
|
||||||
@@ -359,9 +380,40 @@ export function AppRouter() {
|
|||||||
<Route
|
<Route
|
||||||
path="mobile/approvals"
|
path="mobile/approvals"
|
||||||
element={
|
element={
|
||||||
<RequireBusinessApprover>
|
<RequirePermission
|
||||||
|
anyOf={[
|
||||||
|
"expense.view_own",
|
||||||
|
"leave.view_own",
|
||||||
|
"procurement.view_own",
|
||||||
|
"travel.view_own",
|
||||||
|
"expense.approve",
|
||||||
|
"leave.approve",
|
||||||
|
"procurement.approve",
|
||||||
|
"travel.approve",
|
||||||
|
]}
|
||||||
|
>
|
||||||
{withRouteFallback(<MobileApprovalsPage />)}
|
{withRouteFallback(<MobileApprovalsPage />)}
|
||||||
</RequireBusinessApprover>
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="mobile/approvals/:taskId"
|
||||||
|
element={
|
||||||
|
<RequirePermission
|
||||||
|
anyOf={[
|
||||||
|
"expense.view_own",
|
||||||
|
"leave.view_own",
|
||||||
|
"procurement.view_own",
|
||||||
|
"travel.view_own",
|
||||||
|
"expense.approve",
|
||||||
|
"leave.approve",
|
||||||
|
"procurement.approve",
|
||||||
|
"travel.approve",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{withRouteFallback(<MobileApprovalsPage />)}
|
||||||
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { MobilePurchaseRequestDetailPage as default } from "../../pages/MobilePurchaseRequestDetailPage";
|
||||||
@@ -23,6 +23,7 @@ export const MobileMePage = lazy(() => import("./MobileMePage"));
|
|||||||
export const MobileNotificationsPage = lazy(() => import("./MobileNotificationsPage"));
|
export const MobileNotificationsPage = lazy(() => import("./MobileNotificationsPage"));
|
||||||
export const MobilePortalPage = lazy(() => import("./MobilePortalPage"));
|
export const MobilePortalPage = lazy(() => import("./MobilePortalPage"));
|
||||||
export const MobileProfilePage = lazy(() => import("./MobileProfilePage"));
|
export const MobileProfilePage = lazy(() => import("./MobileProfilePage"));
|
||||||
|
export const MobilePurchaseRequestDetailPage = lazy(() => import("./MobilePurchaseRequestDetailPage"));
|
||||||
export const MobileReimbursementDetailPage = lazy(() => import("./MobileReimbursementDetailPage"));
|
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"));
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: clamp(7px, 2vw, 9px);
|
||||||
border: 1px solid rgb(228 228 231);
|
border: 1px solid rgb(228 228 231);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: rgb(255 255 255);
|
background: rgb(255 255 255);
|
||||||
|
|||||||
@@ -0,0 +1,742 @@
|
|||||||
|
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/mobile-oa-information-dedupe-governance");
|
||||||
|
const IOS_WECOM_UA =
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 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 RolePreset = "employee" | "manager";
|
||||||
|
|
||||||
|
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-dg7-mobile-information-dedupe", 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(role: RolePreset) {
|
||||||
|
return {
|
||||||
|
id: `employee-dg7-${role}`,
|
||||||
|
employeeNo: role === "manager" ? "M-DG7" : "E-DG7",
|
||||||
|
name: role === "manager" ? "测试审批人" : "测试员工",
|
||||||
|
phone: "13800000000",
|
||||||
|
email: `${role}@hyinda.com`,
|
||||||
|
departmentId: "dept-dg7",
|
||||||
|
directManagerId: role === "manager" ? null : "employee-dg7-manager",
|
||||||
|
status: "active",
|
||||||
|
notificationPushEnabled: true,
|
||||||
|
roleCodes: role === "manager" ? ["manager"] : ["employee"],
|
||||||
|
profileCompleteness: {
|
||||||
|
hasEmergencyContact: true,
|
||||||
|
hasBankAccount: true,
|
||||||
|
hasAvatar: false,
|
||||||
|
hasIdNumber: false,
|
||||||
|
score: 60,
|
||||||
|
},
|
||||||
|
wecomBinding: {
|
||||||
|
externalUserId: `wxwork-dg7-${role}`,
|
||||||
|
createdAt: "2026-05-12T08:00:00.000Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigationItem(key: string, href: string, label: string, icon: string) {
|
||||||
|
return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaveType = {
|
||||||
|
id: "leave-type-dg7",
|
||||||
|
code: "annual",
|
||||||
|
name: "年假",
|
||||||
|
description: null,
|
||||||
|
enabled: true,
|
||||||
|
requiresAttachment: false,
|
||||||
|
attachmentMimeTypes: [],
|
||||||
|
sortOrder: 1,
|
||||||
|
policy: {
|
||||||
|
id: "leave-policy-dg7",
|
||||||
|
annualQuotaDays: 5,
|
||||||
|
enabled: true,
|
||||||
|
description: null,
|
||||||
|
},
|
||||||
|
createdAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const leaveDetail = {
|
||||||
|
id: "leave-dg7-approved",
|
||||||
|
ownerId: "employee-dg7-employee",
|
||||||
|
departmentId: "dept-dg7",
|
||||||
|
status: "approved",
|
||||||
|
startDate: "2026-05-12",
|
||||||
|
endDate: "2026-05-12",
|
||||||
|
startSession: "am",
|
||||||
|
endSession: "pm",
|
||||||
|
requestedDays: 1,
|
||||||
|
reason: "年假",
|
||||||
|
attachment: null,
|
||||||
|
leaveType: {
|
||||||
|
id: leaveType.id,
|
||||||
|
code: leaveType.code,
|
||||||
|
name: leaveType.name,
|
||||||
|
requiresAttachment: false,
|
||||||
|
attachmentMimeTypes: [],
|
||||||
|
},
|
||||||
|
owner: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
|
||||||
|
pendingWorkflow: null,
|
||||||
|
approvalHistory: [
|
||||||
|
{
|
||||||
|
id: "history-leave-dg7",
|
||||||
|
taskId: "task-leave-dg7",
|
||||||
|
actorId: "employee-dg7-manager",
|
||||||
|
actorName: "测试审批人",
|
||||||
|
actorEmployeeNo: "M-DG7",
|
||||||
|
action: "approve",
|
||||||
|
comment: "同意",
|
||||||
|
createdAt: "2026-05-12T09:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: "2026-05-12T08:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-12T09:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const purchaseDetail = {
|
||||||
|
id: "purchase-dg7-approved",
|
||||||
|
requestNo: "CG20260512001",
|
||||||
|
applicantId: "employee-dg7-employee",
|
||||||
|
submitterId: "employee-dg7-employee",
|
||||||
|
departmentId: "dept-dg7",
|
||||||
|
legalEntityId: "legal-dg7",
|
||||||
|
costCenterId: "cost-dg7",
|
||||||
|
supplierId: "supplier-dg7",
|
||||||
|
documentCategoryId: null,
|
||||||
|
budgetPoolId: null,
|
||||||
|
title: "办公用品采购",
|
||||||
|
reason: "补充办公用品",
|
||||||
|
currencyCode: "CNY",
|
||||||
|
totalAmount: "320.00",
|
||||||
|
expectedDeliveryDate: "2026-05-20",
|
||||||
|
status: "approved",
|
||||||
|
createdAt: "2026-05-12T08:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-12T09:00:00.000Z",
|
||||||
|
applicant: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
|
||||||
|
submitter: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
|
||||||
|
department: { id: "dept-dg7", name: "研发部" },
|
||||||
|
legalEntity: { id: "legal-dg7", code: "HYD", name: "海音达" },
|
||||||
|
costCenter: { id: "cost-dg7", code: "RD", name: "研发中心", legalEntityId: "legal-dg7" },
|
||||||
|
supplier: { id: "supplier-dg7", code: "SUP-DG7", name: "测试供应商" },
|
||||||
|
documentCategory: null,
|
||||||
|
budgetPool: null,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "purchase-item-dg7",
|
||||||
|
description: "键盘",
|
||||||
|
specification: "无线",
|
||||||
|
quantity: "2",
|
||||||
|
unitPrice: "160.00",
|
||||||
|
lineAmount: "320.00",
|
||||||
|
isAssetCandidate: false,
|
||||||
|
createdAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
id: "attachment-dg7",
|
||||||
|
originalName: "采购说明.pdf",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
fileSize: 10240,
|
||||||
|
uploadedBy: "employee-dg7-employee",
|
||||||
|
createdAt: "2026-05-12T08:10:00.000Z",
|
||||||
|
uploader: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pendingWorkflow: null,
|
||||||
|
approvalHistory: [
|
||||||
|
{
|
||||||
|
id: "history-purchase-dg7",
|
||||||
|
taskId: "task-purchase-dg7",
|
||||||
|
actorId: "employee-dg7-manager",
|
||||||
|
actorName: "测试审批人",
|
||||||
|
actorEmployeeNo: "M-DG7",
|
||||||
|
action: "approve",
|
||||||
|
comment: "同意",
|
||||||
|
createdAt: "2026-05-12T09:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
archiveOrder: null,
|
||||||
|
permissions: {
|
||||||
|
canEditDraft: false,
|
||||||
|
canSubmit: false,
|
||||||
|
canWithdraw: false,
|
||||||
|
canArchiveOrder: false,
|
||||||
|
},
|
||||||
|
riskFlags: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildNotifications() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "notification-task-dg7",
|
||||||
|
type: "workflow.task_assigned",
|
||||||
|
title: "报销待审批",
|
||||||
|
body: "报销单 BX20260512001 等待处理",
|
||||||
|
targetType: "workflow_task",
|
||||||
|
targetId: "task-dg7",
|
||||||
|
readAt: null,
|
||||||
|
createdAt: "2026-05-12T08:15:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notification-purchase-dg7",
|
||||||
|
type: "purchase_request.approved",
|
||||||
|
title: "采购已通过",
|
||||||
|
body: "采购单 CG20260512001 已通过",
|
||||||
|
targetType: "purchase_request",
|
||||||
|
targetId: purchaseDetail.id,
|
||||||
|
readAt: null,
|
||||||
|
createdAt: "2026-05-12T08:10:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notification-leave-dg7",
|
||||||
|
type: "leave.approved",
|
||||||
|
title: "请假已通过",
|
||||||
|
body: "年假申请已通过",
|
||||||
|
targetType: "leave_request",
|
||||||
|
targetId: leaveDetail.id,
|
||||||
|
readAt: null,
|
||||||
|
createdAt: "2026-05-12T08:05:00.000Z",
|
||||||
|
},
|
||||||
|
] satisfies MockNotification[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowTaskSummary() {
|
||||||
|
return {
|
||||||
|
id: "task-dg7",
|
||||||
|
instanceId: "instance-dg7",
|
||||||
|
stepId: "step-dg7",
|
||||||
|
stepName: "主管审批",
|
||||||
|
assigneeId: "employee-dg7-manager",
|
||||||
|
assigneeRole: null,
|
||||||
|
status: "pending",
|
||||||
|
outcome: null,
|
||||||
|
createdAt: "2026-05-12T08:00:00.000Z",
|
||||||
|
completedAt: null,
|
||||||
|
workflowInstance: {
|
||||||
|
entityType: "reimbursement",
|
||||||
|
entityId: "reimbursement-dg7",
|
||||||
|
currentStatus: "reviewing",
|
||||||
|
},
|
||||||
|
businessSummary: {
|
||||||
|
reference: "BX20260512001",
|
||||||
|
title: "差旅报销",
|
||||||
|
applicantName: "测试员工",
|
||||||
|
amount: "168.00",
|
||||||
|
duration: null,
|
||||||
|
businessStatus: "reviewing",
|
||||||
|
},
|
||||||
|
allowedActions: ["approve", "reject", "return", "transfer"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowTaskDetail() {
|
||||||
|
return {
|
||||||
|
task: {
|
||||||
|
id: "task-dg7",
|
||||||
|
instanceId: "instance-dg7",
|
||||||
|
stepId: "step-dg7",
|
||||||
|
stepName: "主管审批",
|
||||||
|
assigneeId: "employee-dg7-manager",
|
||||||
|
assigneeRole: null,
|
||||||
|
status: "pending",
|
||||||
|
outcome: null,
|
||||||
|
comment: null,
|
||||||
|
createdAt: "2026-05-12T08:00:00.000Z",
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
workflowInstance: {
|
||||||
|
id: "instance-dg7",
|
||||||
|
entityType: "reimbursement",
|
||||||
|
currentStatus: "reviewing",
|
||||||
|
currentStepId: "step-dg7",
|
||||||
|
contextData: { standardViolations: [] },
|
||||||
|
startedAt: "2026-05-12T08:00:00.000Z",
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
reimbursement: {
|
||||||
|
id: "reimbursement-dg7",
|
||||||
|
formNumber: "BX20260512001",
|
||||||
|
status: "reviewing",
|
||||||
|
reimbursementDate: "2026-05-12",
|
||||||
|
totalAmount: "168.00",
|
||||||
|
description: "差旅报销",
|
||||||
|
owner: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
|
||||||
|
submitter: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
|
||||||
|
},
|
||||||
|
leaveRequest: null,
|
||||||
|
purchaseRequest: null,
|
||||||
|
travelRequest: null,
|
||||||
|
allowedActions: ["approve", "reject", "return", "transfer"],
|
||||||
|
history: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reimbursementListItem() {
|
||||||
|
return {
|
||||||
|
id: "reimbursement-dg7",
|
||||||
|
formNumber: "BX20260512001",
|
||||||
|
status: "reviewing",
|
||||||
|
reimbursementDate: "2026-05-12",
|
||||||
|
totalAmount: "168.00",
|
||||||
|
description: "差旅报销",
|
||||||
|
payment: {
|
||||||
|
paymentVoucherNo: null,
|
||||||
|
paymentDate: null,
|
||||||
|
paymentChannel: null,
|
||||||
|
paidAt: null,
|
||||||
|
paidBy: null,
|
||||||
|
},
|
||||||
|
owner: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
|
||||||
|
submitter: { id: "employee-dg7-employee", employeeNo: "E-DG7", name: "测试员工" },
|
||||||
|
project: null,
|
||||||
|
pendingWorkflow: {
|
||||||
|
instanceId: "instance-dg7",
|
||||||
|
stepId: "step-dg7",
|
||||||
|
allowedActions: [],
|
||||||
|
task: {
|
||||||
|
id: "task-dg7",
|
||||||
|
stepName: "主管审批",
|
||||||
|
assigneeId: "employee-dg7-manager",
|
||||||
|
assigneeRole: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: "2026-05-12T08:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-12T08:00:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockApis(page: Page, role: RolePreset, notifications: MockNotification[]) {
|
||||||
|
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-dg7-${role}-token`, expiresIn: 900, mustChangePassword: false })),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/me") {
|
||||||
|
await fulfillJson(route, buildMe(role));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/system/navigation") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
workspace: {
|
||||||
|
id: "workspace-dg7",
|
||||||
|
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"),
|
||||||
|
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
||||||
|
navigationItem("leave", "/leave-requests", "请假", "event_available"),
|
||||||
|
],
|
||||||
|
admin: [],
|
||||||
|
backup: [],
|
||||||
|
},
|
||||||
|
recommendedRolePresets: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/portal/home") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
workspace: { id: "workspace-dg7", persisted: true, profile: "small_team_internal" },
|
||||||
|
greeting: { name: buildMe(role).name },
|
||||||
|
cards: [],
|
||||||
|
pendingTasks: { count: role === "manager" ? 1 : 0, href: "/workflow/tasks" },
|
||||||
|
myApplications: [],
|
||||||
|
riskSummary: null,
|
||||||
|
announcements: [],
|
||||||
|
quickLinks: [],
|
||||||
|
recentActivities: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/attendance/me/today") {
|
||||||
|
await fulfillJson(route, { workDate: "2026-05-12", needClockIn: false, exemptionReason: null, item: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/workflow/tasks/summary") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
scope: "mine",
|
||||||
|
pendingTotal: role === "manager" ? 1 : 0,
|
||||||
|
overdueTotal: 0,
|
||||||
|
earliestPendingAt: role === "manager" ? "2026-05-12T08:00:00.000Z" : null,
|
||||||
|
averagePendingAgeHours: role === "manager" ? 1 : 0,
|
||||||
|
byEntityType: role === "manager" ? [{ key: "reimbursement", pendingTotal: 1, overdueTotal: 0 }] : [],
|
||||||
|
byStep: role === "manager" ? [{ key: "主管审批", pendingTotal: 1, overdueTotal: 0 }] : [],
|
||||||
|
byAssigneeRole: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/workflow/tasks") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: Number(url.searchParams.get("pageSize") ?? 100),
|
||||||
|
total: role === "manager" ? 1 : 0,
|
||||||
|
items: role === "manager" ? [workflowTaskSummary()] : [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/workflow/tasks/task-dg7") {
|
||||||
|
await fulfillJson(route, workflowTaskDetail());
|
||||||
|
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 typeFilter = url.searchParams.get("type");
|
||||||
|
const types = typeFilter ? typeFilter.split(",") : null;
|
||||||
|
const items = notifications.filter((item) => {
|
||||||
|
if (unreadOnly && item.readAt) return false;
|
||||||
|
if (types && !types.includes(item.type)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
await fulfillJson(route, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
|
||||||
|
total: items.length,
|
||||||
|
unreadOnly,
|
||||||
|
unreadCount: notifications.filter((item) => !item.readAt).length,
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const markReadMatch = pathName.match(/^\/api\/notifications\/([^/]+)\/mark-read$/);
|
||||||
|
if (markReadMatch && request.method() === "POST") {
|
||||||
|
const item = notifications.find((candidate) => candidate.id === markReadMatch[1]);
|
||||||
|
if (item) {
|
||||||
|
item.readAt = "2026-05-12T08: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 notifications) {
|
||||||
|
item.readAt = item.readAt ?? "2026-05-12T08:20:00.000Z";
|
||||||
|
}
|
||||||
|
await fulfillJson(route, { updatedCount: notifications.length });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/reimbursements" && request.method() === "GET") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
|
||||||
|
total: 1,
|
||||||
|
scope: "own",
|
||||||
|
items: [reimbursementListItem()],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/leave-types") {
|
||||||
|
await fulfillJson(route, { items: [leaveType] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/leave-balances") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
employeeId: "employee-dg7-employee",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
leaveType,
|
||||||
|
policy: leaveType.policy,
|
||||||
|
granted: 5,
|
||||||
|
adjusted: 0,
|
||||||
|
reserved: 0,
|
||||||
|
used: 1,
|
||||||
|
available: 4,
|
||||||
|
periodStart: "2026-01-01",
|
||||||
|
periodEnd: "2026-12-31",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === `/api/leave-requests/${leaveDetail.id}`) {
|
||||||
|
await fulfillJson(route, leaveDetail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/leave-requests") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
|
||||||
|
total: 1,
|
||||||
|
scope: "own",
|
||||||
|
items: [leaveDetail],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === `/api/purchase-requests/${purchaseDetail.id}`) {
|
||||||
|
await fulfillJson(route, purchaseDetail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathName === "/api/purchase-requests" && request.method() === "GET") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
|
||||||
|
total: 1,
|
||||||
|
mine: true,
|
||||||
|
items: [purchaseDetail],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fulfillJson(route, { message: `unhandled mock api: ${pathName}` }, 404);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginForMobile(page: Page, role: RolePreset) {
|
||||||
|
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(`${role}@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 newMobilePage(browser: Browser, width = 390) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width, height: width < 360 ? 568 : 844 },
|
||||||
|
isMobile: true,
|
||||||
|
hasTouch: true,
|
||||||
|
userAgent: IOS_WECOM_UA,
|
||||||
|
locale: "zh-CN",
|
||||||
|
serviceWorkers: "block",
|
||||||
|
});
|
||||||
|
return { context, page: await context.newPage() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectNoMobileShellRegression(page: Page) {
|
||||||
|
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||||
|
const metrics = await 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),
|
||||||
|
visibleTables: Array.from(document.querySelectorAll("table")).filter(isVisible).length,
|
||||||
|
pcShellGridCount: document.querySelectorAll(".page-shell-grid").length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.width + 2);
|
||||||
|
expect(metrics.visibleTables).toBe(0);
|
||||||
|
expect(metrics.pcShellGridCount).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectActionTileGapStable(page: Page) {
|
||||||
|
const gaps = await page.evaluate(() =>
|
||||||
|
Array.from(document.querySelectorAll<HTMLElement>("[data-testid='mobile-action-grid'] a")).map((tile) => {
|
||||||
|
const icon = tile.querySelector<HTMLElement>("[data-testid$='-icon']");
|
||||||
|
const label = tile.querySelector<HTMLElement>("[data-testid$='-label']");
|
||||||
|
if (!icon || !label) return null;
|
||||||
|
return label.getBoundingClientRect().top - icon.getBoundingClientRect().bottom;
|
||||||
|
}).filter((value): value is number => value !== null),
|
||||||
|
);
|
||||||
|
expect(gaps.length).toBeGreaterThan(0);
|
||||||
|
for (const gap of gaps) {
|
||||||
|
expect(gap).toBeGreaterThanOrEqual(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("DG7 移动信息架构与展示去重治理", () => {
|
||||||
|
test.skip(process.env.E2E_MODE === "live", "DG7 本地专项使用 mock 数据验证信息架构。");
|
||||||
|
|
||||||
|
test("普通员工底部入口变为进度视角,首页宫格间距在多视口稳定", async ({ browser }) => {
|
||||||
|
for (const width of [320, 360, 375, 390, 393, 414, 430]) {
|
||||||
|
const notifications = buildNotifications();
|
||||||
|
const mobile = await newMobilePage(browser, width);
|
||||||
|
try {
|
||||||
|
await mockApis(mobile.page, "employee", notifications);
|
||||||
|
await loginForMobile(mobile.page, "employee");
|
||||||
|
|
||||||
|
await mobile.page.goto("/mobile");
|
||||||
|
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("进度");
|
||||||
|
await expect(mobile.page.getByTestId("mobile-action-approvals-label")).toHaveText("进度");
|
||||||
|
await expect(mobile.page.getByTestId("mobile-pending-summary")).toHaveCount(0);
|
||||||
|
await expectActionTileGapStable(mobile.page);
|
||||||
|
await expectNoMobileShellRegression(mobile.page);
|
||||||
|
|
||||||
|
await mobile.page.getByTestId("mobile-nav-approvals").click();
|
||||||
|
await expect(mobile.page).toHaveURL(/\/mobile\/approvals$/);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-approvals-progress")).toBeVisible();
|
||||||
|
await expect(mobile.page.getByTestId("mobile-approvals-list")).toHaveCount(0);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-approvals-approve")).toHaveCount(0);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-approval-progress-card")).toHaveCount(3);
|
||||||
|
await expectNoMobileShellRegression(mobile.page);
|
||||||
|
await mobile.page.screenshot({ path: screenshotPath(`employee-progress-w${width}`), fullPage: true });
|
||||||
|
} finally {
|
||||||
|
await mobile.context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("审批人首页只保留一处待办摘要,审批详情使用 Push 页并隐藏底部 Tab", async ({ browser }) => {
|
||||||
|
const notifications = buildNotifications();
|
||||||
|
const mobile = await newMobilePage(browser, 390);
|
||||||
|
try {
|
||||||
|
await mockApis(mobile.page, "manager", notifications);
|
||||||
|
await loginForMobile(mobile.page, "manager");
|
||||||
|
|
||||||
|
await mobile.page.goto("/mobile");
|
||||||
|
await expect(mobile.page.getByTestId("mobile-nav-approvals-label")).toHaveText("审批");
|
||||||
|
await expect(mobile.page.getByTestId("mobile-pending-summary")).toHaveCount(1);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-notifications")).not.toContainText("报销待审批");
|
||||||
|
await expect(mobile.page.getByTestId("mobile-action-approvals")).not.toContainText("待批");
|
||||||
|
|
||||||
|
const badgeMetrics = await mobile.page.evaluate(() => {
|
||||||
|
const nav = document.querySelector<HTMLElement>("[data-testid='mobile-nav-approvals']");
|
||||||
|
const icon = nav?.querySelector<HTMLElement>("[aria-hidden='true']");
|
||||||
|
const badge = nav?.querySelector<HTMLElement>("[data-testid='mobile-nav-approvals-badge']");
|
||||||
|
if (!icon || !badge) return null;
|
||||||
|
const iconRect = icon.getBoundingClientRect();
|
||||||
|
const badgeRect = badge.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
iconLeft: iconRect.left,
|
||||||
|
iconRight: iconRect.right,
|
||||||
|
badgeLeft: badgeRect.left,
|
||||||
|
badgeRight: badgeRect.right,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(badgeMetrics).not.toBeNull();
|
||||||
|
expect(badgeMetrics!.badgeLeft).toBeLessThanOrEqual(badgeMetrics!.iconRight + 4);
|
||||||
|
expect(badgeMetrics!.badgeRight).toBeGreaterThanOrEqual(badgeMetrics!.iconLeft - 14);
|
||||||
|
|
||||||
|
await mobile.page.getByTestId("mobile-pending-summary").getByRole("link").first().click();
|
||||||
|
await expect(mobile.page).toHaveURL(/\/mobile\/approvals\/task-dg7$/);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-approvals-detail")).toBeVisible();
|
||||||
|
await expect(mobile.page.getByTestId("mobile-approvals-list")).toHaveCount(0);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-approvals-detail")).not.toContainText("业务单号");
|
||||||
|
await expect(mobile.page.getByTestId("mobile-approvals-detail")).toContainText("报销单 BX20260512001");
|
||||||
|
await expectNoMobileShellRegression(mobile.page);
|
||||||
|
await mobile.page.screenshot({ path: screenshotPath("manager-approval-push-detail"), fullPage: true });
|
||||||
|
} finally {
|
||||||
|
await mobile.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("通知中心点击通知直达请假和采购详情,不再回到通知列表或首页", async ({ browser }) => {
|
||||||
|
const notifications = buildNotifications();
|
||||||
|
const mobile = await newMobilePage(browser, 390);
|
||||||
|
try {
|
||||||
|
await mockApis(mobile.page, "employee", notifications);
|
||||||
|
await loginForMobile(mobile.page, "employee");
|
||||||
|
|
||||||
|
await mobile.page.goto("/mobile/notifications");
|
||||||
|
await expect(mobile.page.getByTestId("mobile-notification-card")).toHaveCount(3);
|
||||||
|
|
||||||
|
await mobile.page.getByTestId("mobile-notification-card").filter({ hasText: "采购已通过" }).click();
|
||||||
|
await expect(mobile.page).toHaveURL(/\/mobile\/purchase-requests\/purchase-dg7-approved$/);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-purchase-detail-summary")).toBeVisible();
|
||||||
|
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
|
||||||
|
await expectNoMobileShellRegression(mobile.page);
|
||||||
|
|
||||||
|
await mobile.page.goto("/mobile/notifications");
|
||||||
|
await mobile.page.getByTestId("mobile-notification-card").filter({ hasText: "请假已通过" }).click();
|
||||||
|
await expect(mobile.page).toHaveURL(/\/mobile\/leave\/leave-dg7-approved$/);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-leave-record-card")).toBeVisible();
|
||||||
|
await expect(mobile.page.getByTestId("mobile-leave-form")).toHaveCount(0);
|
||||||
|
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
|
||||||
|
await expectNoMobileShellRegression(mobile.page);
|
||||||
|
await mobile.page.screenshot({ path: screenshotPath("notification-direct-leave-detail"), fullPage: true });
|
||||||
|
} finally {
|
||||||
|
await mobile.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -88,6 +88,8 @@ AGENTS.md 只放硬约束和入口,不复制长规则。
|
|||||||
- 新移动页默认纳入统一响应式 token 和跨视口视觉矩阵,覆盖 320、360、375、390、393、414、430px;不得只按某一台手机打补丁。
|
- 新移动页默认纳入统一响应式 token 和跨视口视觉矩阵,覆盖 320、360、375、390、393、414、430px;不得只按某一台手机打补丁。
|
||||||
- 专用 `/mobile` 路由默认使用统一移动槽位、`mobile-card`、移动列表卡片和吸底操作;不得把桌面 `card-std p-4` 作为手机主容器,也不得为单个页面自造另一套间距、卡片或图标布局。
|
- 专用 `/mobile` 路由默认使用统一移动槽位、`mobile-card`、移动列表卡片和吸底操作;不得把桌面 `card-std p-4` 作为手机主容器,也不得为单个页面自造另一套间距、卡片或图标布局。
|
||||||
- 角色差异采用“同框架、按角色裁剪内容和动作”:员工、审批人、财务、管理员、负责人看到不同入口和动作,但不创建互不一致的角色专属移动 UI。
|
- 角色差异采用“同框架、按角色裁剪内容和动作”:员工、审批人、财务、管理员、负责人看到不同入口和动作,但不创建互不一致的角色专属移动 UI。
|
||||||
|
- 信息展示必须先去重再上屏:首页只保留一处待办/进度摘要,通知只作为事件流并直达业务详情,底部角标只表达数量;同一卡片或同一行只展示一次业务编号和一次状态标签。
|
||||||
|
- 审批入口默认承载“待我审批”和“我发起的进度”两种视角;普通员工不能看到不可点或无权限的审批入口,审批人/财务/负责人只能看到服务端允许的动作。
|
||||||
- 真机验证暂停时记录为 `PAUSED`;自动化通过不能替代代表性真机确认,也不能关闭仍待人工确认的移动阻断。
|
- 真机验证暂停时记录为 `PAUSED`;自动化通过不能替代代表性真机确认,也不能关闭仍待人工确认的移动阻断。
|
||||||
|
|
||||||
截至 R7P,RI6 报销票据体验、LP6 请假采购体验、ME6 自动化/部署收口、QG6 QA 治理以及 R7/OPS7/DOC7 草案材料均已完成;`ME6-3` 代表性企业微信真机抽查因设备不便继续待确认。后续默认进入等待门禁状态:不得关闭 `ME6-3`,不得把 QG6 或公网移动视觉矩阵当成真机替代,也不得从文档草案直接启动真实 R7。R7 真实执行必须保持 `R7E-*` 为 TODO,直到用户确认最终人员、角色、窗口、回滚联系人、问题登记负责人和每日收口节奏,并补足真机门禁或明确接受风险。
|
截至 R7P,RI6 报销票据体验、LP6 请假采购体验、ME6 自动化/部署收口、QG6 QA 治理以及 R7/OPS7/DOC7 草案材料均已完成;`ME6-3` 代表性企业微信真机抽查因设备不便继续待确认。后续默认进入等待门禁状态:不得关闭 `ME6-3`,不得把 QG6 或公网移动视觉矩阵当成真机替代,也不得从文档草案直接启动真实 R7。R7 真实执行必须保持 `R7E-*` 为 TODO,直到用户确认最终人员、角色、窗口、回滚联系人、问题登记负责人和每日收口节奏,并补足真机门禁或明确接受风险。
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ R6 之后的 PC、移动和审批体验参考国内主流协同/OA 产品的共
|
|||||||
- PC 高密度、移动轻任务是双端分工底线;不得把桌面表格和批量管理能力原样搬到手机端。
|
- PC 高密度、移动轻任务是双端分工底线;不得把桌面表格和批量管理能力原样搬到手机端。
|
||||||
- 企业微信只做入口、绑定、免登录、个人应用消息和 deeplink,不替代 OA 正式审批页。
|
- 企业微信只做入口、绑定、免登录、个人应用消息和 deeplink,不替代 OA 正式审批页。
|
||||||
- 审批体验以“待办聚合、清晰状态、明确下一步、可催办和可追溯”为目标;AP6 v1 已完成催办、超时提醒和审批效率摘要,后续只在独立 Issue 中增强配置治理、升级处理或更复杂的节点 SLA。
|
- 审批体验以“待办聚合、清晰状态、明确下一步、可催办和可追溯”为目标;AP6 v1 已完成催办、超时提醒和审批效率摘要,后续只在独立 Issue 中增强配置治理、升级处理或更复杂的节点 SLA。
|
||||||
|
- 移动信息分层必须清楚:`待办` 是需要我处理的任务,`进度` 是我发起的单据状态,`通知` 是事件消息,`详情` 是业务事实源;首页、审批中心、通知中心不得把同一批待办重复铺开。
|
||||||
- 竞品资料只用于抽象原则;不得复制钉钉、飞书、企业微信、WeUI 或 Ant Design 的页面文案、视觉细节和专有交互。
|
- 竞品资料只用于抽象原则;不得复制钉钉、飞书、企业微信、WeUI 或 Ant Design 的页面文案、视觉细节和专有交互。
|
||||||
|
|
||||||
参考资料:
|
参考资料:
|
||||||
@@ -64,6 +65,17 @@ R6 之后的 PC、移动和审批体验参考国内主流协同/OA 产品的共
|
|||||||
- 空状态只说明现状和下一步,例如“暂无草稿”“暂无待处理审批”。
|
- 空状态只说明现状和下一步,例如“暂无草稿”“暂无待处理审批”。
|
||||||
- 表单说明放在字段错误、帮助文本或 hover/折叠信息里,不放在首屏大段文案。
|
- 表单说明放在字段错误、帮助文本或 hover/折叠信息里,不放在首屏大段文案。
|
||||||
|
|
||||||
|
## 信息层级与去重规则
|
||||||
|
|
||||||
|
- 首页只做工作台摘要:同一页面最多出现一处待办或进度列表;底部 Tab 红点只表达全局数量,通知列表只表达事件,不作为待办列表副本。
|
||||||
|
- 审批入口承载两类视角:审批人看“待我审批/已处理”,员工看“我发起的/审批进度”;普通员工不能看到不可点或无权限的审批入口。
|
||||||
|
- 通知卡片表达“发生了什么”,点击应直达审批任务或业务详情;目标不存在、已删除或无权限时显示可理解提示,不跳回首页。
|
||||||
|
- 同一卡片或同一表格行只展示一次主标识。标题为“报销单 BX20260512001”时,正文不再出现“业务单号:BX20260512001”。
|
||||||
|
- 状态只展示一次,优先使用右侧状态标签;不要在标题、副标题和正文里重复写“已通过/业务状态:已通过/状态:已通过”。
|
||||||
|
- 卡片固定槽位:第一行左侧为业务类型加业务编号或摘要,第一行右侧为状态;第二行放申请人、金额/天数、日期;第三行可选当前节点或更新时间。
|
||||||
|
- PC 高密度表格允许保留独立“单号/编号”列,但同一行的标题或摘要不得再拼接同一编号;确需复制编号时放在单号列或详情页摘要区。
|
||||||
|
- UUID、taskId、entityId、内部状态码和调试字段不进入用户主展示;审计页、管理员排障页和打印留痕页按权限例外处理。
|
||||||
|
|
||||||
## 色彩规则
|
## 色彩规则
|
||||||
|
|
||||||
- 主色:品牌蓝/青色系,用于主按钮、选中态、链接和轻量提示。
|
- 主色:品牌蓝/青色系,用于主按钮、选中态、链接和轻量提示。
|
||||||
@@ -104,12 +116,14 @@ R6 之后的 PC、移动和审批体验参考国内主流协同/OA 产品的共
|
|||||||
- 手机端默认隐藏 PC 侧边栏和顶栏,使用移动首页、底部导航和轻量卡片。
|
- 手机端默认隐藏 PC 侧边栏和顶栏,使用移动首页、底部导航和轻量卡片。
|
||||||
- 菜单不应占用大量空间;常用入口放底部导航,更多入口放“我的”。
|
- 菜单不应占用大量空间;常用入口放底部导航,更多入口放“我的”。
|
||||||
- 移动底部导航固定为“首页、审批、报销、我的”;票据上传作为首页快捷入口和报销页内操作,不作为底部一级 Tab。
|
- 移动底部导航固定为“首页、审批、报销、我的”;票据上传作为首页快捷入口和报销页内操作,不作为底部一级 Tab。
|
||||||
|
- 移动“审批”是审批与进度入口,不只服务审批人;员工进入后查看我发起的报销、请假、采购进度,审批人进入后处理待我审批。
|
||||||
- 移动端必须按响应式规则自动适配,不按单个机型写补丁;验收至少覆盖 320、360、375、390、393、414、430px 宽度。
|
- 移动端必须按响应式规则自动适配,不按单个机型写补丁;验收至少覆盖 320、360、375、390、393、414、430px 宽度。
|
||||||
- 移动首页一级快捷入口使用图标宫格:图标在上、短标题在下,4 项等宽,辅助说明不在小屏常驻展示,避免图标和文字挤压;标题单行省略,不允许逐字换行。
|
- 移动首页一级快捷入口使用图标宫格:图标在上、短标题在下,4 项等宽,辅助说明不在小屏常驻展示,避免图标和文字挤压;标题单行省略,不允许逐字换行。
|
||||||
- 移动端页面边距、卡片间距、底部导航高度和吸底操作高度使用统一 token;不通过 viewport 缩放字体解决拥挤问题。
|
- 移动端页面边距、卡片间距、底部导航高度和吸底操作高度使用统一 token;不通过 viewport 缩放字体解决拥挤问题。
|
||||||
- 次级入口和信息字段可按最小宽度自动从两列变一列;核心操作按钮必须保持明确可点,不得被内容、安全区或底部栏遮挡。
|
- 次级入口和信息字段可按最小宽度自动从两列变一列;核心操作按钮必须保持明确可点,不得被内容、安全区或底部栏遮挡。
|
||||||
- 报销详情、审批详情等深层 Push 页隐藏底部导航,使用顶部返回和上下文吸底操作,避免底部 Tab 在深层页面假选中。
|
- 报销详情、审批详情等深层 Push 页隐藏底部导航,使用顶部返回和上下文吸底操作,避免底部 Tab 在深层页面假选中。
|
||||||
- 企业微信个人应用消息 deeplink 应直达移动待办或业务详情;首次进入如需点击进入工作台,应保持后续可直接回到目标页。
|
- 企业微信个人应用消息 deeplink 应直达移动待办或业务详情;首次进入如需点击进入工作台,应保持后续可直接回到目标页。
|
||||||
|
- 首页最近通知只展示事件消息,不能重复完整待办卡片;如果某条待办已在首页待处理摘要出现,通知区域不再重复显示同一待办内容。
|
||||||
- 移动列表使用卡片和状态标签,首屏优先展示“谁提交、什么事项、金额/天数、当前状态和下一步”。
|
- 移动列表使用卡片和状态标签,首屏优先展示“谁提交、什么事项、金额/天数、当前状态和下一步”。
|
||||||
- 列表使用卡片,不直接显示桌面表格。
|
- 列表使用卡片,不直接显示桌面表格。
|
||||||
- 详情页按摘要、业务字段、发票/附件、审批流、日志分区展示;普通员工移动端不默认展示后台审计细节。
|
- 详情页按摘要、业务字段、发票/附件、审批流、日志分区展示;普通员工移动端不默认展示后台审计细节。
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 公网地址 | `https://oa.hyinda.com` |
|
| 公网地址 | `https://oa.hyinda.com` |
|
||||||
| 当前门禁 | R5 已 `DONE/DONE/DONE`;R6 首轮小范围试运行已完成;R6-ISSUE-003 采购提交阻断已热修、部署并复跑通过;PC6/PA6/FL6/AP6/RI6/LP6 后续优化已完成;ME6 自动化、部署和公网视觉复检已完成,`ME6-3` 代表性企业微信真机抽查因设备不便暂缓;QG6 非业务 QA 治理已完成但不替代真机 |
|
| 当前门禁 | R5 已 `DONE/DONE/DONE`;R6 首轮小范围试运行已完成;R6-ISSUE-003 采购提交阻断已热修、部署并复跑通过;PC6/PA6/FL6/AP6/RI6/LP6 后续优化已完成;ME6 自动化、部署和公网视觉复检已完成,`ME6-3` 代表性企业微信真机抽查因设备不便暂缓;QG6 非业务 QA 治理已完成但不替代真机 |
|
||||||
| 当前代码基线 | QG6 QA 治理升级已部署到 `https://oa.hyinda.com`,并完成 `/ready`、public bundle、live smoke readiness、公网移动视觉矩阵复检;R7/OPS7/DOC7 草案材料已准备;真机抽查待用户确认 |
|
| 当前代码基线 | DG7/MRF7 移动信息架构与展示去重治理已部署到 `https://oa.hyinda.com`,并完成 `/ready`、public bundle、live smoke readiness、公网移动视觉矩阵复检;`ME6-3` 代表性企业微信真机抽查仍待用户确认 |
|
||||||
| 试运行窗口 | `2026-05-12 14:00-17:00 Asia/Shanghai`;若现场无法执行,顺延到 `2026-05-13 14:00-17:00 Asia/Shanghai` |
|
| 试运行窗口 | `2026-05-12 14:00-17:00 Asia/Shanghai`;若现场无法执行,顺延到 `2026-05-13 14:00-17:00 Asia/Shanghai` |
|
||||||
| 回滚联系人 | `wangshunli` |
|
| 回滚联系人 | `wangshunli` |
|
||||||
| 问题登记负责人 | `wangshunli` |
|
| 问题登记负责人 | `wangshunli` |
|
||||||
@@ -104,6 +104,8 @@ QG6 QA 治理收口(2026-05-12):非真机阶段 QA 治理升级已完成
|
|||||||
|
|
||||||
R7/OPS7/DOC7 非真机准备(2026-05-12):已创建 `issues/2026-05-12_17-49-03-r7-expanded-trialrun-prep-ops-doc-materials.csv`。R7 草案见 `docs/trialrun/r7-expanded-trialrun-prep.md`,只固定范围、角色占位、门禁、问题分级、每日收口和退出标准;`R7E-1..R7E-3` 继续保持 `TODO`,真实 R7 未启动。OPS7 运维交接 Runbook 见 `docs/trialrun/ops7-runbook.md`,只记录部署、回滚、备份恢复、账号交接、live readiness 和企业微信通知检查清单,不执行生产配置变更或数据库恢复。DOC7 用户手册与 FAQ 见 `docs/trialrun/r7-user-guide-and-faq.md`,只描述当前已实现的报销、请假、采购、审批、企业微信入口和财务台账能力,不承诺预算、ERP、AI、完整差旅、复杂 BPM 或审批设计器。`ME6-3` 真机门禁仍未关闭;R7 真实启动仍需用户确认最终人员、窗口,并补足真机门禁或明确接受风险。
|
R7/OPS7/DOC7 非真机准备(2026-05-12):已创建 `issues/2026-05-12_17-49-03-r7-expanded-trialrun-prep-ops-doc-materials.csv`。R7 草案见 `docs/trialrun/r7-expanded-trialrun-prep.md`,只固定范围、角色占位、门禁、问题分级、每日收口和退出标准;`R7E-1..R7E-3` 继续保持 `TODO`,真实 R7 未启动。OPS7 运维交接 Runbook 见 `docs/trialrun/ops7-runbook.md`,只记录部署、回滚、备份恢复、账号交接、live readiness 和企业微信通知检查清单,不执行生产配置变更或数据库恢复。DOC7 用户手册与 FAQ 见 `docs/trialrun/r7-user-guide-and-faq.md`,只描述当前已实现的报销、请假、采购、审批、企业微信入口和财务台账能力,不承诺预算、ERP、AI、完整差旅、复杂 BPM 或审批设计器。`ME6-3` 真机门禁仍未关闭;R7 真实启动仍需用户确认最终人员、窗口,并补足真机门禁或明确接受风险。
|
||||||
|
|
||||||
|
DG7/MRF7 移动信息架构与展示去重治理收口(2026-05-12):已根据 iPhone 12、iPhone 14 和 Redmi K60 企业微信真机反馈建立首页/审批/通知/业务卡片去重规约,并完成移动端实现与部署。员工底部“审批”入口改为“进度”视角,展示我发起的报销、请假、采购进度且不显示审批动作;审批人仍使用“审批”视角。首页只保留一处待处理摘要,首页通知不再重复完整待办;底部审批角标移到图标右上外侧,不覆盖图标或文字。审批详情改为 `/mobile/approvals/:taskId` Push 页并隐藏底部 Tab;通知中心卡片整体可点,先标记已读再直达报销、请假、采购或审批移动详情;采购和请假通知不再回首页或新建表单。PC/移动卡片展示引入共享业务主标识工具,同一卡片不重复业务编号或状态;`check-ui-design-rules.mjs` 已增加重复业务编号扫描规则。验证结果:`client typecheck/build`、`check-ui-design-rules --self-test/--strict`、DG7 专项 3 passed、WCM6 11 passed、R124/R6 响应式/MUD6 15 passed。已执行 `bash deploy/trialrun/deploy.sh`,公网 `/ready` 正常,public bundle `findings=0`,live smoke readiness PASS,报告 `artifacts/qa-flow/live-smoke-readiness-20260512-205559`;公网移动视觉矩阵 7 passed,报告 `artifacts/qa-flow/dg7-mobile-information-dedupe-governance-visual-matrix-20260512-205634`。因当前无法做完整三机复测,`ME6-3` 继续保持 TODO,不用本轮自动化替代真机门禁。
|
||||||
|
|
||||||
## 结论
|
## 结论
|
||||||
|
|
||||||
R70 初次试运行暂停项已在 2026-04-28 的 R71 定向修复后关闭。基于本地环境复测,经理团队日历与员工报销缺票提示/发票上传绑定提交链路均已通过,可恢复下一轮本地人工试运行。
|
R70 初次试运行暂停项已在 2026-04-28 的 R71 定向修复后关闭。基于本地环境复测,经理团队日历与员工报销缺票提示/发票上传绑定提交链路均已通过,可恢复下一轮本地人工试运行。
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
|
DG7-1,移动信息架构与展示去重规约同步,"把首页/审批/通知/卡片去重规则固化到产品设计规则、交付实践和项目 Skill。","1) 规约明确首页只保留一处待办摘要;2) 审批入口承载待我审批和我发起的进度;3) 通知不作为待办列表副本;4) 同一卡片主标识和状态只展示一次。","流程级验证:validate_issues_csv.py 校验本 CSV;quick_validate.py 校验 Skill;rg 核对 首页/审批/通知/卡片去重/业务编号 等关键术语已进入事实源。",python3|rg|manual,DONE,DONE,DONE,docs/product-design-rules.md|docs/agent-development-practices.md|.agents/skills/hyb-oa-delivery/SKILL.md,[],只改文档与 Skill;不改业务逻辑。
|
||||||
|
MRF7-2,移动首页与审批入口真机反馈修复,"处理员工审批入口不可点、首页待办重复、底部角标位置、首页图标文字间距和审批详情内嵌列表等真机反馈。","1) 普通员工底部审批可进入审批与进度页且无审批动作;2) 首页最多一处待办/进度摘要;3) 底部审批角标锚定图标右上角;4) 审批详情以 Push 详情呈现并隐藏底部 Tab;5) 首页宫格图文间距在 320-430px 稳定。","流程级验证:Playwright 以员工、审批人、财务账号覆盖 /mobile 与 /mobile/approvals;移动视口 320/360/375/390/393/414/430;断言底部审批可点、首页无三处重复待办、角标贴近图标、审批详情隐藏底部 Tab。",playwright|npm,DONE,DONE,DONE,client/src/pages/MobilePortalPage.tsx|client/src/pages/MobileApprovalsPage.tsx|client/src/components/mobile/MobileBottomNav.tsx|client/src/layouts/AppShellLayout.tsx|client/src/styles/mobile-touch.css,"[""DG7-1""]",不改服务端权限;前端展示裁剪不能代替服务端授权。
|
||||||
|
DIG7-3,全局业务卡片展示去重,"梳理移动与 PC 高频卡片/列表,统一主标题、副标题、状态、编号展示,移除同卡片重复业务单号/状态。","1) 移动审批卡片标题已有业务编号时不再重复显示业务单号;2) 报销、请假、采购、通知卡片遵循主标识只出现一次;3) UUID 不进入主展示;4) PC 高频表格不在同一行重复同一编号。","流程级验证:check-ui-design-rules --strict;Playwright 覆盖移动审批/报销/请假/采购/通知和 PC 审批/报销/请假/采购/通知;人工核对同一卡片无重复单号和状态。",npm|playwright|manual,DONE,DONE,DONE,client/src/lib|client/src/pages|client/src/components,"[""MRF7-2""]",展示治理,不改变接口或数据模型。
|
||||||
|
NLG7-4,通知直达与移动详情落点治理,"统一 notification target 到移动详情的映射,通知中心不再作为业务详情替代页。","1) 首页通知卡片点击优先直达移动目标;2) 通知中心卡片整体可点并先标记已读;3) 采购通知进入移动采购详情;4) 请假通知进入请假记录详情;5) 无目标或无权限时不跳首页并显示可理解提示。","流程级验证:构造审批/报销/请假/采购通知 mock;点击首页通知和通知中心卡片;验证目标路径、已读状态和无目标提示。",playwright|npm,DONE,DONE,DONE,client/src/lib/notificationLinks.ts|client/src/lib/runtimeContext.ts|client/src/pages/MobileNotificationsPage.tsx|client/src/pages/MobilePortalPage.tsx|client/src/pages/MobileLeavePage.tsx|client/src/pages/MobilePurchaseRequestDetailPage.tsx,"[""MRF7-2"",""DIG7-3""]",不新增通知通道;继续复用企业微信个人应用消息 deeplink 语义。
|
||||||
|
QG7-5,展示去重 QA 扫描增强,"扩展设计规则扫描,覆盖同卡片重复单号/编号、通知与待办重复、移动页面桌面表格残留等风险。","1) check-ui-design-rules 能扫描新增风险;2) 保留合理白名单;3) 严格模式通过;4) 规则说明不记录敏感信息。","流程级验证:node scripts/qa/check-ui-design-rules.mjs --strict;构造或复用 fixture 确认新增规则可命中;git diff --check。",node|manual,DONE,DONE,DONE,scripts/qa/check-ui-design-rules.mjs|client/tests/e2e,"[""DIG7-3"",""NLG7-4""]",静态扫描只能兜底;动态重复仍需视觉和人工复核。
|
||||||
|
QG7-6,本地回归部署与真机门禁回填,"完成本地验证、部署公网、复检 ready/bundle/live smoke,并记录 ME6-3 仍待代表性真机复测。","1) client typecheck/build 通过;2) 移动视觉专项通过;3) 公网 /ready、bundle、live readiness 通过;4) CSV 状态仅在 Test_Method 通过后回填 DONE;5) ME6-3 不伪造关闭。","流程级验证:cd client && npm run typecheck && npm run build;移动 Playwright 矩阵;bash deploy/trialrun/deploy.sh;curl /ready;public bundle 检查;run-live-smoke-readiness.sh;更新 manual-trialrun-report。",npm|playwright|bash|curl,DONE,DONE,DONE,client/tests/e2e|docs/trialrun/manual-trialrun-report.md|deploy/trialrun,"[""QG7-5""]",面向用户可测改动必须部署后再提示真机复测;R7 真实执行仍等待 ME6-3 或风险接受。
|
||||||
|
@@ -410,6 +410,43 @@ function scanReimbursementRawCodeDisplay(file, source) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scanDuplicateBusinessIdentifierDisplay(file, source) {
|
||||||
|
const normalized = normalizeFindingFile(file);
|
||||||
|
if (!/^client\/src\/(?:pages|components)\//.test(normalized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patterns = [
|
||||||
|
{
|
||||||
|
regex: /业务单号[::][\s\S]{0,160}\{task\.reference\}/g,
|
||||||
|
message: "审批卡片标题已有业务对象时,不应在同一卡片再次展示业务单号。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /业务:\$\{entityLabel\([^}]+\)\}\s*·\s*\$\{reference/g,
|
||||||
|
message: "审批卡片副标题不应重复拼接业务类型和业务编号,应使用固定主标题槽位。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: /业务单号[::][\s\S]{0,220}(?:报销单号|采购单号|差旅单号)[::]/g,
|
||||||
|
message: "同一详情摘要不应同时展示通用业务单号和具体业务单号。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
let match = pattern.regex.exec(source);
|
||||||
|
while (match) {
|
||||||
|
addFinding({
|
||||||
|
rule: "display.no_duplicate_business_identifier",
|
||||||
|
severity: "error",
|
||||||
|
file,
|
||||||
|
line: lineNumber(source, match.index),
|
||||||
|
message: pattern.message,
|
||||||
|
evidence: match[0].replace(/\s+/g, " ").slice(0, 180),
|
||||||
|
});
|
||||||
|
match = pattern.regex.exec(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function extractStringList(source, name) {
|
function extractStringList(source, name) {
|
||||||
const assignment = new RegExp(`const\\s+${name}\\s*=\\s*\\[([\\s\\S]*?)\\];`, "m").exec(source);
|
const assignment = new RegExp(`const\\s+${name}\\s*=\\s*\\[([\\s\\S]*?)\\];`, "m").exec(source);
|
||||||
if (!assignment) {
|
if (!assignment) {
|
||||||
@@ -460,6 +497,7 @@ function scanFileSource(file, source) {
|
|||||||
scanUuidPrimaryDisplay(file, source);
|
scanUuidPrimaryDisplay(file, source);
|
||||||
scanRawBusinessIdentifiers(file, source);
|
scanRawBusinessIdentifiers(file, source);
|
||||||
scanReimbursementRawCodeDisplay(file, source);
|
scanReimbursementRawCodeDisplay(file, source);
|
||||||
|
scanDuplicateBusinessIdentifierDisplay(file, source);
|
||||||
scanNavigationTitleCoverage(file, source);
|
scanNavigationTitleCoverage(file, source);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +513,7 @@ function runSelfTest() {
|
|||||||
file: "client/src/pages/MobileBadFixture.tsx",
|
file: "client/src/pages/MobileBadFixture.tsx",
|
||||||
source: `
|
source: `
|
||||||
export function MobileBadFixture() {
|
export function MobileBadFixture() {
|
||||||
return <div><table><tbody /></table><aside>备用模块</aside><button>Upload</button><div>任务ID:{task.id}</div></div>;
|
return <div><table><tbody /></table><aside>备用模块</aside><button>Upload</button><div>任务ID:{task.id}</div><div>业务单号:{task.reference}</div></div>;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
expectedRules: [
|
expectedRules: [
|
||||||
@@ -483,6 +521,7 @@ function runSelfTest() {
|
|||||||
"mobile.no_pc_shell",
|
"mobile.no_pc_shell",
|
||||||
"copy.no_english_upload_labels",
|
"copy.no_english_upload_labels",
|
||||||
"data.no_uuid_primary_display",
|
"data.no_uuid_primary_display",
|
||||||
|
"display.no_duplicate_business_identifier",
|
||||||
],
|
],
|
||||||
expectedSuppressedRules: [],
|
expectedSuppressedRules: [],
|
||||||
},
|
},
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户