DG7/MRF7 移动信息架构与展示去重治理
这个提交包含在:
@@ -37,12 +37,11 @@ export function MobileBottomNav() {
|
||||
>
|
||||
<div className="relative z-[71] grid grid-cols-4 gap-1">
|
||||
{MOBILE_NAV_ITEMS.map((item) => {
|
||||
const disabled = item.key === "approvals"
|
||||
? !canAccessApprovals
|
||||
: item.anyOf
|
||||
const disabled = item.anyOf
|
||||
? !hasAnyPermission(permissions, [...item.anyOf])
|
||||
: false;
|
||||
const badge = item.key === "approvals" && pendingCount > 0 ? resolveBadgeLabel(pendingCount) : null;
|
||||
const label = item.key === "approvals" && !canAccessApprovals ? "进度" : item.label;
|
||||
|
||||
if (disabled) {
|
||||
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"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
title={`${item.label}不可用`}
|
||||
title={`${label}不可用`}
|
||||
>
|
||||
<MobileIcon name={item.icon} className="size-5" />
|
||||
<span
|
||||
className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
data-testid={`mobile-nav-${item.key}-label`}
|
||||
>
|
||||
{item.label}
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -83,26 +82,26 @@ export function MobileBottomNav() {
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span>
|
||||
<span className="relative">
|
||||
<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
|
||||
className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
data-testid={`mobile-nav-${item.key}-label`}
|
||||
>
|
||||
{item.label}
|
||||
{label}
|
||||
</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>
|
||||
|
||||
@@ -27,7 +27,10 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||
{ path: "/mobile/assets/scan", title: "移动资产扫描", group: "移动工作台" },
|
||||
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
|
||||
{ path: "/mobile/capture", title: "移动 PDF 发票上传", group: "移动工作台" },
|
||||
{ path: "/mobile/leave", title: "移动请假", group: "移动工作台" },
|
||||
{ path: "/mobile/me", title: "移动我的", group: "移动工作台" },
|
||||
{ path: "/mobile/purchase-requests", title: "移动采购详情", group: "移动工作台" },
|
||||
{ path: "/mobile/reimbursements", title: "移动报销", group: "移动工作台" },
|
||||
{ path: "/mobile", title: "移动首页", group: "移动工作台" },
|
||||
{ path: "/settings/profile", title: "个人设置", group: "账号设置" },
|
||||
{ path: "/system/workspace", title: "工作区设置", group: "管理入口" },
|
||||
|
||||
@@ -11,7 +11,7 @@ import { resolveMobileEquivalentPath, shouldPreferMobileWorkplace } from "../lib
|
||||
export function AppShellLayout() {
|
||||
const location = useLocation();
|
||||
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 mobileEquivalentPath = !isMobileRoute && shouldPreferMobileWorkplace()
|
||||
? 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;
|
||||
};
|
||||
|
||||
function encodeSegment(value: string) {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
export function resolveNotificationTargetLink(item: NotificationTarget) {
|
||||
if (!item.targetType || !item.targetId) return null;
|
||||
|
||||
@@ -41,6 +45,36 @@ export function resolveNotificationTargetLink(item: NotificationTarget) {
|
||||
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) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
|
||||
@@ -43,6 +43,7 @@ function encodeSegment(value: string) {
|
||||
|
||||
export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
||||
const normalizedPath = pathname.replace(/\/+$/, "") || "/";
|
||||
const searchParams = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
||||
|
||||
if (normalizedPath === "/" || normalizedPath === "/portal" || normalizedPath === "/dashboard") {
|
||||
return "/mobile";
|
||||
@@ -71,6 +72,10 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -80,7 +85,7 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
||||
|
||||
const workflowTaskMatch = normalizedPath.match(/^\/workflow\/tasks\/([^/]+)$/);
|
||||
if (workflowTaskMatch?.[1]) {
|
||||
return `/mobile/approvals?${new URLSearchParams({ taskId: workflowTaskMatch[1] }).toString()}`;
|
||||
return `/mobile/approvals/${encodeSegment(workflowTaskMatch[1])}`;
|
||||
}
|
||||
|
||||
if (normalizedPath === "/workflow/tasks") {
|
||||
@@ -104,7 +109,6 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
||||
if (
|
||||
normalizedPath.startsWith("/projects") ||
|
||||
normalizedPath.startsWith("/contracts-registry") ||
|
||||
normalizedPath.startsWith("/purchase-requests") ||
|
||||
normalizedPath.startsWith("/travel-requests") ||
|
||||
normalizedPath.startsWith("/payment-batches") ||
|
||||
normalizedPath.startsWith("/ap-operations") ||
|
||||
@@ -119,5 +123,18 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||
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 { 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 {
|
||||
approveWorkflowTask,
|
||||
@@ -26,6 +29,9 @@ import { Spinner } from "../components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
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 {
|
||||
cacheWorkflowTaskDetail,
|
||||
enqueueWorkflowActionDraft,
|
||||
@@ -61,6 +67,14 @@ type VisibleTaskCard = {
|
||||
detail: WorkflowTaskDetail | null;
|
||||
};
|
||||
|
||||
type EmployeeProgressCard = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
const ENTITY_FILTER_OPTIONS: Array<{ value: EntityFilter; label: string }> = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "leave", label: "请假" },
|
||||
@@ -120,15 +134,8 @@ function businessStatusLabel(status: string | null | undefined) {
|
||||
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) {
|
||||
if (!value || isInternalId(value)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
return compactBusinessReference(value);
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
@@ -209,13 +216,19 @@ function toVisibleRemoteTask(task: WorkflowTaskSummary): VisibleTaskCard {
|
||||
const summary = task.businessSummary;
|
||||
const reference = publicReference(summary?.reference ?? task.workflowInstance?.entityId);
|
||||
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 {
|
||||
id: task.id,
|
||||
title: summary?.title ?? task.stepName,
|
||||
subtitle:
|
||||
task.status === "completed"
|
||||
? `业务:${entityLabel(entityType)} · ${reference ?? "未生成业务编号"} · 完成于 ${formatDateTime(task.completedAt)}`
|
||||
: `业务:${entityLabel(entityType)} · ${reference ?? "未生成业务编号"}`,
|
||||
title,
|
||||
subtitle,
|
||||
status: task.status,
|
||||
entityType,
|
||||
entityId: task.workflowInstance?.entityId ?? null,
|
||||
@@ -252,11 +265,66 @@ function matchesSearch(card: VisibleTaskCard, keyword: string) {
|
||||
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() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const routeParams = useParams();
|
||||
const { me, permissions, accessToken, offlineAccessMode } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const deepLinkTaskId = searchParams.get("taskId")?.trim() ?? "";
|
||||
const [searchParams] = useSearchParams();
|
||||
const deepLinkTaskId = routeParams.taskId?.trim() || searchParams.get("taskId")?.trim() || "";
|
||||
const isPushDetail = Boolean(deepLinkTaskId);
|
||||
|
||||
const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId);
|
||||
const [taskTab, setTaskTab] = useState<MobileTaskTab>("pending");
|
||||
@@ -274,13 +342,30 @@ export function MobileApprovalsPage() {
|
||||
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const autoSyncRequestedRef = useRef(false);
|
||||
const detailSectionRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const ownerEmployeeId = me?.id ?? null;
|
||||
const canAccessRemoteApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
|
||||
const canUseRemote = isOnline && Boolean(accessToken) && canAccessRemoteApprovals;
|
||||
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(() => {
|
||||
setSelectedTaskId(deepLinkTaskId);
|
||||
}, [deepLinkTaskId]);
|
||||
@@ -293,30 +378,8 @@ export function MobileApprovalsPage() {
|
||||
}
|
||||
}, [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) {
|
||||
updateTaskSelection(taskId);
|
||||
scrollDetailIntoViewOnMobile();
|
||||
navigate(`/mobile/approvals/${encodeURIComponent(taskId)}`);
|
||||
}
|
||||
|
||||
async function invalidateWorkflowQueries(taskId: string | null = selectedTaskId) {
|
||||
@@ -391,8 +454,12 @@ export function MobileApprovalsPage() {
|
||||
.filter((item) => item.detail.task.status === "pending")
|
||||
.map((item) => ({
|
||||
id: item.detail.task.id,
|
||||
title: item.taskSnapshot.stepName ?? item.detail.task.stepName,
|
||||
subtitle: `业务:${entityLabel(item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType)} · 缓存于 ${formatDateTime(item.savedAt)}`,
|
||||
title: businessPrimaryLabel({
|
||||
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,
|
||||
entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType ?? null,
|
||||
entityId: item.taskSnapshot.entityId,
|
||||
@@ -432,21 +499,50 @@ export function MobileApprovalsPage() {
|
||||
const selectedTask = detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail;
|
||||
const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null;
|
||||
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 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(() => {
|
||||
const visibleIds = new Set(filteredTaskCards.map((task) => task.id));
|
||||
setSelectedBatchTaskIds((current) => current.filter((taskId) => visibleIds.has(taskId)));
|
||||
}, [filteredTaskCards]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTaskId || !filteredTaskCards.length) {
|
||||
return;
|
||||
}
|
||||
updateTaskSelection(filteredTaskCards[0].id);
|
||||
}, [filteredTaskCards, selectedTaskId]);
|
||||
|
||||
const canTransfer = Boolean(
|
||||
canUseRemote &&
|
||||
selectedTaskAllowsActions &&
|
||||
@@ -566,7 +662,7 @@ export function MobileApprovalsPage() {
|
||||
setTransferToApproverId("");
|
||||
setError("");
|
||||
setMessage("");
|
||||
updateTaskSelection("");
|
||||
navigate("/mobile/approvals", { replace: true });
|
||||
toast.success("操作成功");
|
||||
},
|
||||
onError: (actionError) => {
|
||||
@@ -656,7 +752,7 @@ export function MobileApprovalsPage() {
|
||||
setError(result.failures.length ? result.failures.slice(0, 3).join(";") : "");
|
||||
setMessage(summary);
|
||||
if (canUseRemote && result.taskIds.includes(selectedTaskId)) {
|
||||
updateTaskSelection("");
|
||||
navigate("/mobile/approvals", { replace: true });
|
||||
}
|
||||
|
||||
if (result.failed > 0) {
|
||||
@@ -793,11 +889,16 @@ export function MobileApprovalsPage() {
|
||||
return (
|
||||
<PageScaffold className="space-y-4">
|
||||
<PageHeader
|
||||
title="移动审批"
|
||||
subtitle={deepLinkTaskId ? "已定位指定待办。" : "处理待办。"}
|
||||
title={isPushDetail ? "审批详情" : canAccessRemoteApprovals ? "审批" : "审批进度"}
|
||||
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">
|
||||
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存待办,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
|
||||
</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>
|
||||
) : 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">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -986,9 +1124,8 @@ export function MobileApprovalsPage() {
|
||||
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
||||
</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">
|
||||
申请人:{task.applicantName ?? "-"} · 金额/天数:{task.metric ?? "-"} · 业务状态:{businessStatusLabel(task.businessStatus)}
|
||||
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
|
||||
</div>
|
||||
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
||||
</button>
|
||||
@@ -1011,9 +1148,8 @@ export function MobileApprovalsPage() {
|
||||
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
||||
</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">
|
||||
申请人:{task.applicantName ?? "-"} · 金额/天数:{task.metric ?? "-"} · 业务状态:{businessStatusLabel(task.businessStatus)}
|
||||
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
|
||||
</div>
|
||||
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
||||
</button>
|
||||
@@ -1021,8 +1157,10 @@ export function MobileApprovalsPage() {
|
||||
})}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
|
||||
@@ -1046,44 +1184,37 @@ export function MobileApprovalsPage() {
|
||||
{selectedTask ? (
|
||||
<>
|
||||
<div className="mobile-list-card text-sm">
|
||||
<div>任务状态:{workflowStatusLabel(selectedTask.task.status)}</div>
|
||||
<div>步骤:{selectedTask.task.stepName}</div>
|
||||
<div>对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
|
||||
<div>业务单号:{selectedDisplayReference ?? "-"}</div>
|
||||
<div>业务状态:{businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}</div>
|
||||
<div>申请人:{selectedTaskCard?.applicantName ?? "-"}</div>
|
||||
<div>金额/天数:{selectedTaskCard?.metric ?? "-"}</div>
|
||||
<div>创建时间:{formatDateTime(selectedTask.task.createdAt)}</div>
|
||||
<div>完成时间:{formatDateTime(selectedTask.task.completedAt)}</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-zinc-900">{selectedDetailTitle}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{joinBusinessMeta([`节点:${selectedTask.task.stepName}`, `创建:${formatDateTime(selectedTask.task.createdAt)}`])}</div>
|
||||
</div>
|
||||
<StatusBadge status={selectedTask.task.status} label={workflowStatusLabel(selectedTask.task.status)} />
|
||||
</div>
|
||||
<div className="mt-3 space-y-1 text-zinc-600">
|
||||
<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 ? (
|
||||
<>
|
||||
<div>报销单号:{selectedTask.reimbursement.formNumber ?? "-"}</div>
|
||||
<div>金额:{selectedTask.reimbursement.totalAmount ?? "-"}</div>
|
||||
<div>提交人:{selectedTask.reimbursement.submitter?.name ?? "-"}</div>
|
||||
</>
|
||||
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
{joinBusinessMeta([`报销日期:${selectedTask.reimbursement.reimbursementDate ?? "-"}`, selectedTask.reimbursement.description])}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTask.leaveRequest ? (
|
||||
<>
|
||||
<div>假期类型:{selectedTask.leaveRequest.leaveType?.name ?? "-"}</div>
|
||||
<div>申请人:{selectedTask.leaveRequest.owner?.name ?? "-"}</div>
|
||||
<div>请假天数:{selectedTask.leaveRequest.requestedDays}</div>
|
||||
</>
|
||||
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
{joinBusinessMeta([`假期类型:${selectedTask.leaveRequest.leaveType?.name ?? "-"}`, `${selectedTask.leaveRequest.startDate ?? "-"} 至 ${selectedTask.leaveRequest.endDate ?? "-"}`])}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTask.purchaseRequest ? (
|
||||
<>
|
||||
<div>采购单号:{selectedTask.purchaseRequest.requestNo ?? "-"}</div>
|
||||
<div>申请人:{selectedTask.purchaseRequest.applicant?.name ?? "-"}</div>
|
||||
<div>供应商:{selectedTask.purchaseRequest.supplier?.name ?? "-"}</div>
|
||||
<div>金额:{selectedTask.purchaseRequest.totalAmount ?? "-"}</div>
|
||||
</>
|
||||
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
{joinBusinessMeta([`供应商:${selectedTask.purchaseRequest.supplier?.name ?? "-"}`, selectedTask.purchaseRequest.title])}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTask.travelRequest ? (
|
||||
<>
|
||||
<div>差旅单号:{selectedTask.travelRequest.requestNo ?? "-"}</div>
|
||||
<div>申请人:{selectedTask.travelRequest.applicant?.name ?? "-"}</div>
|
||||
<div>出差类型:{selectedTask.travelRequest.tripType === "international" ? "国际" : "国内"}</div>
|
||||
<div>预估金额:{selectedTask.travelRequest.estimatedAmount ?? "-"}</div>
|
||||
</>
|
||||
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
{joinBusinessMeta([`出差类型:${selectedTask.travelRequest.tripType === "international" ? "国际" : "国内"}`, selectedTask.travelRequest.purpose])}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1191,6 +1322,7 @@ export function MobileApprovalsPage() {
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mobile-card space-y-3" data-testid="mobile-approvals-queue">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createLeaveRequest,
|
||||
fetchLeaveBalances,
|
||||
fetchLeaveRequestDetail,
|
||||
fetchLeaveRequests,
|
||||
fetchLeaveTypes,
|
||||
patchLeaveRequest,
|
||||
@@ -60,11 +62,13 @@ function LeaveRecordCard({
|
||||
isSaving,
|
||||
onEdit,
|
||||
onSubmit,
|
||||
showActions = true,
|
||||
}: {
|
||||
item: LeaveRequestItem;
|
||||
isSaving: boolean;
|
||||
onEdit: (item: LeaveRequestItem) => void;
|
||||
onSubmit: (item: LeaveRequestItem) => void;
|
||||
showActions?: boolean;
|
||||
}) {
|
||||
const canEdit = item.status === "draft" || item.status === "rejected";
|
||||
const canSubmit = item.status === "draft" || item.status === "rejected";
|
||||
@@ -104,7 +108,7 @@ function LeaveRecordCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canEdit || canSubmit ? (
|
||||
{showActions && (canEdit || canSubmit) ? (
|
||||
<div className="mobile-two-action-grid mt-4">
|
||||
{canEdit ? (
|
||||
<Button variant="secondary" type="button" disabled={isSaving} onClick={() => onEdit(item)}>
|
||||
@@ -124,6 +128,7 @@ function LeaveRecordCard({
|
||||
|
||||
export function MobileLeavePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { leaveRequestId = "" } = useParams();
|
||||
const { permissions } = useAuth();
|
||||
const canCreateLeave = hasPermission(permissions, "leave.create");
|
||||
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 () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["mobile", "leave"] }),
|
||||
@@ -265,6 +276,35 @@ export function MobileLeavePage() {
|
||||
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 (
|
||||
<PageScaffold className="space-y-3 pb-4">
|
||||
<PageHeader title="请假" />
|
||||
|
||||
@@ -52,7 +52,6 @@ function MobileActionLink({
|
||||
|
||||
export function MobileMePage() {
|
||||
const { me, permissions, refreshMe, logout } = useAuth();
|
||||
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
||||
const canAccessAdmin = hasAnyPermission(permissions, [
|
||||
"*",
|
||||
"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 canAccessProjects = hasAnyPermission(permissions, ["project.view_own", "project.view_all", "project.manage"]);
|
||||
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
||||
|
||||
const profileQuery = useQuery({
|
||||
enabled: Boolean(me?.id),
|
||||
@@ -74,7 +74,7 @@ export function MobileMePage() {
|
||||
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
|
||||
onSuccess: async () => {
|
||||
await refreshMe();
|
||||
toast.success("外部推送偏好已更新");
|
||||
toast.success("企业微信消息提醒偏好已更新");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
@@ -124,7 +124,7 @@ export function MobileMePage() {
|
||||
<MobileActionLink to="/mobile/notifications" label="通知中心" icon="notifications" />
|
||||
<MobileActionLink to="/mobile/leave" label="请假" icon="event_available" />
|
||||
<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}
|
||||
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
|
||||
</div>
|
||||
@@ -140,7 +140,7 @@ export function MobileMePage() {
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
<SectionPanel title="外部通知推送">
|
||||
<SectionPanel title="企业微信消息提醒">
|
||||
<label className="flex items-center gap-3 text-sm text-zinc-800">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -149,8 +149,11 @@ export function MobileMePage() {
|
||||
disabled={!me || notificationPreferenceMutation.isPending}
|
||||
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
|
||||
/>
|
||||
接收外部渠道推送
|
||||
接收企业微信消息提醒
|
||||
</label>
|
||||
<p className="mt-2 text-xs leading-5 text-zinc-500">
|
||||
关闭后仍保留 OA 站内通知,不影响企业微信登录、绑定和工作台入口。
|
||||
</p>
|
||||
</SectionPanel>
|
||||
|
||||
<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 { 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 { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
@@ -9,8 +9,7 @@ import { Pagination } from "../components/common/Pagination";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { resolveNotificationTargetLink } from "../lib/notificationLinks";
|
||||
import { resolveMobileEquivalentPath } from "../lib/runtimeContext";
|
||||
import { resolveMobileNotificationTargetLink } from "../lib/notificationLinks";
|
||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -33,19 +32,13 @@ function resolveNotificationError(error: unknown) {
|
||||
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) {
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
export function MobileNotificationsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
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({
|
||||
mutationFn: () => markAllNotificationsRead(),
|
||||
onSuccess: async () => {
|
||||
@@ -174,9 +182,15 @@ export function MobileNotificationsPage() {
|
||||
) : null}
|
||||
|
||||
{notificationItems.map((item) => {
|
||||
const targetLink = resolveMobileNotificationTarget(item);
|
||||
const targetLink = resolveMobileNotificationTargetLink(item);
|
||||
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="min-w-0">
|
||||
<div className="text-sm font-semibold text-zinc-900">{item.title}</div>
|
||||
@@ -188,28 +202,8 @@ export function MobileNotificationsPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-zinc-500">{formatTime(item.createdAt)}</div>
|
||||
<div className="mobile-two-action-grid mt-3">
|
||||
{targetLink ? (
|
||||
<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>
|
||||
<div className="mt-3 text-xs text-primary">{targetLink ? "查看详情" : "暂无直达详情"}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import { isApiError } from "../api/types";
|
||||
import { fetchWorkflowTasks, fetchWorkflowTaskSummary } from "../api/workflow";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
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 { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
@@ -22,7 +24,6 @@ import {
|
||||
MOBILE_LEAVE_PERMISSIONS,
|
||||
MOBILE_REIMBURSEMENT_PERMISSIONS,
|
||||
} from "../features/mobile/navigation";
|
||||
import { buildMobileApprovalsPath } from "../pwa";
|
||||
|
||||
type MobileAction = {
|
||||
key: string;
|
||||
@@ -97,14 +98,6 @@ function attendanceExemptionLabel(reason: unknown) {
|
||||
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) {
|
||||
if (!attendanceToday) {
|
||||
return null;
|
||||
@@ -180,7 +173,7 @@ export function MobilePortalPage() {
|
||||
|
||||
const notificationsQuery = useQuery({
|
||||
queryKey: ["mobile", "portal", "notifications"],
|
||||
queryFn: () => fetchNotifications({ page: 1, pageSize: 3 }),
|
||||
queryFn: () => fetchNotifications({ page: 1, pageSize: 6 }),
|
||||
});
|
||||
|
||||
const attendanceClockInMutation = useMutation({
|
||||
@@ -219,25 +212,22 @@ export function MobilePortalPage() {
|
||||
const pendingApprovalCount = canAccessApprovals
|
||||
? workflowSummaryQuery.data?.pendingTotal ?? pendingCountQuery.data?.total ?? 0
|
||||
: 0;
|
||||
const overdueApprovalCount = canAccessApprovals ? workflowSummaryQuery.data?.overdueTotal ?? 0 : 0;
|
||||
const workflowEntityTypeCount = workflowSummaryQuery.data?.byEntityType?.length ?? 0;
|
||||
const pendingTasks = pendingCountQuery.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 actions: MobileAction[] = [
|
||||
canAccessApprovals
|
||||
const primaryActions = [
|
||||
canAccessReimbursements
|
||||
? {
|
||||
key: "approvals",
|
||||
label: "审批",
|
||||
meta: overdueApprovalCount > 0
|
||||
? `${pendingApprovalCount} 待处理 / ${overdueApprovalCount} 超时`
|
||||
: pendingApprovalCount > 0
|
||||
? `${pendingApprovalCount} 条待处理`
|
||||
: "暂无待办",
|
||||
icon: "fact_check",
|
||||
to: "/mobile/approvals",
|
||||
tone: pendingApprovalCount > 0 ? "warning" : "primary",
|
||||
key: "reimbursements",
|
||||
label: "报销",
|
||||
meta: "新建与进度",
|
||||
icon: "request_quote",
|
||||
to: "/mobile/reimbursements",
|
||||
}
|
||||
: null,
|
||||
canAccessCapture
|
||||
@@ -250,15 +240,14 @@ export function MobilePortalPage() {
|
||||
tone: "primary",
|
||||
}
|
||||
: null,
|
||||
canAccessReimbursements
|
||||
? {
|
||||
key: "reimbursements",
|
||||
label: "报销",
|
||||
meta: "新建与进度",
|
||||
icon: "request_quote",
|
||||
to: "/mobile/reimbursements",
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: "approvals",
|
||||
label: canAccessApprovals ? "审批" : "进度",
|
||||
meta: canAccessApprovals ? "待办与已处理" : "我的申请进度",
|
||||
icon: "fact_check",
|
||||
to: "/mobile/approvals",
|
||||
tone: "primary",
|
||||
},
|
||||
{
|
||||
key: "me",
|
||||
label: "我的",
|
||||
@@ -266,6 +255,8 @@ export function MobilePortalPage() {
|
||||
icon: "person",
|
||||
to: "/mobile/me",
|
||||
},
|
||||
].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
|
||||
const secondaryActions = [
|
||||
canAccessLeave
|
||||
? {
|
||||
key: "leave",
|
||||
@@ -275,16 +266,69 @@ export function MobilePortalPage() {
|
||||
to: "/mobile/leave",
|
||||
}
|
||||
: 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 (
|
||||
<PageScaffold className="space-y-4">
|
||||
<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">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-base font-semibold text-zinc-900">待处理</div>
|
||||
{pendingApprovalCount > 0 ? <span className="badge-warning">{pendingApprovalCount} 待批</span> : null}
|
||||
<div className="text-base font-semibold text-zinc-900">常用</div>
|
||||
</div>
|
||||
<div className="mobile-icon-grid" data-testid="mobile-action-grid">
|
||||
{actions.map((action) => (
|
||||
@@ -338,58 +382,6 @@ export function MobilePortalPage() {
|
||||
</section>
|
||||
) : 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">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-base font-semibold text-zinc-900">通知</div>
|
||||
@@ -404,15 +396,15 @@ export function MobilePortalPage() {
|
||||
{resolveErrorMessage(notificationsQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
{!notificationsQuery.isLoading && !notifications.length ? (
|
||||
{!notificationsQuery.isLoading && !homeNotifications.length ? (
|
||||
<p className="text-sm text-zinc-500">暂无通知。</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
{notifications.map((item) => (
|
||||
{homeNotifications.map((item) => (
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
||||
@@ -96,7 +96,7 @@ export function MobileProfilePage() {
|
||||
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
|
||||
onSuccess: async () => {
|
||||
await refreshMe();
|
||||
toast.success("外部推送偏好已更新");
|
||||
toast.success("企业微信消息提醒偏好已更新");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveProfileError(error));
|
||||
@@ -144,9 +144,9 @@ export function MobileProfilePage() {
|
||||
</dl>
|
||||
</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">
|
||||
<span>接收外部渠道推送</span>
|
||||
<span>接收企业微信消息提醒</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-5 w-5 rounded border-zinc-300"
|
||||
@@ -155,6 +155,9 @@ export function MobileProfilePage() {
|
||||
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-2 text-xs leading-5 text-zinc-500">
|
||||
关闭后仍保留 OA 站内通知,不影响企业微信登录、绑定和工作台入口。
|
||||
</p>
|
||||
</SectionPanel>
|
||||
|
||||
<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 }),
|
||||
onSuccess: async () => {
|
||||
await refreshMe();
|
||||
toast.success("外部推送偏好已更新");
|
||||
toast.success("企业微信消息提醒偏好已更新");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveProfileError(error));
|
||||
@@ -137,7 +137,7 @@ export function ProfilePage() {
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
<SectionPanel title="外部通知推送" description="控制是否接收企微等外部渠道推送;关闭后仅保留站内通知,不影响企业微信绑定与工作台登录。">
|
||||
<SectionPanel title="企业微信消息提醒" description="控制是否接收企业微信个人应用消息;关闭后仍保留 OA 站内通知,不影响企业微信绑定与工作台登录。">
|
||||
<div className="space-y-3 text-sm text-zinc-600">
|
||||
<label className="flex items-center gap-3 text-zinc-800">
|
||||
<input
|
||||
@@ -147,7 +147,7 @@ export function ProfilePage() {
|
||||
disabled={!me || notificationPreferenceMutation.isPending}
|
||||
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
|
||||
/>
|
||||
接收外部渠道推送
|
||||
接收企业微信消息提醒
|
||||
</label>
|
||||
<p>适用于审批待办、报销付款、资产流转等业务提醒。关闭后不会停止站内通知,也不会移除你的业务权限。</p>
|
||||
</div>
|
||||
|
||||
+5
-2
@@ -65,8 +65,7 @@ export function buildMobileApprovalsPath(taskId?: string | null) {
|
||||
return "/mobile/approvals";
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ taskId: normalizedTaskId });
|
||||
return `/mobile/approvals?${params.toString()}`;
|
||||
return `/mobile/approvals/${encodeURIComponent(normalizedTaskId)}`;
|
||||
}
|
||||
|
||||
export function extractWorkflowTaskId(pathname: string) {
|
||||
@@ -79,6 +78,10 @@ export function isOfflineSnapshotAllowedPath(pathname: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^\/mobile\/approvals\/[^/]+$/.test(pathname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(extractWorkflowTaskId(pathname));
|
||||
}
|
||||
|
||||
|
||||
+54
-2
@@ -36,6 +36,7 @@ import {
|
||||
MobileNotificationsPage,
|
||||
MobilePortalPage,
|
||||
MobileProfilePage,
|
||||
MobilePurchaseRequestDetailPage,
|
||||
MobileReimbursementDetailPage,
|
||||
MobileReimbursementsPage,
|
||||
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
|
||||
path="mobile/assets/scan"
|
||||
element={
|
||||
@@ -359,9 +380,40 @@ export function AppRouter() {
|
||||
<Route
|
||||
path="mobile/approvals"
|
||||
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 />)}
|
||||
</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 MobilePortalPage = lazy(() => import("./MobilePortalPage"));
|
||||
export const MobileProfilePage = lazy(() => import("./MobileProfilePage"));
|
||||
export const MobilePurchaseRequestDetailPage = lazy(() => import("./MobilePurchaseRequestDetailPage"));
|
||||
export const MobileReimbursementDetailPage = lazy(() => import("./MobileReimbursementDetailPage"));
|
||||
export const MobileReimbursementsPage = lazy(() => import("./MobileReimbursementsPage"));
|
||||
export const NotificationChannelsPage = lazy(() => import("./NotificationChannelsPage"));
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
gap: clamp(7px, 2vw, 9px);
|
||||
border: 1px solid rgb(228 228 231);
|
||||
border-radius: 14px;
|
||||
background: rgb(255 255 255);
|
||||
|
||||
在新工单中引用
屏蔽一个用户