R51 移动端增强:入口页、底部导航与审批批量处理
这个提交包含在:
@@ -0,0 +1,94 @@
|
|||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchWorkflowTasks } from "../../api/workflow";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
import { hasAnyPermission } from "../../auth/permissions";
|
||||||
|
import {
|
||||||
|
MOBILE_APPROVAL_PERMISSIONS,
|
||||||
|
MOBILE_NAV_ITEMS,
|
||||||
|
} from "../../features/mobile/navigation";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
function resolveBadgeLabel(count: number) {
|
||||||
|
if (count > 99) {
|
||||||
|
return "99+";
|
||||||
|
}
|
||||||
|
return String(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileBottomNav() {
|
||||||
|
const { permissions } = useAuth();
|
||||||
|
const canAccessApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
|
||||||
|
|
||||||
|
const pendingCountQuery = useQuery({
|
||||||
|
enabled: canAccessApprovals,
|
||||||
|
queryKey: ["mobile", "workflow", "pending-count"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 1 });
|
||||||
|
return response.total;
|
||||||
|
},
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingCount = pendingCountQuery.data ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed inset-x-0 bottom-0 z-20 border-t border-zinc-200 bg-white/95 px-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-[0_-8px_24px_rgba(15,23,42,0.08)] backdrop-blur md:hidden">
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{MOBILE_NAV_ITEMS.map((item) => {
|
||||||
|
const disabled = item.anyOf ? !hasAnyPermission(permissions, [...item.anyOf]) : false;
|
||||||
|
const badge = item.key === "approvals" && pendingCount > 0 ? resolveBadgeLabel(pendingCount) : null;
|
||||||
|
|
||||||
|
if (disabled) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
className="touch-target flex flex-col items-center justify-center gap-1 rounded-2xl px-2 py-2 text-xs font-medium text-zinc-400"
|
||||||
|
disabled
|
||||||
|
aria-disabled="true"
|
||||||
|
title={`${item.label}不可用`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.key}
|
||||||
|
end
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"touch-target flex flex-col items-center justify-center gap-1 rounded-2xl px-2 py-2 text-xs font-medium transition-colors",
|
||||||
|
isActive ? "bg-zinc-900 text-white shadow-sm" : "text-zinc-500 hover:bg-zinc-50 hover:text-zinc-900",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<span className="relative">
|
||||||
|
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
|
||||||
|
{badge ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute -right-3 -top-2 min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none",
|
||||||
|
isActive ? "bg-white text-zinc-900" : "bg-red-500 text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export const MOBILE_APPROVAL_PERMISSIONS = [
|
||||||
|
"expense.approve",
|
||||||
|
"leave.approve",
|
||||||
|
"procurement.approve",
|
||||||
|
"travel.approve",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const MOBILE_CAPTURE_PERMISSIONS = ["invoice.upload", "invoice.manage"] as const;
|
||||||
|
|
||||||
|
export const MOBILE_ASSET_SCAN_PERMISSIONS = [
|
||||||
|
"asset.view_own",
|
||||||
|
"asset.view_all",
|
||||||
|
"asset.manage",
|
||||||
|
"asset.transfer",
|
||||||
|
"asset.inventory",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const MOBILE_LEAVE_PERMISSIONS = ["leave.view_own", "leave.view_all"] as const;
|
||||||
|
|
||||||
|
export const MOBILE_REIMBURSEMENT_PERMISSIONS = ["expense.view_own", "expense.view_all"] as const;
|
||||||
|
|
||||||
|
export type MobileNavItem = {
|
||||||
|
key: "home" | "approvals" | "capture" | "me";
|
||||||
|
to: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
anyOf?: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOBILE_NAV_ITEMS: MobileNavItem[] = [
|
||||||
|
{ key: "home", to: "/mobile", label: "首页", icon: "home" },
|
||||||
|
{ key: "approvals", to: "/mobile/approvals", label: "审批", icon: "fact_check", anyOf: MOBILE_APPROVAL_PERMISSIONS },
|
||||||
|
{ key: "capture", to: "/mobile/capture", label: "拍照", icon: "photo_camera", anyOf: MOBILE_CAPTURE_PERMISSIONS },
|
||||||
|
{ key: "me", to: "/mobile/me", label: "我的", icon: "person" },
|
||||||
|
];
|
||||||
@@ -16,6 +16,11 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
|||||||
{ path: "/assets", title: "资产", group: "主导航" },
|
{ path: "/assets", title: "资产", group: "主导航" },
|
||||||
{ path: "/workflow/tasks/", title: "审批详情", group: "备用模块" },
|
{ path: "/workflow/tasks/", title: "审批详情", group: "备用模块" },
|
||||||
{ path: "/workflow/tasks", title: "审批待办", group: "备用模块" },
|
{ path: "/workflow/tasks", title: "审批待办", group: "备用模块" },
|
||||||
|
{ path: "/mobile/assets/scan", title: "移动资产扫描", group: "移动工作台" },
|
||||||
|
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
|
||||||
|
{ path: "/mobile/capture", title: "移动拍照上传", group: "移动工作台" },
|
||||||
|
{ path: "/mobile/me", 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: "管理入口" },
|
||||||
{ path: "/people", title: "人员管理", group: "管理入口" },
|
{ path: "/people", title: "人员管理", group: "管理入口" },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Outlet, useLocation } from "react-router-dom";
|
import { Outlet, useLocation } from "react-router-dom";
|
||||||
import { ErrorBoundary } from "../components/common/ErrorBoundary";
|
import { ErrorBoundary } from "../components/common/ErrorBoundary";
|
||||||
|
import { MobileBottomNav } from "../components/mobile/MobileBottomNav";
|
||||||
import { Sidebar } from "../components/navigation/Sidebar";
|
import { Sidebar } from "../components/navigation/Sidebar";
|
||||||
import { Topbar } from "../components/navigation/Topbar";
|
import { Topbar } from "../components/navigation/Topbar";
|
||||||
import { resolvePageMeta } from "../features/navigation/schema";
|
import { resolvePageMeta } from "../features/navigation/schema";
|
||||||
@@ -8,6 +9,7 @@ import { writeRecentVisit } from "../lib/recentVisits";
|
|||||||
|
|
||||||
export function AppShellLayout() {
|
export function AppShellLayout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const isMobileRoute = location.pathname.startsWith("/mobile");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pathname = location.pathname;
|
const pathname = location.pathname;
|
||||||
@@ -24,13 +26,15 @@ export function AppShellLayout() {
|
|||||||
<main className="flex min-w-0 flex-1 flex-col lg:ml-72">
|
<main className="flex min-w-0 flex-1 flex-col lg:ml-72">
|
||||||
<Topbar />
|
<Topbar />
|
||||||
|
|
||||||
<div className="flex-1 px-4 py-6 sm:px-8 sm:py-8">
|
<div className={`flex-1 px-4 py-6 sm:px-8 sm:py-8 ${isMobileRoute ? "pb-24 md:pb-8" : ""}`}>
|
||||||
<div className="layout-container max-w-none">
|
<div className="layout-container max-w-none">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isMobileRoute ? <MobileBottomNav /> : null}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+632
-162
@@ -1,4 +1,4 @@
|
|||||||
import { 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 { useSearchParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -13,11 +13,16 @@ import {
|
|||||||
returnWorkflowTask,
|
returnWorkflowTask,
|
||||||
transferWorkflowTask,
|
transferWorkflowTask,
|
||||||
type WorkflowTaskDetail,
|
type WorkflowTaskDetail,
|
||||||
|
type WorkflowTaskSummary,
|
||||||
} from "../api/workflow";
|
} from "../api/workflow";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
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 { Button } from "../components/ui/button";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
import { Spinner } from "../components/ui/spinner";
|
import { Spinner } from "../components/ui/spinner";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||||
import { Textarea } from "../components/ui/textarea";
|
import { Textarea } from "../components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
cacheWorkflowTaskDetail,
|
cacheWorkflowTaskDetail,
|
||||||
@@ -33,10 +38,33 @@ import {
|
|||||||
} from "../mobile/workflowActionQueue";
|
} from "../mobile/workflowActionQueue";
|
||||||
|
|
||||||
type MobileAction = "approve" | "reject" | "return" | "transfer";
|
type MobileAction = "approve" | "reject" | "return" | "transfer";
|
||||||
|
type MobileTaskTab = "pending" | "completed";
|
||||||
|
type EntityFilter = "all" | "leave" | "reimbursement" | "purchase_request" | "travel_request";
|
||||||
type ReplayClassification = "failed" | "conflicted";
|
type ReplayClassification = "failed" | "conflicted";
|
||||||
|
|
||||||
function entityLabel(entityType: string | undefined) {
|
type VisibleTaskCard = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
status: string;
|
||||||
|
entityType: string | null;
|
||||||
|
entityId: string | null;
|
||||||
|
reference: string | null;
|
||||||
|
allowedActions: MobileAction[];
|
||||||
|
completedAt: string | null;
|
||||||
|
isCached: boolean;
|
||||||
|
detail: WorkflowTaskDetail | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENTITY_FILTER_OPTIONS: Array<{ value: EntityFilter; label: string }> = [
|
||||||
|
{ value: "all", label: "全部" },
|
||||||
|
{ value: "leave", label: "请假" },
|
||||||
|
{ value: "reimbursement", label: "报销" },
|
||||||
|
{ value: "purchase_request", label: "采购" },
|
||||||
|
{ value: "travel_request", label: "差旅" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function entityLabel(entityType: string | undefined | null) {
|
||||||
if (entityType === "leave") {
|
if (entityType === "leave") {
|
||||||
return "请假";
|
return "请假";
|
||||||
}
|
}
|
||||||
@@ -52,6 +80,25 @@ function entityLabel(entityType: string | undefined) {
|
|||||||
return entityType || "未知";
|
return entityType || "未知";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workflowStatusLabel(status: string) {
|
||||||
|
if (status === "pending") {
|
||||||
|
return "待处理";
|
||||||
|
}
|
||||||
|
if (status === "completed") {
|
||||||
|
return "已处理";
|
||||||
|
}
|
||||||
|
if (status === "rejected") {
|
||||||
|
return "已驳回";
|
||||||
|
}
|
||||||
|
if (status === "cancelled") {
|
||||||
|
return "已取消";
|
||||||
|
}
|
||||||
|
if (status === "skipped") {
|
||||||
|
return "已跳过";
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveErrorMessage(error: unknown) {
|
function resolveErrorMessage(error: unknown) {
|
||||||
if (isApiError(error)) {
|
if (isApiError(error)) {
|
||||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||||
@@ -88,7 +135,11 @@ function queueStatusLabel(status: WorkflowDraftQueueItem["status"]) {
|
|||||||
return "存在冲突";
|
return "存在冲突";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(value: string) {
|
function formatDateTime(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return new Date(value).toLocaleString("zh-CN");
|
return new Date(value).toLocaleString("zh-CN");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -96,6 +147,67 @@ function formatDateTime(value: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSearchValue(value: string | null | undefined) {
|
||||||
|
return String(value ?? "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinRecentDays(value: string | null | undefined, days: number) {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date(value).getTime();
|
||||||
|
if (Number.isNaN(timestamp)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return timestamp >= Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesEntityFilter(entityType: string | null, filter: EntityFilter) {
|
||||||
|
return filter === "all" || entityType === filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toVisibleRemoteTask(task: WorkflowTaskSummary): VisibleTaskCard {
|
||||||
|
const entityType = task.workflowInstance?.entityType ?? null;
|
||||||
|
const entityId = task.workflowInstance?.entityId ?? null;
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
title: task.stepName,
|
||||||
|
subtitle:
|
||||||
|
task.status === "completed"
|
||||||
|
? `业务:${entityLabel(entityType)} · 完成于 ${formatDateTime(task.completedAt)}`
|
||||||
|
: `业务:${entityLabel(entityType)} · 实体ID:${entityId ?? "-"}`,
|
||||||
|
status: task.status,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
reference: entityId ?? task.id,
|
||||||
|
allowedActions: task.allowedActions,
|
||||||
|
completedAt: task.completedAt,
|
||||||
|
isCached: false,
|
||||||
|
detail: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesSearch(card: VisibleTaskCard, keyword: string) {
|
||||||
|
if (!keyword) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchValue = normalizeSearchValue(keyword);
|
||||||
|
const haystack = [
|
||||||
|
card.title,
|
||||||
|
entityLabel(card.entityType),
|
||||||
|
card.id,
|
||||||
|
card.entityId,
|
||||||
|
card.reference,
|
||||||
|
]
|
||||||
|
.map((item) => normalizeSearchValue(item))
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return haystack.includes(searchValue);
|
||||||
|
}
|
||||||
|
|
||||||
export function MobileApprovalsPage() {
|
export function MobileApprovalsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { me, accessToken, offlineAccessMode } = useAuth();
|
const { me, accessToken, offlineAccessMode } = useAuth();
|
||||||
@@ -103,6 +215,13 @@ export function MobileApprovalsPage() {
|
|||||||
const deepLinkTaskId = searchParams.get("taskId")?.trim() ?? "";
|
const deepLinkTaskId = searchParams.get("taskId")?.trim() ?? "";
|
||||||
|
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId);
|
const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId);
|
||||||
|
const [taskTab, setTaskTab] = useState<MobileTaskTab>("pending");
|
||||||
|
const [entityFilter, setEntityFilter] = useState<EntityFilter>("all");
|
||||||
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
const deferredSearch = useDeferredValue(searchInput);
|
||||||
|
const [batchMode, setBatchMode] = useState(false);
|
||||||
|
const [selectedBatchTaskIds, setSelectedBatchTaskIds] = useState<string[]>([]);
|
||||||
|
const [batchRejectComment, setBatchRejectComment] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [comment, setComment] = useState("");
|
const [comment, setComment] = useState("");
|
||||||
@@ -114,11 +233,20 @@ export function MobileApprovalsPage() {
|
|||||||
|
|
||||||
const ownerEmployeeId = me?.id ?? null;
|
const ownerEmployeeId = me?.id ?? null;
|
||||||
const canUseRemote = isOnline && Boolean(accessToken);
|
const canUseRemote = isOnline && Boolean(accessToken);
|
||||||
|
const remoteTaskStatus = taskTab === "completed" ? "completed" : "pending";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedTaskId(deepLinkTaskId);
|
setSelectedTaskId(deepLinkTaskId);
|
||||||
}, [deepLinkTaskId]);
|
}, [deepLinkTaskId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (taskTab === "completed") {
|
||||||
|
setBatchMode(false);
|
||||||
|
setSelectedBatchTaskIds([]);
|
||||||
|
setBatchRejectComment("");
|
||||||
|
}
|
||||||
|
}, [taskTab]);
|
||||||
|
|
||||||
function updateTaskSelection(taskId: string) {
|
function updateTaskSelection(taskId: string) {
|
||||||
setSelectedTaskId(taskId);
|
setSelectedTaskId(taskId);
|
||||||
const nextParams = new URLSearchParams(searchParams);
|
const nextParams = new URLSearchParams(searchParams);
|
||||||
@@ -130,10 +258,21 @@ export function MobileApprovalsPage() {
|
|||||||
setSearchParams(nextParams, { replace: true });
|
setSearchParams(nextParams, { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function invalidateWorkflowQueries(taskId: string | null = selectedTaskId) {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-detail", taskId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, taskId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "draft-queue", ownerEmployeeId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "pending-count"] }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
const tasksQuery = useQuery({
|
const tasksQuery = useQuery({
|
||||||
enabled: canUseRemote,
|
enabled: canUseRemote,
|
||||||
queryKey: ["mobile", "workflow", "tasks"],
|
queryKey: ["mobile", "workflow", "tasks", remoteTaskStatus],
|
||||||
queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 30 }),
|
queryFn: () => fetchWorkflowTasks({ status: remoteTaskStatus, page: 1, pageSize: 100 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const detailQuery = useQuery({
|
const detailQuery = useQuery({
|
||||||
@@ -168,23 +307,80 @@ export function MobileApprovalsPage() {
|
|||||||
void cacheWorkflowTaskDetail(detailQuery.data, ownerEmployeeId).then(async () => {
|
void cacheWorkflowTaskDetail(detailQuery.data, ownerEmployeeId).then(async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] }),
|
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, detailQuery.data?.task.id] }),
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, detailQuery.data.task.id],
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}, [detailQuery.data, ownerEmployeeId, queryClient]);
|
}, [detailQuery.data, ownerEmployeeId, queryClient]);
|
||||||
|
|
||||||
const selectedTask = useMemo<WorkflowTaskDetail | null>(() => {
|
const baseTaskCards = useMemo<VisibleTaskCard[]>(() => {
|
||||||
if (detailQuery.data) {
|
if (canUseRemote) {
|
||||||
return detailQuery.data;
|
const remoteItems = (tasksQuery.data?.items ?? []).filter((item) =>
|
||||||
|
taskTab === "completed" ? isWithinRecentDays(item.completedAt, 30) : true,
|
||||||
|
);
|
||||||
|
return remoteItems.map(toVisibleRemoteTask);
|
||||||
}
|
}
|
||||||
if (!canUseRemote) {
|
|
||||||
return cachedDetailQuery.data?.detail ?? null;
|
if (taskTab === "completed") {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}, [cachedDetailQuery.data?.detail, canUseRemote, detailQuery.data]);
|
return (cachedTasksQuery.data ?? [])
|
||||||
|
.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)}`,
|
||||||
|
status: item.detail.task.status,
|
||||||
|
entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType ?? null,
|
||||||
|
entityId: item.taskSnapshot.entityId,
|
||||||
|
reference: item.taskSnapshot.reference ?? item.detail.task.id,
|
||||||
|
allowedActions: item.detail.allowedActions,
|
||||||
|
completedAt: item.detail.task.completedAt,
|
||||||
|
isCached: true,
|
||||||
|
detail: item.detail,
|
||||||
|
}));
|
||||||
|
}, [cachedTasksQuery.data, canUseRemote, taskTab, tasksQuery.data?.items]);
|
||||||
|
|
||||||
|
const filteredTaskCards = useMemo(
|
||||||
|
() =>
|
||||||
|
baseTaskCards.filter(
|
||||||
|
(card) => matchesEntityFilter(card.entityType, entityFilter) && matchesSearch(card, deferredSearch),
|
||||||
|
),
|
||||||
|
[baseTaskCards, deferredSearch, entityFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pendingTaskMap = useMemo(
|
||||||
|
() => new Map(filteredTaskCards.map((task) => [task.id, task])),
|
||||||
|
[filteredTaskCards],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedCachedTaskDetail = useMemo(
|
||||||
|
() => filteredTaskCards.find((task) => task.id === selectedTaskId)?.detail ?? null,
|
||||||
|
[filteredTaskCards, selectedTaskId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedTask = detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail;
|
||||||
|
const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null;
|
||||||
|
const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending");
|
||||||
|
const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote;
|
||||||
|
|
||||||
|
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(
|
const canTransfer = Boolean(
|
||||||
canUseRemote &&
|
canUseRemote &&
|
||||||
|
selectedTaskAllowsActions &&
|
||||||
selectedTask?.allowedActions.includes("transfer"),
|
selectedTask?.allowedActions.includes("transfer"),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -208,6 +404,7 @@ export function MobileApprovalsPage() {
|
|||||||
() => currentQueueItems.filter((item) => item.status !== "conflicted"),
|
() => currentQueueItems.filter((item) => item.status !== "conflicted"),
|
||||||
[currentQueueItems],
|
[currentQueueItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function queueOfflineDraft(action: WorkflowDraftAction) {
|
async function queueOfflineDraft(action: WorkflowDraftAction) {
|
||||||
try {
|
try {
|
||||||
if (!selectedTaskId) {
|
if (!selectedTaskId) {
|
||||||
@@ -232,6 +429,7 @@ export function MobileApprovalsPage() {
|
|||||||
detail: selectedTask,
|
detail: selectedTask,
|
||||||
});
|
});
|
||||||
await refreshDraftQueueCache();
|
await refreshDraftQueueCache();
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] });
|
||||||
setComment("");
|
setComment("");
|
||||||
setError("");
|
setError("");
|
||||||
setMessage(action === "approve" ? "当前离线,通过草稿已加入待同步队列。" : "当前离线,驳回草稿已加入待同步队列。");
|
setMessage(action === "approve" ? "当前离线,通过草稿已加入待同步队列。" : "当前离线,驳回草稿已加入待同步队列。");
|
||||||
@@ -293,16 +491,13 @@ export function MobileApprovalsPage() {
|
|||||||
return { action };
|
return { action };
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await Promise.all([
|
await invalidateWorkflowQueries(selectedTaskId);
|
||||||
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-detail", selectedTaskId] }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] }),
|
|
||||||
]);
|
|
||||||
setComment("");
|
setComment("");
|
||||||
setTransferReason("");
|
setTransferReason("");
|
||||||
setTransferToApproverId("");
|
setTransferToApproverId("");
|
||||||
setError("");
|
setError("");
|
||||||
setMessage("");
|
setMessage("");
|
||||||
|
updateTaskSelection("");
|
||||||
toast.success("操作成功");
|
toast.success("操作成功");
|
||||||
},
|
},
|
||||||
onError: (actionError) => {
|
onError: (actionError) => {
|
||||||
@@ -313,6 +508,100 @@ export function MobileApprovalsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const batchActionMutation = useMutation({
|
||||||
|
mutationFn: async (input: { action: WorkflowDraftAction; taskIds: string[]; comment?: string }) => {
|
||||||
|
if (!input.taskIds.length) {
|
||||||
|
throw new Error("请先选择至少一条待办");
|
||||||
|
}
|
||||||
|
if (input.action === "reject" && !input.comment?.trim()) {
|
||||||
|
throw new Error("批量驳回必须填写共享意见");
|
||||||
|
}
|
||||||
|
|
||||||
|
let succeeded = 0;
|
||||||
|
let failed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
for (const taskId of input.taskIds) {
|
||||||
|
const taskCard = pendingTaskMap.get(taskId);
|
||||||
|
if (!taskCard) {
|
||||||
|
skipped += 1;
|
||||||
|
failures.push(`${taskId}: 当前筛选结果中找不到该待办`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!taskCard.allowedActions.includes(input.action)) {
|
||||||
|
skipped += 1;
|
||||||
|
failures.push(`${taskId}: 当前待办不允许${input.action === "approve" ? "通过" : "驳回"}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (canUseRemote) {
|
||||||
|
if (input.action === "approve") {
|
||||||
|
await approveWorkflowTask(taskId);
|
||||||
|
} else {
|
||||||
|
await rejectWorkflowTask(taskId, input.comment!.trim());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!taskCard.detail) {
|
||||||
|
throw new Error("当前待办尚未缓存,请联网后重试");
|
||||||
|
}
|
||||||
|
await enqueueWorkflowActionDraft({
|
||||||
|
taskId,
|
||||||
|
action: input.action,
|
||||||
|
comment: input.action === "reject" ? input.comment?.trim() ?? null : null,
|
||||||
|
ownerEmployeeId,
|
||||||
|
detail: taskCard.detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
succeeded += 1;
|
||||||
|
} catch (batchError) {
|
||||||
|
failed += 1;
|
||||||
|
failures.push(`${taskId}: ${resolveErrorMessage(batchError)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...input, succeeded, failed, skipped, failures };
|
||||||
|
},
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
if (canUseRemote) {
|
||||||
|
await invalidateWorkflowQueries(selectedTaskId);
|
||||||
|
} else {
|
||||||
|
await refreshDraftQueueCache();
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionLabel = result.action === "approve" ? "通过" : "驳回";
|
||||||
|
const summary = canUseRemote
|
||||||
|
? `批量${actionLabel}完成:成功 ${result.succeeded},失败 ${result.failed},跳过 ${result.skipped}`
|
||||||
|
: `离线批量${actionLabel}已入队:成功 ${result.succeeded},失败 ${result.failed},跳过 ${result.skipped}`;
|
||||||
|
|
||||||
|
setBatchMode(false);
|
||||||
|
setSelectedBatchTaskIds([]);
|
||||||
|
setBatchRejectComment("");
|
||||||
|
setComment("");
|
||||||
|
setError(result.failures.length ? result.failures.slice(0, 3).join(";") : "");
|
||||||
|
setMessage(summary);
|
||||||
|
if (canUseRemote && result.taskIds.includes(selectedTaskId)) {
|
||||||
|
updateTaskSelection("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.failed > 0) {
|
||||||
|
toast.error(summary);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(summary);
|
||||||
|
},
|
||||||
|
onError: (batchError) => {
|
||||||
|
const nextError = resolveErrorMessage(batchError);
|
||||||
|
setError(nextError);
|
||||||
|
setMessage("");
|
||||||
|
toast.error(nextError);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const isActionSubmitting = actionMutation.isPending || offlineDraftSubmitting;
|
const isActionSubmitting = actionMutation.isPending || offlineDraftSubmitting;
|
||||||
|
|
||||||
const syncMutation = useMutation({
|
const syncMutation = useMutation({
|
||||||
@@ -351,11 +640,7 @@ export function MobileApprovalsPage() {
|
|||||||
return { succeeded, failed, conflicted };
|
return { succeeded, failed, conflicted };
|
||||||
},
|
},
|
||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
await Promise.all([
|
await invalidateWorkflowQueries(selectedTaskId);
|
||||||
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "draft-queue", ownerEmployeeId] }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-detail", selectedTaskId] }),
|
|
||||||
]);
|
|
||||||
setError("");
|
setError("");
|
||||||
setMessage(`草稿同步完成:成功 ${result.succeeded},失败 ${result.failed},冲突 ${result.conflicted}`);
|
setMessage(`草稿同步完成:成功 ${result.succeeded},失败 ${result.failed},冲突 ${result.conflicted}`);
|
||||||
if (result.succeeded || result.failed || result.conflicted) {
|
if (result.succeeded || result.failed || result.conflicted) {
|
||||||
@@ -399,27 +684,43 @@ export function MobileApprovalsPage() {
|
|||||||
}
|
}
|
||||||
}, [canUseRemote, retryableQueueItems, syncMutation]);
|
}, [canUseRemote, retryableQueueItems, syncMutation]);
|
||||||
|
|
||||||
const pendingTasks = tasksQuery.data?.items ?? [];
|
function toggleBatchTask(taskId: string) {
|
||||||
const cachedTasks = cachedTasksQuery.data ?? [];
|
setSelectedBatchTaskIds((current) =>
|
||||||
const visibleTaskCards = canUseRemote
|
current.includes(taskId) ? current.filter((id) => id !== taskId) : [...current, taskId],
|
||||||
? pendingTasks.map((task) => ({
|
);
|
||||||
id: task.id,
|
}
|
||||||
title: task.stepName,
|
|
||||||
subtitle: `业务:${entityLabel(task.workflowInstance?.entityType)}`,
|
|
||||||
status: task.status,
|
|
||||||
}))
|
|
||||||
: cachedTasks.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)}`,
|
|
||||||
status: item.detail.task.status,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null;
|
function toggleSelectAllVisibleTasks() {
|
||||||
const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote;
|
const visibleIds = filteredTaskCards.map((task) => task.id);
|
||||||
|
const allSelected = visibleIds.length > 0 && visibleIds.every((taskId) => selectedBatchTaskIds.includes(taskId));
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedBatchTaskIds([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedBatchTaskIds(visibleIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBatchAction(action: WorkflowDraftAction) {
|
||||||
|
batchActionMutation.mutate({
|
||||||
|
action,
|
||||||
|
taskIds: selectedBatchTaskIds,
|
||||||
|
comment: action === "reject" ? batchRejectComment : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const listTitle = taskTab === "pending" ? "待办任务" : "已处理任务";
|
||||||
|
const listSubtitle = canUseRemote
|
||||||
|
? taskTab === "pending"
|
||||||
|
? "在线串行审批,支持进入批量处理模式。"
|
||||||
|
: "展示最近 30 天已处理任务。"
|
||||||
|
: taskTab === "pending"
|
||||||
|
? "离线状态下仅展示已缓存待办,并支持批量入队。"
|
||||||
|
: "离线模式下暂不提供已处理历史。";
|
||||||
|
const allVisibleSelected =
|
||||||
|
filteredTaskCards.length > 0 && filteredTaskCards.every((task) => selectedBatchTaskIds.includes(task.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<PageScaffold className="space-y-4">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="移动审批"
|
title="移动审批"
|
||||||
subtitle={deepLinkTaskId ? `深链待办:${deepLinkTaskId}` : "小屏快速处理待办任务,支持离线草稿与联网重放。"}
|
subtitle={deepLinkTaskId ? `深链待办:${deepLinkTaskId}` : "小屏快速处理待办任务,支持离线草稿与联网重放。"}
|
||||||
@@ -439,45 +740,202 @@ 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}
|
||||||
|
|
||||||
<div className="card-std space-y-3 p-4" data-testid="mobile-approvals-list">
|
<section className="card-std space-y-4 p-4" data-testid="mobile-approvals-list">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">待办任务</h3>
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">{listTitle}</h3>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">{listSubtitle}</p>
|
||||||
|
</div>
|
||||||
<span className={`text-sm ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>{isOnline ? "在线" : "离线"}</span>
|
<span className={`text-sm ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>{isOnline ? "在线" : "离线"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={taskTab} onValueChange={(value) => setTaskTab(value as MobileTaskTab)}>
|
||||||
|
<TabsList className="flex w-full justify-start gap-1">
|
||||||
|
<TabsTrigger value="pending">待处理</TabsTrigger>
|
||||||
|
<TabsTrigger value="completed" disabled={!canUseRemote}>已处理</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Tabs value={entityFilter} onValueChange={(value) => setEntityFilter(value as EntityFilter)}>
|
||||||
|
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
||||||
|
{ENTITY_FILTER_OPTIONS.map((option) => (
|
||||||
|
<TabsTrigger key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input
|
||||||
|
data-testid="mobile-approvals-search"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(event) => setSearchInput(event.target.value)}
|
||||||
|
placeholder="搜索步骤名、业务类型、任务 ID 或业务实体 ID"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{taskTab === "pending" ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={batchMode ? "default" : "secondary"}
|
||||||
|
disabled={!filteredTaskCards.length}
|
||||||
|
onClick={() => {
|
||||||
|
setBatchMode((current) => !current);
|
||||||
|
setSelectedBatchTaskIds([]);
|
||||||
|
setBatchRejectComment("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{batchMode ? "退出批量处理" : "批量处理"}
|
||||||
|
</Button>
|
||||||
|
{batchMode ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!filteredTaskCards.length}
|
||||||
|
onClick={toggleSelectAllVisibleTasks}
|
||||||
|
>
|
||||||
|
{allVisibleSelected ? "清空选择" : "全选当前筛选结果"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<span className="text-sm text-zinc-500">
|
||||||
|
当前结果 {filteredTaskCards.length} 条
|
||||||
|
{batchMode ? `,已选 ${selectedBatchTaskIds.length} 条` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-zinc-500">已处理页仅展示最近 30 天内的 completed 任务。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{batchMode ? (
|
||||||
|
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-3 text-sm">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="text-zinc-700">已选择 {selectedBatchTaskIds.length} 条待办,可在线串行处理或离线逐条入队。</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending}
|
||||||
|
onClick={() => handleBatchAction("approve")}
|
||||||
|
>
|
||||||
|
{canUseRemote ? "批量通过" : "离线批量加入通过草稿"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending || !batchRejectComment.trim()}
|
||||||
|
onClick={() => handleBatchAction("reject")}
|
||||||
|
>
|
||||||
|
{canUseRemote ? "批量驳回" : "离线批量加入驳回草稿"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
className="mt-3"
|
||||||
|
rows={3}
|
||||||
|
value={batchRejectComment}
|
||||||
|
onChange={(event) => setBatchRejectComment(event.target.value)}
|
||||||
|
placeholder="批量驳回共享意见,所有选中任务将复用这条说明。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{canUseRemote && tasksQuery.isLoading ? (
|
{canUseRemote && tasksQuery.isLoading ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-zinc-500"><Spinner size="sm" />加载中...</div>
|
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!canUseRemote ? (
|
{canUseRemote && tasksQuery.isError ? (
|
||||||
<p className="text-sm text-zinc-500">离线状态下展示已缓存待办;若未缓存任务详情,可通过深链任务号联网后重试。</p>
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{resolveErrorMessage(tasksQuery.error)}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!visibleTaskCards.length ? (
|
{!canUseRemote && taskTab === "completed" ? (
|
||||||
<p className="text-sm text-zinc-500">{canUseRemote ? "暂无待办任务。" : "暂无已缓存待办。"}</p>
|
<p className="text-sm text-zinc-500">离线模式仅缓存待处理任务详情;已处理历史需联网查看。</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!filteredTaskCards.length && !(canUseRemote && tasksQuery.isLoading) ? (
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
{taskTab === "completed"
|
||||||
|
? "最近 30 天暂无符合筛选条件的已处理任务。"
|
||||||
|
: canUseRemote
|
||||||
|
? "暂无符合筛选条件的待办任务。"
|
||||||
|
: "暂无符合筛选条件的已缓存待办。"}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{visibleTaskCards.map((task) => (
|
{filteredTaskCards.map((task) => {
|
||||||
<button
|
const isSelected = selectedTaskId === task.id;
|
||||||
key={task.id}
|
const isBatchSelected = selectedBatchTaskIds.includes(task.id);
|
||||||
className={`w-full rounded-lg border px-3 py-2 text-left text-sm ${
|
|
||||||
selectedTaskId === task.id ? "border-primary bg-primary-50 text-primary" : "border-zinc-200 bg-white text-zinc-800"
|
|
||||||
}`}
|
|
||||||
data-testid={`mobile-approvals-task-${task.id}`}
|
|
||||||
onClick={() => updateTaskSelection(task.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<div className="font-medium">{task.title}</div>
|
|
||||||
<div className="text-xs text-zinc-500">任务ID: {task.id}</div>
|
|
||||||
<div className="text-xs text-zinc-500">{task.subtitle}</div>
|
|
||||||
<div className="text-xs text-zinc-500">状态:{task.status}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card-std space-y-3 p-4" data-testid="mobile-approvals-detail">
|
if (batchMode && taskTab === "pending") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className={`flex gap-3 rounded-xl border px-3 py-3 ${
|
||||||
|
isBatchSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-4 w-4 rounded border-zinc-300"
|
||||||
|
checked={isBatchSelected}
|
||||||
|
onChange={() => toggleBatchTask(task.id)}
|
||||||
|
aria-label={`选择任务 ${task.id}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 flex-1 text-left"
|
||||||
|
data-testid={`mobile-approvals-task-${task.id}`}
|
||||||
|
onClick={() => toggleBatchTask(task.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="font-medium text-zinc-900">{task.title}</div>
|
||||||
|
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">任务ID: {task.id}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
|
||||||
|
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={task.id}
|
||||||
|
className={`w-full rounded-xl border px-3 py-3 text-left ${
|
||||||
|
isSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white"
|
||||||
|
}`}
|
||||||
|
data-testid={`mobile-approvals-task-${task.id}`}
|
||||||
|
onClick={() => updateTaskSelection(task.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="font-medium text-zinc-900">{task.title}</div>
|
||||||
|
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">任务ID: {task.id}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
|
||||||
|
{task.reference && task.reference !== task.id ? (
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">业务参考:{task.reference}</div>
|
||||||
|
) : null}
|
||||||
|
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card-std space-y-3 p-4" 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}
|
||||||
@@ -487,7 +945,9 @@ export function MobileApprovalsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selectedTaskId && canUseRemote && detailQuery.isError ? (
|
{selectedTaskId && canUseRemote && detailQuery.isError ? (
|
||||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveErrorMessage(detailQuery.error)}</p>
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{resolveErrorMessage(detailQuery.error)}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isOfflineUncachedDeepLink ? (
|
{isOfflineUncachedDeepLink ? (
|
||||||
@@ -499,10 +959,12 @@ export function MobileApprovalsPage() {
|
|||||||
{selectedTask ? (
|
{selectedTask ? (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||||
<div>任务状态:{selectedTask.task.status}</div>
|
<div>任务状态:{workflowStatusLabel(selectedTask.task.status)}</div>
|
||||||
<div>步骤:{selectedTask.task.stepName}</div>
|
<div>步骤:{selectedTask.task.stepName}</div>
|
||||||
<div>对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
|
<div>对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
|
||||||
<div>业务参考:{selectedReference?.reference ?? "-"}</div>
|
<div>业务参考:{selectedReference?.reference ?? "-"}</div>
|
||||||
|
<div>创建时间:{formatDateTime(selectedTask.task.createdAt)}</div>
|
||||||
|
<div>完成时间:{formatDateTime(selectedTask.task.completedAt)}</div>
|
||||||
{selectedTask.reimbursement ? (
|
{selectedTask.reimbursement ? (
|
||||||
<>
|
<>
|
||||||
<div>报销单号:{selectedTask.reimbursement.formNumber ?? "-"}</div>
|
<div>报销单号:{selectedTask.reimbursement.formNumber ?? "-"}</div>
|
||||||
@@ -535,100 +997,108 @@ export function MobileApprovalsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Textarea
|
{!selectedTaskAllowsActions ? (
|
||||||
data-testid="mobile-approvals-comment"
|
<p className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
|
||||||
onChange={(event) => setComment(event.target.value)}
|
当前任务已处理,仅供查看,不支持再次审批。
|
||||||
placeholder="驳回 / 退回意见;离线仅支持驳回意见入队。"
|
</p>
|
||||||
rows={3}
|
) : (
|
||||||
value={comment}
|
<>
|
||||||
/>
|
|
||||||
|
|
||||||
{!canUseRemote ? (
|
|
||||||
<p className="text-xs text-zinc-500">离线状态下仅支持通过 / 驳回草稿,退回与转审需联网执行。</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
data-testid="mobile-approvals-approve"
|
|
||||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
|
|
||||||
onClick={() => {
|
|
||||||
if (canUseRemote) {
|
|
||||||
actionMutation.mutate("approve");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void queueOfflineDraft("approve");
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{canUseRemote ? "通过" : "离线加入通过草稿"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
data-testid="mobile-approvals-reject"
|
|
||||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
|
|
||||||
onClick={() => {
|
|
||||||
if (canUseRemote) {
|
|
||||||
actionMutation.mutate("reject");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void queueOfflineDraft("reject");
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
{canUseRemote ? "驳回" : "离线加入驳回草稿"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
data-testid="mobile-approvals-return"
|
|
||||||
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
|
|
||||||
onClick={() => actionMutation.mutate("return")}
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
退回
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canTransfer ? (
|
|
||||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-3 text-sm">
|
|
||||||
<p className="font-medium text-zinc-800">转审</p>
|
|
||||||
<select
|
|
||||||
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
|
||||||
data-testid="mobile-approvals-transfer-to"
|
|
||||||
onChange={(event) => setTransferToApproverId(event.target.value)}
|
|
||||||
value={transferToApproverId}
|
|
||||||
>
|
|
||||||
<option value="">请选择目标审批人</option>
|
|
||||||
{(approversQuery.data?.items ?? []).map((approver) => (
|
|
||||||
<option key={approver.id} value={approver.id}>
|
|
||||||
{approver.name} ({approver.employeeNo})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
data-testid="mobile-approvals-transfer-reason"
|
data-testid="mobile-approvals-comment"
|
||||||
onChange={(event) => setTransferReason(event.target.value)}
|
onChange={(event) => setComment(event.target.value)}
|
||||||
placeholder="转审理由(至少10字)"
|
placeholder="驳回 / 退回意见;离线仅支持驳回意见入队。"
|
||||||
rows={2}
|
rows={3}
|
||||||
value={transferReason}
|
value={comment}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
{!canUseRemote ? (
|
||||||
data-testid="mobile-approvals-transfer"
|
<p className="text-xs text-zinc-500">离线状态下仅支持通过 / 驳回草稿,退回与转审需联网执行。</p>
|
||||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("transfer")}
|
) : null}
|
||||||
onClick={() => actionMutation.mutate("transfer")}
|
|
||||||
type="button"
|
<div className="flex flex-wrap gap-2">
|
||||||
variant="secondary"
|
<Button
|
||||||
>
|
data-testid="mobile-approvals-approve"
|
||||||
提交转审
|
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
|
||||||
</Button>
|
onClick={() => {
|
||||||
</div>
|
if (canUseRemote) {
|
||||||
) : null}
|
actionMutation.mutate("approve");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void queueOfflineDraft("approve");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{canUseRemote ? "通过" : "离线加入通过草稿"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="mobile-approvals-reject"
|
||||||
|
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
|
||||||
|
onClick={() => {
|
||||||
|
if (canUseRemote) {
|
||||||
|
actionMutation.mutate("reject");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void queueOfflineDraft("reject");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{canUseRemote ? "驳回" : "离线加入驳回草稿"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="mobile-approvals-return"
|
||||||
|
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
|
||||||
|
onClick={() => actionMutation.mutate("return")}
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
退回
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canTransfer ? (
|
||||||
|
<div className="space-y-2 rounded-lg border border-zinc-200 p-3 text-sm">
|
||||||
|
<p className="font-medium text-zinc-800">转审</p>
|
||||||
|
<select
|
||||||
|
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||||
|
data-testid="mobile-approvals-transfer-to"
|
||||||
|
onChange={(event) => setTransferToApproverId(event.target.value)}
|
||||||
|
value={transferToApproverId}
|
||||||
|
>
|
||||||
|
<option value="">请选择目标审批人</option>
|
||||||
|
{(approversQuery.data?.items ?? []).map((approver) => (
|
||||||
|
<option key={approver.id} value={approver.id}>
|
||||||
|
{approver.name} ({approver.employeeNo})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
data-testid="mobile-approvals-transfer-reason"
|
||||||
|
onChange={(event) => setTransferReason(event.target.value)}
|
||||||
|
placeholder="转审理由(至少10字)"
|
||||||
|
rows={2}
|
||||||
|
value={transferReason}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
data-testid="mobile-approvals-transfer"
|
||||||
|
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("transfer")}
|
||||||
|
onClick={() => actionMutation.mutate("transfer")}
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
提交转审
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div className="card-std space-y-3 p-4" data-testid="mobile-approvals-queue">
|
<section className="card-std space-y-3 p-4" data-testid="mobile-approvals-queue">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">离线审批草稿队列</h3>
|
<h3 className="text-base font-semibold text-zinc-900">离线审批草稿队列</h3>
|
||||||
<span className="text-sm text-zinc-500">{currentQueueItems.length ? `待处理 ${currentQueueItems.length} 项` : "无待处理项"}</span>
|
<span className="text-sm text-zinc-500">{currentQueueItems.length ? `待处理 ${currentQueueItems.length} 项` : "无待处理项"}</span>
|
||||||
@@ -655,7 +1125,7 @@ export function MobileApprovalsPage() {
|
|||||||
<div className="font-medium text-zinc-900">
|
<div className="font-medium text-zinc-900">
|
||||||
{item.taskSnapshot.reference ?? item.taskId} · {item.action === "approve" ? "通过" : "驳回"}
|
{item.taskSnapshot.reference ?? item.taskId} · {item.action === "approve" ? "通过" : "驳回"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-zinc-500">业务:{entityLabel(item.taskSnapshot.entityType ?? undefined)} · 步骤:{item.taskSnapshot.stepName ?? "-"}</div>
|
<div className="text-zinc-500">业务:{entityLabel(item.taskSnapshot.entityType)} · 步骤:{item.taskSnapshot.stepName ?? "-"}</div>
|
||||||
<div className="text-zinc-500">状态:{queueStatusLabel(item.status)} · 更新时间:{formatDateTime(item.updatedAt)}</div>
|
<div className="text-zinc-500">状态:{queueStatusLabel(item.status)} · 更新时间:{formatDateTime(item.updatedAt)}</div>
|
||||||
<div className="text-zinc-500">意见:{item.comment ?? "-"}</div>
|
<div className="text-zinc-500">意见:{item.comment ?? "-"}</div>
|
||||||
<div className="text-zinc-500">最近错误:{item.lastError ?? "-"}</div>
|
<div className="text-zinc-500">最近错误:{item.lastError ?? "-"}</div>
|
||||||
@@ -704,7 +1174,7 @@ export function MobileApprovalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</PageScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { updateNotificationPreferences } from "../api/auth";
|
||||||
|
import { fetchEmployeeProfile } from "../api/employees";
|
||||||
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import { isApiError } from "../api/types";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||||
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
|
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
|
||||||
|
function resolveErrorMessage(error: unknown) {
|
||||||
|
if (isApiError(error)) {
|
||||||
|
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "请求失败,请稍后重试。";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileMePage() {
|
||||||
|
const { me, refreshMe } = useAuth();
|
||||||
|
|
||||||
|
const profileQuery = useQuery({
|
||||||
|
enabled: Boolean(me?.id),
|
||||||
|
queryKey: ["mobile", "me", "profile", me?.id],
|
||||||
|
queryFn: () => fetchEmployeeProfile(me!.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const notificationPreferenceMutation = useMutation({
|
||||||
|
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await refreshMe();
|
||||||
|
toast.success("外部推送偏好已更新");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(resolveErrorMessage(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile = profileQuery.data?.employee;
|
||||||
|
const departmentName = profile?.department?.name ?? (me?.departmentId ? "已关联组织" : "待管理员维护");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageScaffold className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title="我的"
|
||||||
|
subtitle="查看个人信息、进入完整档案并管理外部推送开关。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SectionPanel title="个人信息" description="头像与部门信息从完整员工档案读取。">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<EmployeeAvatar name={me?.name ?? "员工"} src={profile?.avatarUrl} size="xl" />
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<div className="text-lg font-semibold text-zinc-900">{me?.name ?? "-"}</div>
|
||||||
|
<div className="text-sm text-zinc-500">{departmentName}</div>
|
||||||
|
{me?.employeeNo ? <div className="text-xs text-zinc-400">员工号:{me.employeeNo}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{profileQuery.isLoading ? <p className="mt-4 text-sm text-zinc-500">加载档案信息中...</p> : null}
|
||||||
|
{profileQuery.isError ? (
|
||||||
|
<p className="mt-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{resolveErrorMessage(profileQuery.error)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</SectionPanel>
|
||||||
|
|
||||||
|
<SectionPanel title="完整员工档案" description="办公地点、紧急联系人、绩效和薪资等完整信息仍在员工档案页维护或查看。">
|
||||||
|
{me?.id ? (
|
||||||
|
<Button asChild type="button" className="w-full">
|
||||||
|
<Link to={`/people/${me.id}`}>进入完整员工档案</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</SectionPanel>
|
||||||
|
|
||||||
|
<SectionPanel title="外部通知推送" description="关闭后仅停止外部渠道推送,站内通知仍保留。">
|
||||||
|
<label className="flex items-center gap-3 text-sm text-zinc-800">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-zinc-300"
|
||||||
|
checked={Boolean(me?.notificationPushEnabled ?? true)}
|
||||||
|
disabled={!me || notificationPreferenceMutation.isPending}
|
||||||
|
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
|
||||||
|
/>
|
||||||
|
接收外部渠道推送
|
||||||
|
</label>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-zinc-500">
|
||||||
|
适用于审批待办、报销付款和资产流转提醒。关闭后不会影响站内通知或系统登录。
|
||||||
|
</p>
|
||||||
|
</SectionPanel>
|
||||||
|
</PageScaffold>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { fetchNotifications } from "../api/notifications";
|
||||||
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import { isApiError } from "../api/types";
|
||||||
|
import { fetchMyPresence, upsertMyPresence, type PresenceItem } from "../api/presence";
|
||||||
|
import { fetchWorkflowTasks } from "../api/workflow";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { hasAnyPermission } from "../auth/permissions";
|
||||||
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import {
|
||||||
|
MOBILE_APPROVAL_PERMISSIONS,
|
||||||
|
MOBILE_ASSET_SCAN_PERMISSIONS,
|
||||||
|
MOBILE_CAPTURE_PERMISSIONS,
|
||||||
|
MOBILE_LEAVE_PERMISSIONS,
|
||||||
|
MOBILE_REIMBURSEMENT_PERMISSIONS,
|
||||||
|
} from "../features/mobile/navigation";
|
||||||
|
import { buildMobileApprovalsPath } from "../pwa";
|
||||||
|
|
||||||
|
const QUICK_PRESENCE_OPTIONS = [
|
||||||
|
{ label: "在岗", value: "office" as const },
|
||||||
|
{ label: "居家", value: "remote" as const },
|
||||||
|
{ label: "外出", value: "out" as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
function resolveErrorMessage(error: unknown) {
|
||||||
|
if (isApiError(error)) {
|
||||||
|
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "请求失败,请稍后重试。";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string) {
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleString("zh-CN");
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function presenceStatusLabel(item: PresenceItem | null) {
|
||||||
|
if (!item) {
|
||||||
|
return "未更新";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (item.status) {
|
||||||
|
case "office":
|
||||||
|
return "在岗";
|
||||||
|
case "remote":
|
||||||
|
return "居家";
|
||||||
|
case "out":
|
||||||
|
return "外出";
|
||||||
|
case "travel":
|
||||||
|
return "出差";
|
||||||
|
case "leave":
|
||||||
|
return "请假";
|
||||||
|
default:
|
||||||
|
return item.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canQuickUpdatePresence(item: PresenceItem | null) {
|
||||||
|
return !(item?.status === "leave" && item.source === "leave_sync");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNotificationTargetPath(
|
||||||
|
item: { targetType: string | null; targetId: string | null },
|
||||||
|
canAccessApprovals: boolean,
|
||||||
|
) {
|
||||||
|
if (!item.targetType || !item.targetId) {
|
||||||
|
return "/notifications";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.targetType === "workflow_task" && canAccessApprovals) {
|
||||||
|
return buildMobileApprovalsPath(item.targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.targetType === "employee") {
|
||||||
|
return "/mobile/me";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.targetType === "reimbursement_form") {
|
||||||
|
return `/reimbursements/${item.targetId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/notifications";
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickLinkCard({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
to,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
to: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
if (disabled) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm text-zinc-400">
|
||||||
|
<div className="font-medium">{label}</div>
|
||||||
|
<div className="mt-1 text-xs">{description}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={to} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm text-zinc-700 shadow-sm transition-colors hover:bg-zinc-50">
|
||||||
|
<div className="font-medium text-zinc-900">{label}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">{description}</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobilePortalPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { permissions } = useAuth();
|
||||||
|
const canAccessApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
|
||||||
|
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
|
||||||
|
const canAccessAssetScan = hasAnyPermission(permissions, [...MOBILE_ASSET_SCAN_PERMISSIONS]);
|
||||||
|
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
|
||||||
|
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
|
||||||
|
|
||||||
|
const presenceQuery = useQuery({
|
||||||
|
queryKey: ["mobile", "portal", "presence"],
|
||||||
|
queryFn: () => fetchMyPresence(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingCountQuery = useQuery({
|
||||||
|
enabled: canAccessApprovals,
|
||||||
|
queryKey: ["mobile", "workflow", "pending-count"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 1 });
|
||||||
|
return response.total;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const notificationsQuery = useQuery({
|
||||||
|
queryKey: ["mobile", "portal", "notifications"],
|
||||||
|
queryFn: () => fetchNotifications({ page: 1, pageSize: 5 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const presenceMutation = useMutation({
|
||||||
|
mutationFn: (status: "office" | "remote" | "out") => upsertMyPresence({ status }),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["mobile", "portal", "presence"],
|
||||||
|
(current: { item: PresenceItem | null; date: string } | undefined) => ({
|
||||||
|
item: response.item,
|
||||||
|
date: current?.date ?? response.item.date,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
toast.success("今日考勤状态已更新");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(resolveErrorMessage(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const presenceItem = presenceQuery.data?.item ?? null;
|
||||||
|
const notifications = notificationsQuery.data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageScaffold className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title="移动首页"
|
||||||
|
subtitle="处理今日考勤、审批和高频入口。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="card-std space-y-4 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-zinc-900">今日考勤状态</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
|
{presenceQuery.isError ? "当前无法读取考勤状态" : `当前状态:${presenceStatusLabel(presenceItem)}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="badge-neutral">{presenceQuery.data?.date ?? "今日"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{presenceItem?.status === "leave" && presenceItem.source === "leave_sync" ? (
|
||||||
|
<p className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||||
|
当前日期已被审批通过的请假覆盖,不能在移动首页手动修改。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{QUICK_PRESENCE_OPTIONS.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
variant={presenceItem?.status === option.value ? "default" : "secondary"}
|
||||||
|
className="touch-target"
|
||||||
|
disabled={presenceMutation.isPending || presenceQuery.isError || !canQuickUpdatePresence(presenceItem)}
|
||||||
|
onClick={() => presenceMutation.mutate(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card-std space-y-3 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-zinc-900">待审批</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
|
{canAccessApprovals ? "点击进入移动审批" : "当前账号没有移动审批权限"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={canAccessApprovals ? "badge-warning" : "badge-neutral"}>
|
||||||
|
{canAccessApprovals ? `${pendingCountQuery.data ?? 0} 条` : "不可用"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canAccessApprovals ? (
|
||||||
|
<Button asChild type="button" className="w-full">
|
||||||
|
<Link to="/mobile/approvals">进入移动审批</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-500">
|
||||||
|
你当前没有审批权限,审批页入口已禁用。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card-std space-y-3 p-4">
|
||||||
|
<div className="text-sm font-semibold text-zinc-900">快捷入口</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<QuickLinkCard label="提交请假" description="进入请假申请页" to="/leave-requests" disabled={!canAccessLeave} />
|
||||||
|
<QuickLinkCard label="提交报销" description="进入报销申请页" to="/reimbursements" disabled={!canAccessReimbursements} />
|
||||||
|
<QuickLinkCard label="拍照上传" description="打开移动拍照上传" to="/mobile/capture" disabled={!canAccessCapture} />
|
||||||
|
<QuickLinkCard label="资产扫描" description="进入扫码与盘点" to="/mobile/assets/scan" disabled={!canAccessAssetScan} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card-std space-y-3 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-sm font-semibold text-zinc-900">最近通知</div>
|
||||||
|
<Button asChild size="sm" type="button" variant="secondary">
|
||||||
|
<Link to="/notifications">查看全部</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notificationsQuery.isLoading ? <p className="text-sm text-zinc-500">加载通知中...</p> : null}
|
||||||
|
{notificationsQuery.isError ? (
|
||||||
|
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{resolveErrorMessage(notificationsQuery.error)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{!notificationsQuery.isLoading && !notifications.length ? (
|
||||||
|
<p className="text-sm text-zinc-500">暂无通知。</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{notifications.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
to={resolveNotificationTargetPath(item, canAccessApprovals)}
|
||||||
|
className="block rounded-2xl border border-zinc-200 bg-white px-4 py-3 text-sm shadow-sm transition-colors hover:bg-zinc-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||||
|
{!item.readAt ? <span className="badge-warning shrink-0">未读</span> : null}
|
||||||
|
</div>
|
||||||
|
{item.body ? <div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-500">{item.body}</div> : null}
|
||||||
|
<div className="mt-2 text-[11px] text-zinc-400">{formatDateTime(item.createdAt)}</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</PageScaffold>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ import {
|
|||||||
MobileApprovalsPage,
|
MobileApprovalsPage,
|
||||||
MobileAssetScanPage,
|
MobileAssetScanPage,
|
||||||
MobileCapturePage,
|
MobileCapturePage,
|
||||||
|
MobileMePage,
|
||||||
|
MobilePortalPage,
|
||||||
NotificationChannelsPage,
|
NotificationChannelsPage,
|
||||||
NotificationsPage,
|
NotificationsPage,
|
||||||
OrganizationPage,
|
OrganizationPage,
|
||||||
@@ -257,6 +259,10 @@ export function AppRouter() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route path="mobile" element={withRouteFallback(<MobilePortalPage />)} />
|
||||||
|
|
||||||
|
<Route path="mobile/me" element={withRouteFallback(<MobileMePage />)} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="mobile/capture"
|
path="mobile/capture"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { MobileMePage as default } from "../../pages/MobileMePage";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { MobilePortalPage as default } from "../../pages/MobilePortalPage";
|
||||||
@@ -16,6 +16,8 @@ export const MasterDataPage = lazy(() => import("./MasterDataPage"));
|
|||||||
export const MobileApprovalsPage = lazy(() => import("./MobileApprovalsPage"));
|
export const MobileApprovalsPage = lazy(() => import("./MobileApprovalsPage"));
|
||||||
export const MobileAssetScanPage = lazy(() => import("./MobileAssetScanPage"));
|
export const MobileAssetScanPage = lazy(() => import("./MobileAssetScanPage"));
|
||||||
export const MobileCapturePage = lazy(() => import("./MobileCapturePage"));
|
export const MobileCapturePage = lazy(() => import("./MobileCapturePage"));
|
||||||
|
export const MobileMePage = lazy(() => import("./MobileMePage"));
|
||||||
|
export const MobilePortalPage = lazy(() => import("./MobilePortalPage"));
|
||||||
export const NotificationChannelsPage = lazy(() => import("./NotificationChannelsPage"));
|
export const NotificationChannelsPage = lazy(() => import("./NotificationChannelsPage"));
|
||||||
export const NotificationsPage = lazy(() => import("./NotificationsPage"));
|
export const NotificationsPage = lazy(() => import("./NotificationsPage"));
|
||||||
export const AnnouncementsPage = lazy(() => import("./AnnouncementsPage"));
|
export const AnnouncementsPage = lazy(() => import("./AnnouncementsPage"));
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
|
"A1","R51 执行合同建档与范围冻结","创建并校验 R51 移动端增强执行合同 CSV,冻结范围仅包含移动首页、移动我的页、底部导航和移动审批页增强;明确本轮不新增后端端点、不修改 PWA start_url、不做独立 mobile layout。","issues/2026-04-05_r51-mobile-enhancement.csv 列完整、Dependencies 为 JSON 数组字符串、Test_Method 可复现;范围仅覆盖移动端前端增强,不混入后端 API 变更或权限码调整。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-05_r51-mobile-enhancement.csv。检查:1) 对照 AGENTS.md 与 issues/README.md 确认 12 列完整;2) 范围限定为前端增强;3) A2-A6 依赖链与 reviewer/qa 收口一致。","manual","DONE","DONE","DONE","issues/2026-04-05_r51-mobile-enhancement.csv","[]","2026-04-05:Issue CSV 已创建并通过 validate_issues_csv.py 校验,范围与依赖已冻结。"
|
||||||
|
"A2","移动路由补齐与底部导航统一挂载","新增 /mobile 与 /mobile/me 路由,补充移动端底部导航组件,并在 /mobile/* 下统一挂载,保持现有 AppShellLayout、桌面导航和权限守卫结构不变。","/mobile 与 /mobile/me 可访问;底部导航固定展示 首页/审批/拍照/我的 四个 tab;仅在移动视口显示;无权限 tab 显示禁用态不跳 403;桌面端 /mobile/* 不出现底部导航。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 已登录用户访问 /mobile 可见底部导航;2) 进入 /mobile/approvals、/mobile/capture、/mobile/assets/scan、/mobile/me 时导航高亮正确;3) 桌面视口不显示底部导航。","manual","DONE","DONE","DONE","client/src/router/index.tsx|client/src/router/lazy|client/src/layouts/AppShellLayout.tsx|client/src/components/mobile/MobileBottomNav.tsx","[""A1""]","2026-04-05:移动路由、底部导航与 /mobile/* 留白接线完成;验证通过 `npm run typecheck` 与 `npm run build`。"
|
||||||
|
"A3","移动首页与移动我的页落地","新增 MobilePortalPage 与 MobileMePage,复用现有 presence、workflow、notifications、employees 与 auth API 展示移动首页和移动个人页,并提供考勤快捷更新、完整档案入口与推送开关。","/mobile 显示今日考勤、待审批数量、快捷入口和最近 5 条通知;今日考勤支持 office/remote/out 快捷更新并即时刷新;/mobile/me 展示姓名、部门、头像、完整档案入口和外部推送开关;不新增后端字段。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 移动首页可直接更新考勤状态;2) leave_sync 状态下禁止误改;3) 最近通知点击可跳转;4) 我的页可展示头像/部门名并切换推送开关。","manual","DONE","DONE","DONE","client/src/pages/MobilePortalPage.tsx|client/src/pages/MobileMePage.tsx|client/src/api/notifications.ts|client/src/api/presence.ts|client/src/api/employees.ts|client/src/api/auth.ts","[""A2""]","2026-04-05:移动首页与移动我的页已复用现有 API 落地,覆盖考勤快捷更新、通知跳转、完整档案入口与推送开关;验证通过 `npm run typecheck` 与 `npm run build`。"
|
||||||
|
"A4","移动审批搜索 分类筛选与已处理页签","增强 MobileApprovalsPage,增加待处理/已处理双层 tab、业务类型 tab 与前端搜索,复用现有 workflow/tasks API;已处理仅展示最近 30 天 completed 任务。","有审批权限用户可在 /mobile/approvals 按待处理/已处理切换、按业务类型过滤、按关键字搜索;已处理 tab 仅展示最近 30 天 status=completed 数据;现有单条详情与单条审批不回归。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 搜索匹配步骤名/业务类型/任务ID/业务实体ID;2) 业务类型 tab 过滤请假/报销/采购/差旅;3) 已处理页签展示最近 30 天 completed 任务。","manual","DONE","DONE","DONE","client/src/pages/MobileApprovalsPage.tsx|client/src/api/workflow.ts","[""A2""]","2026-04-05:移动审批已补齐待处理/已处理双层 tab、业务类型筛选与前端搜索;已处理仅保留最近 30 天 completed 数据;验证通过 `npm run typecheck` 与 `npm run build`。"
|
||||||
|
"A5","批量审批与离线批量入队/同步收口","在 MobileApprovalsPage 增加批量处理模式与多选框,支持批量通过和共享意见的批量驳回;在线串行调用现有 API,离线逐条复用 workflowActionQueue 入队与同步。","待处理页签可进入批量模式并选择多条任务;批量通过在线串行成功并汇总结果;批量驳回必须填写共享意见;离线模式下批量通过/驳回逐条写入现有审批草稿队列并沿用现有重放/冲突分类逻辑。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 在线批量通过后列表刷新与 badge 数同步减少;2) 在线批量驳回无共享意见时禁止提交;3) 离线批量操作逐条入队,恢复联网后可自动/手动同步。","manual","DONE","DONE","DONE","client/src/pages/MobileApprovalsPage.tsx|client/src/mobile/workflowActionQueue.ts","[""A4""]","2026-04-05:已完成批量通过、共享意见批量驳回、离线逐条入队与自动/手动同步收口;reviewer 自审重点检查离线队列复用、重复处理与冲突分类,无阻塞问题。"
|
||||||
|
"A6","R51 reviewer QA 与提交收口","执行 reviewer 风格复核、前端 post-check、业务回归和 CSV 状态回填,确认移动首页、我的页、底部导航与移动审批增强不回归。","A2-A5 的 Review1_Status 与 Regression_Status 有证据回填;typecheck/build 通过;移动首页考勤更新、通知跳转、移动我的页、移动审批搜索/筛选/已处理/批量审批主链路回归通过。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 普通已登录用户在移动视口访问 /mobile 可见考勤卡、待审批卡、快捷入口、最近通知和底部导航;2) /mobile/me 展示头像、部门名、姓名、完整档案入口与推送开关;3) 有审批权限用户在 /mobile/approvals 可用搜索、业务类型筛选、已处理 tab 与批量审批;4) 桌面视口访问 /mobile/* 不显示底部导航。","manual","DONE","DONE","DONE","issues/2026-04-05_r51-mobile-enhancement.csv|client/src/pages/MobilePortalPage.tsx|client/src/pages/MobileMePage.tsx|client/src/pages/MobileApprovalsPage.tsx|client/src/components/mobile/MobileBottomNav.tsx","[""A3"",""A5""]","2026-04-05:reviewer 自审完成,`npm run typecheck` 与 `npm run build` 通过;移动首页、移动我的页、移动审批与底部导航主链路已收口。"
|
||||||
|
在新工单中引用
屏蔽一个用户